From e563b86ede24cb80cb8b683d8ea10f4e557a4f06 Mon Sep 17 00:00:00 2001 From: s1to Date: Thu, 9 Jul 2026 09:56:30 +0200 Subject: [PATCH] Oasis 0.8.7 mobile (nodejs-project baseline) --- .gitignore | 1 + README.md | 328 + main.js | 75 + package.json | 159 + src/backend/backend.js | 8158 ++++++++ src/backend/blobHandler.js | 279 + src/backend/collab_content.js | 66 + src/backend/dedupe.js | 52 + src/backend/logsPdf.js | 206 + src/backend/media-favorites.js | 126 + src/backend/nameCache.js | 20 + src/backend/opinion_categories.js | 50 + src/backend/renderTextWithStyles.js | 139 + src/backend/renderUrl.js | 92 + src/backend/sanitizeHtml.js | 45 + src/backend/smartContractPdf.js | 228 + src/backend/updater.js | 121 + src/backend/vote_tally.js | 116 + src/backend/wallet_addresses.js | 17 + src/client/assets/images/anarchy.png | Bin 0 -> 33926 bytes src/client/assets/images/default-avatar.png | Bin 0 -> 354997 bytes src/client/assets/images/default-market.png | Bin 0 -> 167430 bytes src/client/assets/images/default-tribe.png | Bin 0 -> 244056 bytes src/client/assets/images/democracy.png | Bin 0 -> 29550 bytes src/client/assets/images/dictatorship.png | Bin 0 -> 30739 bytes src/client/assets/images/favicon.svg | 4 + src/client/assets/images/karmatocracy.png | Bin 0 -> 36717 bytes src/client/assets/images/majority.png | Bin 0 -> 33470 bytes src/client/assets/images/minority.png | Bin 0 -> 26411 bytes src/client/assets/images/snh-oasis.jpg | Bin 0 -> 53493 bytes src/client/assets/images/world-map.svg | 25 + src/client/assets/images/worldmap-z2.png | Bin 0 -> 135491 bytes src/client/assets/larp/houses.json | 92 + src/client/assets/larp/images/academia.jpg | Bin 0 -> 20452 bytes src/client/assets/larp/images/arrakis.jpg | Bin 0 -> 17030 bytes src/client/assets/larp/images/dogma.jpg | Bin 0 -> 14894 bytes src/client/assets/larp/images/helix.jpg | Bin 0 -> 16289 bytes src/client/assets/larp/images/hermandad.jpg | Bin 0 -> 17429 bytes src/client/assets/larp/images/quark.jpg | Bin 0 -> 15292 bytes src/client/assets/larp/images/solaris.jpg | Bin 0 -> 19047 bytes src/client/assets/larp/images/terraverde.jpg | Bin 0 -> 21767 bytes src/client/assets/larp/images/unsystem.jpg | Bin 0 -> 18395 bytes src/client/assets/styles/highlight.css | 67 + src/client/assets/styles/mobile.css | 916 + src/client/assets/styles/style.css | 6441 ++++++ src/client/assets/themes/OasisMobile.css | 1005 + src/client/assets/translations/i18n.js | 15 + src/client/assets/translations/oasis_ar.js | 3872 ++++ src/client/assets/translations/oasis_de.js | 3868 ++++ src/client/assets/translations/oasis_en.js | 3873 ++++ src/client/assets/translations/oasis_es.js | 3873 ++++ src/client/assets/translations/oasis_eu.js | 3873 ++++ src/client/assets/translations/oasis_fr.js | 3872 ++++ src/client/assets/translations/oasis_hi.js | 3872 ++++ src/client/assets/translations/oasis_it.js | 3873 ++++ src/client/assets/translations/oasis_pt.js | 3873 ++++ src/client/assets/translations/oasis_ru.js | 3872 ++++ src/client/assets/translations/oasis_zh.js | 3873 ++++ src/client/cli-cmd-aliases.js | 10 + src/client/gui.js | 124 + src/client/middleware.js | 210 + src/client/oasis_client.js | 70 + src/client/public/docs/ecoin.md | 115 + src/configs/AI-history.json | 1 + src/configs/agenda-config.json | 3 + src/configs/banking-allocations.json | 1 + src/configs/banking-eco-history.json | 1 + src/configs/banking-epochs.json | 1 + src/configs/config-manager.js | 109 + src/configs/fediverse-accounts.json | 1 + src/configs/follow_state.json | 1 + src/configs/media-favorites.json | 13 + src/configs/oasis-config.json | 79 + src/configs/server-config.json | 61 + src/configs/shared-state.js | 35 + src/configs/snh-invite-code.json | 7 + src/configs/wallet-addresses.json | 1 + src/games/8ball/index.html | 285 + src/games/8ball/thumbnail.svg | 21 + src/games/arkanoid/index.html | 209 + src/games/arkanoid/thumbnail.svg | 36 + src/games/artillery/index.html | 272 + src/games/artillery/thumbnail.svg | 12 + src/games/asteroids/index.html | 250 + src/games/asteroids/thumbnail.svg | 25 + src/games/audiopendulum/index.html | 693 + src/games/audiopendulum/thumbnail.svg | 31 + src/games/cocoland/index.html | 236 + src/games/cocoland/thumbnail.svg | 26 + src/games/cocoman/index.html | 323 + src/games/cocoman/thumbnail.svg | 22 + src/games/ecoinflow/index.html | 793 + src/games/ecoinflow/thumbnail.svg | 113 + src/games/flipflop/index.html | 238 + src/games/flipflop/thumbnail.svg | 44 + src/games/labyrinth/index.html | 229 + src/games/labyrinth/thumbnail.svg | 16 + src/games/neoninfiltrator/index.html | 351 + src/games/neoninfiltrator/thumbnail.svg | 73 + src/games/pingpong/index.html | 192 + src/games/pingpong/thumbnail.svg | 11 + src/games/rockpaperscissors/index.html | 182 + src/games/rockpaperscissors/thumbnail.svg | 17 + src/games/spaceinvaders/index.html | 234 + src/games/spaceinvaders/thumbnail.svg | 26 + src/games/tetris/index.html | 242 + src/games/tetris/thumbnail.svg | 22 + src/games/tiktaktoe/index.html | 190 + src/games/tiktaktoe/thumbnail.svg | 18 + src/maps/map_renderer.js | 223 + src/models/activity_model.js | 686 + src/models/agenda_model.js | 340 + src/models/audios_model.js | 387 + src/models/banking_model.js | 1394 ++ src/models/blockchain_model.js | 397 + src/models/bookmarking_model.js | 350 + src/models/calendars_model.js | 1084 + src/models/chats_model.js | 875 + src/models/cipher_model.js | 52 + src/models/courts_model.js | 964 + src/models/crypto.js | 530 + src/models/cv_model.js | 177 + src/models/documents_model.js | 370 + src/models/events_model.js | 633 + src/models/exportmode_model.js | 52 + src/models/favorites_model.js | 178 + src/models/fediverse_model.js | 409 + src/models/feed_model.js | 435 + src/models/forum_model.js | 532 + src/models/games_model.js | 67 + src/models/images_model.js | 343 + src/models/inhabitants_model.js | 570 + src/models/jobs_model.js | 558 + src/models/larp_model.js | 942 + src/models/legacy_model.js | 99 + src/models/logs_model.js | 362 + src/models/main_models.js | 2347 +++ src/models/maps_model.js | 920 + src/models/market_model.js | 583 + src/models/melody_model.js | 181 + src/models/opinions_model.js | 268 + src/models/pads_model.js | 877 + src/models/panicmode_model.js | 15 + src/models/parliament_model.js | 1531 ++ src/models/pixelia_model.js | 144 + src/models/pm_model.js | 147 + src/models/projects_model.js | 601 + src/models/reports_model.js | 353 + src/models/search_model.js | 406 + src/models/shops_model.js | 954 + src/models/stats_model.js | 622 + src/models/tags_model.js | 207 + src/models/tasks_model.js | 369 + src/models/tombstone_validator.js | 39 + src/models/torrents_model.js | 378 + src/models/transfers_model.js | 430 + src/models/trending_model.js | 226 + src/models/tribe_crypto.js | 1 + src/models/tribes_content_model.js | 362 + src/models/tribes_model.js | 939 + src/models/videos_model.js | 332 + src/models/viewer_filters.js | 130 + src/models/votes_model.js | 382 + src/models/wallet_model.js | 61 + src/server/SSB_server.js | 191 + src/server/nodemon.json | 15 + src/server/package-lock.json | 17813 +++++++++++++++++ src/server/package.json | 159 + src/server/ssb_config.js | 26 + src/server/ssb_metadata.js | 141 + src/views/AI_view.js | 147 + src/views/activity_view.js | 1881 ++ src/views/agenda_view.js | 260 + src/views/audio_view.js | 566 + src/views/banking_views.js | 694 + src/views/blockchain_view.js | 554 + src/views/bookmark_view.js | 511 + src/views/calendars_view.js | 444 + src/views/chats_view.js | 387 + src/views/cipher_view.js | 101 + src/views/clearnet_view.js | 310 + src/views/courts_view.js | 1407 ++ src/views/cv_view.js | 257 + src/views/document_view.js | 465 + src/views/event_view.js | 578 + src/views/favorites_view.js | 182 + src/views/fediverse_view.js | 229 + src/views/feed_view.js | 396 + src/views/forum_view.js | 415 + src/views/games_view.js | 151 + src/views/graphos_view.js | 153 + src/views/image_view.js | 541 + src/views/indexing_view.js | 39 + src/views/inhabitants_view.js | 422 + src/views/invites_view.js | 314 + src/views/jobs_view.js | 781 + src/views/larp_view.js | 434 + src/views/legacy_view.js | 65 + src/views/logs_view.js | 249 + src/views/main_views.js | 3940 ++++ src/views/maps_view.js | 541 + src/views/markdown.js | 43 + src/views/market_view.js | 737 + src/views/melody_view.js | 144 + src/views/modules_view.js | 150 + src/views/opinions_view.js | 397 + src/views/pads_view.js | 392 + src/views/parliament_view.js | 986 + src/views/peers_view.js | 144 + src/views/pixelia_view.js | 92 + src/views/pm_view.js | 97 + src/views/projects_view.js | 760 + src/views/report_view.js | 696 + src/views/search_view.js | 660 + src/views/settings_view.js | 368 + src/views/shops_view.js | 711 + src/views/stats_view.js | 505 + src/views/tags_view.js | 91 + src/views/task_view.js | 488 + src/views/torrents_view.js | 428 + src/views/transfer_view.js | 571 + src/views/trending_view.js | 300 + src/views/tribes_view.js | 1900 ++ src/views/video_view.js | 484 + src/views/vote_view.js | 371 + src/views/wallet_view.js | 168 + 226 files changed, 141968 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 main.js create mode 100644 package.json create mode 100644 src/backend/backend.js create mode 100644 src/backend/blobHandler.js create mode 100644 src/backend/collab_content.js create mode 100644 src/backend/dedupe.js create mode 100644 src/backend/logsPdf.js create mode 100644 src/backend/media-favorites.js create mode 100644 src/backend/nameCache.js create mode 100644 src/backend/opinion_categories.js create mode 100644 src/backend/renderTextWithStyles.js create mode 100644 src/backend/renderUrl.js create mode 100644 src/backend/sanitizeHtml.js create mode 100644 src/backend/smartContractPdf.js create mode 100644 src/backend/updater.js create mode 100644 src/backend/vote_tally.js create mode 100644 src/backend/wallet_addresses.js create mode 100644 src/client/assets/images/anarchy.png create mode 100644 src/client/assets/images/default-avatar.png create mode 100644 src/client/assets/images/default-market.png create mode 100644 src/client/assets/images/default-tribe.png create mode 100644 src/client/assets/images/democracy.png create mode 100644 src/client/assets/images/dictatorship.png create mode 100644 src/client/assets/images/favicon.svg create mode 100644 src/client/assets/images/karmatocracy.png create mode 100644 src/client/assets/images/majority.png create mode 100644 src/client/assets/images/minority.png create mode 100644 src/client/assets/images/snh-oasis.jpg create mode 100644 src/client/assets/images/world-map.svg create mode 100644 src/client/assets/images/worldmap-z2.png create mode 100644 src/client/assets/larp/houses.json create mode 100644 src/client/assets/larp/images/academia.jpg create mode 100644 src/client/assets/larp/images/arrakis.jpg create mode 100644 src/client/assets/larp/images/dogma.jpg create mode 100644 src/client/assets/larp/images/helix.jpg create mode 100644 src/client/assets/larp/images/hermandad.jpg create mode 100644 src/client/assets/larp/images/quark.jpg create mode 100644 src/client/assets/larp/images/solaris.jpg create mode 100644 src/client/assets/larp/images/terraverde.jpg create mode 100644 src/client/assets/larp/images/unsystem.jpg create mode 100644 src/client/assets/styles/highlight.css create mode 100644 src/client/assets/styles/mobile.css create mode 100644 src/client/assets/styles/style.css create mode 100644 src/client/assets/themes/OasisMobile.css create mode 100644 src/client/assets/translations/i18n.js create mode 100644 src/client/assets/translations/oasis_ar.js create mode 100644 src/client/assets/translations/oasis_de.js create mode 100644 src/client/assets/translations/oasis_en.js create mode 100644 src/client/assets/translations/oasis_es.js create mode 100644 src/client/assets/translations/oasis_eu.js create mode 100644 src/client/assets/translations/oasis_fr.js create mode 100644 src/client/assets/translations/oasis_hi.js create mode 100644 src/client/assets/translations/oasis_it.js create mode 100644 src/client/assets/translations/oasis_pt.js create mode 100644 src/client/assets/translations/oasis_ru.js create mode 100644 src/client/assets/translations/oasis_zh.js create mode 100644 src/client/cli-cmd-aliases.js create mode 100644 src/client/gui.js create mode 100644 src/client/middleware.js create mode 100644 src/client/oasis_client.js create mode 100644 src/client/public/docs/ecoin.md create mode 100644 src/configs/AI-history.json create mode 100644 src/configs/agenda-config.json create mode 100644 src/configs/banking-allocations.json create mode 100644 src/configs/banking-eco-history.json create mode 100644 src/configs/banking-epochs.json create mode 100644 src/configs/config-manager.js create mode 100644 src/configs/fediverse-accounts.json create mode 100644 src/configs/follow_state.json create mode 100644 src/configs/media-favorites.json create mode 100644 src/configs/oasis-config.json create mode 100644 src/configs/server-config.json create mode 100644 src/configs/shared-state.js create mode 100644 src/configs/snh-invite-code.json create mode 100644 src/configs/wallet-addresses.json create mode 100644 src/games/8ball/index.html create mode 100644 src/games/8ball/thumbnail.svg create mode 100644 src/games/arkanoid/index.html create mode 100644 src/games/arkanoid/thumbnail.svg create mode 100644 src/games/artillery/index.html create mode 100644 src/games/artillery/thumbnail.svg create mode 100644 src/games/asteroids/index.html create mode 100644 src/games/asteroids/thumbnail.svg create mode 100644 src/games/audiopendulum/index.html create mode 100644 src/games/audiopendulum/thumbnail.svg create mode 100644 src/games/cocoland/index.html create mode 100644 src/games/cocoland/thumbnail.svg create mode 100644 src/games/cocoman/index.html create mode 100644 src/games/cocoman/thumbnail.svg create mode 100644 src/games/ecoinflow/index.html create mode 100644 src/games/ecoinflow/thumbnail.svg create mode 100644 src/games/flipflop/index.html create mode 100644 src/games/flipflop/thumbnail.svg create mode 100644 src/games/labyrinth/index.html create mode 100644 src/games/labyrinth/thumbnail.svg create mode 100644 src/games/neoninfiltrator/index.html create mode 100644 src/games/neoninfiltrator/thumbnail.svg create mode 100644 src/games/pingpong/index.html create mode 100644 src/games/pingpong/thumbnail.svg create mode 100644 src/games/rockpaperscissors/index.html create mode 100644 src/games/rockpaperscissors/thumbnail.svg create mode 100644 src/games/spaceinvaders/index.html create mode 100644 src/games/spaceinvaders/thumbnail.svg create mode 100644 src/games/tetris/index.html create mode 100644 src/games/tetris/thumbnail.svg create mode 100644 src/games/tiktaktoe/index.html create mode 100644 src/games/tiktaktoe/thumbnail.svg create mode 100644 src/maps/map_renderer.js create mode 100644 src/models/activity_model.js create mode 100644 src/models/agenda_model.js create mode 100644 src/models/audios_model.js create mode 100644 src/models/banking_model.js create mode 100644 src/models/blockchain_model.js create mode 100644 src/models/bookmarking_model.js create mode 100644 src/models/calendars_model.js create mode 100644 src/models/chats_model.js create mode 100644 src/models/cipher_model.js create mode 100644 src/models/courts_model.js create mode 100644 src/models/crypto.js create mode 100644 src/models/cv_model.js create mode 100644 src/models/documents_model.js create mode 100644 src/models/events_model.js create mode 100644 src/models/exportmode_model.js create mode 100644 src/models/favorites_model.js create mode 100644 src/models/fediverse_model.js create mode 100644 src/models/feed_model.js create mode 100644 src/models/forum_model.js create mode 100644 src/models/games_model.js create mode 100644 src/models/images_model.js create mode 100644 src/models/inhabitants_model.js create mode 100644 src/models/jobs_model.js create mode 100644 src/models/larp_model.js create mode 100644 src/models/legacy_model.js create mode 100644 src/models/logs_model.js create mode 100644 src/models/main_models.js create mode 100644 src/models/maps_model.js create mode 100644 src/models/market_model.js create mode 100644 src/models/melody_model.js create mode 100644 src/models/opinions_model.js create mode 100644 src/models/pads_model.js create mode 100644 src/models/panicmode_model.js create mode 100644 src/models/parliament_model.js create mode 100644 src/models/pixelia_model.js create mode 100644 src/models/pm_model.js create mode 100644 src/models/projects_model.js create mode 100644 src/models/reports_model.js create mode 100644 src/models/search_model.js create mode 100644 src/models/shops_model.js create mode 100644 src/models/stats_model.js create mode 100644 src/models/tags_model.js create mode 100644 src/models/tasks_model.js create mode 100644 src/models/tombstone_validator.js create mode 100644 src/models/torrents_model.js create mode 100644 src/models/transfers_model.js create mode 100644 src/models/trending_model.js create mode 100644 src/models/tribe_crypto.js create mode 100644 src/models/tribes_content_model.js create mode 100644 src/models/tribes_model.js create mode 100644 src/models/videos_model.js create mode 100644 src/models/viewer_filters.js create mode 100644 src/models/votes_model.js create mode 100644 src/models/wallet_model.js create mode 100644 src/server/SSB_server.js create mode 100644 src/server/nodemon.json create mode 100644 src/server/package-lock.json create mode 100644 src/server/package.json create mode 100644 src/server/ssb_config.js create mode 100644 src/server/ssb_metadata.js create mode 100644 src/views/AI_view.js create mode 100644 src/views/activity_view.js create mode 100644 src/views/agenda_view.js create mode 100644 src/views/audio_view.js create mode 100644 src/views/banking_views.js create mode 100644 src/views/blockchain_view.js create mode 100644 src/views/bookmark_view.js create mode 100644 src/views/calendars_view.js create mode 100644 src/views/chats_view.js create mode 100644 src/views/cipher_view.js create mode 100644 src/views/clearnet_view.js create mode 100644 src/views/courts_view.js create mode 100644 src/views/cv_view.js create mode 100644 src/views/document_view.js create mode 100644 src/views/event_view.js create mode 100644 src/views/favorites_view.js create mode 100644 src/views/fediverse_view.js create mode 100644 src/views/feed_view.js create mode 100644 src/views/forum_view.js create mode 100644 src/views/games_view.js create mode 100644 src/views/graphos_view.js create mode 100644 src/views/image_view.js create mode 100644 src/views/indexing_view.js create mode 100644 src/views/inhabitants_view.js create mode 100644 src/views/invites_view.js create mode 100644 src/views/jobs_view.js create mode 100644 src/views/larp_view.js create mode 100644 src/views/legacy_view.js create mode 100644 src/views/logs_view.js create mode 100644 src/views/main_views.js create mode 100644 src/views/maps_view.js create mode 100644 src/views/markdown.js create mode 100644 src/views/market_view.js create mode 100644 src/views/melody_view.js create mode 100644 src/views/modules_view.js create mode 100644 src/views/opinions_view.js create mode 100644 src/views/pads_view.js create mode 100644 src/views/parliament_view.js create mode 100644 src/views/peers_view.js create mode 100644 src/views/pixelia_view.js create mode 100644 src/views/pm_view.js create mode 100644 src/views/projects_view.js create mode 100644 src/views/report_view.js create mode 100644 src/views/search_view.js create mode 100644 src/views/settings_view.js create mode 100644 src/views/shops_view.js create mode 100644 src/views/stats_view.js create mode 100644 src/views/tags_view.js create mode 100644 src/views/task_view.js create mode 100644 src/views/torrents_view.js create mode 100644 src/views/transfer_view.js create mode 100644 src/views/trending_view.js create mode 100644 src/views/tribes_view.js create mode 100644 src/views/video_view.js create mode 100644 src/views/vote_view.js create mode 100644 src/views/wallet_view.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..528aea2 --- /dev/null +++ b/README.md @@ -0,0 +1,328 @@ +# 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. + + ![SNH](https://solarnethub.com/git/snh-oasis-logo3.jpg "SolarNET.HuB") + +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. + + ![SNH](https://solarnethub.com/git/snh-oasis-settings.png "SolarNET.HuB") + +But it has others features that are also really interesting, for example: + + + Support for multiple languages. + + ![SNH](https://solarnethub.com/git/snh-oasis-languages.png "SolarNET.HuB") + + + Modularity to set your own environment. + + ![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB") + + + Support for multiple themes (including Mobile Theme). + + ![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB") + ![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB") + ![SNH](https://solarnethub.com/git/snh-matrix-theme.png "SolarNET.HuB") + +And some other nice modules, like for example: + + + A complex Reddit-styled forum system. + + ![SNH](https://solarnethub.com/git/snh-forum.png "SolarNET.HuB") + + + Or a collaborative -offline- maps system. + + ![SNH](https://solarnethub.com/git/snh-maps.jpeg "SolarNET.HuB") + +And much more, that we invite you to discover by yourself ;-) + + ![SNH](https://solarnethub.com/git/snh-games.jpeg "SolarNET.HuB") + +---------- + +## 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). + +Both the codebase and the inhabitants can generate new modules. + +---------- + +## C-AI (collective artificial intelligence) + +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. + + ![SNH](https://solarnethub.com/git/oasis-ai-example2.png "SolarNET.HuB") + +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. + + + https://wiki.solarnethub.com/socialnet/ai + +---------- + +## Parliament + +Oasis contains its own Parliament (Government system). + + ![SNH](https://solarnethub.com/git/oasis-parliament.png "SolarNET.HuB") + +## Courts + +Oasis contains its own Courts (Justice system). + + ![SNH](https://solarnethub.com/git/oasis-courts.png "SolarNET.HuB") + +## ECOin + +Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace. + + ![SNH](https://solarnethub.com/git/oasis-tomatoes-example.png "SolarNET.HuB") + +You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions. + + + 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. + + ![SNH](https://solarnethub.com/git/oasis-banking.png "SolarNET.HuB") + +---------- + +## Carbon Footprinting + +Oasis contains its own carbon footprint meter. + + ![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting.png "SolarNET.HuB") + +All transmissions, stored files, and interactions are measured to understand the environmental impact of the network. + + ![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting2.png "SolarNET.HuB") + +And also of each inhabitant. + + ![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting3.png "SolarNET.HuB") + +---------- + +## L.A.R.P. + +Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses. + + ![SNH](https://solarnethub.com/git/oasis-larp-schema.jpg "SolarNET.HuB") + +The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics. + + + 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 + +---------- + +## Fediverse + +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). + + ![SNH](https://solarnethub.com/git/oasis-fediverse.png "SolarNET.HuB") + +Connect your account from **Settings → Fediverse**, then open it from the **Fediverse** menu. Each network lives in its own space. + + + Mastodon connect guide: [docs/FEDIVERSE/MASTODON/connect.md](docs/FEDIVERSE/MASTODON/connect.md) + +---------- + +## Invite codes (for PUBs and TRIBES): + +Oasis is a TRUSTNET. This means you need an invitation code to enter the PUBs (managed by inhabitants or hacklabs). + +Similarly, TRIBES (groups in Oasis) require an entry code. + + ![SNH](https://solarnethub.com/git/snh-oasis-invites.png "SolarNET.HuB") + +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. + + + https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants + +---------- + +## Architecture: + +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. + + ![SNH](https://solarnethub.com/git/snh-meshnet.png "SolarNET.HuB") + +This means that information is able to distribute across multiple machines, without requiring direct connections between them. + + ![SNH](https://solarnethub.com/git/gossip-graph1.png "SolarNET.HuB") + +Even though Alice and Dan lack a direct connection, they can still exchange feeds: + + ![SNH](https://solarnethub.com/git/gossip-graph2.png "SolarNET.HuB") + +This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB +to reach Alice, and visa-versa. + +---------- + +## Backend: + +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. + + ![SNH](https://solarnethub.com/git/ssb-participants-perspective.png "SolarNET.HuB") + +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))). + + ![SNH](https://solarnethub.com/git/snh-pub-feed.png "SolarNET.HuB") + +Just visit: https://pub.solarnethub.com/ + + ![SNH](https://solarnethub.com/git/snh-pub-laplaza.png "SolarNET.HuB") + +And also you can visit periodically the public statistic of the SNH-PUB: + + ![SNH](https://solarnethub.com/git/snh-pub-stats.png "SolarNET.HuB") + +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 diff --git a/main.js b/main.js new file mode 100644 index 0000000..7cfeff0 --- /dev/null +++ b/main.js @@ -0,0 +1,75 @@ +console.log('[Oasis Mobile] Node.js backend starting...'); + +process.env.HOME = process.env.HOME || require('path').resolve(__dirname, '..', '..'); +process.env.ssb_appname = process.env.ssb_appname || 'ssb'; +process.env.OASIS_MOBILE = '1'; +process.env.CHLORIDE_JS = '1'; + +const path = require('path'); +const fs = require('fs'); + +const tmpDir = path.join(process.env.HOME, 'tmp'); +try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {} +process.env.TMPDIR = tmpDir; +process.env.TMP = tmpDir; +process.env.TEMP = tmpDir; +try { require('os').tmpdir = () => tmpDir; } catch (e) {} + +try { + const dns = require('dns'); + const net = require('net'); + const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4']; + try { dns.setServers(servers); } catch (e) {} + try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {} + + const localResolve = (hostname, family) => { + if (hostname === 'localhost' || hostname === 'localhost.localdomain' || hostname === 'ip6-localhost') { + return family === 6 ? { address: '::1', family: 6 } : { address: '127.0.0.1', family: 4 }; + } + const ipv = net.isIP(hostname); + if (ipv) return { address: hostname, family: ipv }; + return null; + }; + + const safeLookup = function (hostname, options, callback) { + if (typeof options === 'function') { callback = options; options = {}; } + options = options || {}; + const family = options.family === 6 ? 6 : 4; + const direct = localResolve(hostname, family); + if (direct) { + return process.nextTick(() => options.all + ? callback(null, [direct]) + : callback(null, direct.address, direct.family)); + } + 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); + }); + }; + + dns.lookup = safeLookup; + if (dns.promises) { + dns.promises.lookup = (hostname, options) => new Promise((resolve, reject) => { + safeLookup(hostname, options || {}, (err, address, family) => { + if (err) return reject(err); + if (options && options.all) return resolve(address); + resolve({ address, family }); + }); + }); + } +} catch (e) {} + +const backendPath = path.join(__dirname, 'src', 'backend', 'backend.js'); + +console.log('[Oasis] Launching Oasis Social Network Utopia...'); +console.log('[Oasis] Backend path:', backendPath); +console.log('[Oasis] HOME:', process.env.HOME); + +try { + require(backendPath); + console.log('[Oasis] Backend started on port 3000'); +} catch (error) { + console.error('[Oasis] Error starting backend:', error); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..00bb761 --- /dev/null +++ b/package.json @@ -0,0 +1,159 @@ +{ + "name": "@krakenslab/oasis", + "version": "0.8.7", + "description": "Oasis - Social Networking Utopia", + "repository": { + "type": "git", + "url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git" + }, + "license": "AGPL-3.0", + "author": "psy ", + "main": "src/index.js", + "bin": { + "oasis": "npm run start" + }, + "scripts": { + "start": "npm run start:ssb && sleep 10 && npm run start:backend", + "start:backend": "node ../backend/backend.js", + "start:ssb": "node SSB_server.js start &", + "dev": "npm run dev:backend", + "dev:backend": "nodemon", + "postinstall": "node ../../scripts/patch-node-modules.js" + }, + "dependencies": { + "@koa/router": "^13.1.0", + "@open-rpc/client-js": "^1.8.1", + "@xenova/transformers": "^2.17.2", + "abstract-level": "^2.0.1", + "archiver": "^7.0.1", + "axios": "^1.10.0", + "cors": "^2.8.5", + "debug": "^4.3.1", + "dompurify": "^3.3.1", + "env-paths": "^2.2.1", + "epidemic-broadcast-trees": "^9.0.4", + "express": "^5.1.0", + "file-type": "^16.5.4", + "highlight.js": "11.0.0", + "hyperaxe": "^2.0.1", + "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz", + "is-svg": "^4.4.0", + "jsdom": "^28.0.0", + "koa": "^2.7.0", + "koa-body": "^6.0.1", + "koa-mount": "^4.0.0", + "koa-static": "^5.0.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "module-alias": "^2.2.3", + "moment": "^2.30.1", + "multiblob": "^1.13.0", + "multiserver": "^3.3.1", + "multiserver-address": "^1.0.1", + "muxrpc": "^8.0.0", + "muxrpc-validation": "^3.0.2", + "muxrpcli": "^3.1.2", + "node-llama-cpp": "^3.10.0", + "non-private-ip": "^2.2.0", + "open": "^8.4.2", + "openpgp": "^6.3.0", + "packet-stream": "^2.0.6", + "packet-stream-codec": "^1.2.0", + "pdfjs-dist": "^5.2.133", + "pretty-ms": "^7.0.1", + "pull-abortable": "^4.1.1", + "pull-cat": "~1.1.5", + "pull-file": "^1.0.0", + "pull-many": "~1.0.6", + "pull-paramap": "^1.2.2", + "pull-pushable": "^2.2.0", + "pull-sort": "^1.0.2", + "pull-stream": "^3.7.0", + "punycode.js": "^2.3.1", + "qrcode": "^1.5.4", + "secret-stack": "^6.3.1", + "ssb-about": "^2.0.1", + "ssb-autofollow": "^1.1.0", + "ssb-backlinks": "^2.1.1", + "ssb-blobs": "^2.0.1", + "ssb-box": "^1.0.1", + "ssb-caps": "^1.0.1", + "ssb-client": "^4.9.0", + "ssb-config": "^3.4.4", + "ssb-conn": "6.0.3", + "ssb-conn-db": "^1.0.5", + "ssb-conn-hub": "^1.2.0", + "ssb-conn-staging": "^1.0.0", + "ssb-db": "^20.4.1", + "ssb-device-address": "^1.1.6", + "ssb-ebt": "^9.1.2", + "ssb-friend-pub": "^1.0.7", + "ssb-friends": "^5.0.0", + "ssb-gossip": "^1.1.1", + "ssb-invite": "^3.0.3", + "ssb-invite-client": "^1.3.3", + "ssb-keys": "^8.0.0", + "ssb-lan": "^1.0.0", + "ssb-legacy-conn": "^1.0.17", + "ssb-links": "^3.0.10", + "ssb-logging": "^1.0.0", + "ssb-markdown": "^3.6.0", + "ssb-master": "^1.0.3", + "ssb-meme": "^1.1.0", + "ssb-mentions": "^0.5.2", + "ssb-msgs": "^5.2.0", + "ssb-no-auth": "^1.0.0", + "ssb-onion": "^1.0.0", + "ssb-partial-replication": "^3.0.1", + "ssb-plugins": "^1.0.2", + "ssb-private": "^1.1.0", + "ssb-query": "^2.4.5", + "ssb-ref": "^2.16.0", + "ssb-replication-scheduler": "^3.0.0", + "ssb-search": "^1.3.0", + "ssb-server": "file:packages/ssb-server", + "ssb-tangle": "^1.0.1", + "ssb-thread-schema": "^1.1.1", + "ssb-threads": "^10.0.4", + "ssb-unix-socket": "^1.0.0", + "ssb-ws": "^6.2.3", + "util": "^0.12.5", + "yargs": "^17.7.2" + }, + "overrides": { + "caller-path": "^4.0.0", + "highlight.js": "11.0.0", + "@babel/traverse": "7.23.2", + "trim": "0.0.3", + "json5": "2.2.2", + "debug": "^4.3.1", + "postcss": "8.4.31", + "punycode": "2.3.1", + "ejs": "3.1.10", + "babel-traverse": "7.0.0-alpha.1", + "ssb-conn": "6.0.3", + "ssb-ref": "^2.16.0", + "secret-stack": "^6.3.1", + "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz", + "lodash.set": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + }, + "optionalDependencies": { + "fsevents": "^2.3.2", + "sharp": "^0.33.5" + }, + "devDependencies": { + "nodemon": "^3.1.0" + }, + "bugs": { + "url": "https://github.com/epsylon/oasis/issues" + }, + "homepage": "https://github.com/epsylon/oasis", + "directories": { + "doc": "docs" + }, + "keywords": [], + "engines": { + "node": "^10.0.0 || >=12.0.0" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/src/backend/backend.js b/src/backend/backend.js new file mode 100644 index 0000000..d193464 --- /dev/null +++ b/src/backend/backend.js @@ -0,0 +1,8158 @@ +#!/usr/bin/env node +"use strict"; +const path = require("path"); +const fs = require("fs"); +const promisesFs = fs.promises; +const os = require('os'); +const envPaths = require("../server/node_modules/env-paths"); +const {cli} = require("../client/oasis_client"); +const SSBconfig = require('../server/SSB_server.js'); +const moment = require('../server/node_modules/moment'); +const FileType = require('../server/node_modules/file-type'); +const ssbRef = require("../server/node_modules/ssb-ref"); +const defaultConfig = {}; +const defaultConfigFile = path.join(envPaths("oasis", { suffix: "" }).config, "/default.json"); +let haveConfig = false; +try { + Object.assign(defaultConfig, JSON.parse(fs.readFileSync(defaultConfigFile, "utf8"))); + haveConfig = true; +} catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${defaultConfigFile}`); throw e; } } +const config = cli(defaultConfig, defaultConfigFile); +if (config.debug) { + process.env.DEBUG = "oasis,oasis:*"; +} +const axiosMod = require('../server/node_modules/axios'); +const axios = axiosMod.default || axiosMod; +const { spawn } = require('child_process'); +let fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer; +try { + ({ fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer } = require('../AI/buildAIContext.js')); +} catch (e) { + const noop = () => {}; + fieldsForSnippet = noop; + buildContext = noop; + clip = (t) => t; + publishExchange = noop; + publishExchangeVote = noop; + getBestTrainedAnswer = () => null; +} +let aiStarted = false; +function startAI() { + if (aiStarted) return; + aiStarted = true; + try { + const aiProcess = spawn('node', [path.resolve(__dirname, '../AI/ai_service.mjs')], { detached: true, stdio: 'ignore' }); + aiProcess.unref(); + } catch (e) {} +} +const ADDR_PATH = path.join(__dirname, '..', 'configs', 'wallet-addresses.json'); +const readAddrMap = () => { try { return JSON.parse(fs.readFileSync(ADDR_PATH, 'utf8')); } catch { return {}; } }; +const writeAddrMap = (map) => { fs.mkdirSync(path.dirname(ADDR_PATH), { recursive: true }); fs.writeFileSync(ADDR_PATH, JSON.stringify(map, null, 2)); }; + +//parliament model +let electionInFlight = null; +const ensureTerm = async () => { + const cur = await parliamentModel.getCurrentTerm().catch(() => null); + if (cur) return cur; + if (electionInFlight) return electionInFlight; + electionInFlight = parliamentModel.resolveElection().catch(() => null).finally(() => { electionInFlight = null; }); + return electionInFlight; +}; + +let sweepInFlight = null; +const runSweepOnce = async () => { + if (sweepInFlight) return sweepInFlight; + sweepInFlight = parliamentModel.sweepProposals().catch(e => console.error('sweepProposals failed:', e)).finally(() => { sweepInFlight = null; }); + return sweepInFlight; +}; + +async function buildState(filter) { + const f = (filter || 'government').toLowerCase(); + await ensureTerm(); + await runSweepOnce(); + const [govCard, candidatures, proposals, canPropose, laws, historical] = await Promise.all([ + parliamentModel.getGovernmentCard(), + parliamentModel.listCandidatures('OPEN'), + parliamentModel.listProposalsCurrent(), + parliamentModel.canPropose(), + parliamentModel.listLaws(), + parliamentModel.listHistorical() + ]); + return { filter: f, governmentCard: govCard, candidatures, proposals, canPropose, laws, historical }; +} + +function pickLeader(cands = []) { + if (!cands.length) return null; + return [...cands].sort((a, b) => { + const d = (x, y) => y - x; + return d(Number(a.votes||0), Number(b.votes||0)) || d(Number(a.karma||0), Number(b.karma||0)) || + (Number(a.profileSince||0) - Number(b.profileSince||0)) || + (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) || + String(a.targetId).localeCompare(String(b.targetId)); + })[0]; +} + +async function buildLeaderMeta(leader) { + if (!leader) return null; + if (leader.targetType === 'inhabitant') { + let name = null, image = null, description = null; + try { name = about?.name && await about.name(leader.targetId); } catch {} + try { image = about?.image && await about.image(leader.targetId); } catch {} + try { description = about?.description && await about.description(leader.targetId); } catch {} + const imgId = typeof image === 'string' ? image : image?.link || image?.url || null; + return { isTribe: false, name: name || leader.targetId, avatarUrl: imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-avatar.png', bio: typeof description === 'string' ? description : '' }; + } + let tribe = null; + try { tribe = await tribesModel.getTribeById(leader.targetId); } catch {} + const imgId = tribe?.image || null; + return { isTribe: true, name: leader.targetTitle || tribe?.title || tribe?.name || leader.targetId, avatarUrl: imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-tribe.png', bio: tribe?.description || '' }; +} + +const safeArr = v => Array.isArray(v) ? v : []; +const safeText = v => String(v || '').trim(); +const safeReturnTo = (ctx, fb, ap) => { const rt = ctx.request?.body?.returnTo || ctx.query?.returnTo; return typeof rt === 'string' && ap?.some(p => rt.startsWith(p)) ? rt : fb; }; + +// anti-injections +const { stripDangerousTags, sanitizeHtml } = require('./sanitizeHtml'); + +const sharedState = require('../configs/shared-state'); + +module.exports = stripDangerousTags; + +const sanitizeMsgText = (msg) => { + if (!msg?.value?.content) return msg; + const c = msg.value.content; + if (typeof c.text === 'string') c.text = stripDangerousTags(c.text); + if (typeof c.description === 'string') c.description = stripDangerousTags(c.description); + if (typeof c.title === 'string') c.title = stripDangerousTags(c.title); + if (typeof c.contentWarning === 'string') c.contentWarning = stripDangerousTags(c.contentWarning); + return msg; +}; +const sanitizeMessages = (msgs) => Array.isArray(msgs) ? msgs.map(sanitizeMsgText) : msgs; + +const parseBool01 = v => String(Array.isArray(v) ? v[v.length - 1] : v || '') === '1'; +const sendErrorPage = (ctx, message, { title, status } = {}) => { + const { errorView } = require('../views/main_views'); + const ref = ctx.request.header.referer; + let backHref = '/'; + try { + if (ref) { + const u = new URL(ref); + if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) { + backHref = u.pathname + u.search + u.hash; + } + } + } catch (_) {} + if (status) ctx.status = status; + ctx.type = 'html'; + ctx.body = errorView({ title, message, backHref }); +}; + +const safeRefererRedirect = (ctx, fallback = '/') => { + const ref = ctx.request.header.referer; + if (!ref) { ctx.redirect(fallback); return; } + try { + const u = new URL(ref); + if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== ctx.host) { + ctx.redirect(fallback); + return; + } + ctx.redirect(u.pathname + u.search + u.hash); + } catch (_) { + ctx.redirect(fallback); + } +}; +const isLoopbackRequest = (ctx) => { + const raw = String((ctx.request && ctx.request.ip) || ctx.ip || (ctx.socket && ctx.socket.remoteAddress) || ''); + const ip = raw.replace(/^::ffff:/, ''); + return ip === '127.0.0.1' || ip === '::1' || ip === 'localhost'; +}; +const checkMod = (ctx, mod) => { + const cfg = getConfig(); + const serverValue = cfg.modules?.[mod]; + if (serverValue === 'off') return false; + const cookieValue = ctx.cookies.get(mod); + if (cookieValue) return cookieValue === 'on'; + return serverValue === 'on' || serverValue === undefined; +}; +const getViewerId = () => SSBconfig?.config?.keys?.id || SSBconfig?.keys?.id; + +const _carbonCache = new Map(); +const CARBON_TTL_MS = 5 * 60 * 1000; +async function getCarbonGramsForFeed(feedId) { + if (!feedId) return 0; + const now = Date.now(); + const cached = _carbonCache.get(feedId); + if (cached && (now - cached.ts) < CARBON_TTL_MS) return cached.grams; + try { + const pullMod = require("../server/node_modules/pull-stream"); + const ssbX = await cooler.open(); + const bytes = await new Promise((resolve) => { + let total = 0; + pullMod( + ssbX.createUserStream({ id: feedId }), + pullMod.drain( + (m) => { try { total += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} }, + () => resolve(total) + ) + ); + }); + const grams = (bytes / (1024 * 1024)) * 0.095; + _carbonCache.set(feedId, { ts: now, grams }); + return grams; + } catch (_) { + return 0; + } +} + +function synthesizeMelodyWav(sequence) { + const sampleRate = 8000; + const fadeMs = 1; + const gapMs = 3; + const speed = 3; + const pilotMs = 80; + const pilotFreq = 807; + const pilotSamples = Math.floor(pilotMs * sampleRate / 1000); + const fadeSamplesShort = Math.floor(fadeMs * sampleRate / 1000); + + let totalSamples = pilotSamples; + for (const n of sequence) { + totalSamples += Math.floor((((n.durMs || 250) / speed) + gapMs) * sampleRate / 1000); + } + const stegoMaxBytes = 2 + 2 + 4096; + const minSamples = stegoMaxBytes * 8; + if (totalSamples < minSamples) totalSamples = minSamples; + const dataLen = totalSamples * 2; + const buf = Buffer.alloc(44 + dataLen); + buf.write('RIFF', 0); + buf.writeUInt32LE(36 + dataLen, 4); + buf.write('WAVE', 8); + buf.write('fmt ', 12); + buf.writeUInt32LE(16, 16); + buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); + buf.writeUInt32LE(sampleRate, 24); + buf.writeUInt32LE(sampleRate * 2, 28); + buf.writeUInt16LE(2, 32); + buf.writeUInt16LE(16, 34); + buf.write('data', 36); + buf.writeUInt32LE(dataLen, 40); + + let off = 44; + const writeSquare = (freq, samples, fadeSamples) => { + if (samples <= 0) return; + const period = sampleRate / Math.max(1, freq); + for (let i = 0; i < samples; i++) { + let env = 0.45; + if (fadeSamples > 0) { + if (i < fadeSamples) env *= i / fadeSamples; + else if (i > samples - fadeSamples) env *= (samples - i) / fadeSamples; + } + const phase = (i % period) / period; + const s = (phase < 0.5 ? 1 : -1) * env; + buf.writeInt16LE((s * 32767) | 0, off); + off += 2; + } + }; + + writeSquare(pilotFreq, pilotSamples, fadeSamplesShort); + + for (const n of sequence) { + const freq = Number(n.freq) || 440; + const noteSamples = Math.floor(((n.durMs || 250) / speed) * sampleRate / 1000); + const gapSamples = Math.floor(gapMs * sampleRate / 1000); + const fadeSamples = Math.min(noteSamples >> 3, fadeSamplesShort); + writeSquare(freq, noteSamples, fadeSamples); + for (let i = 0; i < gapSamples; i++) { + buf.writeInt16LE(0, off); + off += 2; + } + } + return buf; +} + +async function listAvailableBlockIds(ids) { + const list = Array.isArray(ids) ? ids.filter(id => typeof id === 'string' && id.length > 0) : []; + const out = new Set(); + if (list.length === 0) return out; + try { + const ssbClient2 = await cooler.open(); + await Promise.all(list.map(id => new Promise((resolve) => { + try { + ssbClient2.get(id, (err, msg) => { + if (!err && msg) out.add(id); + resolve(); + }); + } catch (_) { resolve(); } + }))); + } catch (_) {} + return out; +} + +async function runTranscode(audio) { + if (!audio || !audio.url) return null; + try { + const ssbClient2 = await cooler.open(); + const buf = await new Promise((resolve, reject) => { + pull( + ssbClient2.blobs.get(audio.url), + pull.collect((err, chunks) => err ? reject(err) : resolve(Buffer.concat(chunks))) + ); + }); + const raw = melodyModel.extractTextFromWav(buf); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return { + id: typeof parsed.id === 'string' ? parsed.id : null, + ts: Number.isFinite(parsed.ts) ? Number(parsed.ts) : null, + msg: typeof parsed.msg === 'string' ? parsed.msg : '' + }; + } + return { id: null, ts: null, msg: String(raw) }; + } catch (_) { + return { id: null, ts: null, msg: String(raw) }; + } + } catch (_) { + return null; + } +} + +let _localLanIPv4 = null; +const getLocalLanIPv4 = () => { + if (_localLanIPv4) return _localLanIPv4; + try { + const ifaces = os.networkInterfaces(); + for (const name of Object.keys(ifaces)) { + for (const info of (ifaces[name] || [])) { + if (info && info.family === 'IPv4' && !info.internal) { + _localLanIPv4 = info.address; + return _localLanIPv4; + } + } + } + } catch (_) {} + return null; +}; +const resolveExternalBaseUrl = (ctx) => { + const protocol = ctx.protocol || 'http'; + const rawHost = String(ctx.host || '').trim(); + const [hostname, port] = rawHost.split(':'); + const loopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + if (loopback) { + const lan = getLocalLanIPv4(); + if (lan) return `${protocol}://${lan}${port ? ':' + port : ''}`; + } + return `${protocol}://${rawHost}`; +}; +const getUserTribeIds = async (uid) => { + const allTribes = await tribesModel.listAll().catch(() => []); + const memberTribes = allTribes.filter(t => t.members.includes(uid)); + const idSets = await Promise.all(memberTribes.map(t => tribesModel.getChainIds(t.id).catch(() => [t.id]))); + return new Set(idSets.flat()); +}; +const refreshInboxCount = async (messagesOpt) => { + const messages = messagesOpt || await pmModel.listAllPrivate(); + const userId = getViewerId(); + const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId); + const filtered = messages.filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true); + sharedState.setInboxCount(filtered.filter(isToUser).length); +}; + +const PM_CRYPTER_MAX = 4600; + +const buildInboxMessages = async () => { + let messages = sanitizeMessages(await pmModel.listAllPrivate()); + const cfgNow = getConfig(); + if (cfgNow.pmVisibility === 'mutuals') { + const viewer = getViewerId(); + const mutualCache = new Map(); + const isMutual = async (id) => { + if (id === viewer) return true; + if (mutualCache.has(id)) return mutualCache.get(id); + let rel; + try { rel = await friend.getRelationship(id); } catch (e) { rel = null; } + const m = !!(rel && rel.following && rel.followsMe); + mutualCache.set(id, m); + return m; + }; + const filtered = []; + for (const msg of messages) { + const author = msg?.value?.author || msg?.author; + if (author === viewer) { filtered.push(msg); continue; } + if (await isMutual(author)) filtered.push(msg); + } + messages = filtered; + } + return messages; +}; +const mediaFavorites = require("./media-favorites.js"); +const customStyleFile = path.join(envPaths("oasis", { suffix: "" }).config, "/custom-style.css"); +let haveCustomStyle = false; +try { fs.readFileSync(customStyleFile, "utf8"); haveCustomStyle = true; } catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${customStyleFile}`); throw e; } } +const { get } = require("node:http"); +const debug = require("../server/node_modules/debug")("oasis"); +const log = (formatter, ...args) => { + const isDebugEnabled = debug.enabled; + debug.enabled = true; + debug(formatter, ...args); + debug.enabled = isDebugEnabled; +}; +delete config._; +delete config.$0; +const { host } = config; +const { port } = config; +const url = `http://${host}:${port}`; +debug("Current configuration: %O", config); +debug(`You can save the above to ${defaultConfigFile} to make \ +these settings the default. See the readme for details.`); +const { saveConfig, getConfig } = require('../configs/config-manager'); +const configPath = path.join(__dirname, '../configs/oasis-config.json'); +const oasisCheckPath = "/.well-known/oasis"; +process.on("uncaughtException", function (err) { + if (err["code"] === "EADDRINUSE") { + get(url + oasisCheckPath, (res) => { + let rawData = ""; + res.on("data", (chunk) => { + rawData += chunk; + }); + res.on("end", () => { + log(rawData); + if (rawData === "oasis") { + log(`Oasis is already running on host ${host} and port ${port}`); + if (config.open === true && process.env.OASIS_MOBILE !== '1') { + log("Opening link to existing instance of Oasis"); + open(url); + } else { + log( + "Not opening your browser because opening is disabled by your config" + ); + } + process.exit(0); + } else { + throw new Error(`Another server is already running at ${url}. +It might be another copy of Oasis or another program on your computer. +You can run Oasis on a different port number with this option: + oasis --port ${config.port + 1} +Alternatively, you can set the default port in ${defaultConfigFile} with: + { + "port": ${config.port + 1} + } +`); + } + }); + }); + } else if (err && (err.name === 'OpenError' || (typeof err.message === 'string' && /Resource temporarily unavailable/i.test(err.message) && /\.ssb\/.*LOCK/i.test(err.message)))) { + console.log(""); + console.log("Another Oasis instance is already running on this machine. Close the other instance (or kill the process) and try again."); + console.log(""); + process.exit(1); + } else { + console.log(""); + console.log("Oasis traceback (share below content with devs to report!):"); + console.log("==========================================================="); + console.log(err); + console.log(""); + } +}); +process.argv = []; +const http = require("../client/middleware"); +const {koaBody} = require("../server/node_modules/koa-body"); +const { nav, ul, li, a, form, button, div, section, h2, p } = require("../server/node_modules/hyperaxe"); +const open = require("../server/node_modules/open"); +const pull = require("../server/node_modules/pull-stream"); +const koaRouter = require("../server/node_modules/@koa/router"); +const ssbMentions = require("../server/node_modules/ssb-mentions"); +const isSvg = require('../server/node_modules/is-svg'); +const { isFeed, isMsg, isBlob } = require("../server/node_modules/ssb-ref"); +const ssb = require("../client/gui"); +const router = new koaRouter(); + +async function fetchProfileItems(feedId, prefs) { + const MAX_PER_SECTION = 5; + const items = { shops: [], jobs: [], events: [], projects: [], posts: [], audios: [], videos: [], images: [], documents: [], torrents: [] }; + const tasks = []; + if (prefs.profileShops) tasks.push((async () => { + try { + const shops = await shopsModel.listAll({ filter: 'all' }).catch(() => []); + items.shops = (shops || []).filter(s => s.author === feedId && String(s.visibility || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileJobs) tasks.push((async () => { + try { + const jobs = await jobsModel.listJobs('ALL', feedId).catch(() => []); + items.jobs = (jobs || []).filter(j => j.author === feedId && String(j.status || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileEvents) tasks.push((async () => { + try { + const events = await eventsModel.listAll(feedId, 'all').catch(() => []); + items.events = (events || []).filter(e => e.organizer === feedId && String(e.status || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileProjects) tasks.push((async () => { + try { + const projects = await projectsModel.listProjects('ALL').catch(() => []); + items.projects = (projects || []).filter(p => p.author === feedId && String(p.status || '').toUpperCase() !== 'CANCELLED').slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profilePosts) tasks.push((async () => { + try { + const ssbX = await cooler.open(); + items.posts = await new Promise((resolve) => { + try { + pull( + ssbX.createUserStream({ id: feedId, reverse: true, limit: 200 }), + pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'post' && !m.value.content.root), + pull.collect((err, arr) => { + if (err || !Array.isArray(arr)) return resolve([]); + resolve(arr.slice(0, MAX_PER_SECTION)); + }) + ); + } catch (_) { resolve([]); } + }); + } catch (_) {} + })()); + if (prefs.profileAudios) tasks.push((async () => { + try { + const audios = await audiosModel.listAll('all').catch(() => []); + items.audios = (audios || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileVideos) tasks.push((async () => { + try { + const videos = await videosModel.listAll('all').catch(() => []); + items.videos = (videos || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileImages) tasks.push((async () => { + try { + const images = await imagesModel.listAll('all').catch(() => []); + items.images = (images || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileDocuments) tasks.push((async () => { + try { + const documents = await documentsModel.listAll('all').catch(() => []); + items.documents = (documents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileTorrents) tasks.push((async () => { + try { + const torrents = await torrentsModel.listAll('all').catch(() => []); + items.torrents = (torrents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + if (prefs.profileBookmarks) tasks.push((async () => { + try { + const bookmarks = await bookmarksModel.listAll('all').catch(() => []); + items.bookmarks = (bookmarks || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION); + } catch (_) {} + })()); + await Promise.all(tasks); + return items; +} + +const extractMentions = async (text) => { + const mentions = ssbMentions(text) || []; + const resolvedMentions = await Promise.all(mentions.map(async (mention) => { + const name = mention.name || await about.name(mention.link); + return { + link: mention.link, + name: name || 'Anonymous', + }; + })); + return resolvedMentions; +}; +const cooler = ssb({ offline: config.offline, port: config.port, host: config.host, isPublic: config.public }); +const models = require("../models/main_models"); +const { about, blob, friend, meta, post, vote, spreads, lifetime } = models({ + cooler, + isPublic: config.public, +}); +const { handleBlobUpload, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js'); +const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : null; +const exportmodeModel = require('../models/exportmode_model'); +const panicmodeModel = require('../models/panicmode_model'); +const cipherModel = require('../models/cipher_model'); +const legacyModel = require('../models/legacy_model'); +const walletModel = require('../models/wallet_model') +const pmModel = require('../models/pm_model')({ cooler, isPublic: config.public }); +const bookmarksModel = require("../models/bookmarking_model")({ cooler, isPublic: config.public }); +const opinionsModel = require('../models/opinions_model')({ cooler, isPublic: config.public }); +const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.public, pmModel }); +const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public }); +const ssbConfig = require('../server/ssb_config'); +const tribeCrypto = require('../models/crypto')(ssbConfig.path, 'tribes'); +const chatCrypto = require('../models/crypto')(ssbConfig.path, 'chats'); +const padCrypto = require('../models/crypto')(ssbConfig.path, 'pads'); +const mapCrypto = require('../models/crypto')(ssbConfig.path, 'maps'); +const calendarCrypto = require('../models/crypto')(ssbConfig.path, 'calendars'); +const eventCrypto = require('../models/crypto')(ssbConfig.path, 'events'); +const forumCrypto = require('../models/crypto')(ssbConfig.path, 'forum'); +const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto }); +const eventsModel = require('../models/events_model')({ cooler, isPublic: config.public, tribeCrypto, eventCrypto, tribesModel }); +const larpModel = require('../models/larp_model')({ cooler, tribesModel, tribeCrypto }); +const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); +const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public }); +const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public }); +const calendarsModel = require('../models/calendars_model')({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }); +const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public }); +const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public }); +const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public }); +const imagesModel = require("../models/images_model")({ cooler, isPublic: config.public }); +const audiosModel = require("../models/audios_model")({ cooler, isPublic: config.public }); +const torrentsModel = require("../models/torrents_model")({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); +const videosModel = require("../models/videos_model")({ cooler, isPublic: config.public }); +const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public }); +const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public }); +const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); +const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }); +const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); +const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); +const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); +const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel, padsModel }); +const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public }); +const melodyModel = require('../models/melody_model')({ cooler }); +const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto }); +const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public, tribeCrypto, forumCrypto }); +const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto }); +const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto }); +const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, chatCrypto, tribesModel }); +const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public }); +const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }); +const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public, tribeCrypto, mapCrypto, tribesModel }); +const gamesModel = require('../models/games_model')({ cooler }); +const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public }); +const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }); +const logsModel = require("../models/logs_model")({ cooler }); +const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } }); +const fediverseModel = require('../models/fediverse_model')({ isPublic: config.public }); +const { renderGovernance: renderTribeGovernance } = require('../views/tribes_view'); +const viewerFilters = require('../models/viewer_filters'); + +const scanPendingFollows = async (viewerId) => { + if (!viewerId) return; + if (!viewerFilters.isFrictionActive()) return; + const pullStream = require('../server/node_modules/pull-stream'); + const ssbClient = await cooler.open(); + const limit = getConfig().ssbLogStream?.limit || 1000; + const rows = await new Promise((res, rej) => { + pullStream( + ssbClient.createLogStream({ reverse: true, limit }), + pullStream.collect((err, arr) => err ? rej(err) : res(arr || [])) + ); + }); + const accepted = new Set(viewerFilters.loadAccepted()); + const pendingIds = new Set(viewerFilters.listPending().map(x => x.followerId)); + for (const msg of rows) { + const c = msg.value?.content; + if (!c || c.type !== 'contact') continue; + if (c.contact !== viewerId) continue; + if (c.following !== true) continue; + const author = msg.value?.author; + if (!author || author === viewerId) continue; + if (accepted.has(author)) continue; + if (pendingIds.has(author)) continue; + viewerFilters.enqueuePending(author); + pendingIds.add(author); + } +}; + +const { section: hSection } = require('../server/node_modules/hyperaxe'); + +const renderPendingFollows = (items) => { + const { template: tpl, i18n: i18nLocal } = require('../views/main_views'); + const { div, h2, p, form, button, input, ul, li, span, a } = require('../server/node_modules/hyperaxe'); + return tpl( + i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests', + hSection( + div({ class: 'tags-header' }, + h2(i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests'), + p(i18nLocal.pmMutualNotice || '') + ), + (!Array.isArray(items) || items.length === 0) + ? p('—') + : ul({}, items.map(it => + li({}, + span({ style: 'font-weight:bold' }, it.name || it.followerId), + ' — ', + span({ class: 'muted' }, it.followerId.slice(0, 14) + '…'), + ' ', + form({ method: 'POST', action: '/inhabitants/follow/accept', style: 'display:inline' }, + input({ type: 'hidden', name: 'followerId', value: it.followerId }), + button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingAccept || 'Accept') + ), + ' ', + form({ method: 'POST', action: '/inhabitants/follow/reject', style: 'display:inline' }, + input({ type: 'hidden', name: 'followerId', value: it.followerId }), + button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingReject || 'Reject') + ) + ) + )) + ) + ); +}; + +const makeCtxMutualCache = () => { + const cache = new Map(); + const frictionActive = viewerFilters.isFrictionActive(); + return async (otherId) => { + if (!otherId) return false; + if (cache.has(otherId)) return cache.get(otherId); + let rel; + try { rel = await friend.getRelationship(otherId); } catch (e) { rel = null; } + const basic = !!(rel && rel.following && rel.followsMe); + const mutual = frictionActive ? (basic && viewerFilters.isAccepted(otherId)) : basic; + cache.set(otherId, mutual); + return mutual; + }; +}; + +const extractItemAuthor = (item) => { + if (!item) return null; + if (typeof item === 'string') return null; + if (item.value && item.value.author) return item.value.author; + if (item.author) return item.author; + if (item.feed) return item.feed; + if (item.organizer) return item.organizer; + if (item.proposer) return item.proposer; + if (item.owner) return item.owner; + if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id; + return null; +}; + +const extractItemTribeId = (item) => { + if (!item || typeof item !== 'object') return null; + if (item.tribeId) return item.tribeId; + if (item.value && item.value.content && item.value.content.tribeId) return item.value.content.tribeId; + if (item.content && item.content.tribeId) return item.content.tribeId; + return null; +}; + +const getViewerTribeAccessSets = async (userId) => { + if (!userId) return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() }; + try { + const all = await tribesModel.listAll(); + const memberOf = new Set(); + const createdBy = new Set(); + const privateNotAccessible = new Set(); + for (const t of all) { + const isMember = Array.isArray(t.members) && t.members.includes(userId); + const isCreator = t.author === userId; + if (isCreator) { createdBy.add(t.id); memberOf.add(t.id); } + else if (isMember) memberOf.add(t.id); + const ancestryPrivate = await (async () => { + try { const eff = await tribesModel.getEffectiveStatus(t.id); return eff.isPrivate; } catch (e) { return !!t.isAnonymous; } + })(); + if (ancestryPrivate && !isMember && !isCreator) privateNotAccessible.add(t.id); + } + return { memberOf, createdBy, privateNotAccessible }; + } catch (e) { + return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() }; + } +}; + +const applyListFilters = async (items, ctx, opts = {}) => { + if (!Array.isArray(items)) return items; + const cfg = getConfig(); + const viewer = getViewerId(); + const wishMutuals = cfg.wish === 'mutuals'; + let out = items; + if (!opts.skipTribeAccess) { + const { memberOf, createdBy, privateNotAccessible } = await getViewerTribeAccessSets(viewer); + out = out.filter(it => { + const tid = extractItemTribeId(it); + if (!tid) return true; + if (memberOf.has(tid) || createdBy.has(tid)) return true; + if (privateNotAccessible.has(tid)) return false; + return true; + }); + } + if (wishMutuals && !opts.skipMutual) { + const isMutual = makeCtxMutualCache(); + const filtered = []; + for (const it of out) { + const a = extractItemAuthor(it); + if (!a || a === viewer) { filtered.push(it); continue; } + if (await isMutual(a)) filtered.push(it); + } + out = filtered; + } + return out; +}; +const enrichItemLifetime = async (item, opts = {}) => { + if (!item) return item; + try { + const key = opts.key ?? (item.id || item.key || item.rootId); + const author = opts.author ?? (item.author || item.organizer || item.createdBy || item.seller || item.from); + const createdAt = opts.createdAt ?? item.createdAt; + item.lifetime = await lifetime.forContent({ key, author, createdAt }); + } catch (_) {} + return item; +}; +const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto }); +tribesModel.processIncomingKeys().then(async () => { + try { + const viewerId = getViewerId(); + const mine = (await tribesModel.listAll()).filter(t => t.author === viewerId); + for (const t of mine) { + await tribesModel.ensureTribeKeyDistribution(t.id).catch(() => {}); + await tribesModel.ensureFollowTribeMembers(t.id).catch(() => {}); + } + await tribesModel.pruneOrphanKeys().catch(() => {}); + } catch (_) {} +}).catch(err => { + if (config.debug) console.error('tribe-keys scan error:', err.message); +}); +courtsModel.processIncomingCourtsKeys().catch(err => { + if (config.debug) console.error('courts-keys scan error:', err.message); +}); +const getVoteComments = async (voteId) => { + const raw = await post.topicComments(voteId); + return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId) + .sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0)); +}; +const enrichWithComments = async (items, idKey = 'id') => { + await Promise.all(items.map(async x => { x.commentCount = (await getVoteComments(x[idKey] || x.key || x.rootId)).length; })); + return items; +}; +const enrichMsgSize = async (items, idKey = 'id') => { + try { + const ssbX = await cooler.open(); + await Promise.all((items || []).map(async (x) => { + const id = x && (x[idKey] || x.key || x.rootId); + if (!id) return; + try { + const raw = await new Promise((resolve) => ssbX.get(id, (err, v) => resolve(err ? null : v))); + if (raw) x.msgSize = Buffer.byteLength(JSON.stringify(raw), 'utf8'); + } catch (_) {} + })); + } catch (_) {} + return items; +}; +const withCount = (item, comments) => ({ ...item, commentCount: comments.length }); +const resolveMapUrl = async (mapUrl) => { + if (!mapUrl) return null; + try { + const mapKey = decodeURIComponent(String(mapUrl).replace(/^\/maps\//, '')); + return await mapsModel.getMapById(mapKey, null); + } catch (_) { return null; } +}; + +const mediaResolvers = { + images: id => imagesModel.resolveRootId(id), + audios: id => audiosModel.resolveRootId(id), + videos: id => videosModel.resolveRootId(id), + documents: id => documentsModel.resolveRootId(id), + bookmarks: id => bookmarksModel.resolveRootId(id), + shops: id => shopsModel.resolveRootId(id), + chats: id => chatsModel.resolveRootId(id), + maps: id => mapsModel.resolveRootId(id), + pads: id => padsModel.resolveRootId(id), + calendars: id => calendarsModel.resolveRootId(id), + torrents: id => torrentsModel.resolveRootId(id) +}; +const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', calendars: 'calendarsMod', torrents: 'torrentsMod' }; +const favAction = async (ctx, kind, action) => { + if (!checkMod(ctx, mediaModCheck[kind])) { ctx.redirect('/modules'); return; } + try { + const rootId = await mediaResolvers[kind](ctx.params.id); + if (rootId) await mediaFavorites[action + 'Favorite'](kind, rootId); + } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`])); +}; +const commentAction = async (ctx, kind, idParam) => { + const modKey = mediaModCheck[kind]; + if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } + const itemId = ctx.params[idParam]; + let text = stripDangerousTags((ctx.request.body.text || '').trim()); + const rt = safeReturnTo(ctx, `/${kind}/${encodeURIComponent(itemId)}`, [`/${kind}`]); + const blobMarkdown = await handleBlobUpload(ctx, 'blob'); + if (blobMarkdown) text += blobMarkdown; + if (!text) { ctx.redirect(rt); return; } + await post.publish({ text, root: itemId, dest: itemId }); + ctx.redirect(rt); +}; +const opinionModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel }; +const deleteModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel }; +const opinionAction = async (ctx, kind, idParam) => { + const modKey = mediaModCheck[kind]; + if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } + await opinionModels[kind].createOpinion(ctx.params[idParam], ctx.params.category); + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`])); +}; +const deleteAction = async (ctx, kind, deleteFn = 'delete' + kind.charAt(0).toUpperCase() + kind.slice(1, -1) + 'ById') => { + const modKey = mediaModCheck[kind]; + if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } + await deleteModels[kind][deleteFn](ctx.params.id); + ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`])); +}; + +const mediaCreateModels = { audios: audiosModel, videos: videosModel }; +const mediaCreateAction = async (ctx, kind) => { + const modKey = mediaModCheck[kind]; + if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } + const blob = await handleBlobUpload(ctx, kind.slice(0, -1)); + const { tags, title, description, mapUrl } = ctx.request.body; + await mediaCreateModels[kind][`create${kind.charAt(0).toUpperCase()}${kind.slice(1, -1)}`](blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), stripDangerousTags(mapUrl || "")); + ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=all`, [`/${kind}`])); +}; +const mediaUpdateAction = async (ctx, kind) => { + const modKey = mediaModCheck[kind]; + if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } + const { tags, title, description, mapUrl } = ctx.request.body; + const singular = kind.slice(0, -1); + const blob = ctx.request.files?.[singular] ? await handleBlobUpload(ctx, singular) : null; + await mediaCreateModels[kind][`update${kind.charAt(0).toUpperCase()}${kind.slice(1, -1)}ById`](ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), stripDangerousTags(mapUrl || "")); + ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`])); +}; +const qf = (ctx, def = 'all') => ctx.query.filter || def; +const qp = (ctx, def = 1) => Math.max(1, parseInt(ctx.query.page) || def); +const dedupeLarpHouseTribes = (list) => { + const arr = Array.isArray(list) ? list : []; + const earliest = new Map(); + for (const t of arr) { + const larpTag = (Array.isArray(t.tags) ? t.tags : []).find(x => String(x).startsWith('larp-')); + if (!larpTag) continue; + const ts = Number(Date.parse(t.createdAt || '')) || 0; + const prev = earliest.get(larpTag); + if (!prev || ts < prev.ts) earliest.set(larpTag, { id: t.id, ts }); + } + if (!earliest.size) return arr; + return arr.filter(t => { + const larpTag = (Array.isArray(t.tags) ? t.tags : []).find(x => String(x).startsWith('larp-')); + if (!larpTag) return true; + return earliest.get(larpTag).id === t.id; + }); +}; +about._startNameWarmup(); +// Route-triggered author-name resolution: warms nameCache with the ACTUAL name +// for each feed id about to be rendered, so every userLink() date·author footer +// (and comments) shows @name instead of the raw oasis id. Call before rendering. +const FEED_RE = /^@.+\.ed25519$/; +const AUTHOR_FIELDS = ['author', 'from', 'to', 'organizer', 'proposer', 'seller', 'recipient', 'judgeId', 'accuser', 'respondentId', 'createdBy']; +const warmNames = async (ids) => { + const uniq = [...new Set((ids || []).filter(v => v && FEED_RE.test(String(v))).map(String))]; + if (!uniq.length) return; + await Promise.all(uniq.slice(0, 600).map(fid => about.name(fid).catch(() => {}))); +}; +const collectAuthorIds = (items) => { + const out = []; + for (const it of (Array.isArray(items) ? items : [items])) { + if (!it) continue; + if (it.author) out.push(it.author); + const c = it.value?.content || it.content || it; + if (c && typeof c === 'object') for (const f of AUTHOR_FIELDS) if (c[f]) out.push(c[f]); + } + return out; +}; +const warmAuthorNames = async (...lists) => { + try { await warmNames(lists.flatMap(l => collectAuthorIds(l))); } catch (_) {} +}; +const OASIS_VERSION = (() => { try { return String(require('../server/package.json').version || ''); } catch (_) { return ''; } })(); +const getOasisVersion = async (feedId) => { + if (!feedId) return null; + try { + const ssb = await cooler.open(); + return await new Promise((res) => pull( + ssb.createUserStream({ id: feedId, reverse: true }), + pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'oasisVersion'), + pull.take(1), + pull.collect((e, a) => res(e || !a || !a.length ? null : (a[0].value.content.version || null))) + )); + } catch (_) { return null; } +}; +(async () => { + try { + const ssb = await cooler.open(); + const mine = await getOasisVersion(ssb.id); + if (OASIS_VERSION && mine !== OASIS_VERSION) { + ssb.publish({ type: 'oasisVersion', version: OASIS_VERSION, updatedAt: new Date().toISOString() }, () => {}); + } + } catch (_) {} +})(); +async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) { + if (!text) return ''; + const escHtml = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const mentionByFeed = {}; + Object.values(mentions).forEach(arr => { + arr.forEach(m => { + mentionByFeed[m.feed] = m; + }); + }); + text = text.replace(/\[@([^\]]+)\]\(([^)]+)\)/g, (_, name, id) => { + return `@${escHtml(name)}`; + }); + const words = text.split(' '); + text = (await Promise.all( + words.map(async (word) => { + const match = /@([A-Za-z0-9_\-\.+=\/]+\.ed25519)/.exec(word); + if (match && match[1]) { + const feedId = match[1]; + const feedWithAt = feedId.startsWith('@') ? feedId : `@${feedId}`; + let resolvedName; + if (feedId === myFeedId || feedWithAt === myFeedId) { + resolvedName = myUsername; + } else { + try { resolvedName = await about.name(feedWithAt); } catch { resolvedName = feedId.slice(0, 8); } + } + return word.replace(match[0], `@${escHtml(resolvedName)}`); + } + return word; + }) + )).join(' '); + text = text + .replace(/!\[image:[^\]]+\]\(([^)]+)\)/g, (_, id) => + `image`) + .replace(/\[audio:[^\]]+\]\(([^)]+)\)/g, (_, id) => + ``) + .replace(/\[video:[^\]]+\]\(([^)]+)\)/g, (_, id) => + ``) + .replace(/\[pdf:([^\]]*)\]\(([^)]+)\)/g, (_, name, id) => { + const { i18n } = require("../views/main_views"); + const label = name || (i18n && i18n.pdfFallbackLabel) || 'PDF'; + return `${escHtml(label)}`; + }); + return text; +} + +async function resolveMentionText(text) { + if (!text || typeof text !== 'string') return text; + const mentionRe = /@([A-Za-z0-9_\-\.+=\/]+\.ed25519)/g; + const matches = [...text.matchAll(mentionRe)]; + if (!matches.length) return text; + const seen = new Map(); + for (const m of matches) { + const raw = m[1]; + const feed = raw.startsWith('@') ? raw : `@${raw}`; + if (seen.has(feed)) continue; + let name; + try { name = await about.name(feed); } catch { name = feed.slice(1, 9); } + seen.set(feed, name); + } + return text.replace(mentionRe, (full, id) => { + const feed = id.startsWith('@') ? id : `@${id}`; + const name = seen.get(feed) || feed.slice(1, 9); + return `[@${name}](${feed})`; + }); +} + +const preparePreview = async function (ctx) { + let text = String(ctx.request.body.text || "") + if (text.length > 8000) text = text.slice(0, 8000) + const contentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || "")) + const ensureAt = (id) => { + const s = String(id || "") + if (!s) return "" + return s.startsWith("@") ? s : `@${s.replace(/^@+/, "")}` + } + const stripAt = (id) => String(id || "").replace(/^@+/, "") + const norm = (s) => String(s || "").trim().toLowerCase() + const ssbClient = await cooler.open() + const authorMeta = { + id: ssbClient.id, + name: await about.name(ssbClient.id), + image: await about.image(ssbClient.id), + } + const myId = String(authorMeta.id) + text = text.replace( + /\[@([^\]]+)\]\s*\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g, + (_m, label, feed) => `[@${label}](@${stripAt(feed)})` + ) + const mentions = {} + const normalizeMatch = (m) => { + const feed = ensureAt(m?.feed || m?.link || m?.id || "") + const name = String(m?.name || "") + const img = m?.img || m?.image || null + const rel = m?.rel || {} + return { ...m, feed, name, img, rel } + } + const pushUnique = (key, arr) => { + const prev = Array.isArray(mentions[key]) ? mentions[key] : [] + const seen = new Set(prev.map((x) => String(x?.feed || ""))) + const out = prev.slice() + for (const x of arr) { + const f = String(x?.feed || "") + if (!f) continue + if (seen.has(f)) continue + seen.add(f) + out.push(x) + } + if (out.length) mentions[key] = out + } + const chooseByPhrase = (matches, phrase) => { + const p = norm(phrase) + const exact = matches.filter((mm) => norm(mm.name) === p) + if (exact.length) return exact + const starts = matches.filter((mm) => norm(mm.name).startsWith(p)) + if (starts.length) return starts + const incl = matches.filter((mm) => norm(mm.name).includes(p)) + if (incl.length) return incl + return null + } + const rex = /(^|\s)(?!\[)@([a-zA-Z0-9\-/.=+]{3,})(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?\b/g + let m + while ((m = rex.exec(text)) !== null) { + const w1 = m[2] + const w2 = m[3] + const w3 = m[4] + if (/\.ed25519$/.test(w1)) { + const feed = ensureAt(w1) + const [name, img, rel] = await Promise.all([ + about.name(feed), + about.image(feed), + friend.getRelationship(feed).catch(() => ({ followsMe: false, following: false, blocking: false, me: false })) + ]) + pushUnique(w1, [{ feed, name, img, rel }]) + continue + } + const phrase1 = w1 + const phrase2 = w2 ? `${w1} ${w2}` : null + const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null + const matchesRaw = about.named(w1) || [] + const matchesAll = matchesRaw.map(normalizeMatch) + const matches = matchesAll.filter((mm) => String(mm.feed) !== myId && !mm?.rel?.me) + let chosenKey = phrase1 + let chosenMatches = matches + if (phrase3) { + const best3 = chooseByPhrase(matches, phrase3) + if (best3 && best3.length) { + chosenKey = phrase3 + chosenMatches = best3 + } else if (phrase2) { + const best2 = chooseByPhrase(matches, phrase2) + if (best2 && best2.length) { + chosenKey = phrase2 + chosenMatches = best2 + } + } + } else if (phrase2) { + const best2 = chooseByPhrase(matches, phrase2) + if (best2 && best2.length) { + chosenKey = phrase2 + chosenMatches = best2 + } + } + if (chosenMatches.length > 0) { + pushUnique(chosenKey, chosenMatches) + } + } + Object.keys(mentions).forEach((key) => { + const matches = Array.isArray(mentions[key]) ? mentions[key] : [] + const meaningful = matches.filter((mm) => (mm?.rel?.followsMe || mm?.rel?.following) && !mm?.rel?.blocking && String(mm?.feed || "") !== myId && !mm?.rel?.me) + mentions[key] = meaningful.length > 0 ? meaningful : matches + }) + const rexReplace = /(^|\s)(?!\[)@([a-zA-Z0-9\-/.=+]{3,})(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?\b/g + const replacer = (match, prefix, w1, w2, w3) => { + const phrase1 = w1 + const phrase2 = w2 ? `${w1} ${w2}` : null + const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null + const tryKey = (k) => { + const arr = mentions[k] + if (arr && arr.length === 1) { + return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})` + } + return null + } + if (/\.ed25519$/.test(w1)) { + const arr = mentions[w1] + if (arr && arr.length === 1) return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})` + return match + } + const r3 = phrase3 ? tryKey(phrase3) : null + if (r3) return r3 + const r2 = phrase2 ? tryKey(phrase2) : null + if (r2) return r2 + const r1 = tryKey(phrase1) + if (r1) return r1 + return match + } + text = text.replace(rexReplace, replacer) + const blobMarkdown = await handleBlobUpload(ctx, "blob") + if (blobMarkdown) { + text += blobMarkdown + } + const renderedText = await renderBlobMarkdown( + text, + mentions, + authorMeta.id, + authorMeta.name + ) + const hasBrTags = //i.test(renderedText) + const hasBlockTags = /<(p|div|ul|ol|li|pre|blockquote|h[1-6]|table|tr|td|th|section|article)\b/i.test(renderedText) + let formattedText = renderedText + if (!hasBrTags && !hasBlockTags && /[\r\n]/.test(renderedText)) { + formattedText = renderedText.replace(/\r\n|\r|\n/g, "
") + } + return { authorMeta, text, formattedText, mentions, contentWarning } +} +const megabyte = Math.pow(2, 20); +const maxSize = 50 * megabyte; +const collectFediverseMedia = async (ctx) => { + const files = ctx.request.files && ctx.request.files.media; + if (!files) return []; + const arr = Array.isArray(files) ? files : [files]; + const out = []; + for (const f of arr.slice(0, 4)) { + if (!f || !f.filepath || !Number(f.size || 0)) continue; + const m = await fediverseModel.uploadMedia(f).catch(() => null); + if (m && m.id) out.push(m); + } + return out; +}; +const fediverseReturnTo = (ctx, fallback) => { + const rt = ctx.request.body && ctx.request.body.returnTo; + return typeof rt === 'string' && rt.startsWith('/fediverse') ? rt : fallback; +}; +const homeDir = os.homedir(); +const blobsPath = path.join(homeDir, '.ssb', 'blobs', 'tmp'); +const FEDIVERSE_TMP_PREFIX = 'fediverse-'; +const FEDIVERSE_MIME_BY_EXT = { jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif', webp:'image/webp', avif:'image/avif', mp4:'video/mp4', m4v:'video/mp4', webm:'video/webm', mov:'video/quicktime', ogg:'audio/ogg', mp3:'audio/mpeg', wav:'audio/wav' }; +const fediverseExtFromName = (n) => { const m = String(n || '').match(/\.([a-zA-Z0-9]+)$/); return m ? m[1].toLowerCase() : ''; }; +const fediverseMimeFromExt = (ext) => FEDIVERSE_MIME_BY_EXT[ext] || 'application/octet-stream'; +const isFediverseTmpName = (n) => typeof n === 'string' && /^fediverse-[0-9]+-[0-9]+\.[a-zA-Z0-9]+$/.test(n); +const fediverseTmpType = (name) => { + const mime = fediverseMimeFromExt(fediverseExtFromName(name)); + return /^video\//.test(mime) ? 'video' : /^audio\//.test(mime) ? 'audio' : 'image'; +}; +const sweepFediverseTmp = () => { + try { + const now = Date.now(); + for (const f of fs.readdirSync(blobsPath)) { + if (!f.startsWith(FEDIVERSE_TMP_PREFIX)) continue; + const full = path.join(blobsPath, f); + try { if (now - fs.statSync(full).mtimeMs > 30 * 60 * 1000) fs.unlinkSync(full); } catch (_) {} + } + } catch (_) {} +}; +const saveFediverseTempMedia = (ctx) => { + const files = ctx.request.files && ctx.request.files.media; + if (!files) return []; + const arr = Array.isArray(files) ? files : [files]; + try { fs.mkdirSync(blobsPath, { recursive: true }); } catch (_) {} + const out = []; + let i = 0; + for (const f of arr.slice(0, 4)) { + if (!f || !f.filepath || !Number(f.size || 0)) continue; + const ext = fediverseExtFromName(f.originalFilename) || fediverseExtFromName(f.filepath) || 'bin'; + const name = `${FEDIVERSE_TMP_PREFIX}${Date.now()}-${i++}.${ext}`; + try { + fs.copyFileSync(f.filepath, path.join(blobsPath, name)); + out.push({ name, type: /^video|^audio/.test(fediverseMimeFromExt(ext)) ? fediverseMimeFromExt(ext).split('/')[0] : 'image' }); + } catch (_) {} + } + return out; +}; +const publishFediverseTempMedia = async (names) => { + const list = Array.isArray(names) ? names : (names ? [names] : []); + const ids = []; + for (const name of list.slice(0, 4)) { + if (!isFediverseTmpName(name)) continue; + const full = path.join(blobsPath, name); + if (!fs.existsSync(full)) continue; + const ext = fediverseExtFromName(name); + const m = await fediverseModel.uploadMedia({ filepath: full, mimetype: fediverseMimeFromExt(ext), originalFilename: name, size: 1 }).catch(() => null); + if (m && m.id) ids.push(m.id); + try { fs.unlinkSync(full); } catch (_) {} + } + return ids; +}; +const gossipPath = path.join(homeDir, '.ssb', 'gossip.json'); +const unfollowedPath = path.join(homeDir, '.ssb', 'gossip_unfollowed.json'); +const ensureJSONFile = (p, init = []) => { fs.mkdirSync(path.dirname(p), { recursive: true }); if (!fs.existsSync(p)) fs.writeFileSync(p, JSON.stringify(init, null, 2), 'utf8'); }; +const readJSON = p => { ensureJSONFile(p, []); try { return JSON.parse(fs.readFileSync(p, 'utf8') || '[]'); } catch { return []; } }; +const writeJSON = (p, d) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(d, null, 2), 'utf8'); }; +const canonicalKey = k => { let c = String(k).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/'); if (!c.endsWith('=')) c += '='; return `@${c}.ed25519`; }; +const msAddrFrom = (h, p, k) => `net:${h}:${Number(p) || 8008}~shs:${canonicalKey(k).slice(1, -9)}`; +ensureJSONFile(gossipPath, []); +ensureJSONFile(unfollowedPath, []); +const koaBodyMiddleware = koaBody({ + multipart: true, + formidable: { + uploadDir: blobsPath, + keepExtensions: true, + maxFieldsSize: maxSize, + maxFileSize: maxSize, + hash: 'sha256', + }, + parsedMethods: ['POST'], +}); +const resolveCommentComponents = async function (ctx) { + let parentId; + try { + parentId = decodeURIComponent(ctx.params.message); + } catch { + parentId = ctx.params.message; + } + const parentMessage = await post.get(parentId); + if (!parentMessage || !parentMessage.value) { + throw new Error("Invalid parentMessage or missing 'value'"); + } + const myFeedId = await meta.myFeedId(); + const hasRoot = + typeof parentMessage?.value?.content?.root === "string" && + ssbRef.isMsg(parentMessage.value.content.root); + const hasFork = + typeof parentMessage?.value?.content?.fork === "string" && + ssbRef.isMsg(parentMessage.value.content.fork); + const rootMessage = hasRoot + ? hasFork + ? parentMessage + : await post.get(parentMessage.value.content.root) + : parentMessage; + const messages = await post.topicComments(rootMessage.key); + messages.push(rootMessage); + let contentWarning; + if (ctx.request.body) { + const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || "").trim()); + contentWarning = rawContentWarning.length > 0 ? rawContentWarning : undefined; + } + return { messages, myFeedId, parentMessage, contentWarning }; +}; +const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView, inviteRequiredView, clearnetInhabitantView, clearnetBlogView } = require("../views/main_views"); +const { activityView } = require("../views/activity_view"); +const { cvView, createCVView } = require("../views/cv_view"); +const { indexingView } = require("../views/indexing_view"); +const { pixeliaView } = require("../views/pixelia_view"); +const { melodyView } = require("../views/melody_view"); +const { gamesView } = require("../views/games_view"); +const { statsView } = require("../views/stats_view"); +const { tribesView, tribeView, renderInvitePage } = require("../views/tribes_view"); +const { agendaView } = require("../views/agenda_view"); +const { documentView, singleDocumentView } = require("../views/document_view"); +const { inhabitantsView, inhabitantsProfileView } = require("../views/inhabitants_view"); +const { walletViewRender, walletView, walletHistoryView, walletReceiveView, walletSendFormView, walletSendConfirmView, walletSendResultView, walletErrorView } = require("../views/wallet_view"); +const { pmView } = require("../views/pm_view"); +const { tagsView } = require("../views/tags_view"); +const { videoView, singleVideoView } = require("../views/video_view"); +const { audioView, singleAudioView, audiosTranscodeView, audioTranscodeDetailView } = require("../views/audio_view"); +const { torrentsView, singleTorrentView } = require("../views/torrents_view"); +const { eventView, singleEventView, clearnetEventView, renderEventInvitePage } = require("../views/event_view"); +const { invitesView } = require("../views/invites_view"); +const { modulesView } = require("../views/modules_view"); +const { reportView, singleReportView } = require("../views/report_view"); +const { taskView, singleTaskView } = require("../views/task_view"); +const { voteView } = require("../views/vote_view"); +const { bookmarkView, singleBookmarkView } = require("../views/bookmark_view"); +const { feedView, feedCreateView, singleFeedView } = require("../views/feed_view"); +const { legacyView } = require("../views/legacy_view"); +const { opinionsView } = require("../views/opinions_view"); +const { peersView } = require("../views/peers_view"); +const { graphosView } = require("../views/graphos_view"); +const { larpListView, larpHouseView, larpTestView, larpTestResultView } = require("../views/larp_view"); +const { searchView } = require("../views/search_view"); +const { transferView, singleTransferView } = require("../views/transfer_view"); +const { cipherView } = require("../views/cipher_view"); +const { imageView, singleImageView } = require("../views/image_view"); +const { mapsView, singleMapView } = require("../views/maps_view"); +const { settingsView } = require("../views/settings_view"); +const { fediverseView, fediverseThreadView, fediverseOverviewView, fediversePreviewView } = require("../views/fediverse_view"); +const { trendingView } = require("../views/trending_view"); +const { marketView, singleMarketView } = require("../views/market_view"); +const { aiView } = require("../views/AI_view"); +const { forumView, singleForumView, renderForumInvitePage } = require("../views/forum_view"); +const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view"); +const { jobsView, singleJobsView, renderJobForm, clearnetJobView } = require("../views/jobs_view"); +const { shopsView, singleShopView, singleProductView, editProductView, shopOrdersView, myPurchasesView, clearnetShopView, renderShopInvitePage } = require("../views/shops_view"); +const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view"); +const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); +const { calendarsView, singleCalendarView, renderCalendarInvitePage } = require("../views/calendars_view"); +const { projectsView, singleProjectView, clearnetProjectView } = require("../views/projects_view") +const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views") +const { favoritesView } = require("../views/favorites_view"); +const { logsView } = require("../views/logs_view"); +const { buildLogsPdf } = require("./logsPdf"); +const { buildSmartContractPdf } = require("./smartContractPdf"); +const { parliamentView } = require("../views/parliament_view"); +const { courtsView, courtsCaseView } = require('../views/courts_view'); +let sharp; +try { + sharp = require("sharp"); +} catch (e) { +} +const readmePath = path.join(__dirname, "..", ".." ,"README.md"); +const packagePath = path.join(__dirname, "..", "server", "package.json"); +const readme = fs.readFileSync(readmePath, "utf8"); +const version = JSON.parse(fs.readFileSync(packagePath, "utf8")).version; +const nullImageId = '&0000000000000000000000000000000000000000000=.sha256'; +const getAvatarUrl = img => !img || img === nullImageId ? '/assets/images/default-avatar.png' : `/image/256/${encodeURIComponent(img)}`; +const MAX_TITLE_LENGTH = 150; +const MAX_TEXT_LENGTH = 8000; +const parseSizeMB = (s) => { if (!s) return 0; const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i); if (!m) return 0; const v = parseFloat(m[1]), u = m[2].toUpperCase(); return u === 'GB' ? v * 1024 : u === 'MB' ? v : u === 'KB' ? v / 1024 : v / (1024 * 1024); }; +const tooLong = (ctx, value, max, label) => { + if (value && value.length > max) { + sendErrorPage(ctx, `${label} too long (max ${max})`, { status: 400 }); + return true; + } + return false; +}; + +const buildEffectivePrivateChainIds = async () => { + const ids = new Set(); + const all = await tribesModel.listAll().catch(() => []); + for (const tr of all) { + try { + const eff = await tribesModel.getEffectiveStatus(tr.id); + if (!eff || !eff.isPrivate) continue; + const chain = await tribesModel.getChainIds(tr.id).catch(() => [tr.id]); + for (const cid of chain) ids.add(cid); + } catch (_) {} + } + return ids; +}; + +const isBlockRestricted = (block, effPrivateChainIds) => { + if (!block) return false; + const c = block.content || {}; + const t = c.type || block.type || ''; + const isPrivate = String(c.isPublic || '').toLowerCase() === 'private'; + const tribeMsgInPrivate = t === 'tribe' && (effPrivateChainIds.has(block.id) || (c.replaces && effPrivateChainIds.has(c.replaces))); + const tribeKeysInPrivate = t === 'tribe-keys' && c.tribeId && effPrivateChainIds.has(c.tribeId); + const tribeContentInPrivate = !!c.tribeId && effPrivateChainIds.has(c.tribeId); + return tribeMsgInPrivate || + tribeKeysInPrivate || + tribeContentInPrivate || + t.startsWith('courts') || + t === 'job' || t === 'job_sub' || + c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' || + isPrivate; +}; + +router + .param("imageSize", (imageSize, ctx, next) => { + const size = Number(imageSize); + const isInteger = size % 1 === 0; + const overMinSize = size > 2; + const underMaxSize = size <= 256; + ctx.assert( + isInteger && overMinSize && underMaxSize, + 400, + "Invalid image size" + ); + return next(); + }) + .param("blobId", (blobId, ctx, next) => { + ctx.assert(ssbRef.isBlob(blobId), 400, "Invalid blob link"); + return next(); + }) + .param("message", (message, ctx, next) => { + ctx.assert(ssbRef.isMsg(message), 400, "Invalid message link"); + return next(); + }) + .param("feed", (message, ctx, next) => { + ctx.assert(ssbRef.isFeedId(message), 400, "Invalid feed link"); + return next(); + }) + .get("/", async (ctx) => { + const currentConfig = getConfig(); + if (currentConfig.ux?.current === "ainav") { + const { ainavHomeView } = require("../views/main_views"); + let recentTags = []; + try { + const all = await tagsModel.listTags('top'); + recentTags = (all || []).slice(0, 10); + } catch (_) {} + ctx.body = ainavHomeView({ recentTags }); + return; + } + const homePage = currentConfig.homePage || "activity"; + ctx.redirect(`/${homePage}`); + }) + .get("/robots.txt", (ctx) => { + ctx.body = "User-agent: *\nDisallow: /"; + }) + .get(oasisCheckPath, (ctx) => { + ctx.body = "oasis"; + }) + .get('/stats', async (ctx) => { + const filter = qf(ctx, 'ALL'), stats = await statsModel.getStats(filter); + const myId = getViewerId(); + const myAddress = await bankingModel.getUserAddress(myId); + const addrRows = await bankingModel.listAddressesMerged(); + stats.banking = { + myAddress: myAddress || null, + totalAddresses: Array.isArray(addrRows) ? addrRows.length : 0 + }; + stats.gpgFingerprint = await about.gpgFingerprint(myId).catch(() => ''); + try { stats.logsCount = await logsModel.countLogs(); } catch { stats.logsCount = 0; } + const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize); + const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2)); + const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1; + const hcH = inhabitants > 0 ? parseFloat((hcT / inhabitants).toFixed(2)) : 0; + sharedState.setCarbonHcT(hcT); + sharedState.setCarbonHcH(hcH); + sharedState.setInhabitantCount(inhabitants); + try { stats.ecoTaxStats = await bankingModel.calculateEcoTaxStats(); } catch (_) { stats.ecoTaxStats = null; } + try { stats.userEcoinTax = await bankingModel.getUserEcoinTax(getViewerId()); } catch (_) { stats.userEcoinTax = 0; } + ctx.body = statsView(stats, filter); + }) + .get("/public/popular/:period", async (ctx) => { + if (!checkMod(ctx, 'popularMod')) return ctx.redirect('/modules'); + const i18n = require("../client/assets/translations/i18n"), lang = ctx.cookies.get('language') || getConfig().language || 'en', t = i18n[lang] || i18n['en']; + const messages = sanitizeMessages(await post.popular({ period: ctx.params.period })); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))), spreadMap }); + }) + .get("/modules", async (ctx) => { + const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts']; + const cfg = getConfig().modules; + ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {})); + }) + .get('/ai', async (ctx) => { + if (!checkMod(ctx, 'aiMod')) return ctx.redirect('/modules'); + startAI(); + const lang = ctx.cookies.get('language') || getConfig().language || 'en', historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json'); + require('../views/main_views').setLanguage(lang); + let chatHistory = []; try { chatHistory = JSON.parse(fs.readFileSync(historyPath, 'utf-8')); } catch {} + ctx.body = aiView(chatHistory, getConfig().ai?.prompt?.trim() || ''); + }) + .get('/games', async (ctx) => { + if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; } + const filter = qf(ctx, 'all'); + const hall = await gamesModel.getHallOfFame(); + ctx.body = gamesView(filter, hall); + }) + .get('/games/:name', async (ctx) => { + if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; } + const { gameShellView } = require('../views/games_view'); + ctx.body = gameShellView(ctx.params.name); + }) + .post('/games/submit-score', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; } + const { game, score } = ctx.request.body; + try { await gamesModel.submitScore(game, score); } catch (_) {} + ctx.redirect('/games?filter=scoring'); + }) + .get('/pixelia', async (ctx) => { + if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; } + const pixelArt = await pixeliaModel.listPixels(); + ctx.body = pixeliaView(pixelArt); + }) + .get('/melody', async (ctx) => { + if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; } + const rawFilter = String(ctx.query?.filter || 'mine').toLowerCase(); + const filter = rawFilter === 'all' ? 'all' : 'mine'; + const viewerId = getViewerId(); + const data = await melodyModel.getUserMelody(viewerId); + let bcsAudios = []; + if (filter === 'all' && checkMod(ctx, 'audiosMod')) { + bcsAudios = await audiosModel.listAll({ filter: 'bcs', viewerId }).catch(() => []); + bcsAudios = bcsAudios.filter(a => String(a.author) !== String(viewerId)); + } + ctx.body = melodyView({ ...data, filter, bcsAudios }); + }) + .get('/melody/audio.wav', async (ctx) => { + if (!checkMod(ctx, 'melodyMod')) { ctx.status = 404; return; } + const viewerId = getViewerId(); + const data = await melodyModel.getUserMelody(viewerId); + if (!data.sequence || data.sequence.length === 0) { ctx.status = 404; return; } + ctx.type = 'audio/wav'; + ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + if (ctx.query.download === '1') { + const safeName = String(viewerId || 'oasis').replace(/["\r\n\\]/g, ''); + ctx.set('Content-Disposition', `attachment; filename="${safeName}.wav"`); + } + ctx.body = synthesizeMelodyWav(data.sequence); + }) + .post('/melody/upload', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; } + if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } + const body = ctx.request.body || {}; + const stegoMessage = String(body.stegoMessage || '').slice(0, 280); + const viewerId = getViewerId(); + const data = await melodyModel.getUserMelody(viewerId); + if (!data.sequence || data.sequence.length === 0) { ctx.redirect('/melody'); return; } + let wav = synthesizeMelodyWav(data.sequence); + const ssbClient = await cooler.open(); + const stegoPayload = JSON.stringify({ + id: ssbClient.id, + ts: Date.now(), + msg: stegoMessage + }); + try { wav = melodyModel.embedTextInWav(wav, stegoPayload); } catch (_) {} + const blobId = await new Promise((resolve, reject) => { + pull( + pull.values([wav]), + ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref))) + ); + }); + const title = `BCS-${viewerId || ssbClient.id}`; + try { + await audiosModel.createBcsAudio(blobId, title, '', data.sequence); + } catch (_) { + ctx.redirect('/melody'); + return; + } + ctx.redirect('/audios?filter=bcs'); + }) + .get('/melody/transcode/:id', async (ctx) => { + if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; } + if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } + const viewerId = getViewerId(); + let audio; + try { audio = await audiosModel.getAudioById(ctx.params.id, viewerId); } catch (_) { audio = null; } + if (!audio) { ctx.redirect('/melody?filter=all'); return; } + let itemSize = null; + try { const blk = await blockchainModel.getBlockById(audio.key, viewerId); if (blk && Number.isFinite(blk.size)) itemSize = blk.size; } catch (_) {} + ctx.body = await audioTranscodeDetailView({ audio, itemSize }); + }) + .post('/melody/transcode/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'melodyMod')) { ctx.redirect('/modules'); return; } + if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } + const viewerId = getViewerId(); + let audio; + try { audio = await audiosModel.getAudioById(ctx.params.id, viewerId); } catch (_) { audio = null; } + if (!audio) { ctx.redirect('/melody?filter=all'); return; } + const stegoPayload = await runTranscode(audio); + const compositionIds = (audio.bcsComposition || []).map(n => n && n.id).filter(Boolean); + const availableIds = await listAvailableBlockIds(compositionIds); + let itemSize = null; + try { const blk = await blockchainModel.getBlockById(audio.key, viewerId); if (blk && Number.isFinite(blk.size)) itemSize = blk.size; } catch (_) {} + ctx.body = await audioTranscodeDetailView({ audio, decoded: true, stegoPayload, availableIds, itemSize }); + }) + + .get('/blockexplorer', async (ctx) => { + const userId = getViewerId(); + const query = ctx.query || {}; + const search = { + id: query.id || '', + author: query.author || '', + from: query.from || '', + to: query.to || '' + }; + const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0); + let filter = query.filter || 'recent'; + if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all'; + const blockchainData = await blockchainModel.listBlockchain(filter, userId, search); + const effPrivateChainIds = await buildEffectivePrivateChainIds(); + for (const block of blockchainData) { + block.restricted = isBlockRestricted(block, effPrivateChainIds); + } + const inspectId = String(query.inspect || '').trim(); + let inspect = null; + if (inspectId) { + try { + const blk = await blockchainModel.getBlockById(inspectId, userId); + if (blk && blk.id) { + const sizeBytes = Number(blk.size || 0); + const grams = (sizeBytes / (1024 * 1024)) * 0.095; + const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1); + inspect = { block: inspectId, found: true, size: sizeBytes, author: blk.author, blockType: blk.type, ecoinTax }; + } + } catch (_) {} + if (!inspect) { + try { + const ssbClient = await cooler.open(); + const raw = await new Promise((resolve, reject) => ssbClient.get(inspectId, (err, v) => err ? reject(err) : resolve(v))); + if (raw) { + const sizeBytes = Buffer.byteLength(JSON.stringify(raw), 'utf8'); + let bType = (raw.content && typeof raw.content === 'object' && raw.content.type) || null; + if (!bType && typeof raw.content === 'string' && raw.content.endsWith('.box')) bType = 'encrypted'; + const grams = (sizeBytes / (1024 * 1024)) * 0.095; + const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1); + inspect = { block: inspectId, found: true, size: sizeBytes, author: raw.author, blockType: bType || 'unknown', ecoinTax }; + } + } catch (_) {} + } + if (!inspect) inspect = { block: inspectId, found: false }; + } + ctx.body = renderBlockchainView(blockchainData, filter, userId, search, { inspect }); + }) + .get('/blockexplorer/block/:id', async (ctx) => { + const userId = getViewerId(); + const query = ctx.query || {}; + const search = { + id: query.id || '', + author: query.author || '', + from: query.from || '', + to: query.to || '' + }; + const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0); + let filter = query.filter || 'recent'; + if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all'; + const blockId = ctx.params.id; + let block = await blockchainModel.getBlockById(blockId, userId); + if (!block) block = { id: blockId, notAvailable: true }; + const viewMode = query.view || 'block'; + let restricted = false; + if (block) { + const effPrivateChainIds = await buildEffectivePrivateChainIds(); + restricted = isBlockRestricted(block, effPrivateChainIds); + const c = block.content || {}; + if (!restricted && tribeCrypto && (c.encryptedPayload || tribeCrypto.isTribeMsg(c))) { + try { + const decrypted = await tribeCrypto.decryptFromTribe(c, tribesModel); + if (decrypted && !decrypted._undecryptable) { + block = { ...block, content: decrypted }; + } + } catch (_) {} + } + } + ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted); + }) + .get("/public/latest", async (ctx) => { + if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; } + const messages = sanitizeMessages(await post.latest()); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + ctx.body = await latestView({ messages, spreadMap }); + }) + .get("/public/latest/extended", async (ctx) => { + if (!checkMod(ctx, 'extendedMod')) { ctx.redirect('/modules'); return; } + const messages = sanitizeMessages(await post.latestExtended()); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + ctx.body = await extendedView({ messages, spreadMap }); + }) + .get("/public/latest/topics", async (ctx) => { + if (!checkMod(ctx, 'topicsMod')) { ctx.redirect('/modules'); return; } + const messages = sanitizeMessages(await post.latestTopics()); + const channels = await post.channels(); + const list = channels.map((c) => { + return li(a({ href: `/hashtag/${c}` }, `#${c}`)); + }); + const prefix = nav(ul(list)); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + ctx.body = await topicsView({ messages, prefix, spreadMap }); + }) + .get("/public/latest/summaries", async (ctx) => { + if (!checkMod(ctx, 'summariesMod')) { ctx.redirect('/modules'); return; } + const messages = sanitizeMessages(await post.latestSummaries()); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + ctx.body = await summaryView({ messages, spreadMap }); + }) + .get("/public/latest/threads", async (ctx) => { + if (!checkMod(ctx, 'threadsMod')) { ctx.redirect('/modules'); return; } + const messages = sanitizeMessages(await post.latestThreads()); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + ctx.body = await threadsView({ messages, spreadMap }); + }) + .get('/author/:feed', async (ctx) => { + const feedId = decodeURIComponent(ctx.params.feed || ''), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1); + if (lt > 0 && gt > 0 && gt >= lt) throw new Error('Given search range is empty'); + const visibilityPrefs = await about.visibilityPrefs(feedId).catch(() => null); + const deviceSource = await about.deviceSource(feedId).catch(() => null); + const stats = await inhabitantsModel.getInhabitantStats(feedId, getViewerId()).catch(() => ({})); + const rawPrefs = visibilityPrefs || {}; + const needsBanking = (rawPrefs.karma !== false) || rawPrefs.ubi === true; + const needsWallet = rawPrefs.wallet === true; + const needsCarbon = rawPrefs.ecoTax !== false; + const needsLarp = rawPrefs.larpSign === true; + const [description, name, image, messages, firstPost, lastPost, relationship, ecoAddress, bankData, allActions, carbonGrams, larpHouseKey, gpgFingerprint, lastUserActivityTs] = await Promise.all([ + about.description(feedId), + about.name(feedId), + about.image(feedId), + post.fromPublicFeed(feedId, gt, lt), + post.firstBy(feedId), + post.latestBy(feedId), + friend.getRelationship(feedId), + needsWallet ? bankingModel.getUserAddress(feedId).catch(() => null) : Promise.resolve(null), + needsBanking ? bankingModel.getBankingData(feedId).catch(() => ({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 })) : Promise.resolve({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 }), + activityModel.listFeed('all').catch(() => []), + needsCarbon ? getCarbonGramsForFeed(feedId).catch(() => 0) : Promise.resolve(0), + needsLarp ? larpModel.getUserHouse(feedId).catch(() => null) : Promise.resolve(null), + about.gpgFingerprint(feedId).catch(() => ''), + inhabitantsModel.getLastActivityTimestampByUserId(feedId).catch(() => null) + ]); + const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null; + const sanitizedMsgs = sanitizeMessages(messages); + const userActions = (allActions || []).filter(a => a && a.author === feedId && a.type !== 'tombstone' && a.type !== 'post'); + const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; }; + const pickTs = obj => { if (!obj) return 0; const v = obj.value || obj; return normTs(v.timestamp || v.ts || v.time || v.meta?.timestamp || 0); }; + const latestFromStream = Math.max(pickTs(lastPost), pickTs(firstPost), Array.isArray(messages) && messages.length ? Math.max(...messages.map(pickTs)) : 0); + const fullLastTs = Math.max(latestFromStream, Number(lastUserActivityTs) || 0); + const { bucket: lastActivityBucket } = inhabitantsModel.bucketLastActivity(fullLastTs || null); + const profileItems = await fetchProfileItems(feedId, rawPrefs); + const profileFilterType = String(ctx.query.type || '').toLowerCase(); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); + const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); + await warmAuthorNames(allActions, sanitizedMsgs, profileItems); + ctx.body = await authorView({ feedId, oasisVersion: (String(feedId) === String(getViewerId()) ? OASIS_VERSION : null) || await getOasisVersion(feedId), messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, deviceSource, stats, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); + }) + .get("/search", async (ctx) => { + const inhabitantQ = String(ctx.query.inhabitant || '').trim(); + if (inhabitantQ && /^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(inhabitantQ)) { + ctx.redirect(`/author/${encodeURIComponent(inhabitantQ)}`); + return; + } + const fromTs = ctx.query.from ? new Date(ctx.query.from).getTime() : null; + const toTs = ctx.query.to ? new Date(ctx.query.to).getTime() : null; + const query = ctx.query.query || ''; + if (!query) return ctx.body = await searchView({ messages: [], query, types: [] }); + const userId = getViewerId(); + const allTribes = await tribesModel.listAll(); + const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id)); + const applySearchPrivacy = (msgs) => msgs.filter(msg => { + const c = msg.value?.content; + if (!c) return true; + if (Array.isArray(c.recps)) return false; + if (c.type === 'post' && (c.private === true || c.recps)) return false; + if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; + if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false; + if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false; + if (c.status === 'PRIVATE') return false; + if (c.type === 'shop' && (c.visibility === 'CLOSED' || c.encryptedPayload) && c.author !== userId) return false; + return true; + }); + const results = await searchModel.search({ query, types: [] }); + const cfgNow = getConfig(); + const wishMutuals = cfgNow.wish === 'mutuals'; + const wishOnlyLan = cfgNow.wish === 'only-lan'; + const mutualCache = wishMutuals ? makeCtxMutualCache() : null; + let lanKeys = null; + if (wishOnlyLan) { + try { + const ssbX = await cooler.open(); + const snapshot = await ssbX.conn.dbPeers(); + lanKeys = new Set(); + for (const entry of (snapshot || [])) { + const data = Array.isArray(entry) ? entry[1] : entry; + if (!data) continue; + if (data.type === 'lan' && data.key) lanKeys.add(data.key); + } + lanKeys.add(userId); + } catch (_) { lanKeys = new Set([userId]); } + } + const accessSets = await getViewerTribeAccessSets(userId); + const finalResults = {}; + for (const [type, msgs] of Object.entries(results)) { + const privacyFiltered = applySearchPrivacy(msgs).filter(msg => { + const c = msg.value?.content; + if (c && c.tribeId && accessSets.privateNotAccessible.has(c.tribeId)) return false; + return true; + }); + let after = privacyFiltered; + if (wishMutuals) { + const out = []; + for (const m of privacyFiltered) { + const a = m.value?.author || m.value?.content?.author; + if (!a || a === userId) { out.push(m); continue; } + if (await mutualCache(a)) out.push(m); + } + after = out; + } + if (wishOnlyLan && lanKeys) { + after = after.filter(m => { + const a = m.value?.author || m.value?.content?.author; + return a && lanKeys.has(a); + }); + } + const mapped = after.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); + let scoped = mapped; + if (Number.isFinite(fromTs)) scoped = scoped.filter(m => (m.value?.timestamp || m.timestamp || 0) >= fromTs); + if (Number.isFinite(toTs)) scoped = scoped.filter(m => (m.value?.timestamp || m.timestamp || 0) <= toTs); + if (scoped.length > 0) finalResults[type] = scoped; + } + ctx.body = await searchView({ results: finalResults, query, types: [] }); + }) + .get("/images", async (ctx) => { + if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const items = await imagesModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); + const fav = await mediaFavorites.getFavoriteSet('images'); + let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); + await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); + const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key)); + await warmAuthorNames(enriched); + ctx.body = await imageView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap }); + }) + .get("/images/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } + const img = await imagesModel.getImageById(ctx.params.id, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('images'); + ctx.body = await imageView([{ ...img, isFavorite: fav.has(String(img.rootId || img.key)) }], 'edit', img.key, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/images/:imageId", async (ctx) => { + if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } + const { imageId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const img = await imagesModel.getImageById(imageId, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('images'); + const comments = await getVoteComments(img.key); + const imgAuthorPrefs = await about.visibilityPrefs(img.author).catch(() => null); + await enrichItemLifetime(img, { key: img.key }); + ctx.body = await singleImageView({ ...img, isFavorite: fav.has(String(img.rootId || img.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/images?filter=${encodeURIComponent(filter)}`, ['/images']), spreads: await spreads.forMessage(img.key), authorPrefs: imgAuthorPrefs }); + }) + .get("/maps", async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', lat, lng, zoom, tribeId, title, description, markerLabel, tags, mapType } = ctx.query; + const uid = getViewerId(); + const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: uid }); + const fav = await mediaFavorites.getFavoriteSet('maps'); + let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + const myTribeIds = await getUserTribeIds(uid); + enriched = enriched.filter(x => !x.tribeId); + enriched = await applyListFilters(enriched, ctx); + try { enriched = await lifetime.enrichAndFilter(enriched, { getKey: (x) => x.rootId || x.key }); } catch (_) {} + const spreadMap = await spreads.forMessages((enriched || []).map(x => x && (x.key || x.id))); + try { + ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}), spreadMap }); + } catch (e) { + console.error("maps render:", e.message); + ctx.body = await mapsView(enriched, filter, null, { q, spreadMap }); + } + }) + .get("/maps/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + let mapItem; + try { mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/maps?filter=all'); return; } + if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } + if (mapItem.author !== getViewerId()) { ctx.redirect(`/maps/${encodeURIComponent(mapItem.key)}`); return; } + const fav = await mediaFavorites.getFavoriteSet('maps'); + ctx.body = await mapsView([{ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }], 'edit', mapItem.key, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/maps/:mapId", async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + await mapsModel.ingestKeys().catch(() => {}); + const { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query; + const uid = getViewerId(); + let mapItem; + try { + mapItem = await mapsModel.getMapById(mapId, uid); + } catch (e) { + ctx.redirect('/maps?filter=all'); + return; + } + if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } + const fav = await mediaFavorites.getFavoriteSet('maps'); + let tribeMembers = []; + let parentTribe = null; + if (mapItem.tribeId) { + try { + parentTribe = await tribesModel.getTribeById(mapItem.tribeId); + if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; } + tribeMembers = parentTribe.members; + } catch { ctx.redirect('/tribes'); return; } + } else { + const members = Array.isArray(mapItem.members) ? mapItem.members : []; + const mt = String(mapItem.mapType || '').toUpperCase(); + const isOpenAccess = mt === 'OPEN' || mt === 'SINGLE'; + if (!isOpenAccess && mapItem.author !== uid && !members.includes(uid)) { ctx.redirect('/maps?filter=all'); return; } + } + if (String(mapItem.mapType || '').toUpperCase() === 'CLOSED' && mapItem.author !== uid) { + ctx.body = tribeAccessDeniedView(parentTribe); return; + } + ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) }); + }) + .get("/audios", async (ctx) => { + if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const items = await audiosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); + const fav = await mediaFavorites.getFavoriteSet('audios'); + let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); + await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); + const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key)); + await warmAuthorNames(enriched); + ctx.body = await audioView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap }); + }) + .get("/audios/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } + const audio = await audiosModel.getAudioById(ctx.params.id, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('audios'); + ctx.body = await audioView([{ ...audio, isFavorite: fav.has(String(audio.rootId || audio.key)) }], 'edit', audio.key, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/audios/:audioId", async (ctx) => { + if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } + const { audioId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const audio = await audiosModel.getAudioById(audioId, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('audios'); + const comments = await getVoteComments(audio.key); + const audioAuthorPrefs = await about.visibilityPrefs(audio.author).catch(() => null); + await enrichItemLifetime(audio, { key: audio.key }); + ctx.body = await singleAudioView({ ...audio, isFavorite: fav.has(String(audio.rootId || audio.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/audios?filter=${encodeURIComponent(filter)}`, ['/audios']), spreads: await spreads.forMessage(audio.key), authorPrefs: audioAuthorPrefs }); + }) + .get("/torrents", async (ctx) => { + if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', sort = 'recent', tribeId = '' } = ctx.query; + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const items = await torrentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); + const fav = await mediaFavorites.getFavoriteSet('torrents'); + let enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); + const spreadMap = await spreads.forMessages((enriched || []).map(x => x && x.key)); + await warmAuthorNames(enriched); + ctx.body = await torrentsView(enriched, filter, null, { q, sort, viewerPrefs, ...(tribeId ? { tribeId } : {}), spreadMap }); + }) + .get("/torrents/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } + const torrent = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('torrents'); + ctx.body = await torrentsView([{ ...torrent, isFavorite: fav.has(String(torrent.rootId || torrent.key)) }], 'edit', torrent.key, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/torrents/:torrentId", async (ctx) => { + if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } + const { torrentId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const torrent = await torrentsModel.getTorrentById(torrentId, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('torrents'); + const comments = await getVoteComments(torrent.key); + const torrentAuthorPrefs = await about.visibilityPrefs(torrent.author).catch(() => null); + await enrichItemLifetime(torrent, { key: torrent.key }); + ctx.body = await singleTorrentView({ ...torrent, isFavorite: fav.has(String(torrent.rootId || torrent.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/torrents?filter=${encodeURIComponent(filter)}`, ['/torrents']), spreads: await spreads.forMessage(torrent.key), authorPrefs: torrentAuthorPrefs }); + }) + .get("/videos", async (ctx) => { + if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const items = await videosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); + const fav = await mediaFavorites.getFavoriteSet('videos'); + let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); + await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); + const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key)); + await warmAuthorNames(enriched); + ctx.body = await videoView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap }); + }) + .get("/videos/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } + const video = await videosModel.getVideoById(ctx.params.id, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('videos'); + ctx.body = await videoView([{ ...video, isFavorite: fav.has(String(video.rootId || video.key)) }], 'edit', video.key, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/videos/:videoId", async (ctx) => { + if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } + const { videoId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const video = await videosModel.getVideoById(videoId, getViewerId()); + const fav = await mediaFavorites.getFavoriteSet('videos'); + const comments = await getVoteComments(video.key); + const videoAuthorPrefs = await about.visibilityPrefs(video.author).catch(() => null); + await enrichItemLifetime(video, { key: video.key }); + ctx.body = await singleVideoView({ ...video, isFavorite: fav.has(String(video.rootId || video.key)), commentCount: comments.length }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/videos?filter=${encodeURIComponent(filter)}`, ['/videos']), spreads: await spreads.forMessage(video.key), authorPrefs: videoAuthorPrefs }); + }) + .get("/documents", async (ctx) => { + const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const items = await documentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort }); + const fav = await mediaFavorites.getFavoriteSet('documents'); + let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); + await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.rootId || x.key)).length; })); + const spreadMap = await spreads.forMessages(enriched.map(x => x && x.key)); + await warmAuthorNames(enriched); + ctx.body = await documentView(enriched, filter, null, { q, sort, viewerPrefs, spreadMap }); + }) + .get("/documents/edit/:id", async (ctx) => { + const doc = await documentsModel.getDocumentById(ctx.params.id); + const fav = await mediaFavorites.getFavoriteSet('documents'); + ctx.body = await documentView([{ ...doc, isFavorite: fav.has(String(doc.rootId || doc.key)) }], 'edit', doc.key, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/documents/:documentId", async (ctx) => { + const { filter = "all", q = "", sort = "recent" } = ctx.query; + const document = await documentsModel.getDocumentById(ctx.params.documentId); + const fav = await mediaFavorites.getFavoriteSet('documents'); + Object.assign(document, { isFavorite: fav.has(String(document.rootId || document.key)) }); + const comments = await getVoteComments(document.rootId || document.key); + const docAuthorPrefs = await about.visibilityPrefs(document.author).catch(() => null); + await enrichItemLifetime(document, { key: document.key }); + ctx.body = await singleDocumentView(withCount(document, comments), filter, comments, { + q, sort, + returnTo: safeReturnTo(ctx, `/documents/${encodeURIComponent(document.key)}?filter=${encodeURIComponent(filter)}${q ? `&q=${encodeURIComponent(q)}` : ""}${sort ? `&sort=${encodeURIComponent(sort)}` : ""}`, ["/documents"]), + spreads: await spreads.forMessage(document.key), + authorPrefs: docAuthorPrefs + }); + }) + .get('/cv', async ctx => { + const cv = await cvModel.getCVByUserId() + ctx.body = await cvView(cv) + }) + .get('/cv/create', async ctx => { + ctx.body = await createCVView() + }) + .get('/cv/edit/:id', async ctx => { + const cv = await cvModel.getCVByUserId() + ctx.body = await createCVView(cv, true) + }) + .get('/pm', async ctx => { + const { recipients = '', subject = '', quote = '', preview = '' } = ctx.query; + const quoted = quote ? quote.split('\n').map(l => '> ' + l).join('\n') + '\n\n' : ''; + const showPreview = preview === '1'; + ctx.body = await pmView(recipients, subject, quoted, showPreview); + }) + .get('/inbox', async ctx => { + if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } + const messages = await buildInboxMessages(); + await refreshInboxCount(messages); + ctx.body = await privateView({ messages }, ctx.query.filter || undefined); + }) + .post('/inbox/decrypt', koaBody(), async ctx => { + if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } + const { id, key, returnFilter } = ctx.request.body; + const messages = await buildInboxMessages(); + let decrypted = null; + const msg = messages.find(m => m && m.key === id); + if (msg && msg.value?.content?.crypter && typeof key === 'string' && key) { + try { + decrypted = { key: id, text: cipherModel.decryptData(msg.value.content.text, key) }; + } catch (_) { + decrypted = { key: id, error: true }; + } + } + ctx.body = await privateView({ messages }, returnFilter || 'all', decrypted); + }) + .get('/tags', async ctx => { + const filter = qf(ctx), tags = await tagsModel.listTags(filter); + ctx.body = await tagsView(tags, filter); + }) + .get('/reports', async ctx => { + const filter = qf(ctx); + let reports = await enrichWithComments(await reportsModel.listAll()); + reports = await applyListFilters(reports, ctx); + try { reports = await lifetime.enrichAndFilter(reports); } catch (_) {} + await enrichMsgSize(reports); + const spreadMap = await spreads.forMessages((reports || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(reports); + ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap }); + }) + .get('/reports/edit/:id', async ctx => { + const report = await reportsModel.getReportById(ctx.params.id); + ctx.body = await reportView([report], 'edit', ctx.params.id); + }) + .get('/reports/:reportId', async ctx => { + const { reportId } = ctx.params, filter = qf(ctx), report = await reportsModel.getReportById(reportId); + const comments = await getVoteComments(reportId); + await enrichMsgSize([report]); + await enrichItemLifetime(report); + ctx.body = await singleReportView(withCount(report, comments), filter, comments, { spreads: await spreads.forMessage(report.id).catch(() => null) }); + }) + .get('/trending', async (ctx) => { + const filter = qf(ctx, 'TOP'); + let { filtered = [] } = await trendingModel.listTrending(filter); + filtered = await applyListFilters(filtered, ctx); + const spreadMap = new Map(); + const results = await Promise.all(filtered.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null))); + filtered.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); }); + await warmAuthorNames(filtered); + ctx.body = await trendingView(filtered, filter, trendingModel.categories, spreadMap); + }) + .get('/agenda', async (ctx) => { + const filter = qf(ctx); + let data = await agendaModel.listAgenda(filter); + if (Array.isArray(data)) data = await applyListFilters(data, ctx); + ctx.body = await agendaView(data, filter); + }) + .get("/hashtag/:hashtag", async (ctx) => { + const { hashtag } = ctx.params; + const messages = sanitizeMessages(await post.fromHashtag(hashtag)); + ctx.body = await hashtagView({ hashtag, messages }); + }) + .get('/inhabitants', async (ctx) => { + const filter = qf(ctx); + const query = { search: ctx.query.search || '' }; + const userId = getViewerId(); + if (filter === 'pending') { + try { await scanPendingFollows(userId); } catch (e) {} + const pending = viewerFilters.listPending(); + const enriched = await Promise.all(pending.map(async (p) => { + let name = p.followerId; + try { name = await about.name(p.followerId); } catch (_) {} + return { ...p, name }; + })); + ctx.body = renderPendingFollows(enriched); + return; + } + if (['CVs', 'MATCHSKILLS'].includes(filter)) { + Object.assign(query, { + location: ctx.query.location || '', + language: ctx.query.language || '', + skills: ctx.query.skills || '' + }); + } + const inhabitants = await inhabitantsModel.listInhabitants({ filter, ...query }); + const [addresses, karmaList] = await Promise.all([ + bankingModel.listAddressesMerged(), + Promise.all( + inhabitants.map(async (u) => { + try { + const bank = await bankingModel.getBankingData(u.id); + return { id: u.id, karmaScore: bank?.karmaScore || 0, estimatedUBI: bank?.estimatedUBI || 0, lastClaimedDate: bank?.lastClaimedDate || null, totalClaimed: bank?.totalClaimed || 0 }; + } catch { + return { id: u.id, karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 }; + } + }) + ) + ]); + const activityList = await Promise.all( + inhabitants.map(async (u) => { + try { + const ts = await inhabitantsModel.getLastActivityTimestampByUserId(u.id); + const { bucket } = inhabitantsModel.bucketLastActivity(ts || null); + return { id: u.id, lastActivityBucket: bucket }; + } catch { + return { id: u.id, lastActivityBucket: 'red' }; + } + }) + ); + const prefsList = await Promise.all( + inhabitants.map(async (u) => { + try { return { id: u.id, prefs: await about.visibilityPrefs(u.id) }; } + catch { return { id: u.id, prefs: null }; } + }) + ); + const relList = await Promise.all( + inhabitants.map(async (u) => { + try { return { id: u.id, rel: await friend.getRelationship(u.id) }; } + catch { return { id: u.id, rel: null }; } + }) + ); + const gpgList = await Promise.all( + inhabitants.map(async (u) => { + try { return { id: u.id, fp: await about.gpgFingerprint(u.id) }; } + catch { return { id: u.id, fp: '' }; } + }) + ); + const deviceList = await Promise.all( + inhabitants.map(async (u) => { + try { return { id: u.id, src: await about.deviceSource(u.id) }; } + catch { return { id: u.id, src: null }; } + }) + ); + const statsList = await Promise.all( + inhabitants.map(async (u) => { + try { return { id: u.id, stats: await inhabitantsModel.getInhabitantStats(u.id, userId) }; } + catch { return { id: u.id, stats: {} }; } + }) + ); + const carbonList = await Promise.all( + inhabitants.map(async (u) => { + try { return { id: u.id, carbon: await getCarbonGramsForFeed(u.id) }; } + catch { return { id: u.id, carbon: 0 }; } + }) + ); + const larpHouseByUser = await (async () => { + const ssbX = await cooler.open(); + return new Promise((resolve) => { + const byAuthor = new Map(); + pull( + ssbX.createLogStream({ reverse: true }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (!c || c.type !== 'larpJoinHouse') return; + const a = m.value.author; + const ts = m.value.timestamp || 0; + const prev = byAuthor.get(a); + if (!prev || ts > prev.ts) byAuthor.set(a, { house: c.house, ts }); + }, () => { + const out = new Map(); + for (const [a, v] of byAuthor.entries()) out.set(a, v.house); + resolve(out); + }) + ); + }); + })(); + const addrMap = new Map(addresses.map(x => [x.id, x.address])); + const karmaMap = new Map(karmaList.map(x => [x.id, { karmaScore: x.karmaScore, estimatedUBI: x.estimatedUBI, lastClaimedDate: x.lastClaimedDate, totalClaimed: x.totalClaimed }])); + const activityMap = new Map(activityList.map(x => [x.id, x.lastActivityBucket])); + const prefsMap = new Map(prefsList.map(x => [x.id, x.prefs])); + const relMap = new Map(relList.map(x => [x.id, x.rel])); + const gpgMap = new Map(gpgList.map(x => [x.id, x.fp])); + const deviceMap = new Map(deviceList.map(x => [x.id, x.src])); + const statsMap = new Map(statsList.map(x => [x.id, x.stats])); + const carbonMap = new Map(carbonList.map(x => [x.id, x.carbon])); + let enriched = inhabitants.map(u => { + const kd = karmaMap.get(u.id) || {}; + const lhKey = larpHouseByUser.get(u.id) || 'academia'; + const lh = larpModel.getHouse(lhKey); + return { + ...u, + ecoAddress: addrMap.get(u.id) || null, + karmaScore: kd.karmaScore ?? (typeof u.karmaScore === 'number' ? u.karmaScore : 0), + estimatedUBI: kd.estimatedUBI || 0, + lastClaimedDate: kd.lastClaimedDate || null, + totalClaimed: kd.totalClaimed || 0, + lastActivityBucket: activityMap.get(u.id), + visibilityPrefs: prefsMap.get(u.id) || null, + relationship: relMap.get(u.id) || null, + larpHouse: lh ? { key: lhKey, ...lh } : null, + gpgFingerprint: gpgMap.get(u.id) || '', + deviceSource: deviceMap.get(u.id) || null, + stats: statsMap.get(u.id) || {}, + carbonGrams: carbonMap.get(u.id) || 0 + }; + }); + enriched = enriched.filter(u => u.id === userId || u.lastActivityBucket !== 'red'); + if (filter === 'TOP KARMA') { + enriched = enriched.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0)); + } + if (filter === 'TOP ACTIVITY') { + const order = { green: 0, orange: 1, red: 2 }; + enriched = enriched.sort( + (a, b) => (order[a.lastActivityBucket] ?? 3) - (order[b.lastActivityBucket] ?? 3) + ); + } + ctx.body = await inhabitantsView(enriched, filter, query, userId, fediverseModel.hasAccount()); + }) + .get('/inhabitant/:id', async (ctx) => { + const id = ctx.params.id; + const aboutModel = about; + const [aboutMsg, cv, feed, photo, bank, lastTs, visibilityPrefs, carbonGrams, larpHouseKey, deviceSource] = await Promise.all([ + inhabitantsModel.getLatestAboutById(id), + inhabitantsModel.getCVByUserId(id), + inhabitantsModel.getFeedByUserId(id), + inhabitantsModel.getPhotoUrlByUserId(id, 256), + bankingModel.getBankingData(id).catch(() => ({ karmaScore: 0 })), + inhabitantsModel.getLastActivityTimestampByUserId(id).catch(() => null), + aboutModel.visibilityPrefs(id).catch(() => null), + getCarbonGramsForFeed(id).catch(() => 0), + larpModel.getUserHouse(id).catch(() => null), + aboutModel.deviceSource(id).catch(() => null) + ]); + const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null; + const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null); + const currentUserId = getViewerId(); + const stats = await inhabitantsModel.getInhabitantStats(id, currentUserId).catch(() => ({})); + const karmaScore = bank && typeof bank.karmaScore === 'number' ? bank.karmaScore : 0; + const estimatedUBI = bank?.estimatedUBI || 0; + const lastClaimedDate = bank?.lastClaimedDate || null; + const totalClaimed = bank?.totalClaimed || 0; + const oasisVersion = (String(id) === String(currentUserId) ? OASIS_VERSION : null) || await getOasisVersion(id); + await warmAuthorNames(feed); + ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount()); + }) + .get('/parliament', async (ctx) => { + if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules'); + const filter = (ctx.query.filter || 'government').toLowerCase(); + await ensureTerm(); + await runSweepOnce(); + const [governmentCardRaw, candidatures, proposals, futureLaws, canPropose, laws, historical, leaders, revocations, futureRevocations, revocationsEnactedCount, inhabitantsAll] = await Promise.all([ + parliamentModel.getGovernmentCard(), + parliamentModel.listCandidatures('OPEN'), + parliamentModel.listProposalsCurrent(), + parliamentModel.listFutureLawsCurrent(), + parliamentModel.canPropose(), + parliamentModel.listLaws(), + parliamentModel.listHistorical(), + parliamentModel.listLeaders(), + parliamentModel.listRevocationsCurrent(), + parliamentModel.listFutureRevocationsCurrent(), + parliamentModel.countRevocationsEnacted(), + inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }) + ]); + const inhabitantsTotal = Array.isArray(inhabitantsAll) ? inhabitantsAll.length : 0; + const governmentCard = governmentCardRaw ? { ...governmentCardRaw, inhabitantsTotal } : null; + const leader = pickLeader(candidatures || []); + const getActorMeta = async (type, id) => (type === 'tribe' || type === 'inhabitant') ? parliamentModel.getActorMeta({ targetType: type, targetId: id }) : null; + const leaderMeta = leader ? await getActorMeta(leader.targetType || leader.powerType || 'inhabitant', leader.targetId || leader.powerId) : null; + const powerMeta = governmentCard ? await getActorMeta(governmentCard.powerType, governmentCard.powerId) : null; + const buildMetas = async (items, limit) => { + const m = {}; + for (const g of (items || []).slice(0, limit)) { + if (g.powerType === 'tribe' || g.powerType === 'inhabitant') { + const k = `${g.powerType}:${g.powerId}`; + if (!m[k]) m[k] = await getActorMeta(g.powerType, g.powerId); + } + } + return m; + }; + const [historicalMetas, leadersMetas] = await Promise.all([buildMetas(historical, 12), buildMetas(leaders, 20)]); + ctx.body = await parliamentView({ + filter, + inhabitantsTotal, + governmentCard, + candidatures, + proposals, + futureLaws, + canPropose, + laws, + historical, + leaders, + leaderMeta, + powerMeta, + historicalMetas, + leadersMetas, + revocations, + futureRevocations, + revocationsEnactedCount + }); + }) + .get('/courts', async (ctx) => { + if (!checkMod(ctx, 'courtsMod')) return ctx.redirect('/modules'); + try { await courtsModel.sweepCases(); } catch (_) {} + const filter = String(ctx.query.filter || 'cases').toLowerCase(), search = String(ctx.query.search || '').trim(); + const currentUserId = await courtsModel.getCurrentUserId(); + const state = { filter, search, cases: [], myCases: [], trials: [], history: [], nominations: [], userId: currentUserId }; + const searchFilter = (items) => !search ? items : items.filter(c => [c.title, c.description].some(s => String(s || '').toLowerCase().includes(search.toLowerCase()))); + if (filter === 'cases') state.cases = searchFilter((await courtsModel.listCases('open')).map(c => ({ ...c, respondent: c.respondentId || c.respondent }))); + if (filter === 'mycases' || filter === 'actions') { + let myCases = searchFilter(await courtsModel.listCasesForUser(currentUserId)); + if (filter === 'actions') myCases = myCases.filter(c => { + const s = String(c.status || '').toUpperCase(), m = String(c.method || '').toUpperCase(), id = String(currentUserId || ''); + const roles = { a: !!c.isAccuser, r: !!c.isRespondent, m: !!c.isMediator, j: !!c.isJudge, d: !!c.isDictator }; + const open = s === 'OPEN' || s === 'IN_PROGRESS'; + return (roles.r && open) || (m === 'JUDGE' && !c.judgeId && (roles.a || roles.r) && open) || ((roles.j || roles.d || roles.m) && s === 'OPEN') || ((roles.a || roles.r || roles.m) && m === 'MEDIATION' && open) || ((roles.a || roles.r || roles.m || roles.j || roles.d) && open); + }); + state.myCases = myCases; + } + if (filter === 'judges') state.nominations = (await courtsModel.listNominations()) || []; + if (filter === 'history') { + const id = String(currentUserId || ''); + state.history = searchFilter((await courtsModel.listCases('history')).map(c => { + const ma = Array.isArray(c.mediatorsAccuser) ? c.mediatorsAccuser : [], mr = Array.isArray(c.mediatorsRespondent) ? c.mediatorsRespondent : []; + return { ...c, respondent: c.respondentId || c.respondent, mine: [c.accuser, c.respondentId, c.judgeId].map(String).includes(id) || ma.includes(id) || mr.includes(id), publicDetails: c.publicPrefAccuser && c.publicPrefRespondent, decidedAt: c.verdictAt || c.closedAt || c.decidedAt }; + })); + } + ctx.body = await courtsView(state); + }) + .get('/courts/cases/:id', async (ctx) => { + if (!checkMod(ctx, 'courtsMod')) return ctx.redirect('/modules'); + ctx.body = await courtsCaseView({ caseData: await courtsModel.getCaseDetails({ caseId: ctx.params.id }).catch(() => null) }); + }) + .get('/tribes', async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const filter = qf(ctx), search = ctx.query.search || ''; + let tribes = await tribesModel.listTribesForViewer(uid); + tribes = dedupeLarpHouseTribes(tribes); + try { + if (tribes.some(t => (Array.isArray(t.tags) ? t.tags : []).some(x => String(x).startsWith('larp-')))) { + const houses = await larpModel.listHousesWithCounts().catch(() => []); + const countByTag = new Map(houses.map(h => [`larp-${h.name}`, h.memberCount])); + for (const t of tribes) { + const lt = (Array.isArray(t.tags) ? t.tags : []).find(x => String(x).startsWith('larp-')); + if (lt && countByTag.has(lt)) t.memberCount = countByTag.get(lt); + } + } + } catch (_) {} + let filteredTribes = search ? tribes.filter(t => t.title.toLowerCase().includes(search.toLowerCase())) : tribes; + try { filteredTribes = await lifetime.enrichAndFilter(filteredTribes, { getKey: (x) => x.id || x.key }); } catch (_) {} + try { await tribesModel.enrichOpenInvites(filteredTribes); } catch (_) {} + for (const t of filteredTribes) { + if (t.openInviteCode) t.openInviteQr = `/qr-invite/tribe/${encodeURIComponent(t.id)}`; + } + ctx.body = await tribesView(filteredTribes, filter, null, ctx.query, tribes); + }) + .get('/tribes/create', async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + ctx.body = await tribesView([], 'create', null) + }) + .get('/tribes/edit/:id', async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id) + ctx.body = await tribesView([tribe], 'edit', ctx.params.id) + }) + .get('/tribe/:tribeId', async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + await tribesModel.forceSync().catch(() => {}); + await tribesModel.processIncomingKeys().catch(() => {}); + await tribesModel.ensureTribeKeyDistribution(ctx.params.tribeId).catch(() => {}); + await tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {}); + await tribesModel.pruneOrphanKeys().catch(() => {}); + const listByTribeAllChain = async (tribeId, contentType) => { + const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]); + const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => []))); + const seen = new Set(); + return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; }); + }; + const tribe = await tribesModel.getTribeById(ctx.params.tribeId).catch(() => null); + if (!tribe) { ctx.redirect('/tribes'); return; } + const uid = getViewerId(); + const query = { feedFilter: 'TOP', ...ctx.query }; + if (tribe.isAnonymous === true && !tribe.members.includes(uid)) { + ctx.redirect('/tribes'); + return; + } + const section = ctx.query.section || 'activity'; + const contentTypeMap = { events: 'event', tasks: 'task', reports: 'report', votations: 'votation', market: 'market', jobs: 'job', projects: 'project', media: 'media' }; + const mediaSections = { 'media-audio': 'media', 'media-video': 'media', 'media-images': 'media', 'media-documents': 'media', 'media-bookmarks': 'media', 'images': 'media', 'audios': 'media', 'videos': 'media', 'documents': 'media', 'bookmarks': 'media' }; + let sectionData = null; + if (section === 'inhabitants') { + const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }); + sectionData = allInhabitants.filter(u => tribe.members.includes(u.id)); + } else if (section === 'feed') { + sectionData = await listByTribeAllChain(tribe.id, 'feed').catch(() => []); + } else if (section === 'forum') { + const forums = await listByTribeAllChain(tribe.id, 'forum'); + const replies = await listByTribeAllChain(tribe.id, 'forum-reply'); + sectionData = [...forums, ...replies]; + } else if (section === 'subtribes') { + sectionData = await tribesModel.listSubTribes(tribe.id, uid); + } else if (mediaSections[section]) { + sectionData = await listByTribeAllChain(tribe.id, 'media'); + } else if (contentTypeMap[section]) { + sectionData = await listByTribeAllChain(tribe.id, contentTypeMap[section]); + } else if (section === 'activity') { + const allContent = await listByTribeAllChain(tribe.id, null); + const subTribes = await tribesModel.listSubTribes(tribe.id, uid); + const subContent = []; + for (const st of subTribes) { + const stItems = await listByTribeAllChain(st.id, null).catch(() => []); + subContent.push(...stItems.map(item => ({ ...item, tribeName: st.title }))); + } + const [allPadsRaw, allChatsRaw, allCalsRaw, allMapsRaw, allTorrentsRaw, tribeChain] = await Promise.all([ + padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), + torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + const toStandalone = (type, url) => (item) => ({ contentType: type, id: item.rootId || item.key, title: item.title || '', author: item.author, createdAt: item.createdAt, directUrl: url(item) }); + const standaloneItems = [ + ...allPadsRaw.filter(p => tribeChainSet.has(p.tribeId)).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)), + ...allChatsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)), + ...allCalsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)), + ...allMapsRaw.filter(m => tribeChainSet.has(m.tribeId)).map(toStandalone('map', m => `/maps/${encodeURIComponent(m.key || m.id)}`)), + ...allTorrentsRaw.filter(t => tribeChainSet.has(t.tribeId)).map(toStandalone('torrent', t => `/torrents/${encodeURIComponent(t.rootId || t.key)}`)) + ]; + const combined = [...allContent, ...subContent, ...standaloneItems]; + const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }); + const allMembers = [...new Set([...tribe.members, ...subTribes.flatMap(st => st.members || [])])]; + const memberMap = new Map(allInhabitants.filter(u => allMembers.includes(u.id)).map(u => [u.id, u])); + const activities = combined.map(item => ({ ...item, authorName: memberMap.get(item.author)?.name || item.author, timestamp: Date.parse(item.createdAt) || item._ts || 0 })).sort((a, b) => b.timestamp - a.timestamp); + sectionData = { activities, memberMap }; + } else if (section === 'trending') { + const allContent = await listByTribeAllChain(tribe.id, null); + const period = ctx.query.period || 'all'; + let items = allContent.filter(i => i.contentType !== 'forum-reply' && i.contentType !== 'pixelia'); + if (period === 'day') items = items.filter(i => (Date.parse(i.createdAt) || i._ts || 0) >= Date.now() - 86400000); + else if (period === 'week') items = items.filter(i => (Date.parse(i.createdAt) || i._ts || 0) >= Date.now() - 7 * 86400000); + items.sort((a, b) => { + const score = i => (i.refeeds || 0) + (Array.isArray(i.attendees) ? i.attendees.length : 0) + Object.values(i.votes || {}).reduce((s, arr) => s + (Array.isArray(arr) ? arr.length : 0), 0) + (Array.isArray(i.assignees) ? i.assignees.length : 0) + (Array.isArray(i.opinions_inhabitants) ? i.opinions_inhabitants.length : 0); + return score(b) - score(a); + }); + sectionData = { items, period }; + } else if (section === 'tags') { + const allContent = await listByTribeAllChain(tribe.id, null); + const [allPadsT, allChatsT, allCalsT, allMapsT, allTorrentsT, subTribesT, tribeChainT] = await Promise.all([ + padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), + torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.listSubTribes(tribe.id, uid).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSetT = new Set(tribeChainT); + const standaloneTagged = [ + ...allPadsT.filter(p => tribeChainSetT.has(p.tribeId)).map(p => ({ ...p, contentType: 'pad', id: p.rootId || p.key })), + ...allChatsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'chat', id: c.rootId || c.key })), + ...allCalsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'calendar', id: c.rootId || c.key })), + ...allMapsT.filter(m => tribeChainSetT.has(m.tribeId)).map(m => ({ ...m, contentType: 'map', id: m.rootId || m.key })), + ...allTorrentsT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'torrent', id: t.rootId || t.key })), + ...subTribesT.map(st => ({ ...st, contentType: 'tribe', tags: Array.isArray(st.tags) ? st.tags : [], title: st.title, description: st.description, author: st.author, createdAt: st.createdAt })) + ]; + const allTaggable = [...allContent, ...standaloneTagged]; + const tagMap = new Map(); + for (const item of allTaggable) { + for (const tag of (item.tags || []).filter(Boolean)) { + const lower = String(tag).toLowerCase().trim(); + if (!lower) continue; + if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] }); + const entry = tagMap.get(lower); + entry.count++; + entry.items.push(item); + } + } + const selectedTag = (ctx.query.tag || '').toLowerCase().trim(); + sectionData = { tags: [...tagMap.values()].sort((a, b) => b.count - a.count), selectedTag, filteredItems: selectedTag && tagMap.has(selectedTag) ? tagMap.get(selectedTag).items : [] }; + } else if (section === 'maps') { + const [allMaps, tribeChain] = await Promise.all([ + mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allMaps.filter(m => tribeChainSet.has(m.tribeId)); + } else if (section === 'pads') { + const [allPads, tribeChain] = await Promise.all([ + padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allPads.filter(p => tribeChainSet.has(p.tribeId)); + } else if (section === 'chats') { + const [allChats, tribeChain] = await Promise.all([ + chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allChats.filter(c => tribeChainSet.has(c.tribeId)); + } else if (section === 'calendars') { + const [allCals, tribeChain] = await Promise.all([ + calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allCals.filter(c => tribeChainSet.has(c.tribeId)); + } else if (section === 'torrents') { + const [allTorrents, tribeChain] = await Promise.all([ + torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + const standaloneTorrents = allTorrents.filter(t => tribeChainSet.has(t.tribeId)); + const mediaTorrents = (await listByTribeAllChain(tribe.id, 'media').catch(() => [])) + .filter(m => m.mediaType === 'torrent') + .map(m => ({ + key: m.id, + rootId: m.id, + title: m.title || '', + description: m.description || '', + url: m.image || '', + tags: Array.isArray(m.tags) ? m.tags : [], + author: m.author, + createdAt: m.createdAt, + updatedAt: m.updatedAt, + tribeId: m.tribeId, + _isMedia: true + })); + sectionData = [...standaloneTorrents, ...mediaTorrents].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } else if (section === 'search') { + const sq = (ctx.query.q || '').trim().toLowerCase(); + let results = []; + if (sq.length >= 2) { + const allContent = await listByTribeAllChain(tribe.id, null); + results = allContent.filter(item => (item.title || '').toLowerCase().includes(sq) || (item.description || '').toLowerCase().includes(sq) || (item.tags || []).join(' ').toLowerCase().includes(sq)); + } + sectionData = { query: ctx.query.q || '', results }; + } else if (section === 'opinions') { + const allContent = await listByTribeAllChain(tribe.id, null); + const opinionated = allContent.filter(i => i.opinions && Object.keys(i.opinions).length > 0).sort((a, b) => { + const sum = o => Object.values(o.opinions || {}).reduce((s, n) => s + n, 0); + return sum(b) - sum(a); + }); + sectionData = { items: allContent.filter(i => i.contentType !== 'forum-reply' && i.contentType !== 'pixelia'), opinionated }; + } else if (section === 'pixelia') { + const pixels = await listByTribeAllChain(tribe.id, 'pixelia'); + const coordMap = new Map(); + for (const px of pixels) { const existing = coordMap.get(px.title); if (!existing || (Date.parse(px.createdAt) || 0) > (Date.parse(existing.createdAt) || 0)) coordMap.set(px.title, px); } + sectionData = { pixels: [...coordMap.values()] }; + } else if (section === 'overview') { + const events = await listByTribeAllChain(tribe.id, 'event').catch(() => []); + const tasks = await listByTribeAllChain(tribe.id, 'task').catch(() => []); + const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []); + sectionData = { events, tasks, feed }; + } else if (section === 'governance') { + if (tribe.parentTribeId) { ctx.redirect(`/tribe/${encodeURIComponent(tribe.id)}?section=activity`); return; } + const gf = String(ctx.query.filter || 'government'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (isCreator) { try { await parliamentModel.tribe.ensureTerm(tribe.id); } catch (_) {} } + const [term, candidatures, rules, globalTermBase] = await Promise.all([ + parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null), + parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []), + parliamentModel.tribe.listRules(tribe.id).catch(() => []), + parliamentModel.getCurrentTerm().catch(() => null) + ]); + const globalStart = globalTermBase?.startAt || null; + const alreadyPublishedThisGlobalCycle = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribe.id, globalStart).catch(() => false); + const leaders = Array.isArray(term?.leaders) ? term.leaders : []; + const hasElectedCandidate = Array.isArray(candidatures) && candidatures.some(c => (c.status || 'OPEN') === 'OPEN' && Number(c.votes || 0) > 0); + sectionData = { filter: gf, term, candidatures, rules, leaders, isCreator, isMember, canPublishToGlobal: isMember || isCreator, alreadyPublishedThisGlobalCycle, hasElectedCandidate }; + } + const subTribes = await tribesModel.listSubTribes(tribe.id, uid); + tribe.subTribes = subTribes; + if (tribe.parentTribeId) { + try { tribe.parentTribe = await tribesModel.getTribeById(tribe.parentTribeId); } catch (_) {} + } + const resolveItemMentions = async (items) => { + if (!Array.isArray(items)) return items; + for (const item of items) { + if (item.description) item.description = await resolveMentionText(item.description); + } + return items; + }; + if (Array.isArray(sectionData)) { + await resolveItemMentions(sectionData); + } else if (sectionData && typeof sectionData === 'object') { + if (sectionData.activities) await resolveItemMentions(sectionData.activities); + if (sectionData.items) await resolveItemMentions(sectionData.items); + if (sectionData.results) await resolveItemMentions(sectionData.results); + if (sectionData.events) await resolveItemMentions(sectionData.events); + if (sectionData.tasks) await resolveItemMentions(sectionData.tasks); + if (sectionData.feed) await resolveItemMentions(sectionData.feed); + } + try { + const HK = ['academia','solaris','arrakis','terraverde','unsystem','dogma','helix','quark','hermandad']; + const larpTag = Array.isArray(tribe.tags) ? tribe.tags.find(x => String(x).startsWith('larp-')) : null; + if (larpTag) { + const norm = larpTag.slice(5).toLowerCase().replace(/[^a-z0-9]/g, ''); + if (HK.includes(norm)) { + tribe.larpHouseKey = norm; + const houses = await larpModel.listHousesWithCounts().catch(() => []); + const h = (houses || []).find(x => x.key === norm); + if (h && typeof h.memberCount === 'number') tribe.memberCount = h.memberCount; + } + } + } catch (_) {} + try { + const openInvite = await tribesModel.getOpenInvite(tribe.id).catch(() => null); + if (openInvite) { + tribe.openInviteCode = openInvite.code; + tribe.openInviteBy = openInvite.by; + tribe.openInviteQr = `/qr-invite/tribe/${encodeURIComponent(tribe.id)}`; + } + } catch (_) {} + try { await warmAuthorNames(Array.isArray(sectionData) ? sectionData : (sectionData && sectionData.items) || []); } catch (_) {} + ctx.body = await tribeView(tribe, uid, query, section, sectionData); + }) + .get('/activity', async ctx => { + const filter = qf(ctx, 'recent'), userId = getViewerId(); + const q = String((ctx.query && ctx.query.q) || ''); + try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {} + try { await bankingModel.getUserEngagementScore(userId); } catch (_) {} + let allActions = await activityModel.listFeed('all'); + for (const action of allActions) { + if (action.type === 'pad') { + const c = action.value?.content || action.content || {}; + const rootId = action.id || action.key || ''; + const decrypted = await padsModel.decryptContent(c, rootId); + if (decrypted.title) { + if (action.value?.content) { action.value.content.title = decrypted.title; action.value.content.deadline = decrypted.deadline; } + else if (action.content) { action.content.title = decrypted.title; action.content.deadline = decrypted.deadline; } + } + } + if (action.type === 'parliamentProposal' || action.type === 'parliamentRevocation') { + const c = action.value?.content || action.content || {}; + if (c.voteId) { + const derived = await parliamentModel.deriveProposalStatus(c.method, c.voteId).catch(() => null); + if (derived) { + if (action.value?.content) action.value.content.status = derived; + else if (action.content) action.content.status = derived; + } + } + } + } + if (filter === 'larp') { + try { + const gk = larpModel.getGoverningHouseKey(); + const myHouse = await larpModel.getUserHouse(userId).catch(() => null); + const houseKeys = [...new Set([gk, myHouse].filter(Boolean))]; + const seenPosts = new Set(); + for (const hk of houseKeys) { + const posts = await larpModel.listHousePosts(hk, { viewerHouse: myHouse, isGoverning: hk === gk }); + for (const p of posts) { + if (seenPosts.has(p.id)) continue; + seenPosts.add(p.id); + allActions.push({ type: 'larpHousePost', id: p.id, author: p.author, ts: p.ts, content: { type: 'larpHousePost', text: p.text, createdAt: p.createdAt, house: hk } }); + } + } + } catch (_) {} + } + allActions = await applyListFilters(allActions, ctx); + const spreadMap = new Map(); + const SPREADABLE = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const targets = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && SPREADABLE.has(a.type)); + const results = await Promise.all(targets.map(a => spreads.forMessage(a.id).catch(() => null))); + targets.forEach((a, i) => { if (results[i]) spreadMap.set(a.id, results[i]); }); + try { + const uniqAuthors = new Set(); + const collect = (v) => { if (v && /^@.+\.ed25519$/.test(String(v))) uniqAuthors.add(String(v)); }; + for (const a of (allActions || [])) { + collect(a && a.author); + const c = a && (a.value?.content || a.content); + if (c) { collect(c.author); collect(c.proposer); collect(c.organizer); } + const replies = c && Array.isArray(c.replies) ? c.replies : []; + for (const r of replies) collect(r && r.author); + } + const nameOf = {}; + await Promise.all([...uniqAuthors].slice(0, 400).map(async (fid) => { + try { const nm = await about.name(fid); if (nm && nm !== fid.slice(1, 9)) nameOf[fid] = nm; } catch (_) {} + })); + if (Object.keys(nameOf).length) { + for (const a of (allActions || [])) { + const c = a && (a.value?.content || a.content); + const map = {}; + const push = (id) => { const s = id && String(id); if (s && nameOf[s]) map[s] = nameOf[s]; }; + push(a && a.author); push(c && c.author); push(c && c.proposer); push(c && c.organizer); + const replies = c && Array.isArray(c.replies) ? c.replies : []; + for (const r of replies) push(r && r.author); + if (Object.keys(map).length) a.authorNames = map; + } + } + } catch (_) {} + ctx.body = activityView(allActions, filter, userId, q, { spreadMap }); + }) + .get("/profile", async (ctx) => { + const myFeedId = await meta.myFeedId(), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1); + if (lt > 0 && gt > 0 && gt >= lt) throw new Error("Given search range is empty"); + const visibilityPrefs = await about.visibilityPrefs(myFeedId).catch(() => null); + const rawPrefs = visibilityPrefs || {}; + const needsBanking = (rawPrefs.karma !== false) || rawPrefs.ubi === true; + const needsWallet = rawPrefs.wallet === true; + const needsCarbon = rawPrefs.ecoTax !== false; + const needsLarp = rawPrefs.larpSign === true; + const [description, name, image, messages, firstPost, lastPost, ecoAddress, bankData, allActions, carbonGrams, larpHouseKey, gpgFingerprint, lastUserActivityTs] = await Promise.all([ + about.description(myFeedId), + about.name(myFeedId), + about.image(myFeedId), + post.fromPublicFeed(myFeedId, gt, lt), + post.firstBy(myFeedId), + post.latestBy(myFeedId), + needsWallet ? bankingModel.getUserAddress(myFeedId).catch(() => null) : Promise.resolve(null), + needsBanking ? bankingModel.getBankingData(myFeedId).catch(() => ({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 })) : Promise.resolve({ karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 }), + activityModel.listFeed('all').catch(() => []), + needsCarbon ? getCarbonGramsForFeed(myFeedId).catch(() => 0) : Promise.resolve(0), + needsLarp ? larpModel.getUserHouse(myFeedId).catch(() => null) : Promise.resolve(null), + about.gpgFingerprint(myFeedId).catch(() => ''), + inhabitantsModel.getLastActivityTimestampByUserId(myFeedId).catch(() => null) + ]); + const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null; + const stats = await inhabitantsModel.getInhabitantStats(myFeedId, myFeedId).catch(() => ({})); + const userActions = (allActions || []).filter(a => a && a.author === myFeedId && a.type !== 'tombstone' && a.type !== 'post'); + const normTs = t => { const n = Number(t || 0); return !isFinite(n) || n <= 0 ? 0 : n < 1e12 ? n * 1000 : n; }; + const pickTs = obj => { if (!obj) return 0; const v = obj.value || obj; return normTs(v.timestamp || v.ts || v.time || v.meta?.timestamp || 0); }; + const postActivityTs = Math.max(Array.isArray(messages) && messages.length ? Math.max(...messages.map(pickTs)) : 0, pickTs(lastPost), pickTs(firstPost)); + const lastActivityTs = Math.max(postActivityTs, Number(lastUserActivityTs) || 0); + const { bucket: lastActivityBucket } = inhabitantsModel.bucketLastActivity(lastActivityTs || null); + const baseUrl = resolveExternalBaseUrl(ctx); + const profileItems = await fetchProfileItems(myFeedId, rawPrefs); + const profileFilterType = String(ctx.query.type || '').toLowerCase(); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); + const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); + ctx.body = await authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); + }) + .get("/profile/edit", async (ctx) => { + const myFeedId = await meta.myFeedId(); + const [visibilityPrefs, name, description, gpgFingerprint] = await Promise.all([ + about.visibilityPrefs(myFeedId).catch(() => null), + about.name(myFeedId).catch(() => ''), + about.description(myFeedId).catch(() => ''), + about.gpgFingerprint(myFeedId).catch(() => '') + ]); + ctx.body = await editProfileView({ + name, + description, + visibilityPrefs: visibilityPrefs || {}, + feedId: myFeedId, + baseUrl: resolveExternalBaseUrl(ctx), + gpgFingerprint + }); + }) + .post("/profile/edit", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const imageFile = ctx.request.files?.image; + const mime = imageFile?.mimetype || imageFile?.type || ''; + const isImage = mime.startsWith('image/'); + const imageData = isImage && imageFile?.filepath ? await promisesFs.readFile(imageFile.filepath).catch(() => undefined) : undefined; + const gpgFile = ctx.request.files?.gpgKey; + let gpgKeyFingerprint; + let gpgKeyBlob; + if (gpgFile?.filepath && Number(gpgFile.size || 0) > 0) { + if (Number(gpgFile.size) > 51200) throw new Error("GPG key file too large (max 50KB)"); + const raw = await promisesFs.readFile(gpgFile.filepath, 'utf8').catch(() => ''); + const armored = String(raw || '').trim(); + if (armored) { + if (!armored.includes('-----BEGIN PGP PUBLIC KEY BLOCK-----')) { + throw new Error("Invalid GPG key: only armored public keys are accepted"); + } + if (armored.includes('-----BEGIN PGP PRIVATE KEY BLOCK-----')) { + throw new Error("Refusing to publish a private GPG key"); + } + const openpgp = require('../server/node_modules/openpgp'); + const parsed = await openpgp.readKey({ armoredKey: armored }).catch(() => null); + if (!parsed) throw new Error("Could not parse GPG key"); + if (parsed.isPrivate && parsed.isPrivate()) throw new Error("Refusing to publish a private GPG key"); + gpgKeyFingerprint = String(parsed.getFingerprint() || '').toUpperCase(); + gpgKeyBlob = Buffer.from(armored, 'utf8'); + const myFeedId = await meta.myFeedId(); + const keysDir = path.join(ssbConfig.path, 'keys'); + await promisesFs.mkdir(keysDir, { recursive: true }).catch(() => {}); + const safeName = encodeURIComponent(myFeedId) + '.asc'; + await promisesFs.writeFile(path.join(keysDir, safeName), armored, 'utf8'); + } + } + const body = ctx.request.body || {}; + const flag = (v) => v === '1' || v === 'on' || v === true; + const clearnetShops = flag(body.vis_clearnetShops); + const clearnetJobs = flag(body.vis_clearnetJobs); + const clearnetEvents = flag(body.vis_clearnetEvents); + const clearnetProjects = flag(body.vis_clearnetProjects); + const clearnetPosts = flag(body.vis_clearnetPosts); + const clearnetAudios = flag(body.vis_clearnetAudios); + const clearnetVideos = flag(body.vis_clearnetVideos); + const clearnetImages = flag(body.vis_clearnetImages); + const clearnetDocuments = flag(body.vis_clearnetDocuments); + const clearnetTorrents = flag(body.vis_clearnetTorrents); + const clearnetBookmarks = flag(body.vis_clearnetBookmarks); + const profileShops = flag(body.vis_profileShops); + const profileJobs = flag(body.vis_profileJobs); + const profileEvents = flag(body.vis_profileEvents); + const profileProjects = flag(body.vis_profileProjects); + const profilePosts = flag(body.vis_profilePosts); + const profileAudios = flag(body.vis_profileAudios); + const profileVideos = flag(body.vis_profileVideos); + const profileImages = flag(body.vis_profileImages); + const profileDocuments = flag(body.vis_profileDocuments); + const profileTorrents = flag(body.vis_profileTorrents); + const profileBookmarks = flag(body.vis_profileBookmarks); + const visibilityPrefs = { + activity: flag(body.vis_activity), + device: true, + karma: flag(body.vis_karma), + ubi: flag(body.vis_ubi), + wallet: flag(body.vis_wallet), + ecoTax: flag(body.vis_ecoTax), + larpSign: flag(body.vis_larpSign), + gpg: flag(body.vis_gpg), + fediverse: flag(body.vis_fediverse), + fediverseHandle: typeof body.fediverseHandle === 'string' ? body.fediverseHandle : '', + clearnet: clearnetShops || clearnetJobs || clearnetEvents || clearnetProjects || clearnetPosts || clearnetAudios || clearnetVideos || clearnetImages || clearnetDocuments || clearnetTorrents || clearnetBookmarks, + clearnetShops, + clearnetJobs, + clearnetEvents, + clearnetProjects, + clearnetPosts, + clearnetAudios, + clearnetVideos, + clearnetImages, + clearnetDocuments, + clearnetTorrents, + clearnetBookmarks, + profileShops, + profileJobs, + profileEvents, + profileProjects, + profilePosts, + profileAudios, + profileVideos, + profileImages, + profileDocuments, + profileTorrents, + profileBookmarks + }; + await post.publishProfileEdit({ + name: stripDangerousTags(String(body.name || '')), + description: stripDangerousTags(String(body.description || '')), + image: imageData, + visibilityPrefs, + gpgFingerprint: gpgKeyFingerprint, + gpgBlob: gpgKeyBlob + }); + ctx.redirect("/profile"); + }) + .post("/profile/gpg/remove", koaBody(), async (ctx) => { + const myFeedId = await meta.myFeedId(); + const keyPath = path.join(ssbConfig.path, 'keys', encodeURIComponent(myFeedId) + '.asc'); + await promisesFs.unlink(keyPath).catch(() => {}); + await post.publishProfileEdit({ gpgFingerprint: '', gpgBlobId: '' }); + ctx.redirect("/profile"); + }) + .get("/profile/:feed/gpg.asc", async (ctx) => { + const feedId = ctx.params.feed; + const blobId = await about.gpgBlobId(feedId).catch(() => ''); + let armored = ''; + if (blobId) { + const ssbX = await cooler.open(); + armored = await new Promise((resolve) => { + const chunks = []; + pull( + ssbX.blobs.get(blobId), + pull.collect((err, arr) => { + if (err || !arr) return resolve(''); + for (const ch of arr) chunks.push(Buffer.isBuffer(ch) ? ch : Buffer.from(ch)); + resolve(Buffer.concat(chunks).toString('utf8')); + }) + ); + }); + } + if (!armored) { + const keyPath = path.join(ssbConfig.path, 'keys', encodeURIComponent(feedId) + '.asc'); + armored = await promisesFs.readFile(keyPath, 'utf8').catch(() => ''); + } + if (!armored) { ctx.status = 404; ctx.body = ''; return; } + ctx.set('Content-Type', 'application/pgp-keys; charset=utf-8'); + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(feedId)}.asc"`); + ctx.body = armored; + }) + .post("/profile/clearnet-toggle", koaBody(), async (ctx) => { + const myFeedId = await meta.myFeedId(); + const current = await about.visibilityPrefs(myFeedId).catch(() => null) || {}; + const subKeys = [ + 'clearnetShops', 'clearnetJobs', 'clearnetEvents', 'clearnetProjects', + 'clearnetPosts', 'clearnetAudios', 'clearnetVideos', 'clearnetImages', + 'clearnetDocuments', 'clearnetTorrents' + ]; + const anyEnabled = subKeys.some(k => current[k] === true) || current.clearnet === true; + const nextOn = !anyEnabled; + const nextPrefs = { ...current, clearnet: nextOn }; + for (const k of subKeys) nextPrefs[k] = nextOn; + try { + await post.publishProfileEdit({ visibilityPrefs: nextPrefs }); + } catch (e) { console.error('profile/clearnet-toggle:', e.message); } + ctx.redirect('/profile'); + }) + .get("/publish/custom", async (ctx) => { + ctx.body = await publishCustomView(); + }) + .get("/json/:message", async (ctx) => { + if (config.public) { + throw new Error( + "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again." + ); + } + const { message } = ctx.params; + ctx.type = "application/json"; + const json = async (message) => { + const json = await meta.get(message); + return JSON.stringify(json, null, 2); + }; + ctx.body = await json(message); + }) + .get("/blob/:blobId", serveBlob) + .get("/c/blob/:cnBlobId", async (ctx) => { + const blobId = ctx.params.cnBlobId; + if (!isBlob(blobId)) { ctx.status = 404; ctx.body = ''; return; } + let buffer; + try { buffer = await blob.getResolved({ blobId }); } catch (_) {} + if (!buffer) { + ctx.status = 404; ctx.body = ''; return; + } + let mime = 'application/octet-stream'; + try { + const ft = await FileType.fromBuffer(buffer); + if (ft && ft.mime) mime = ft.mime; + } catch (_) {} + if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) { + const head = buffer.slice(0, 128).toString('ascii'); + if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent'; + } + ctx.set('Cache-Control', 'public, max-age=31536000, immutable'); + if (mime.startsWith('image/') && typeof sharp === 'function') { + try { + const img = sharp(buffer, { failOn: 'none' }); + const meta = await img.metadata().catch(() => ({})); + const format = (meta.format && ['jpeg','png','webp','avif','gif'].includes(meta.format)) ? meta.format : 'jpeg'; + const out = await img.rotate().toFormat(format).toBuffer(); + ctx.type = `image/${format === 'jpeg' ? 'jpeg' : format}`; + ctx.body = out; + return; + } catch (_) {} + } + ctx.type = mime; + if (mime === 'application/x-bittorrent') { + ctx.set('Content-Disposition', `attachment; filename="download.torrent"`); + } + ctx.body = buffer; + }) + .get("/qr/:feedId", async (ctx) => { + const feedId = decodeURIComponent(ctx.params.feedId || ''); + const reqSize = parseInt(ctx.query.size, 10); + const width = Number.isFinite(reqSize) ? Math.max(64, Math.min(512, reqSize)) : 240; + try { + const QRCode = require('../server/node_modules/qrcode'); + const targetUrl = `oasis://author/${encodeURIComponent(feedId)}`; + const buf = await QRCode.toBuffer(targetUrl, { type: 'png', width, margin: 1, errorCorrectionLevel: 'M' }); + ctx.set('Content-Type', 'image/png'); + ctx.set('Cache-Control', 'public, max-age=86400'); + ctx.body = buf; + } catch (e) { + ctx.status = 500; + ctx.body = ''; + } + }) + .get("/qr-invite/tribe/:id", async (ctx) => { + if (!checkMod(ctx, 'tribesMod')) { ctx.status = 404; ctx.body = ''; return; } + try { + const oi = await tribesModel.getOpenInvite(ctx.params.id).catch(() => null); + if (!oi) { ctx.status = 404; ctx.body = ''; return; } + const QRCode = require('../server/node_modules/qrcode'); + const joinUrl = `${resolveExternalBaseUrl(ctx)}/tribes/open-invite/join/${encodeURIComponent(ctx.params.id)}`; + const buf = await QRCode.toBuffer(joinUrl, { type: 'png', width: 240, margin: 1, errorCorrectionLevel: 'M' }); + ctx.set('Content-Type', 'image/png'); + ctx.set('Cache-Control', 'public, max-age=3600'); + ctx.body = buf; + } catch (e) { ctx.status = 500; ctx.body = ''; } + }) + .get("/qr-invite/shop/:id", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.status = 404; ctx.body = ''; return; } + try { + const oi = await shopsModel.getOpenInvite(ctx.params.id).catch(() => null); + if (!oi) { ctx.status = 404; ctx.body = ''; return; } + const QRCode = require('../server/node_modules/qrcode'); + const joinUrl = `${resolveExternalBaseUrl(ctx)}/shops/open-invite/join/${encodeURIComponent(ctx.params.id)}`; + const buf = await QRCode.toBuffer(joinUrl, { type: 'png', width: 240, margin: 1, errorCorrectionLevel: 'M' }); + ctx.set('Content-Type', 'image/png'); + ctx.set('Cache-Control', 'public, max-age=3600'); + ctx.body = buf; + } catch (e) { ctx.status = 500; ctx.body = ''; } + }) + .get("/qr-clearnet/:feedId", async (ctx) => { + const feedId = decodeURIComponent(ctx.params.feedId || ''); + const reqSize = parseInt(ctx.query.size, 10); + const width = Number.isFinite(reqSize) ? Math.max(64, Math.min(512, reqSize)) : 240; + try { + const QRCode = require('../server/node_modules/qrcode'); + const targetUrl = `${resolveExternalBaseUrl(ctx)}/c/inhabitant/${encodeURIComponent(feedId)}`; + const buf = await QRCode.toBuffer(targetUrl, { type: 'png', width, margin: 1, errorCorrectionLevel: 'M' }); + ctx.set('Content-Type', 'image/png'); + ctx.set('Cache-Control', 'public, max-age=86400'); + ctx.body = buf; + } catch (e) { + ctx.status = 500; + ctx.body = ''; + } + }) + .get("/image/:imageSize/:blobId", async (ctx) => { + const { blobId, imageSize } = ctx.params; + const size = Number(imageSize); + const fallbackPixel = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64" + ); + const fakeImage = () => { + if (typeof sharp !== "function") { + return Promise.resolve(fallbackPixel); + } + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0.5 }, + }, + }).png().toBuffer(); + }; + try { + const buffer = await blob.getResolved({ blobId }); + if (!buffer) { + ctx.set("Content-Type", "image/png"); + ctx.body = await fakeImage(); + return; + } + const fileType = await FileType.fromBuffer(buffer); + const mimeType = fileType?.mime || "application/octet-stream"; + ctx.set("Content-Type", mimeType); + if (typeof sharp === "function") { + ctx.body = await sharp(buffer) + .resize(size, size) + .png() + .toBuffer(); + } else { + ctx.body = buffer; + } + } catch (err) { + ctx.set("Content-Type", "image/png"); + ctx.body = await fakeImage(); + } + }) + .get("/settings", async (ctx) => { + const cfg = getConfig(), theme = ctx.cookies.get("theme") || "Dark-SNH"; + ctx.body = await settingsView({ theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "", fediverseAccount: fediverseModel.getAccount(), fediverseError: typeof ctx.query.fediverseError === "string" ? ctx.query.fediverseError : "" }); + }) + .get("/peers", async (ctx) => { + const { discoveredPeers, unknownPeers } = await meta.discovered(); + const lanBroadcastActive = !ssbConfig.pub; + const peerMap = new Map(); + const mergePeer = (key, info) => { + if (!key) return; + const prev = peerMap.get(key) || {}; + peerMap.set(key, { + key, + host: info.host || prev.host || null, + port: info.port || prev.port || null, + state: info.state || prev.state || 'idle', + stateChange: info.stateChange || prev.stateChange || null, + source: info.source || prev.source || null + }); + }; + try { + const gossipPathLocal = path.join(os.homedir(), '.ssb', 'gossip.json'); + let gossip = []; + try { gossip = JSON.parse(await promisesFs.readFile(gossipPathLocal, 'utf8')); } catch (_) {} + if (Array.isArray(gossip)) { + for (const g of gossip) { + if (!g || !g.key) continue; + mergePeer(g.key, { host: g.host, port: g.port, state: g.state, stateChange: g.stateChange, source: 'gossip.json' }); + } + } + } catch (_) {} + try { + const ssbX = await cooler.open(); + try { + const gp = (ssbX.gossip && typeof ssbX.gossip.peers === 'function') ? ssbX.gossip.peers() : []; + for (const p of (gp || [])) { + if (!p || !p.key) continue; + mergePeer(p.key, { host: p.host, port: p.port, state: p.state, stateChange: p.stateChange, source: 'gossip' }); + } + } catch (_) {} + try { + const snapshot = (ssbX.conn && typeof ssbX.conn.dbPeers === 'function') ? await ssbX.conn.dbPeers() : []; + for (const entry of (snapshot || [])) { + const data = Array.isArray(entry) ? entry[1] : entry; + const addr = Array.isArray(entry) ? entry[0] : null; + if (!data || !data.key) continue; + let host = data.host, port = data.port; + if ((!host || !port) && addr) { + const m = String(addr).match(/^net:([^:]+):(\d+)/); + if (m) { host = host || m[1]; port = port || Number(m[2]); } + } + mergePeer(data.key, { host, port, state: data.state, stateChange: data.stateChange, source: 'conn.dbPeers' }); + } + } catch (_) {} + try { + const livePeers = (ssbX.peers && typeof ssbX.peers === 'object') ? ssbX.peers : {}; + for (const rawKey of Object.keys(livePeers)) { + if (!rawKey || rawKey === ssbX.id) continue; + const rpcs = livePeers[rawKey]; + if (!Array.isArray(rpcs) || rpcs.length === 0) continue; + const addr = rpcs[0]?.stream?.address || null; + let host = null, port = null; + if (addr) { + const m = String(addr).match(/^net:([^:]+):(\d+)/); + if (m) { host = m[1]; port = Number(m[2]); } + } + mergePeer(rawKey, { host, port, state: 'connected', source: 'rpc' }); + } + } catch (_) {} + try { + if (ssbX.conn && typeof ssbX.conn.stagedPeers === 'function') { + const staged = await new Promise((resolve) => { + try { + pull( + ssbX.conn.stagedPeers(), + pull.take(1), + pull.collect((err, results) => { + if (err || !results || !results[0]) return resolve([]); + resolve(Array.isArray(results[0]) ? results[0] : []); + }) + ); + } catch (_) { resolve([]); } + }); + for (const entry of staged) { + const data = Array.isArray(entry) ? entry[1] : entry; + const addr = Array.isArray(entry) ? entry[0] : null; + if (!data || !data.key) continue; + let host = data.host, port = data.port; + if ((!host || !port) && addr) { + const m = String(addr).match(/^net:([^:]+):(\d+)/); + if (m) { host = host || m[1]; port = port || Number(m[2]); } + } + mergePeer(data.key, { host, port, state: 'staged', source: data.type === 'lan' ? 'lan' : (data.type || 'staged') }); + } + } + } catch (_) {} + } catch (_) {} + const PEER_IDLE_MAX_MS = 10 * 24 * 60 * 60 * 1000; + const nowTs = Date.now(); + const technicalPeers = Array.from(peerMap.values()) + .filter(p => { + const st = String(p.state || ''); + if (st === 'connected' || st === 'connecting' || st === 'staged') return true; + const sc = Number(p.stateChange) || 0; + return !sc || (nowTs - sc) < PEER_IDLE_MAX_MS; + }) + .sort((a, b) => (a.state === 'connected' ? -1 : 1) - (b.state === 'connected' ? -1 : 1)); + ctx.body = await peersView({ onlinePeers: await meta.onlinePeers(), discoveredPeers, unknownPeers, lanBroadcastActive, technicalPeers }); + }) + .get("/graphos", async (ctx) => { + if (!checkMod(ctx, 'graphosMod')) return ctx.redirect('/modules'); + try { + const ssbForLan = await cooler.open(); + try { if (ssbForLan.lan && typeof ssbForLan.lan.stop === 'function') ssbForLan.lan.stop(); } catch (_) {} + try { if (ssbForLan.lan && typeof ssbForLan.lan.start === 'function') ssbForLan.lan.start(); } catch (_) {} + } catch (_) {} + const filter = String(ctx.query?.filter || 'ALL').toUpperCase() === 'MINE' ? 'MINE' : 'ALL'; + const onlinePeers = await meta.onlinePeers(); + const { discoveredPeers, unknownPeers } = filter === 'MINE' + ? { discoveredPeers: [], unknownPeers: [] } + : await meta.discovered(); + const ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open(); + const myId = ssb.id; + const shortId = (key) => { + const core = String(key).replace(/^@/, '').replace(/\.ed25519$/, ''); + return '@' + core.slice(0, 8) + '…'; + }; + const resolveName = async (key) => { + try { + const n = await about.name(key); + if (!n) return shortId(key); + if (n === 'Redacted') return shortId(key); + if (n === String(key).replace(/^@/, '').slice(0, 8)) return shortId(key); + return n; + } catch { + return shortId(key); + } + }; + const GRAPHOS_MAX_NODES = 48; + const FEED_RE = /^@[A-Za-z0-9+/]+=?\.ed25519$/; + const centerRaw = ctx.query?.center ? decodeURIComponent(String(ctx.query.center)) : null; + const isFocus = !!(centerRaw && FEED_RE.test(centerRaw) && centerRaw !== myId); + + if (isFocus) { + const graph = await new Promise((res) => { + try { ssb.friends.graph((err, g) => res(err ? {} : (g || {}))); } catch (_) { res({}); } + }); + const rel = graph[centerRaw] || {}; + const followedAll = Object.entries(rel).filter(([, v]) => v >= 0).map(([k]) => k).filter(k => k !== centerRaw && k !== myId); + const followedIds = followedAll.slice(0, GRAPHOS_MAX_NODES); + const focusPeers = await Promise.all(followedIds.map(async (k) => ({ key: k, name: await resolveName(k), kind: 'discovered' }))); + focusPeers.push({ key: myId, name: await resolveName(myId), kind: 'me' }); + const focusMe = { key: centerRaw, name: await resolveName(centerRaw), kind: 'online' }; + const kpis = { total: followedAll.length, online: 0, discovered: followedAll.length, unknown: 0 }; + ctx.body = await graphosView({ filter, me: focusMe, peers: focusPeers, kpis, focus: String(focusMe.name || centerRaw).replace(/^@/, ''), focusId: centerRaw, shown: followedIds.length, total: followedAll.length }); + return; + } + + const keysOf = (entries) => { + const s = new Set(); + for (const [, data] of entries) if (data && data.key) s.add(data.key); + return s; + }; + const onlineKeys = keysOf(onlinePeers); + const unknownKeys = keysOf(unknownPeers); + // Nodes partition into discovered vs unknown (total = discovered + unknown); + // "online" is an orthogonal status flag (a subset of the nodes). + const seen = new Set([myId]); + const nodes = []; + const addFrom = (entries) => { + for (const [, data] of entries) { + if (!data || !data.key || seen.has(data.key)) continue; + seen.add(data.key); + const isOnline = onlineKeys.has(data.key); + const cat = unknownKeys.has(data.key) ? 'unknown' : 'discovered'; + nodes.push({ key: data.key, isOnline, cat, kind: isOnline ? 'online' : cat }); + } + }; + addFrom(onlinePeers); + addFrom(discoveredPeers); + addFrom(unknownPeers); + const shownNodes = nodes.slice(0, GRAPHOS_MAX_NODES); + const peers = await Promise.all(shownNodes.map(async (p) => ({ + key: p.key, + name: await resolveName(p.key), + kind: p.kind + }))); + const me = { key: myId, name: await resolveName(myId), kind: 'online' }; + const discoveredCount = nodes.filter(n => n.cat === 'discovered').length; + const unknownCount = nodes.filter(n => n.cat === 'unknown').length; + const onlineCount = nodes.filter(n => n.isOnline).length; + const kpis = { + total: discoveredCount + unknownCount, + online: onlineCount, + discovered: discoveredCount, + unknown: unknownCount + }; + ctx.body = await graphosView({ filter, me, peers, kpis, shown: peers.length, total: nodes.length }); + }) + .get("/larp", async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const rawFilter = String(ctx.query?.filter || 'ruling').toLowerCase(); + const filter = rawFilter === 'houses' ? 'houses' : rawFilter === 'rules' ? 'rules' : 'ruling'; + const myFeedId = await meta.myFeedId(); + const [houses, myHouseKey] = await Promise.all([ + larpModel.listHousesWithCounts(), + larpModel.getUserHouse(myFeedId).catch(() => null) + ]); + const cycle = larpModel.computeCycle(); + const governingKey = larpModel.getGoverningHouseKey(); + const governingHouseRaw = larpModel.getHouse(governingKey); + const governingHouse = { key: governingKey, ...governingHouseRaw, memberCount: (houses.find(h => h.key === governingKey) || {}).memberCount || 0 }; + let governingMembers = []; + let governingPosts = []; + if (filter === 'ruling') { + [governingMembers, governingPosts] = await Promise.all([ + larpModel.getMembersOfHouse(governingKey), + larpModel.listHousePosts(governingKey, { viewerHouse: myHouseKey, isGoverning: true }) + ]); + } + const canPost = myHouseKey === governingKey; + try { + const allTribes = await tribesModel.listAll(); + await tribesModel.enrichOpenInvites(allTribes); + for (const h of houses) { + const tr = allTribes.find(t => (t.tags || []).some(x => String(x).startsWith('larp-') && String(x).slice(5).toLowerCase() === h.key)); + if (tr && tr.openInviteCode) h.openInviteCode = tr.openInviteCode; + } + } catch (_) {} + ctx.body = larpListView({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost }); + }) + .get("/larp/test", async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const myFeedId = await meta.myFeedId(); + const [myHouseKey, houses, testStatus] = await Promise.all([ + larpModel.getUserHouse(myFeedId).catch(() => null), + larpModel.listHousesWithCounts(), + larpModel.canTakeTest(myFeedId) + ]); + if (myHouseKey !== 'academia') return ctx.redirect(myHouseKey ? `/larp/${encodeURIComponent(myHouseKey)}` : '/larp'); + const cycle = larpModel.computeCycle(); + const governingKey = larpModel.getGoverningHouseKey(); + const questions = testStatus.allowed ? larpModel.getProfileTest() : []; + ctx.body = larpTestView({ questions, cycle, houses, myHouseKey, governingKey, testStatus }); + }) + .post("/larp/test", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const myFeedId = await meta.myFeedId(); + const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null); + if (myHouseKey !== 'academia') return ctx.redirect(myHouseKey ? `/larp/${encodeURIComponent(myHouseKey)}` : '/larp'); + const body = ctx.request.body || {}; + const answers = []; + for (let i = 0; i < larpModel.TEST_QUESTIONS_COUNT; i += 1) { + const raw = body[`q${i}`]; + const n = parseInt(raw, 10); + answers.push(Number.isFinite(n) ? n : -1); + } + let result = null; + try { result = await larpModel.submitProfileTest({ answers }); } catch (_) { result = null; } + if (!result || result.ok === false) return ctx.redirect('/larp/test'); + const [houses, cycle] = [await larpModel.listHousesWithCounts(), larpModel.computeCycle()]; + const governingKey = larpModel.getGoverningHouseKey(); + const houseKey = result.house || 'academia'; + const houseRaw = larpModel.getHouse(houseKey) || {}; + const house = { key: houseKey, ...houseRaw }; + ctx.body = larpTestResultView({ house, result, cycle, houses, myHouseKey: houseKey, governingKey }); + }) + .get("/larp/test/:house", async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + return ctx.redirect('/larp/test'); + }) + .post("/larp/invite/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const houseKey = String((ctx.request.body && ctx.request.body.house) || '').toLowerCase(); + let result = null; + try { result = await larpModel.createHouseInvite(houseKey); } catch (_) { result = null; } + if (!result) return ctx.redirect(`/larp/${encodeURIComponent(houseKey || '')}`); + ctx.redirect(`/larp/${encodeURIComponent(houseKey)}?invite=${encodeURIComponent(result.code)}`); + }) + .post("/larp/invite/redeem", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const code = String((ctx.request.body && ctx.request.body.code) || '').trim(); + const ret = String((ctx.request.body && ctx.request.body.returnTo) || '').trim(); + let result = null; + try { result = await larpModel.redeemHouseInvite(code); } catch (_) { result = null; } + if (result && result.ok) return ctx.redirect(`/larp/${encodeURIComponent(result.house)}`); + const back = ret && ret.startsWith('/') ? ret : '/larp'; + ctx.redirect(back); + }) + .get("/larp/:house", async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const houseKey = String(ctx.params.house || '').toLowerCase(); + const houseRaw = larpModel.getHouse(houseKey); + if (!houseRaw) return ctx.redirect('/larp'); + const myFeedId = await meta.myFeedId(); + const [members, myHouseKey, houses] = await Promise.all([ + larpModel.getMembersOfHouse(houseKey), + larpModel.getUserHouse(myFeedId).catch(() => null), + larpModel.listHousesWithCounts() + ]); + if (myHouseKey === houseKey) { + Promise.resolve().then(async () => { + try { await larpModel.ensureHouseTribe(houseKey); } catch (_) {} + try { await larpModel.issueAutoInvitesForMyHouse(); } catch (_) {} + try { await larpModel.redeemPendingAutoInvites(); } catch (_) {} + }); + } + const cycle = larpModel.computeCycle(); + const governingKey = larpModel.getGoverningHouseKey(); + const canPost = myHouseKey === houseKey; + const posts = await larpModel.listHousePosts(houseKey, { viewerHouse: myHouseKey, isGoverning: houseKey === governingKey }); + const testStatus = houseKey === 'academia' ? await larpModel.canTakeTest(myFeedId) : null; + const questions = (houseKey === 'academia' && myHouseKey === 'academia' && testStatus && testStatus.allowed) + ? larpModel.getProfileTest() : []; + const house = { key: houseKey, ...houseRaw }; + const rawInvite = String(ctx.query?.invite || '').trim(); + const inviteCode = /^[0-9a-f]{32}$/i.test(rawInvite) && myHouseKey === houseKey ? rawInvite : null; + ctx.body = larpHouseView({ house, members, myHouseKey, cycle, governingKey, houses, posts, canPost, testStatus, inviteCode, questions }); + }) + .post("/larp/join", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const houseKey = String((ctx.request.body && ctx.request.body.house) || '').toLowerCase(); + if (houseKey !== 'academia') { ctx.redirect('/larp'); return; } + try { await larpModel.publishJoin('academia'); } catch (_) {} + ctx.redirect('/larp/academia'); + }) + .post("/larp/leave", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + try { await larpModel.publishLeaveLarp(); } catch (_) {} + ctx.redirect('/larp'); + }) + .get("/larp/tribe/:house", async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const houseKey = String(ctx.params.house || '').toLowerCase(); + const myFeedId = await meta.myFeedId(); + const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null); + if (myHouseKey !== houseKey) return ctx.redirect(`/larp/${encodeURIComponent(houseKey)}`); + let tribe = null; + try { tribe = await larpModel.ensureHouseTribe(houseKey); } catch (_) {} + if (!tribe || !tribe.id) return ctx.redirect(`/larp/${encodeURIComponent(houseKey)}`); + ctx.redirect(`/tribe/${encodeURIComponent(tribe.id)}`); + }) + .post("/larp/post", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); + const b = ctx.request.body || {}; + const houseKey = String(b.house || '').toLowerCase(); + const text = stripDangerousTags(String(b.text || '')); + const myFeedId = await meta.myFeedId(); + const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null); + if (myHouseKey !== houseKey || houseKey === 'academia') { ctx.redirect('/larp'); return; } + try { await larpModel.publishHousePost({ house: houseKey, text }); } catch (_) {} + ctx.redirect(`/larp/${encodeURIComponent(houseKey)}`); + }) + .get("/invites", async (ctx) => { + if (!checkMod(ctx, 'invitesMod')) return ctx.redirect('/modules'); + ctx.body = await invitesView({ flash: String(ctx.query.flash || '') }); + }) + .get("/likes/:feed", async (ctx) => { + const { feed } = ctx.params; + const messages = await post.likes({ feed }); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + ctx.body = await likesView({ messages, feed, name: await about.name(feed), spreadMap }); + }) + .get("/mentions", async (ctx) => { + const { messages, myFeedId } = await post.mentionsMe(); + const tribeMentions = []; + try { + const allTribes = await tribesModel.listAll(); + const myTribes = allTribes.filter(t => t.members.includes(myFeedId)); + for (const t of myTribes) { + const items = await tribesContentModel.listByTribe(t.id, null).catch(() => []); + for (const item of items) { + const text = (item.description || '') + ' ' + (item.title || ''); + if (text.includes(myFeedId) || text.includes(myFeedId.slice(1))) { + tribeMentions.push({ + key: item.id, + value: { + author: item.author, + timestamp: Date.parse(item.createdAt) || item._ts || Date.now(), + content: { + type: 'tribe-content', + text: item.description || item.title || '', + tribeId: t.id, + tribeName: t.title, + contentType: item.contentType, + mentions: { _self: [{ link: myFeedId }] } + } + } + }); + } + } + } + } catch (_) {} + const combined = [...(Array.isArray(messages) ? messages : []), ...tribeMentions]; + for (const msg of combined) { + if (!msg.value) continue; + const authorId = msg.value.author; + if (authorId) { + if (!msg.value.meta) msg.value.meta = {}; + if (!msg.value.meta.author) msg.value.meta.author = {}; + if (!msg.value.meta.author.name) { + try { msg.value.meta.author.name = await about.name(authorId); } catch (_) {} + } + } + } + ctx.body = await mentionsView({ messages: combined, myFeedId }); + }) + .get('/opinions', async (ctx) => { + const filter = qf(ctx, 'TOP'); + let opinions = await opinionsModel.listOpinions(filter); + if (Array.isArray(opinions)) opinions = await applyListFilters(opinions, ctx); + const spreadMap = new Map(); + const list = Array.isArray(opinions) ? opinions : []; + const results = await Promise.all(list.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null))); + list.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); }); + ctx.body = await opinionsView(opinions, filter, spreadMap); + }) + .get("/feed", async (ctx) => { + const filter = String(ctx.query.filter || "ALL").toUpperCase(); + const q = typeof ctx.query.q === "string" ? ctx.query.q : ""; + const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : ""; + const msg = typeof ctx.query.msg === "string" ? ctx.query.msg : ""; + let feeds = await feedModel.listFeeds({ filter, q, tag }); + feeds = await applyListFilters(feeds, ctx); + await warmAuthorNames(feeds); + ctx.body = feedView(feeds, { filter, q, tag, msg }); + }) + .get("/feed/create", async (ctx) => { + const q = typeof ctx.query.q === "string" ? ctx.query.q : ""; + const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : ""; + ctx.body = feedCreateView({ q, tag }); + }) + .get("/feed/:feedId", async (ctx) => { + const feed = await feedModel.getFeedById(ctx.params.feedId); + if (!feed) { ctx.redirect('/feed'); return; } + const comments = await feedModel.getComments(ctx.params.feedId).catch(() => []); + ctx.body = singleFeedView(feed, comments); + }) + .get("/fediverse", async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + const account = fediverseModel.getAccount(); + const stats = account ? await fediverseModel.getAccountStats() : null; + ctx.body = fediverseOverviewView({ account, stats }); + }) + .get("/fediverse/mastodon", async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; } + if (ctx.query.refresh) { try { fediverseModel.invalidateCache(); } catch (_) {} } + const data = await fediverseModel.getTimeline(); + if (data && data.connected) { data.stats = await fediverseModel.getAccountStats(); } + if (data && typeof ctx.query.error === 'string' && ctx.query.error) data.error = ctx.query.error; + ctx.body = fediverseView(data); + }) + .get("/fediverse/mastodon/thread/:id", async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; } + const account = fediverseModel.getAccount(); + const stats = account ? await fediverseModel.getAccountStats() : null; + const thread = await fediverseModel.getThread(ctx.params.id); + const error = (thread && thread.error) || (typeof ctx.query.error === 'string' && ctx.query.error ? ctx.query.error : undefined); + ctx.body = fediverseThreadView({ account, stats, thread, error }); + }) + .get("/fediverse/media", async (ctx) => { + const u = typeof ctx.query.u === 'string' ? ctx.query.u : ''; + const media = await fediverseModel.proxyMedia(u); + if (!media) { ctx.status = 404; ctx.body = ''; return; } + ctx.set('Cache-Control', 'private, max-age=3600'); + ctx.type = media.contentType; + ctx.body = media.buffer; + }) + .get("/fediverse/tmp/:name", async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.status = 404; ctx.body = ''; return; } + const name = ctx.params.name; + if (!isFediverseTmpName(name)) { ctx.status = 404; ctx.body = ''; return; } + try { + const buf = fs.readFileSync(path.join(blobsPath, name)); + ctx.type = fediverseMimeFromExt(fediverseExtFromName(name)); + ctx.set('Cache-Control', 'private, max-age=300'); + ctx.body = buf; + } catch (_) { ctx.status = 404; ctx.body = ''; } + }) + .get("/fediverse/mastodon/preview", async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; } + sweepFediverseTmp(); + const account = fediverseModel.getAccount(); + const stats = account ? await fediverseModel.getAccountStats() : null; + ctx.body = fediversePreviewView({ account, stats }); + }) + .post("/fediverse/mastodon/preview", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + sweepFediverseTmp(); + const text = ctx.request.body?.text || ''; + let existing = ctx.request.body?.tmp || []; + if (!Array.isArray(existing)) existing = existing ? [existing] : []; + existing = existing.filter(isFediverseTmpName).map(name => ({ name, type: fediverseTmpType(name) })); + const fresh = saveFediverseTempMedia(ctx); + const media = [...existing, ...fresh].slice(0, 4); + const account = fediverseModel.getAccount(); + const stats = account ? await fediverseModel.getAccountStats() : null; + const error = (!String(text).trim() && !media.length) ? 'fediverseErrEmpty' : undefined; + ctx.body = fediversePreviewView({ account, stats, text, media, error }); + }) + .post("/fediverse/mastodon/post", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + try { + const text = ctx.request.body?.text || ''; + const tmpIds = await publishFediverseTempMedia(ctx.request.body?.tmp); + const fileIds = (await collectFediverseMedia(ctx)).map(m => m.id); + const mediaIds = [...tmpIds, ...fileIds].slice(0, 4); + await fediverseModel.postStatus({ text, mediaIds }); + } catch (err) { + ctx.redirect('/fediverse/mastodon?error=' + encodeURIComponent(err && err.message ? err.message : 'fediverseErrPost')); + return; + } + ctx.redirect('/fediverse/mastodon'); + }) + .post("/fediverse/mastodon/reply/:id/preview", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + if (!fediverseModel.hasAccount()) { ctx.redirect('/fediverse'); return; } + sweepFediverseTmp(); + const id = ctx.params.id; + const text = ctx.request.body?.text || ''; + let existing = ctx.request.body?.tmp || []; + if (!Array.isArray(existing)) existing = existing ? [existing] : []; + existing = existing.filter(isFediverseTmpName).map(name => ({ name, type: fediverseTmpType(name) })); + const fresh = saveFediverseTempMedia(ctx); + const media = [...existing, ...fresh].slice(0, 4); + const account = fediverseModel.getAccount(); + const stats = account ? await fediverseModel.getAccountStats() : null; + const thread = await fediverseModel.getThread(id); + const parent = thread && thread.status ? thread.status : null; + const error = (!String(text).trim() && !media.length) ? 'fediverseErrEmpty' : undefined; + ctx.body = fediversePreviewView({ account, stats, text, media, error, replyToId: id, parent }); + }) + .post("/fediverse/mastodon/reply/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + const id = ctx.params.id; + try { + const text = ctx.request.body?.text || ''; + const tmpIds = await publishFediverseTempMedia(ctx.request.body?.tmp); + const fileIds = (await collectFediverseMedia(ctx)).map(m => m.id); + const mediaIds = [...tmpIds, ...fileIds].slice(0, 4); + await fediverseModel.postStatus({ text, inReplyToId: id, mediaIds }); + } catch (err) { + ctx.redirect(`/fediverse/mastodon/thread/${encodeURIComponent(id)}?error=` + encodeURIComponent(err && err.message ? err.message : 'fediverseErrPost')); + return; + } + ctx.redirect(`/fediverse/mastodon/thread/${encodeURIComponent(id)}`); + }) + .post("/fediverse/mastodon/boost/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + try { await fediverseModel.reblog(ctx.params.id); } catch (_) {} + ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon')); + }) + .post("/fediverse/mastodon/unboost/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + try { await fediverseModel.unreblog(ctx.params.id); } catch (_) {} + ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon')); + }) + .post("/fediverse/mastodon/fav/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + try { await fediverseModel.favourite(ctx.params.id); } catch (_) {} + ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon')); + }) + .post("/fediverse/mastodon/unfav/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } + try { await fediverseModel.unfavourite(ctx.params.id); } catch (_) {} + ctx.redirect(fediverseReturnTo(ctx, '/fediverse/mastodon')); + }) + .post("/settings/fediverse", koaBody(), async (ctx) => { + try { + const acc = await fediverseModel.connectMastodon({ instance: ctx.request.body?.instance, token: ctx.request.body?.token }); + try { + const host = String(acc.instance || '').replace(/^https?:\/\//, ''); + if (acc.acct && host) await post.publishFediverseHandle(`${acc.acct}@${host}`); + } catch (_) {} + ctx.redirect('/fediverse'); + } catch (err) { + ctx.redirect(`/settings?fediverseError=${encodeURIComponent(err.message || 'fediverseError')}`); + } + }) + .post("/settings/fediverse/disconnect", koaBody(), async (ctx) => { + try { fediverseModel.disconnect(); } catch (_) {} + try { await post.publishFediverseHandle(''); } catch (_) {} + ctx.redirect('/settings'); + }) + .get('/forum', async ctx => { + if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } + const filter = qf(ctx, 'hot'); + let forums = await forumModel.listAll(filter); + forums = await applyListFilters(forums, ctx); + try { forums = await lifetime.enrichAndFilter(forums); } catch (_) {} + const spreadMap = await spreads.forMessages((forums || []).map(x => x && (x.key || x.id))); + await warmAuthorNames(forums); + ctx.body = await forumView(forums, filter, { spreadMap }); + }) + .get('/forum/:forumId', async ctx => { + const msg = await forumModel.getMessageById(ctx.params.forumId), isReply = Boolean(msg.root), forumId = isReply ? msg.root : ctx.params.forumId; + const spreadInfo = await spreads.forMessage(forumId).catch(() => null); + const forumObj = await forumModel.getForumById(forumId); + try { const oi = await forumModel.getOpenInvite(forumId).catch(() => null); if (oi && forumObj) forumObj.openInviteCode = oi.code; } catch (_) {} + const forumMsgs = await forumModel.getMessagesByForumId(forumId); + await warmAuthorNames(forumObj, forumMsgs); + ctx.body = await singleForumView(forumObj, forumMsgs, ctx.query.filter, isReply ? ctx.params.forumId : null, { spreads: spreadInfo }); + }) + .get('/legacy', async (ctx) => { + if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules'); + try { ctx.body = await legacyView(); } catch (error) { sendErrorPage(ctx, error.message); } + }) + .get('/bookmarks', async (ctx) => { + if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); + const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', viewerId = getViewerId(); + const favs = await mediaFavorites.getFavoriteSet("bookmarks"); + let bookmarks = (await bookmarksModel.listAll({ viewerId, filter: filter === "favorites" ? "all" : filter, q, sort })).map(b => ({ ...b, isFavorite: favs.has(String(b.rootId || b.id)) })); + if (filter === "favorites") bookmarks = bookmarks.filter(b => b.isFavorite); + bookmarks = await applyListFilters(bookmarks, ctx); + await enrichWithComments(bookmarks, 'rootId'); + const spreadMap = await spreads.forMessages((bookmarks || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(bookmarks); + ctx.body = await bookmarkView(bookmarks, filter, null, { q, sort, spreadMap }); + }) + .get("/bookmarks/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); + const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await mediaFavorites.getFavoriteSet("bookmarks"); + ctx.body = await bookmarkView([{ ...bookmark, isFavorite: favs.has(String(bookmark.rootId || bookmark.id)) }], "edit", bookmark.id, { returnTo: ctx.query.returnTo || "" }); + }) + .get('/bookmarks/:bookmarkId', async (ctx) => { + if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); + const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', favs = await mediaFavorites.getFavoriteSet("bookmarks"); + const bookmark = await bookmarksModel.getBookmarkById(ctx.params.bookmarkId), root = bookmark.rootId || bookmark.id, comments = await getVoteComments(root); + await enrichItemLifetime(bookmark); + ctx.body = await singleBookmarkView({ ...bookmark, commentCount: comments.length, isFavorite: favs.has(String(root)) }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/bookmarks?filter=${encodeURIComponent(filter)}`, ['/bookmarks']), spreads: await spreads.forMessage(bookmark.id) }); + }) + .get('/tasks', async ctx => { + const filter = qf(ctx); + let tasks = await enrichWithComments(await tasksModel.listAll()); + tasks = await applyListFilters(tasks, ctx); + try { tasks = await lifetime.enrichAndFilter(tasks); } catch (_) {} + await enrichMsgSize(tasks); + const spreadMap = await spreads.forMessages((tasks || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(tasks); + ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap }); + }) + .get('/tasks/edit/:id', async ctx => { + const id = ctx.params.id; + const task = await tasksModel.getTaskById(id); + ctx.body = await taskView(task, 'edit', id, ctx.query.returnTo); + }) + .get('/tasks/:taskId', async ctx => { + const { taskId } = ctx.params, filter = qf(ctx), task = await tasksModel.getTaskById(taskId); + const comments = await getVoteComments(taskId); + await enrichMsgSize([task]); + await enrichItemLifetime(task); + ctx.body = await singleTaskView(withCount(task, comments), filter, comments, { spreads: await spreads.forMessage(task.id).catch(() => null) }); + }) + .get('/events', async (ctx) => { + if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } + const filter = qf(ctx); + let events = await enrichWithComments(await eventsModel.listAll(null, filter)); + events = await applyListFilters(events, ctx); + try { events = await lifetime.enrichAndFilter(events); } catch (_) {} + await enrichMsgSize(events); + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const spreadMap = await spreads.forMessages((events || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(events); + ctx.body = await eventView(events, filter, null, ctx.query.returnTo, { viewerPrefs, spreadMap }); + }) + .get('/events/edit/:id', async (ctx) => { + if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } + const eventId = ctx.params.id; + const event = await eventsModel.getEventById(eventId); + ctx.body = await eventView([event], 'edit', eventId, ctx.query.returnTo); + }) + .get('/events/:eventId', async ctx => { + await eventsModel.ingestKeys().catch(() => {}); + const { eventId } = ctx.params, filter = qf(ctx), event = await eventsModel.getEventById(eventId); + const [comments, mapData, linkedCalendarId] = await Promise.all([ + getVoteComments(eventId), + resolveMapUrl(event.mapUrl), + calendarsModel.findCalendarByLinkText(`/events/${eventId}`).catch(() => null) + ]); + await enrichMsgSize([event]); + await enrichItemLifetime(event, { author: event.organizer }); + try { const oi = await eventsModel.getOpenInvite(eventId).catch(() => null); if (oi) event.openInviteCode = oi.code; } catch (_) {} + const evAuthorPrefs2 = await about.visibilityPrefs(event.organizer).catch(() => null); + await warmAuthorNames(event, comments); + ctx.body = await singleEventView(withCount(event, comments), filter, comments, { mapData, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: evAuthorPrefs2, linkedCalendarId, spreads: await spreads.forMessage(event.id).catch(() => null) }); + }) + .get('/c/events/:eventId', async (ctx) => { + let event; + try { event = await eventsModel.getEventById(ctx.params.eventId); } catch (_) {} + if (!event || String(event.status || '').toUpperCase() === 'CLOSED' || event.isPublic === 'private') { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const evAuthorPrefs = await about.visibilityPrefs(event.organizer).catch(() => null); + if (!evAuthorPrefs || evAuthorPrefs.clearnetEvents !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = await clearnetEventView(event); + }) + .get('/votes', async ctx => { + const filter = qf(ctx); + let voteList = await enrichWithComments(await votesModel.listAll(filter)); + voteList = await applyListFilters(voteList, ctx); + try { voteList = await lifetime.enrichAndFilter(voteList, { getAuthor: (x) => x.createdBy }); } catch (_) {} + await enrichMsgSize(voteList); + const spreadMap = await spreads.forMessages((voteList || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(voteList); + ctx.body = await voteView(voteList, filter, null, [], filter, { spreadMap }); + }) + .get('/votes/edit/:id', async ctx => { + const id = ctx.params.id; + const activeFilter = (ctx.query.filter || 'mine'); + const voteData = await votesModel.getVoteById(id); + ctx.body = await voteView([voteData], 'edit', id, [], activeFilter); + }) + .get('/votes/:voteId', async ctx => { + const { voteId } = ctx.params, filter = qf(ctx), voteData = await votesModel.getVoteById(voteId); + const comments = await getVoteComments(voteId); + await enrichMsgSize([voteData]); + await enrichItemLifetime(voteData, { author: voteData.createdBy }); + ctx.body = await voteView([withCount(voteData, comments)], 'detail', voteId, comments, filter, { spreads: await spreads.forMessage(voteId).catch(() => null) }); + }) + .get("/market", async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const filter = qf(ctx), q = ctx.query.q || "", minPrice = ctx.query.minPrice ?? "", maxPrice = ctx.query.maxPrice ?? "", sort = ctx.query.sort || "recent"; + let marketItems = await marketModel.listAllItems("all"); + await marketModel.checkAuctionItemsStatus(marketItems); + marketItems = await marketModel.listAllItems("all"); + await enrichWithComments(marketItems); + marketItems = await applyListFilters(marketItems, ctx); + if (String(filter || '').toUpperCase() !== 'MINE') { + try { marketItems = await lifetime.enrichAndFilter(marketItems, { getCreatedAt: (x) => x.updatedAt || x.createdAt }); } catch (_) {} + } + const spreadMap = await spreads.forMessages((marketItems || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(marketItems); + ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort, spreadMap }); + }) + .get("/market/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const id = ctx.params.id + let marketItem = await marketModel.getItemById(id) + if (!marketItem) ctx.throw(404, "Item not found") + await marketModel.checkAuctionItemsStatus([marketItem]) + marketItem = await marketModel.getItemById(id) + if (!marketItem) ctx.throw(404, "Item not found") + ctx.body = await marketView([marketItem], "edit", marketItem, { q: "", minPrice: "", maxPrice: "", sort: "recent" }) + }) + .get("/market/:itemId", async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const { itemId } = ctx.params, filter = qf(ctx), q = ctx.query.q || "", minPrice = ctx.query.minPrice ?? "", maxPrice = ctx.query.maxPrice ?? "", sort = ctx.query.sort || "recent"; + let item = await marketModel.getItemById(itemId) + if (!item) ctx.throw(404, "Item not found") + await marketModel.checkAuctionItemsStatus([item]) + item = await marketModel.getItemById(itemId) + if (!item) ctx.throw(404, "Item not found") + const zoom = parseInt(ctx.query.zoom) || 2; + const [comments, mapData] = await Promise.all([getVoteComments(itemId), resolveMapUrl(item.mapUrl)]) + const returnTo = (() => { + const params = [] + if (filter) params.push(`filter=${encodeURIComponent(filter)}`) + if (q) params.push(`q=${encodeURIComponent(q)}`) + if (minPrice !== "" && minPrice != null) params.push(`minPrice=${encodeURIComponent(String(minPrice))}`) + if (maxPrice !== "" && maxPrice != null) params.push(`maxPrice=${encodeURIComponent(String(maxPrice))}`) + if (sort) params.push(`sort=${encodeURIComponent(sort)}`) + return `/market${params.length ? `?${params.join("&")}` : ""}` + })() + await enrichItemLifetime(item, { author: item.seller, createdAt: item.updatedAt || item.createdAt }) + ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo, mapData, zoom, spreads: await spreads.forMessage(item.id).catch(() => null) }) + }) + .get('/jobs', async (ctx) => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + let filter = String(ctx.query.filter || 'ALL').toUpperCase() + if (filter === 'FAVS' || filter === 'NEEDS') filter = 'ALL' + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const query = { + search: ctx.query.search || '', + minSalary: ctx.query.minSalary ?? '', + maxSalary: ctx.query.maxSalary ?? '', + sort: ctx.query.sort || 'recent', + viewerPrefs + } + if (filter === 'CREATE') { + ctx.body = await jobsView([], 'CREATE', query) + return + } + if (filter === 'CV') { + query.location = ctx.query.location || '' + query.language = ctx.query.language || '' + query.skills = ctx.query.skills || '' + const inhabitants = await inhabitantsModel.listInhabitants({ + filter: 'CVs', + ...query + }) + ctx.body = await jobsView(inhabitants, filter, query) + return + } + const viewerId = getViewerId() + let jobs = await jobsModel.listJobs(filter, viewerId, query) + await enrichWithComments(jobs) + jobs = await applyListFilters(jobs, ctx) + if (filter !== 'MINE') { + try { jobs = await lifetime.enrichAndFilter(jobs); } catch (_) {} + } + await enrichMsgSize(jobs) + const spreadMap = await spreads.forMessages((jobs || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(jobs); + ctx.body = await jobsView(jobs, filter, { ...(query || {}), spreadMap }) + }) + .get('/jobs/edit/:id', async (ctx) => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + const id = ctx.params.id + const viewerId = getViewerId() + const job = await jobsModel.getJobById(id, viewerId) + ctx.body = await jobsView([job], 'EDIT', {}) + }) + .get('/jobs/:jobId', async (ctx) => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + let jobId = ctx.params.jobId + if (jobId && jobId.startsWith('%25')) { + try { jobId = decodeURIComponent(jobId); } catch (_) {} + } + let filter = String(ctx.query.filter || 'ALL').toUpperCase() + if (filter === 'FAVS' || filter === 'NEEDS') filter = 'ALL' + const viewerId = getViewerId() + const params = { + search: ctx.query.search || '', + minSalary: ctx.query.minSalary ?? '', + maxSalary: ctx.query.maxSalary ?? '', + sort: ctx.query.sort || 'recent', + returnTo: safeReturnTo(ctx, `/jobs?filter=${encodeURIComponent(filter)}`, ['/jobs']) + } + let job; + try { + job = await jobsModel.getJobById(jobId, viewerId) + } catch (e) { + sendErrorPage(ctx, `Job not found or invalid id: ${jobId}`, { status: 404 }); + return; + } + if (!job) { + sendErrorPage(ctx, `Job not found: ${jobId}`, { status: 404 }); + return; + } + const [comments, mapData] = await Promise.all([getVoteComments(jobId), resolveMapUrl(job.mapUrl)]) + await enrichMsgSize([job]) + let candidates = []; + if (job && String(job.author) === String(viewerId)) { + try { candidates = await inhabitantsModel.getCandidatesForJob(job, viewerId); } catch (_) { candidates = []; } + } + const jobAuthorPrefs2 = await about.visibilityPrefs(job.author).catch(() => null); + await enrichItemLifetime(job) + ctx.body = await singleJobsView(withCount(job, comments), filter, comments, { ...params, mapData, candidates, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: jobAuthorPrefs2, spreads: await spreads.forMessage(job.id).catch(() => null) }) + }) + .get('/c/jobs/:jobId', async (ctx) => { + let job; + try { job = await jobsModel.getJobById(ctx.params.jobId); } catch (_) {} + if (!job || String(job.status || '').toUpperCase() === 'CLOSED' || String(job.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN') { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const jobAuthorPrefs = await about.visibilityPrefs(job.author).catch(() => null); + if (!jobAuthorPrefs || jobAuthorPrefs.clearnetJobs !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = await clearnetJobView(job); + }) + .get("/shops", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + const hasPurchases = (await shopsModel.listMyPurchases().catch(() => [])).length > 0; + if (filter === 'products' || filter === 'prices') { + const products = await shopsModel.listAllProducts({ filter: 'top', sort, viewerId: getViewerId() }); + const enriched = await Promise.all(products.map(async (prod) => { + try { + const shop = await shopsModel.getShopById(prod.shopId); + return { ...prod, shopTitle: shop ? shop.title : '' }; + } catch (_) { return prod; } + })); + ctx.body = await shopsView(enriched, filter, null, { q, sort, viewerPrefs, hasPurchases }); + return; + } + const items = await shopsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); + const fav = await mediaFavorites.getFavoriteSet('shops'); + let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); + let withFeatured = await Promise.all(enriched.map(async (shop) => { + shop.featuredProducts = await shopsModel.listFeaturedProducts(shop.rootId || shop.key); + return shop; + })); + try { withFeatured = await lifetime.enrichAndFilter(withFeatured, { getKey: (x) => x.rootId || x.key }); } catch (_) {} + const spreadMap = await spreads.forMessages((withFeatured || []).map(x => x && (x.key || x.id))); + await warmAuthorNames(withFeatured); + ctx.body = await shopsView(withFeatured, filter, null, { q, sort, viewerPrefs, spreadMap, hasPurchases }); + }) + .get("/shops/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const shop = await shopsModel.getShopById(ctx.params.id); + if (!shop) { ctx.redirect('/shops'); return; } + const fav = await mediaFavorites.getFavoriteSet('shops'); + ctx.body = await shopsView([{ ...shop, isFavorite: fav.has(String(shop.rootId || shop.key)) }], 'edit', shop, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/shops/product/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const product = await shopsModel.getProductById(ctx.params.id); + if (!product) { ctx.redirect('/shops'); return; } + ctx.body = await editProductView(product, ctx.query.shopId || product.shopId, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/shops/product/:productId", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const product = await shopsModel.getProductById(ctx.params.productId); + if (!product) { ctx.redirect('/shops'); return; } + const shop = await shopsModel.getShopById(product.shopId); + const comments = await getVoteComments(product.key); + const myPurchases = await shopsModel.listMyPurchases().catch(() => []); + const productRootId = product.rootId || product.key; + const canRate = myPurchases.some(o => o.productId === productRootId && String(o.status || '').toUpperCase() === 'RECEIVED'); + ctx.body = await singleProductView(withCount(product, comments), shop, comments, { shopId: product.shopId, canRate, returnTo: safeReturnTo(ctx, `/shops/${encodeURIComponent(product.shopId)}`, ['/shops']) }); + }) + .get("/c/audios/:id", async (ctx) => { + let item; try { item = await audiosModel.getAudioById(ctx.params.id); } catch (_) {} + const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null; + if (!item || !p || p.clearnetAudios !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'audio', item }); + }) + .get("/c/videos/:id", async (ctx) => { + let item; try { item = await videosModel.getVideoById(ctx.params.id); } catch (_) {} + const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null; + if (!item || !p || p.clearnetVideos !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'video', item }); + }) + .get("/c/images/:id", async (ctx) => { + let item; try { item = await imagesModel.getImageById(ctx.params.id); } catch (_) {} + const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null; + if (!item || !p || p.clearnetImages !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'image', item }); + }) + .get("/c/documents/:id", async (ctx) => { + let item; try { item = await documentsModel.getDocumentById(ctx.params.id); } catch (_) {} + const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null; + if (!item || !p || p.clearnetDocuments !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'document', item }); + }) + .get("/c/torrents/:id", async (ctx) => { + let item; try { item = await torrentsModel.getTorrentById(ctx.params.id); } catch (_) {} + const p = item && item.author ? await about.visibilityPrefs(item.author).catch(() => null) : null; + if (!item || !p || p.clearnetTorrents !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetMediaView({ kind: 'torrent', item }); + }) + .get("/c/blog/:msgKey", async (ctx) => { + const msgKey = String(ctx.params.msgKey || ''); + let msg; + try { + const ssbX = await cooler.open(); + msg = await new Promise((res, rej) => ssbX.get(msgKey, (err, m) => err || !m ? rej(err || new Error('not found')) : res(m))); + } catch (_) {} + const content = msg && msg.content; + if (!content || content.type !== 'post' || content.root) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const authorId = msg.author; + const postAuthorPrefs = await about.visibilityPrefs(authorId).catch(() => null); + if (!postAuthorPrefs || postAuthorPrefs.clearnetPosts !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const authorName = authorId ? await about.name(authorId).catch(() => '') : ''; + ctx.type = 'text/html'; + ctx.body = await clearnetBlogView({ + msgKey, + text: content.text || '', + author: authorId, + authorName, + contentWarning: content.contentWarning || '', + sentAt: msg.timestamp || content.sentAt + }); + }) + .get("/c/inhabitant/:feedId", async (ctx) => { + const feedId = decodeURIComponent(ctx.params.feedId || ''); + if (!ssbRef.isFeedId(feedId)) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const prefs = await about.visibilityPrefs(feedId).catch(() => null); + if (!prefs || prefs.clearnet !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const [name, description, imageBlobId] = await Promise.all([ + about.name(feedId).catch(() => ''), + about.description(feedId).catch(() => ''), + about.image(feedId).catch(() => null) + ]); + let safeImage = imageBlobId; + if (imageBlobId) { + const { blobIdOf } = require('../views/clearnet_view'); + const bid = blobIdOf(imageBlobId); + if (bid) { + try { + const ssbX = await cooler.open(); + const has = await new Promise(resolve => ssbX.blobs.has(bid, (err, h) => resolve(!err && !!h))); + if (!has) safeImage = null; + } catch (_) { safeImage = null; } + } else { + safeImage = null; + } + } + const MAX_PER_SECTION = 5; + const items = { shops: [], jobs: [], events: [], projects: [], posts: [], audios: [], videos: [], images: [], documents: [], torrents: [] }; + const mediaItemMapper = (m, { withImage = false } = {}) => ({ + id: m.key, + title: m.title || 'Untitled', + image: withImage ? (m.url || null) : null, + snippet: m.description || '', + meta: m.createdAt ? new Date(m.createdAt).toISOString().slice(0, 10) : '' + }); + if (prefs.clearnetShops) { + try { + const shops = await shopsModel.listAll({ filter: 'all' }).catch(() => []); + items.shops = (shops || []).filter(s => s.author === feedId && String(s.visibility || '').toUpperCase() !== 'CLOSED').slice(0, MAX_PER_SECTION).map(s => ({ + id: s.key, + title: s.title || 'Untitled', + image: s.image || null, + snippet: s.shortDescription || s.description || '', + meta: s.location || '' + })); + } catch (_) {} + } + if (prefs.clearnetJobs) { + try { + const jobs = await jobsModel.listJobs('ALL', feedId).catch(() => []); + items.jobs = (jobs || []).filter(j => j.author === feedId && String(j.status || '').toUpperCase() !== 'CLOSED' && String(j.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN').slice(0, MAX_PER_SECTION).map(j => ({ + id: j.id, + title: j.title || 'Untitled', + image: j.image || null, + snippet: j.description || '', + meta: j.location ? String(j.location).toUpperCase() : '' + })); + } catch (_) {} + } + if (prefs.clearnetEvents) { + try { + const events = await eventsModel.listAll(feedId, 'all').catch(() => []); + items.events = (events || []).filter(e => e.organizer === feedId && String(e.status || '').toUpperCase() !== 'CLOSED' && e.isPublic !== 'private').slice(0, MAX_PER_SECTION).map(e => ({ + id: e.id, + title: e.title || 'Untitled', + image: null, + snippet: e.description || '', + meta: e.date ? new Date(e.date).toISOString().slice(0, 10) : (e.location || '') + })); + } catch (_) {} + } + if (prefs.clearnetProjects) { + try { + const projects = await projectsModel.listProjects('ALL').catch(() => []); + items.projects = (projects || []).filter(p => p.author === feedId && String(p.status || '').toUpperCase() !== 'CANCELLED').slice(0, MAX_PER_SECTION).map(p => ({ + id: p.id || p.key, + title: p.title || 'Untitled', + image: p.image || null, + snippet: p.description || '', + meta: p.status ? String(p.status).toUpperCase() : '' + })); + } catch (_) {} + } + if (prefs.clearnetPosts) { + try { + const ssbX = await cooler.open(); + items.posts = await new Promise((resolve) => { + try { + pull( + ssbX.createUserStream({ id: feedId, reverse: true, limit: 200 }), + pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'post' && !m.value.content.root), + pull.collect((err, arr) => { + if (err || !Array.isArray(arr)) return resolve([]); + resolve(arr.slice(0, MAX_PER_SECTION).map(m => { + const c = m.value.content; + const cleanText = String(c.text || '').replace(/<[^>]+>/g, '').replace(/!\[[^\]]*\]\([^)]*\)/g, ''); + const firstLine = cleanText.split('\n').find(l => l.trim()) || ''; + const dateIso = m.value.timestamp ? new Date(m.value.timestamp).toISOString().slice(0, 10) : ''; + return { + id: m.key, + title: c.contentWarning || firstLine.slice(0, 80) || 'Blog', + image: null, + snippet: c.contentWarning ? firstLine.slice(0, 200) : cleanText.slice(0, 200), + meta: dateIso + }; + })); + }) + ); + } catch (_) { resolve([]); } + }); + } catch (_) {} + } + if (prefs.clearnetAudios) { + try { + const audios = await audiosModel.listAll('all').catch(() => []); + items.audios = (audios || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m)); + } catch (_) {} + } + if (prefs.clearnetVideos) { + try { + const videos = await videosModel.listAll('all').catch(() => []); + items.videos = (videos || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m)); + } catch (_) {} + } + if (prefs.clearnetImages) { + try { + const images = await imagesModel.listAll('all').catch(() => []); + items.images = (images || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m, { withImage: true })); + } catch (_) {} + } + if (prefs.clearnetDocuments) { + try { + const documents = await documentsModel.listAll('all').catch(() => []); + items.documents = (documents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m)); + } catch (_) {} + } + if (prefs.clearnetTorrents) { + try { + const torrents = await torrentsModel.listAll('all').catch(() => []); + items.torrents = (torrents || []).filter(m => m.author === feedId).slice(0, MAX_PER_SECTION).map(m => mediaItemMapper(m)); + } catch (_) {} + } + try { + const ssbBlob = await cooler.open(); + const { blobIdOf } = require('../views/clearnet_view'); + const blobCache = new Map(); + const checkBlob = (bid) => new Promise(resolve => { + if (!bid) return resolve(false); + if (blobCache.has(bid)) return resolve(blobCache.get(bid)); + try { + ssbBlob.blobs.has(bid, (err, has) => { + const ok = !err && !!has; + blobCache.set(bid, ok); + resolve(ok); + }); + } catch (_) { resolve(false); } + }); + for (const k of Object.keys(items)) { + for (const it of items[k]) { + const bid = blobIdOf(it.image); + if (bid) { + const ok = await checkBlob(bid); + if (!ok) it.image = null; + } + } + } + } catch (_) {} + const filterType = String(ctx.query.type || '').toLowerCase(); + ctx.type = 'text/html'; + ctx.body = await clearnetInhabitantView({ feedId, name, description, image: safeImage, prefs, items, filterType }); + }) + .get("/c/shops/:shopId", async (ctx) => { + const shop = await shopsModel.getShopById(ctx.params.shopId).catch(() => null); + if (!shop || String(shop.visibility || '').toUpperCase() === 'CLOSED') { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const shopAuthorPrefs = await about.visibilityPrefs(shop.author).catch(() => null); + if (!shopAuthorPrefs || shopAuthorPrefs.clearnetShops !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const products = await shopsModel.listProducts(shop.rootId || shop.key).catch(() => []); + ctx.type = 'text/html'; + ctx.body = await clearnetShopView(shop, products || []); + }) + .get("/shops/purchases", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const purchases = await shopsModel.listMyPurchases().catch(() => []); + ctx.body = await myPurchasesView(purchases); + }) + .get("/shops/:shopId", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const shop = await shopsModel.getShopById(ctx.params.shopId); + if (!shop) { ctx.redirect('/shops'); return; } + if (shop.encrypted && shop.undecryptable && shop.author !== getViewerId()) { ctx.redirect('/invites#invites-shops'); return; } + const fav = await mediaFavorites.getFavoriteSet('shops'); + const [products, comments, mapData] = await Promise.all([shopsModel.listProducts(shop.rootId || shop.key), getVoteComments(shop.key), resolveMapUrl(shop.mapUrl)]); + const baseUrl = resolveExternalBaseUrl(ctx); + const authorPrefs = await about.visibilityPrefs(shop.author).catch(() => null); + await enrichItemLifetime(shop, { key: shop.rootId || shop.key }); + const pendingOrders = shop.author === getViewerId() ? await shopsModel.countPendingOrders(shop.rootId || shop.key).catch(() => 0) : 0; + let shopOpenInviteCode = null, shopOpenInviteQr = null; + try { + const oi = await shopsModel.getOpenInvite(shop.key).catch(() => null); + if (oi) { + shopOpenInviteCode = oi.code; + shopOpenInviteQr = `/qr-invite/shop/${encodeURIComponent(shop.key)}`; + } + } catch (_) {} + await warmAuthorNames(shop, products, comments); + ctx.body = await singleShopView({ ...shop, isFavorite: fav.has(String(shop.rootId || shop.key)), commentCount: comments.length, pendingOrders, openInviteCode: shopOpenInviteCode, openInviteQr: shopOpenInviteQr }, filter, products, comments, { q, sort, returnTo: safeReturnTo(ctx, `/shops?filter=${encodeURIComponent(filter)}`, ['/shops']), mapData, baseUrl, authorPrefs, spreads: await spreads.forMessage(shop.key).catch(() => null) }); + }) + .get("/shops/:shopId/orders", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const shop = await shopsModel.getShopById(ctx.params.shopId); + if (!shop) { sendErrorPage(ctx, "Shop not found", { status: 404 }); return; } + const rootId = shop.rootId || shop.key; + const orders = await shopsModel.listShopOrders(rootId).catch(e => { throw e; }); + ctx.body = await shopOrdersView(shop, orders); + }) + .post("/shops/orders/:orderId/status", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + const shopId = String(b.shopId || ''); + try { + await shopsModel.setOrderStatus(ctx.params.orderId, b.status); + } catch (_) {} + const dest = (typeof b.returnTo === 'string' && b.returnTo.startsWith('/shops')) ? b.returnTo : `/shops/${encodeURIComponent(shopId)}/orders`; + ctx.redirect(dest); + }) + .get("/chats", async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const { filter = 'all', q = '', tribeId = '' } = ctx.query; + const viewerId = getViewerId(); + if (filter === 'create') { + ctx.body = await chatsView([], 'create', null, { q, ...(tribeId ? { tribeId } : {}) }); + return; + } + const modelFilter = filter === "favorites" ? "all" : filter; + const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId }); + const fav = await mediaFavorites.getFavoriteSet('chats'); + const myTribeIds = await getUserTribeIds(viewerId); + const enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched; + finalList = await applyListFilters(finalList, ctx); + try { finalList = await lifetime.enrichAndFilter(finalList, { getKey: (x) => x.rootId || x.key }); } catch (_) {} + const spreadMap = await spreads.forMessages((finalList || []).map(x => x && (x.key || x.id))); + await warmAuthorNames(finalList); + ctx.body = await chatsView(finalList, filter, null, { q, spreadMap }); + }) + .get("/chats/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const chat = await chatsModel.getChatById(ctx.params.id); + if (!chat) { ctx.redirect('/chats'); return; } + ctx.body = await chatsView([], 'edit', chat, { returnTo: ctx.query.returnTo || '' }); + }) + .get("/chats/:chatId", async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + await chatsModel.ingestKeys().catch(() => {}); + const { filter = 'all', q = '' } = ctx.query; + const uid = getViewerId(); + let chat = await chatsModel.getChatById(ctx.params.chatId); + if (!chat) { ctx.redirect('/chats'); return; } + let parentTribe = null; + if (chat.tribeId) { + try { + parentTribe = await tribesModel.getTribeById(chat.tribeId); + if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; } + await tribesModel.processIncomingKeys().catch(() => {}); + chat = await chatsModel.getChatById(ctx.params.chatId); + } catch { ctx.redirect('/tribes'); return; } + } else { + const members = Array.isArray(chat.members) ? chat.members : []; + const isOpen = String(chat.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && chat.author !== uid && !members.includes(uid)) { ctx.redirect('/chats?filter=all'); return; } + } + const fav = await mediaFavorites.getFavoriteSet('chats'); + const messages = await chatsModel.listMessages(chat.rootId || chat.key); + const isTribeMember = !!parentTribe; + ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) }); + }) + .get("/pads", async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || "all").toLowerCase(); + const uid = getViewerId(); + if (filter === "edit") { + const id = ctx.query.id; + if (!id) { ctx.redirect('/pads'); return; } + const pad = await padsModel.getPadById(id); + if (!pad || pad.author !== uid) { ctx.redirect('/pads'); return; } + ctx.body = await padsView([], "edit", pad, {}); + return; + } + const q = String(ctx.query.q || "").trim(); + const tribeId = ctx.query.tribeId || ""; + const pads = await padsModel.listAll({ filter, viewerId: uid }); + const fav = await mediaFavorites.getFavoriteSet('pads'); + let enriched = pads.filter(p => !p.tribeId).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) })); + enriched = await applyListFilters(enriched, ctx); + try { enriched = await lifetime.enrichAndFilter(enriched, { getKey: (x) => x.rootId || x.key }); } catch (_) {} + const spreadMap = await spreads.forMessages((enriched || []).map(x => x && (x.rootId || x.key || x.id))); + await warmAuthorNames(enriched); + ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}), spreadMap }); + }) + .get("/pads/:padId", async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + await padsModel.ingestKeys().catch(() => {}); + const uid = getViewerId(); + let pad = await padsModel.getPadById(ctx.params.padId); + if (!pad) { ctx.redirect('/pads'); return; } + let parentTribe = null; + if (pad.tribeId) { + try { + parentTribe = await tribesModel.getTribeById(pad.tribeId); + if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; } + await tribesModel.processIncomingKeys().catch(() => {}); + pad = await padsModel.getPadById(ctx.params.padId); + } catch { ctx.redirect('/tribes'); return; } + } else { + const members = Array.isArray(pad.members) ? pad.members : []; + const isOpen = String(pad.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && pad.author !== uid && !members.includes(uid)) { ctx.redirect('/pads?filter=all'); return; } + } + const fav = await mediaFavorites.getFavoriteSet('pads'); + const entries = await padsModel.getEntries(pad.rootId); + const versionKey = ctx.query.version || null; + const selectedVersion = versionKey + ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null) + : null; + const baseUrl = `${ctx.protocol}://${ctx.host}`; + const isTribeMember = !!parentTribe; + ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)), isTribeMember }, entries, { baseUrl, selectedVersion }); + }) + .get("/calendars", async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || "all").toLowerCase(); + const uid = getViewerId(); + if (filter === "edit") { + const id = ctx.query.id; + if (!id) { ctx.redirect('/calendars'); return; } + const cal = await calendarsModel.getCalendarById(id); + if (!cal || cal.author !== uid) { ctx.redirect('/calendars'); return; } + ctx.body = await calendarsView([], "edit", cal, {}); + return; + } + const q = String(ctx.query.q || "").trim(); + const tribeId = ctx.query.tribeId || ""; + const modelFilter = filter === "favorites" ? "all" : filter; + const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid }); + const fav = await mediaFavorites.getFavoriteSet('calendars'); + const myTribeIds = await getUserTribeIds(uid); + const enriched = calendars.filter(c => !c.tribeId).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) })); + let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched; + finalList = await applyListFilters(finalList, ctx); + try { finalList = await lifetime.enrichAndFilter(finalList, { getKey: (x) => x.rootId || x.key }); } catch (_) {} + const spreadMap = await spreads.forMessages((finalList || []).map(x => x && (x.rootId || x.key || x.id))); + await warmAuthorNames(finalList); + ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}), spreadMap }); + }) + .get("/calendars/:calId", async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + await calendarsModel.ingestKeys().catch(() => {}); + const uid = getViewerId(); + const cal = await calendarsModel.getCalendarById(ctx.params.calId); + if (!cal) { ctx.redirect('/calendars'); return; } + let parentTribe = null; + if (cal.tribeId) { + try { + parentTribe = await tribesModel.getTribeById(cal.tribeId); + if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; } + } catch { ctx.redirect('/tribes'); return; } + } else { + const participants = Array.isArray(cal.participants) ? cal.participants : []; + const isOpen = String(cal.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && cal.author !== uid && !participants.includes(uid)) { ctx.redirect('/calendars?filter=all'); return; } + } + if (String(cal.status || '').toUpperCase() === 'CLOSED' && cal.author !== uid) { + ctx.body = tribeAccessDeniedView(parentTribe); return; + } + const dates = await calendarsModel.getDatesForCalendar(cal.rootId); + const notesByDate = {}; + for (const d of dates) { + notesByDate[d.key] = await calendarsModel.getNotesForDate(cal.rootId, d.key); + } + const fav = await mediaFavorites.getFavoriteSet('calendars'); + const month = String(ctx.query.month || "").trim() || null; + const day = String(ctx.query.day || "").trim() || null; + await enrichItemLifetime(cal, { key: cal.rootId }); + ctx.body = await singleCalendarView({ ...cal, isFavorite: fav.has(String(cal.rootId)) }, dates, notesByDate, { month, day, spreads: await spreads.forMessage(cal.rootId).catch(() => null) }); + }) + .get("/projects", async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || "ALL").toUpperCase() + const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); + if (filter === "CREATE") { + ctx.body = await projectsView([], "CREATE", null, { viewerPrefs }) + return + } + const modelFilter = filter === "BACKERS" ? "ALL" : filter + let projects = await projectsModel.listProjects(modelFilter) + await enrichWithComments(projects) + projects = await applyListFilters(projects, ctx) + try { projects = await lifetime.enrichAndFilter(projects); } catch (_) {} + await enrichMsgSize(projects) + const spreadMap = await spreads.forMessages((projects || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(projects); + ctx.body = await projectsView(projects, filter, null, { viewerPrefs, spreadMap }) + }) + .get("/projects/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = ctx.params.id + const pr = await projectsModel.getProjectById(id) + ctx.body = await projectsView([pr], "EDIT") + }) + .get("/projects/:projectId", async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const projectId = ctx.params.projectId + const filter = String(ctx.query.filter || "ALL").toUpperCase() + const project = await projectsModel.getProjectById(projectId) + const zoom = parseInt(ctx.query.zoom) || 2; + const [comments, mapData] = await Promise.all([getVoteComments(projectId), resolveMapUrl(project.mapUrl)]) + await enrichMsgSize([project]) + await enrichItemLifetime(project, { key: project.id || project.key }) + ctx.body = await singleProjectView(withCount(project, comments), filter, comments, { mapData, zoom, baseUrl: resolveExternalBaseUrl(ctx), spreads: await spreads.forMessage(project.id).catch(() => null) }) + }) + .get("/c/projects/:projectId", async (ctx) => { + let project; + try { project = await projectsModel.getProjectById(ctx.params.projectId); } catch (_) {} + if (!project || String(project.status || '').toUpperCase() === 'CANCELLED') { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + const projAuthorPrefs = await about.visibilityPrefs(project.author).catch(() => null); + if (!projAuthorPrefs || projAuthorPrefs.clearnetProjects !== true) { + ctx.type = 'text/html'; + ctx.body = require('../views/clearnet_view').renderClearnetNotFound(); + return; + } + ctx.type = 'text/html'; + ctx.body = await clearnetProjectView(project); + }) + .get("/banking", async (ctx) => { + if (!checkMod(ctx, 'bankingMod')) { ctx.redirect('/modules'); return; } + const userId = getViewerId(); + const query = ctx.query; + const filter = (query.filter || 'overview').toLowerCase(); + const q = (query.q || '').trim(); + const msg = (query.msg || '').trim(); + await bankingModel.ensureSelfAddressPublished(); + const data = await bankingModel.listBanking(filter, userId); + data.isPub = bankingModel.isPubNode(); + data.alreadyClaimed = data.summary?.alreadyClaimed || false; + if (filter === 'overview') { + const pending = (data.allocations || []).find(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED")); + data.pendingUBI = pending || null; + } + if (filter === 'addresses' && q) { + data.addresses = (data.addresses || []).filter(x => + String(x.id).toLowerCase().includes(q.toLowerCase()) || + String(x.address).toLowerCase().includes(q.toLowerCase()) + ); + data.search = q; + } + data.flash = msg || ''; + const { ecoValue, inflationFactor, inflationMonthly, ecoTimeMs, currentSupply, isSynced } = await bankingModel.calculateEcoinValue(); + data.exchange = { + ecoValue, + inflationFactor, + inflationMonthly, + ecoTimeMs, + currentSupply, + totalSupply: 25500000, + isSynced + }; + if (filter === 'taxes') { + const inspectBlock = async (blockId) => { + if (!blockId) return null; + try { + const blk = await blockchainModel.getBlockById(blockId, userId); + if (blk && blk.id) { + const sizeBytes = Number(blk.size || 0); + const grams = (sizeBytes / (1024 * 1024)) * 0.095; + const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1); + return { block: blockId, found: true, size: sizeBytes, author: blk.author, blockType: blk.type, ecoinTax }; + } + } catch (_) {} + try { + const ssbClient = await cooler.open(); + const raw = await new Promise((resolve, reject) => ssbClient.get(blockId, (err, v) => err ? reject(err) : resolve(v))); + if (raw) { + const sizeBytes = Buffer.byteLength(JSON.stringify(raw), 'utf8'); + let bType = (raw.content && typeof raw.content === 'object' && raw.content.type) || null; + if (!bType && typeof raw.content === 'string' && raw.content.endsWith('.box')) { + try { + const dec = ssbClient.private.unbox({ key: blockId, value: raw, timestamp: raw.timestamp || 0 }); + if (dec && dec.value && dec.value.content && dec.value.content.type) bType = dec.value.content.type; + } catch (_) {} + if (!bType) bType = 'encrypted'; + } + const grams = (sizeBytes / (1024 * 1024)) * 0.095; + const ecoinTax = grams * (bankingModel.ECOIN_PER_GRAM_CO2 || 0.1); + return { block: blockId, found: true, size: sizeBytes, author: raw.author, blockType: bType || 'unknown', ecoinTax }; + } + } catch (_) {} + return { block: blockId, found: false }; + }; + let firstKey = null; + try { + const ssbClient = await cooler.open(); + firstKey = await new Promise((resolve) => { + let first = null; + pull( + ssbClient.createUserStream({ id: userId, limit: 1 }), + pull.drain( + (m) => { if (!first && m && m.key) first = m.key; }, + () => resolve(first) + ) + ); + }); + } catch (_) {} + let firstBlockSize = 0; + if (firstKey) { + const firstLookup = await inspectBlock(firstKey); + if (firstLookup && firstLookup.found) firstBlockSize = Number(firstLookup.size || 0); + } + data.firstBlock = firstKey || null; + data.firstBlockSize = firstBlockSize; + const blockId = String(query.block || '').trim(); + let lookup = null; + if (blockId) lookup = await inspectBlock(blockId); + data.lookup = lookup; + const VALID_TAX_TYPES = ['eco', 'arch']; + const MANDATORY_TAX_TYPES = ['eco']; + const rawTypes = query.types; + let parsedTypes; + if (rawTypes === undefined) parsedTypes = null; + else if (Array.isArray(rawTypes)) parsedTypes = rawTypes; + else parsedTypes = String(rawTypes).split(','); + const selected = (parsedTypes === null + ? VALID_TAX_TYPES.slice() + : parsedTypes + .map(s => String(s || '').trim().toLowerCase()) + .filter(s => VALID_TAX_TYPES.includes(s)) + ); + const set = new Set(selected); + for (const t of MANDATORY_TAX_TYPES) set.add(t); + data.selectedTaxTypes = Array.from(set); + } + ctx.body = renderBankingView(data, filter, userId, data.isPub); + }) + .get("/banking/allocation/:id", async (ctx) => { + const userId = getViewerId(); + const allocation = await bankingModel.getAllocationById(ctx.params.id); + ctx.body = renderSingleAllocationView(allocation, userId); + }) + .get("/banking/epoch/:id", async (ctx) => { + const epoch = await bankingModel.getEpochById(ctx.params.id); + const allocations = await bankingModel.listEpochAllocations(ctx.params.id); + ctx.body = renderEpochView(epoch, allocations); + }) + .get("/favorites", async (ctx) => { + const filter = qf(ctx), data = await favoritesModel.listAll({ filter }); + ctx.body = await favoritesView(data.items, filter, data.counts); + }) + .get("/logs", async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const view = String(ctx.query.view || 'list'); + const aiModOn = logsModel.isAImodOn(); + if (view === 'create') { + const mode = ctx.query.mode === 'ai' ? 'ai' : 'manual'; + ctx.body = logsView([], 'today', mode, { view: 'create', aiModOn }); + return; + } + if (view === 'edit') { + const id = String(ctx.query.id || ''); + const entry = id ? await logsModel.getLogById(id) : null; + if (!entry) { ctx.redirect('/logs'); return; } + ctx.body = logsView([], 'today', entry.mode, { view: 'edit', aiModOn, entry }); + return; + } + const filter = qf(ctx, 'today'); + const q = String(ctx.query.q || '').trim().toLowerCase(); + const typeQ = String(ctx.query.type || '').trim().toLowerCase(); + const dateQ = String(ctx.query.date || '').trim(); + let items = await logsModel.listLogs(filter); + if (q) items = items.filter(i => String(i.text || '').toLowerCase().includes(q) || String(i.label || '').toLowerCase().includes(q)); + if (typeQ === 'ai' || typeQ === 'manual') items = items.filter(i => (i.mode === 'ai' ? 'ai' : 'manual') === typeQ); + if (/^\d{4}-\d{2}-\d{2}$/.test(dateQ)) { + const start = new Date(dateQ + 'T00:00:00').getTime(); + const end = start + 24 * 60 * 60 * 1000; + items = items.filter(i => i.ts >= start && i.ts < end); + } + ctx.body = logsView(items, filter, null, { view: 'list', aiModOn, search: { q: ctx.query.q || '', type: typeQ, date: dateQ } }); + }) + .get("/logs/view/:id", async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const entry = await logsModel.getLogById(ctx.params.id); + if (!entry) { ctx.redirect('/logs'); return; } + const aiModOn = logsModel.isAImodOn(); + ctx.body = logsView([], 'today', entry.mode, { view: 'detail', aiModOn, entry }); + }) + .post("/logs/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + const mode = b.mode === 'ai' ? 'ai' : 'manual'; + try { + if (mode === 'ai') { startAI(); await logsModel.createAI(); } + else await logsModel.createManual(b.label || '', b.text || ''); + } catch (_) {} + ctx.redirect('/logs'); + }) + .post("/logs/edit/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + try { await logsModel.updateLog(ctx.params.id, { label: b.label || '', text: b.text || '' }); } catch (_) {} + ctx.redirect('/logs'); + }) + .post("/logs/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + try { await logsModel.deleteLog(ctx.params.id); } catch (_) {} + ctx.redirect('/logs'); + }) + .get("/logs/export", async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const items = await logsModel.listLogs('always'); + const pdf = await buildLogsPdf(items, getViewerId()); + ctx.set('Content-Type', 'application/pdf'); + ctx.set('Content-Disposition', `attachment; filename="oasis-logs-${Date.now()}.pdf"`); + ctx.body = pdf; + }) + .get("/logs/export/:id", async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const entry = await logsModel.getLogById(ctx.params.id); + if (!entry) { ctx.redirect('/logs'); return; } + const pdf = await buildLogsPdf([entry], getViewerId()); + ctx.set('Content-Type', 'application/pdf'); + ctx.set('Content-Disposition', `attachment; filename="oasis-log-${Date.now()}.pdf"`); + ctx.body = pdf; + }) + .get('/cipher', async (ctx) => { + if (!checkMod(ctx, 'cipherMod')) { ctx.redirect('/modules'); return; } + try { + ctx.body = await cipherView(); + } catch (error) { + sendErrorPage(ctx, error.message); + } + }) + .get("/thread/:message", async (ctx) => { + const { message } = ctx.params; + const thread = async (message) => { + const messages = await post.fromThread(message); + const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); + return threadView({ messages, spreadMap }); + }; + ctx.body = await thread(message); + }) + .get("/subtopic/:message", async (ctx) => { + const { message } = ctx.params; + const rootMessage = await post.get(message); + const myFeedId = await meta.myFeedId(); + debug("%O", rootMessage); + const messages = [rootMessage]; + const spreadMap = await spreads.forMessages(messages.map(m => m && m.key)).catch(() => new Map()); + ctx.body = await subtopicView({ messages, myFeedId, spreadMap }); + }) + .get("/publish", async (ctx) => { + ctx.body = await publishView(); + }) + .get("/comment/:message", async (ctx) => { + const { messages, myFeedId, parentMessage } = + await resolveCommentComponents(ctx); + const allKeys = [parentMessage && parentMessage.key, ...(messages || []).map(m => m && m.key)].filter(Boolean); + const spreadMap = await spreads.forMessages(allKeys).catch(() => new Map()); + ctx.body = await commentView({ messages, myFeedId, parentMessage, spreadMap }); + }) + .get("/wallet", async (ctx) => { + const { url, user, pass } = getConfig().wallet; + if (!checkMod(ctx, 'walletMod')) { ctx.redirect('/modules'); return; } + try { + const balance = await walletModel.getBalance(url, user, pass); + const address = await walletModel.getAddress(url, user, pass); + const userId = getViewerId(); + if (address && typeof address === "string") { + const map = readAddrMap(); + const was = map[userId]; + if (was !== address) { + map[userId] = address; + writeAddrMap(map); + try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {} + } + } + ctx.body = await walletView(balance, address); + } catch (error) { + ctx.body = await walletErrorView(error); + } + }) + .get("/wallet/history", async (ctx) => { + const { url, user, pass } = getConfig().wallet; + try { + const balance = await walletModel.getBalance(url, user, pass); + const transactions = await walletModel.listTransactions(url, user, pass); + const address = await walletModel.getAddress(url, user, pass); + const userId = getViewerId(); + if (address && typeof address === "string") { + const map = readAddrMap(); + const was = map[userId]; + if (was !== address) { + map[userId] = address; + writeAddrMap(map); + try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {} + } + } + ctx.body = await walletHistoryView(balance, transactions, address); + } catch (error) { + ctx.body = await walletErrorView(error); + } + }) + .get("/wallet/receive", async (ctx) => { + const { url, user, pass } = getConfig().wallet; + try { + const balance = await walletModel.getBalance(url, user, pass); + const address = await walletModel.getAddress(url, user, pass); + const userId = getViewerId(); + if (address && typeof address === "string") { + const map = readAddrMap(); + const was = map[userId]; + if (was !== address) { + map[userId] = address; + writeAddrMap(map); + try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {} + } + } + ctx.body = await walletReceiveView(balance, address); + } catch (error) { + ctx.body = await walletErrorView(error); + } + }) + .get("/wallet/send", async (ctx) => { + const { url, user, pass, fee } = getConfig().wallet; + try { + const balance = await walletModel.getBalance(url, user, pass); + const address = await walletModel.getAddress(url, user, pass); + const userId = getViewerId(); + if (address && typeof address === "string") { + const map = readAddrMap(); + const was = map[userId]; + if (was !== address) { + map[userId] = address; + writeAddrMap(map); + try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {} + } + } + ctx.body = await walletSendFormView(balance, null, null, fee, null, address); + } catch (error) { + ctx.body = await walletErrorView(error); + } + }) + .get('/transfers', async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + let filter = ctx.query.filter || 'all'; if (filter === 'favs') filter = 'all'; + let list = await transfersModel.listAll(filter, getViewerId()); + try { list = await lifetime.enrichAndFilter(list, { getAuthor: (x) => x.from }); } catch (_) {} + await enrichMsgSize(list); + const spreadMap = await spreads.forMessages((list || []).map(x => x && (x.id || x.key))); + const prefill = filter === 'create' ? { to: ctx.query.to || '', amount: ctx.query.amount || '', concept: ctx.query.concept || '', category: ctx.query.category || '' } : undefined; + await warmAuthorNames(list); + ctx.body = await transferView(list, filter, null, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', category: ctx.query.category || '', spreadMap, prefill }); + }) + .get('/transfers/edit/:id', async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + const tr = await transfersModel.getTransferById(ctx.params.id, getViewerId()); + ctx.body = await transferView([tr], 'edit', ctx.params.id, {}); + }) + .get('/transfers/contract/:transferId', async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + const transfer = await transfersModel.getTransferById(ctx.params.transferId, getViewerId()); + if (!transfer) { ctx.redirect('/transfers'); return; } + let block = null; + try { + const ssbX = await cooler.open(); + const msg = await new Promise((resolve) => { + ssbX.get(transfer.id, (err, m) => resolve(err ? null : m)); + }); + if (msg && msg.content) { + block = { + id: transfer.id, + author: msg.author, + ts: msg.timestamp || (msg.value && msg.value.timestamp) || transfer.createdAt, + type: msg.content.type, + size: Buffer.byteLength(JSON.stringify(msg), 'utf8') + }; + } + } catch (_) {} + const pdf = buildSmartContractPdf({ transfer, block, viewerId: getViewerId() }); + ctx.set('Content-Type', 'application/pdf'); + ctx.set('Content-Disposition', `attachment; filename="oasis-smart-contract-${transfer.id.replace(/[^A-Za-z0-9]/g, '_').slice(0, 16)}.pdf"`); + ctx.body = pdf; + }) + .get('/transfers/:transferId', async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + let filter = ctx.query.filter || 'all'; if (filter === 'favs') filter = 'all'; + const transfer = await transfersModel.getTransferById(ctx.params.transferId, getViewerId()); + let block = null; + if (transfer && transfer.id) { + try { + const ssbX = await cooler.open(); + const msg = await new Promise((resolve) => { + ssbX.get(transfer.id, (err, m) => resolve(err ? null : m)); + }); + if (msg && msg.content) { + const size = Buffer.byteLength(JSON.stringify(msg), 'utf8'); + block = { + id: transfer.id, + author: msg.author, + ts: msg.timestamp || (msg.value && msg.value.timestamp) || transfer.createdAt, + type: msg.content.type, + size + }; + if (transfer) transfer.msgSize = size; + } + } catch (_) { block = null; } + } + await enrichItemLifetime(transfer, { author: transfer.from }); + ctx.body = await singleTransferView(transfer, filter, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', returnTo: safeReturnTo(ctx, `/transfers?filter=${encodeURIComponent(filter)}`, ['/transfers']), block, spreads: await spreads.forMessage(transfer.id).catch(() => null) }); + }) + .post('/ai', koaBody(), async (ctx) => { + const { input } = ctx.request.body; + if (!input) { + sendErrorPage(ctx, 'No input provided', { status: 400 }); + return; + } + startAI(); + const i18nAll = require('../client/assets/translations/i18n'); + const lang = ctx.cookies.get('language') || getConfig().language || 'en'; + const translations = i18nAll[lang] || i18nAll['en']; + const { setLanguage } = require('../views/main_views'); + setLanguage(lang); + const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json'); + let chatHistory = []; + try { + const fileData = fs.readFileSync(historyPath, 'utf-8'); + chatHistory = JSON.parse(fileData); + } catch { + chatHistory = []; + } + const config = getConfig(); + const userPrompt = config.ai?.prompt?.trim() || 'Provide an informative and precise response.'; + try { + let aiResponse = ''; + let snippets = []; + const trained = await getBestTrainedAnswer(input); + if (trained && trained.answer) { + aiResponse = trained.answer; + snippets = Array.isArray(trained.ctx) ? trained.ctx : []; + } else { + const response = await axios.post('http://localhost:4001/ai', { input }); + aiResponse = response.data.answer; + snippets = Array.isArray(response.data.snippets) ? response.data.snippets : []; + } + chatHistory.unshift({ + prompt: userPrompt, + question: input, + answer: aiResponse, + timestamp: Date.now(), + trainStatus: 'pending', + snippets + }); + } catch (e) { + chatHistory.unshift({ + prompt: userPrompt, + question: input, + answer: translations.aiServerError || 'The AI could not answer. Please try again.', + timestamp: Date.now(), + trainStatus: 'rejected', + snippets: [] + }); + } + chatHistory = chatHistory.slice(0, 20); + fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8'); + ctx.body = aiView(chatHistory, userPrompt); + }) + .post('/ai/approve', koaBody(), async (ctx) => { + const ts = String(ctx.request.body.ts || ''); + const custom = String(ctx.request.body.custom || '').trim(); + const rawTags = String(ctx.request.body.tags || '').trim(); + const tagsList = rawTags + ? rawTags.split(/[,\n]/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean) + : []; + const ratingRaw = parseInt(ctx.request.body.rating, 10); + const rating = Number.isFinite(ratingRaw) ? Math.max(0, Math.min(5, ratingRaw)) : 0; + const cfg = getConfig(); + const lang = ctx.cookies.get('language') || cfg.language || ''; + const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json'); + let chatHistory = []; + try { + const fileData = fs.readFileSync(historyPath, 'utf-8'); + chatHistory = JSON.parse(fileData); + } catch { + chatHistory = []; + } + const item = chatHistory.find(e => String(e.timestamp) === ts); + if (item) { + try { + if (custom) item.answer = stripDangerousTags(custom); + item.type = 'aiExchange'; + let snippets = fieldsForSnippet('aiExchange', item); + if (snippets.length === 0) { + const context = await buildContext(); + snippets = [context]; + } else { + snippets = snippets.map(snippet => clip(snippet, 200)); + } + await publishExchange({ + q: item.question, + a: item.answer, + ctx: snippets, + tokens: {}, + lang, + tags: tagsList, + rating + }); + item.trainStatus = 'approved'; + if (tagsList.length) item.tags = tagsList; + if (rating > 0) item.rating = rating; + if (lang) item.lang = lang; + } catch { + item.trainStatus = 'failed'; + } + fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8'); + } + const config = getConfig(); + const userPrompt = config.ai?.prompt?.trim() || ''; + ctx.body = aiView(chatHistory, userPrompt); + }) + .post('/ai/exchange/vote', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'aiMod')) return ctx.redirect('/modules'); + const targetId = String((ctx.request.body && ctx.request.body.target) || '').trim(); + const helpful = !((ctx.request.body && ctx.request.body.helpful) === 'no'); + if (targetId) { + try { await publishExchangeVote({ targetId, helpful }); } catch (_) {} + } + const back = String((ctx.request.body && ctx.request.body.returnTo) || '/activity').trim(); + ctx.redirect(back && back.startsWith('/') ? back : '/activity'); + }) + .post('/ai/reject', koaBody(), async (ctx) => { + const i18nAll = require('../client/assets/translations/i18n'); + const lang = ctx.cookies.get('language') || getConfig().language || 'en'; + const { setLanguage } = require('../views/main_views'); + setLanguage(lang); + const ts = String(ctx.request.body.ts || ''); + const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json'); + let chatHistory = []; + try { + const fileData = fs.readFileSync(historyPath, 'utf-8'); + chatHistory = JSON.parse(fileData); + } catch { + chatHistory = []; + } + const item = chatHistory.find(e => String(e.timestamp) === ts); + if (item) { + item.trainStatus = 'rejected'; + fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8'); + } + const config = getConfig(); + const userPrompt = config.ai?.prompt?.trim() || ''; + ctx.body = aiView(chatHistory, userPrompt); + }) + .post('/ai/clear', async (ctx) => { + const i18nAll = require('../client/assets/translations/i18n'); + const lang = ctx.cookies.get('language') || getConfig().language || 'en'; + const { setLanguage } = require('../views/main_views'); + setLanguage(lang); + const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json'); + fs.writeFileSync(historyPath, '[]', 'utf-8'); + const config = getConfig(); + const userPrompt = config.ai?.prompt?.trim() || ''; + ctx.body = aiView([], userPrompt); + }) + .get('/ai/ask', async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const raw = String(ctx.query?.q || ctx.query?.prompt || '').trim(); + if (!raw) { ctx.redirect('/'); return; } + if (!checkMod(ctx, 'aiNavMod')) { ctx.redirect('/search?query=' + encodeURIComponent(raw)); return; } + const routesIndex = require('../AI/routes_index'); + const { aiNavResultsView } = require('../views/main_views'); + const isModuleEnabled = (modName) => checkMod(ctx, modName); + let results = []; + if (checkMod(ctx, 'aiNavMod')) { + try { + const embedder = require('../AI/embedder'); + if (embedder.isInstalled()) { + const vec = await embedder.embed(raw); + if (vec) { + results = await routesIndex.resolveTopK(vec, { isModuleEnabled, embed: embedder.embed }, 8); + } + } + } catch (_) {} + } + if (!Array.isArray(results) || results.length === 0) { + results = routesIndex.resolveKeywordTopK({ isModuleEnabled }, raw, 8); + } + ctx.body = await aiNavResultsView({ query: raw, results: results || [] }); + }) + .post('/ai/ask', koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!checkMod(ctx, 'aiNavMod')) { + sendErrorPage(ctx, require('../views/main_views').i18n.aiNavDisabled || 'AI navigation is disabled.', { status: 403 }); + return; + } + const raw = String(ctx.request.body?.q || ctx.request.body?.prompt || '').trim(); + if (!raw) { ctx.redirect('/'); return; } + const ssbRefLib = require('../server/node_modules/ssb-ref'); + const hashtagMatch = raw.match(/^#([0-9A-Za-z_À-￿-]+)/); + if (hashtagMatch) { + ctx.redirect('/search?query=' + encodeURIComponent('#' + hashtagMatch[1])); + return; + } + const feedMatch = raw.match(/^@?([A-Za-z0-9+/=._-]+\.ed25519)\b/); + if (feedMatch) { + const id = (feedMatch[0].startsWith('@') ? feedMatch[0] : '@' + feedMatch[1]); + if (ssbRefLib.isFeed(id)) { ctx.redirect('/author/' + encodeURIComponent(id)); return; } + } + if (/^https?:\/\//i.test(raw)) { + try { + const u = new URL(raw); + if (u.host === ctx.host) { ctx.redirect(u.pathname + u.search + u.hash); return; } + } catch (_) {} + } + try { + const embedder = require('../AI/embedder'); + const routesIndex = require('../AI/routes_index'); + if (!embedder.isInstalled()) { + ctx.redirect('/search?query=' + encodeURIComponent(raw)); + return; + } + const vec = await embedder.embed(raw); + if (!vec) { + ctx.redirect('/search?query=' + encodeURIComponent(raw)); + return; + } + const isModuleEnabled = (modName) => checkMod(ctx, modName); + const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed }); + if (best && best.path) { ctx.redirect(best.path); return; } + } catch (_) {} + ctx.redirect('/search?query=' + encodeURIComponent(raw)); + }) + .post('/pixelia/paint', koaBody(), async (ctx) => { + const x = Number(ctx.request.body.x), y = Number(ctx.request.body.y), color = ctx.request.body.color; + if (!Number.isFinite(x) || !Number.isFinite(y) || x < 1 || x > 50 || y < 1 || y > 200) { + const errorMessage = 'Coordinates are wrong!'; + const pixelArt = await pixeliaModel.listPixels(); + ctx.body = pixeliaView(pixelArt, errorMessage); + return; + } + await pixeliaModel.paintPixel(x, y, color); + ctx.redirect('/pixelia'); + }) + .post('/pm', koaBody(), async ctx => { + const { recipients, subject, text, crypter, precomputed, crypterKey } = ctx.request.body; + const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean).filter(id => ssbRef.isFeedId(id)); + if (recipientsArr.length === 0) { ctx.throw(400, 'No valid recipients'); return; } + const cfgNow = getConfig(); + if (cfgNow.pmVisibility === 'mutuals') { + const viewer = getViewerId(); + for (const rid of recipientsArr) { + if (rid === viewer) continue; + let rel; + try { rel = await friend.getRelationship(rid); } catch (e) { rel = null; } + const mutual = !!(rel && rel.following && rel.followsMe); + if (!mutual) ctx.throw(403, 'You can only send private messages to habitants with mutual support.'); + } + } + const cleanSubject = stripDangerousTags(subject); + const cleanText = stripDangerousTags(text); + if (crypter) { + let key = (typeof crypterKey === 'string' && crypterKey.length >= 32) ? crypterKey : cipherModel.generateKey(); + let encryptedText; + if (typeof precomputed === 'string' && precomputed) { + encryptedText = precomputed; + } else { + ({ encryptedText } = cipherModel.encryptData(cleanText, key)); + } + if (encryptedText.length > PM_CRYPTER_MAX) { + ctx.body = await pmView(recipients, subject, text, false, '', true); + return; + } + try { + await pmModel.sendMessage(recipientsArr, cleanSubject, encryptedText, true); + } catch (_) { + ctx.body = await pmView(recipients, subject, text, false, '', true); + return; + } + await refreshInboxCount(); + ctx.body = await pmView('', '', '', false, key); + return; + } + await pmModel.sendMessage(recipientsArr, cleanSubject, cleanText); + await refreshInboxCount(); + ctx.redirect('/inbox?filter=sent'); + }) + .post('/pm/preview', koaBody(), async ctx => { + const { recipients = '', subject = '', text = '', crypter } = ctx.request.body; + const validRecipients = (recipients || '').split(',').map(s => s.trim()).filter(Boolean).filter(id => ssbRef.isFeedId(id)); + if (validRecipients.length === 0) { + ctx.body = await pmView(recipients, subject, text, false, '', false, null, true); + return; + } + if (crypter) { + const key = cipherModel.generateKey(); + const { encryptedText } = cipherModel.encryptData(stripDangerousTags(text), key); + if (encryptedText.length > PM_CRYPTER_MAX) { + ctx.body = await pmView(recipients, subject, text, false, '', true); + return; + } + ctx.body = await pmView(recipients, subject, text, true, '', false, { key, cipher: encryptedText }); + return; + } + ctx.body = await pmView(recipients, subject, text, true); + }) + .post('/inbox/delete/:id', koaBody(), async ctx => { + await pmModel.deleteMessageById(ctx.params.id); + await refreshInboxCount(); + ctx.redirect('/inbox'); + }) + .post("/search", koaBody(), async (ctx) => { + const b = ctx.request.body, query = b.query || ""; + let types = b.type || []; + if (typeof types === "string") types = [types]; + if (!Array.isArray(types)) types = []; + if (!query) return ctx.body = await searchView({ messages: [], query, types }); + const userId = getViewerId(); + const allTribes = await tribesModel.listAll(); + const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id)); + const applySearchPrivacy = (msgs) => msgs.filter(msg => { + const c = msg.value?.content; + if (!c) return true; + if (Array.isArray(c.recps)) return false; + if (c.type === 'post' && (c.private === true || c.recps)) return false; + if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; + if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false; + if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false; + if (c.status === 'PRIVATE') return false; + if (c.type === 'shop' && (c.visibility === 'CLOSED' || c.encryptedPayload) && c.author !== userId) return false; + return true; + }); + const results = await searchModel.search({ query, types }); + ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => { + const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); + if (filtered.length > 0) acc[type] = filtered; + return acc; + }, {}), query, types }); + }) + .post("/subtopic/preview/:message", + koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), + async (ctx) => { + const { message } = ctx.params; + const rootMessage = await post.get(message); + const myFeedId = await meta.myFeedId(); + const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning).trim()); + const contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; + const messages = [rootMessage]; + const previewData = await preparePreview(ctx); + ctx.body = await previewSubtopicView({ + messages, + myFeedId, + previewData, + contentWarning, + }); + } + ) + .post("/subtopic/:message", koaBody(), async (ctx) => { + const { message } = ctx.params; + const text = stripDangerousTags(String(ctx.request.body.text)); + const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning).trim()); + const contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; + const publishSubtopic = async ({ message, text }) => { + const mentions = extractMentions(text); + const parent = await post.get(message); + return post.subtopic({ + parent, + message: { text, mentions, contentWarning }, + }); + }; + ctx.body = await publishSubtopic({ message, text }); + ctx.redirect(`/thread/${encodeURIComponent(message)}`); + }) + .post("/comment/preview/:message", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const { messages, contentWarning, myFeedId, parentMessage } = await resolveCommentComponents(ctx); + const previewData = await preparePreview(ctx); + ctx.body = await previewCommentView({ + messages, + myFeedId, + contentWarning, + previewData, + parentMessage, + }); + }) + .post("/comment/:message", koaBody(), async (ctx) => { + let decodedMessage; + try { + decodedMessage = decodeURIComponent(ctx.params.message); + } catch { + decodedMessage = ctx.params.message; + } + const text = stripDangerousTags(String(ctx.request.body.text)); + const rawContentWarning = stripDangerousTags(String(ctx.request.body.contentWarning)); + const contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; + let mentions = extractMentions(text); + if (!Array.isArray(mentions)) mentions = []; + const parent = await meta.get(decodedMessage); + ctx.body = await post.comment({ + parent, + message: { + text, + mentions, + contentWarning + }, + }); + ctx.redirect(`/thread/${encodeURIComponent(parent.key)}`); + }) + .post("/publish/preview", koaBody({multipart: true, formidable: { multiples: false, maxFileSize: maxSize }, urlencoded: true }), async (ctx) => { + const cw = stripDangerousTags(ctx.request.body.contentWarning?.toString().trim() || ""); + ctx.body = await previewView({ previewData: await preparePreview(ctx), contentWarning: cw.length > 0 ? cw : undefined }); + }) + .post("/publish", koaBody({ multipart: true, urlencoded: true, formidable: { multiples: false, maxFileSize: maxSize } }), async (ctx) => { + const b = ctx.request.body, text = stripDangerousTags(b.text?.toString().trim() || ""), cw = stripDangerousTags(b.contentWarning?.toString().trim() || ""); + let mentions = []; + try { mentions = JSON.parse(b.mentions || "[]"); } catch { mentions = await extractMentions(text); } + await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined }); + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect("/public/latest"); + }) + .post("/publish/custom", koaBody(), async (ctx) => { + const text = String(ctx.request.body.text); + const obj = JSON.parse(text); + const ALLOWED_TYPES = ['post','about','contact','vote','pub','channel']; + if (!obj.type || !ALLOWED_TYPES.includes(obj.type)) { ctx.throw(400, 'Invalid message type'); return; } + const sanitizeObj = (o) => { for (const k of Object.keys(o)) { if (typeof o[k] === 'string') o[k] = stripDangerousTags(o[k]); else if (o[k] && typeof o[k] === 'object') sanitizeObj(o[k]); } }; + sanitizeObj(obj); + ctx.body = await post.publishCustom(obj); + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(`/public/latest`); + }) + .post("/follow/:feed", koaBody(), async (ctx) => { + ctx.body = await friend.follow(ctx.params.feed); + safeRefererRedirect(ctx, '/inhabitants'); + }) + .post("/unfollow/:feed", koaBody(), async (ctx) => { + ctx.body = await friend.unfollow(ctx.params.feed); + safeRefererRedirect(ctx, '/inhabitants'); + }) + .post("/block/:feed", koaBody(), async (ctx) => { + ctx.body = await friend.block(ctx.params.feed); + safeRefererRedirect(ctx, '/inhabitants'); + }) + .post("/unblock/:feed", koaBody(), async (ctx) => { + ctx.body = await friend.unblock(ctx.params.feed); + safeRefererRedirect(ctx, '/inhabitants'); + }) + .post("/spread/:message", koaBody(), async (ctx) => { + const { message } = ctx.params; + const ref = ctx.request.header.referer; + let target = '/public/latest'; + try { + if (ref) { + const u = new URL(ref); + if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) { + target = u.pathname + u.search + u.hash; + } + } + } catch (_) {} + if (!message || typeof message !== 'string' || !message.startsWith('%') || !/\.sha256$/.test(message)) { + sendErrorPage(ctx, `Spread failed: invalid message id`, { status: 400 }); + return; + } + try { + const ssb = await cooler.open(); + const myId = ssb.id; + const existing = await new Promise((resolve) => { + const out = []; + pull( + ssb.backlinks.read({ query: [{ $filter: { dest: message } }], reverse: true }), + pull.filter(ref => { + if (!ref || !ref.value || !ref.value.content) return false; + const c = ref.value.content; + if (ref.value.author !== myId) return false; + if (c.type === 'spread' && c.link === message) return true; + if (c.type === 'vote' && c.vote && c.vote.link === message && Number(c.vote.value) === 1) { + const br = Array.isArray(c.branch) ? c.branch : (typeof c.branch === 'string' ? [c.branch] : []); + return br.includes(message); + } + return false; + }), + pull.collect((err, refs) => resolve(!err && refs ? refs : [])) + ); + }); + const tombstoneTargets = new Set(); + await new Promise((resolve) => { + pull( + ssb.createUserStream({ id: myId, reverse: true }), + pull.filter(m => m && m.value && m.value.content && m.value.content.type === 'tombstone'), + pull.drain(m => { tombstoneTargets.add(m.value.content.target); }, () => resolve()) + ); + }); + const activeExisting = existing.filter(r => !tombstoneTargets.has(r.key)); + let tombstoneTargetId = activeExisting.length > 0 ? activeExisting[0].key : spreads.getCachedActiveOwnSpreadKey(message); + if (tombstoneTargetId) { + const tombstone = { type: 'tombstone', target: tombstoneTargetId, deletedAt: new Date().toISOString(), author: myId }; + await new Promise((res, rej) => ssb.publish(tombstone, (e, m) => e ? rej(e) : res(m))); + spreads.noteOwnTombstone(tombstoneTargetId); + ctx.redirect(target); + return; + } + let recps = []; + try { + const raw = await new Promise((res) => { + let done = false; + const timer = setTimeout(() => { if (!done) { done = true; res(null); } }, 1500); + ssb.get({ id: message, private: true, meta: true }, (err, v) => { + if (done) return; + done = true; + clearTimeout(timer); + res(err ? null : v); + }); + }); + const value = raw && raw.value ? raw.value : raw; + const isPrivate = !!(raw && raw.meta && raw.meta.private === true); + if (isPrivate && value && value.content && Array.isArray(value.content.recps)) { + recps = value.content.recps.map(r => typeof r === 'string' ? r : (r && r.link)).filter(Boolean); + } + } catch (_) {} + const content = { type: 'spread', link: message, expression: '🔁' }; + if (recps.length) content.recps = recps; + const publishedMsg = await new Promise((res, rej) => ssb.publish(content, (e, msg) => { + if (e) rej(e); + else res(msg); + })); + if (publishedMsg && publishedMsg.key) spreads.noteOwnSpread(message, publishedMsg.key); + } catch (e) { + sendErrorPage(ctx, `Spread failed: ${e.message || e}`, { status: 500 }); + return; + } + ctx.redirect(target); + }) + .post("/like/:message", koaBody(), async (ctx) => { + const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue); + const ref = ctx.request.header.referer; + let target = '/public/latest'; + try { + if (ref) { + const u = new URL(ref); + if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) { + u.hash = `centered-footer-${encodeURIComponent(message)}`; + target = u.pathname + u.search + u.hash; + } + } + } catch (_) {} + const msgData = await post.get(message); + const isPrivate = msgData.value.meta.private === true; + const normalized = (isPrivate ? msgData.value.content.recps : []).map(r => typeof r === 'string' ? r : r?.link).filter(Boolean); + ctx.body = await vote.publish({ messageKey: message, value: voteValue, recps: normalized.length ? normalized : undefined }); + ctx.redirect(target); + }) + .post('/forum/create', koaBody(), async ctx => { + const { category, title, text, isPublic } = ctx.request.body; + const isPrivate = String(isPublic || 'public').toLowerCase() === 'private'; + await forumModel.createForum(category, stripDangerousTags(title), stripDangerousTags(text), isPrivate); + ctx.redirect('/forum'); + }) + .post('/forum/:id/message', koaBody(), async ctx => { + const { message, parentId } = ctx.request.body; + const cleanedMsg = stripDangerousTags(message); + const mentions = await extractMentions(cleanedMsg); + await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined }, parentId); + ctx.redirect(`/forum/${encodeURIComponent(ctx.params.id)}`); + }) + .post('/forum/:forumId/vote', koaBody(), async ctx => { + await forumModel.voteContent(ctx.request.body.target, parseInt(ctx.request.body.value, 10)); + ctx.redirect(ctx.get('referer') || `/forum/${encodeURIComponent(ctx.params.forumId)}`); + }) + .post('/forum/delete/:id', koaBody(), async ctx => { + const forum = await forumModel.getForumById(ctx.params.id).catch(() => null); + if (!forum || forum.author !== getViewerId()) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; } + await forumModel.deleteForumById(ctx.params.id); + ctx.redirect('/forum'); + }) + .post('/forum/generate-invite/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } + try { + const { code } = await forumModel.generateInvite(ctx.params.id); + ctx.body = renderForumInvitePage(code); + } catch (_) { + ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum'])); + } + }) + .post('/forum/open-invite/create/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } + try { await forumModel.generateOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum'])); + }) + .post('/forum/open-invite/remove/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } + try { await forumModel.removeOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(ctx.params.id)}`, ['/forum'])); + }) + .post('/forum/join-code', koaBody(), async ctx => { + if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || '').trim(); + try { + const { forumId } = await forumModel.joinByInvite(code); + ctx.redirect(safeReturnTo(ctx, `/forum/${encodeURIComponent(forumId)}`, ['/forum'])); + } catch (_) { + ctx.redirect(safeReturnTo(ctx, '/forum', ['/forum'])); + } + }) + .post('/legacy/export', koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const pw = ctx.request.body.password; + if (!pw || pw.length < 32) return ctx.redirect('/legacy'); + try { + ctx.body = { message: 'Data exported successfully!', file: await legacyModel.exportData({ password: pw }) }; + ctx.redirect('/legacy'); + } catch (error) { ctx.status = 500; ctx.body = { error: `Error: ${error.message}` }; ctx.redirect('/legacy'); } + }) + .post('/legacy/import', koaBody({ + multipart: true, + formidable: { + keepExtensions: true, + uploadDir: '/tmp', + } + }), async (ctx) => { + const uploadedFile = ctx.request.files?.uploadedFile, pw = ctx.request.body.importPassword; + if (!uploadedFile) { ctx.body = { error: 'No file uploaded' }; return ctx.redirect('/legacy'); } + if (!pw || pw.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/legacy'); } + try { + await legacyModel.importData({ filePath: uploadedFile.filepath, password: pw }); + ctx.body = { message: 'Data imported successfully!' }; + ctx.redirect('/legacy'); + } catch (error) { ctx.body = { error: error.message }; ctx.redirect('/legacy'); } + }) + .post('/trending/:contentId/:category', async (ctx) => { + const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id; + if ((await trendingModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) { + ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/trending'); + } + await trendingModel.createVote(contentId, category); ctx.redirect('/trending'); + }) + .post('/opinions/:contentId/:category', async (ctx) => { + const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id; + if ((await opinionsModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) { + ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/opinions'); + } + await opinionsModel.createVote(contentId, category); ctx.redirect('/opinions'); + }) + .post('/agenda/discard/:itemId', async (ctx) => { + await agendaModel.discardItem(ctx.params.itemId); ctx.redirect('/agenda'); + }) + .post('/agenda/restore/:itemId', async (ctx) => { + await agendaModel.restoreItem(ctx.params.itemId); ctx.redirect('/agenda?filter=discarded'); + }) + .post("/feed/create", koaBody(), async (ctx) => { + const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : ""; + const mentions = await extractMentions(text); + await feedModel.createFeed(text, mentions); + ctx.redirect("/feed?filter=ALL&msg=feedPublished"); + }) + .post("/feed/opinions/:feedId/:category", async (ctx) => { + const { feedId, category } = ctx.params; + try { + await feedModel.addOpinion(feedId, category); + } catch { /* already voted or invalid — ignore */ } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(ctx.get("Referer") || "/feed"); + }) + .post("/feed/refeed/:id", koaBody(), async (ctx) => { + try { + await feedModel.createRefeed(ctx.params.id); + } catch (e) { + if (e.message !== "Already refeeded") throw e; + } + ctx.redirect(ctx.get("Referer") || "/feed"); + }) + .post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : ""; + const imageMarkdown = ctx.request.files?.blob ? await handleBlobUpload(ctx, 'blob') : null; + const fullText = imageMarkdown ? (text ? text + '\n' : '') + imageMarkdown : text; + await feedModel.addComment(ctx.params.feedId, fullText); + ctx.redirect(`/feed/${encodeURIComponent(ctx.params.feedId)}`); + }) + .post("/bookmarks/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + await bookmarksModel.createBookmark(stripDangerousTags(b.url), b.tags, stripDangerousTags(b.description), b.category, b.lastVisit); + ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=all', ['/bookmarks'])); + }) + .post("/bookmarks/update/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + await bookmarksModel.updateBookmarkById(ctx.params.id, { url: stripDangerousTags(b.url), tags: b.tags, description: stripDangerousTags(b.description), category: b.category, lastVisit: b.lastVisit }); + ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=mine', ['/bookmarks'])); + }) + .post("/bookmarks/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'bookmarks')) + .post("/bookmarks/opinions/:bookmarkId/:category", koaBody(), async ctx => opinionAction(ctx, 'bookmarks', 'bookmarkId')) + .post("/bookmarks/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'add')) + .post("/bookmarks/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'remove')) + .post("/bookmarks/:bookmarkId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'bookmarks', 'bookmarkId')) + .post("/images/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } + const blob = await handleBlobUpload(ctx, 'image'), b = ctx.request.body; + await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); + ctx.redirect(safeReturnTo(ctx, '/images?filter=all', ['/images'])); + }) + .post("/images/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, blob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); + ctx.redirect(safeReturnTo(ctx, '/images?filter=mine', ['/images'])); + }) + .post("/images/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'images')) + .post("/images/opinions/:imageId/:category", koaBody(), async ctx => opinionAction(ctx, 'images', 'imageId')) + .post("/images/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'images', 'add')) + .post("/images/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'images', 'remove')) + .post("/images/:imageId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'images', 'imageId')) + .post("/maps/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + if (b.tribeId) { + const t = await tribesModel.getTribeById(b.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + const imageId = extractBlobId(await handleBlobUpload(ctx, 'image')) || ""; + const newMap = await mapsModel.createMap(b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), b.tribeId || null, stripDangerousTags(b.markerLabel), imageId); + const redir = b.tribeId ? `/tribe/${encodeURIComponent(b.tribeId)}?section=maps` : safeReturnTo(ctx, '/maps?filter=all', ['/maps']); + ctx.redirect(redir); + }) + .post("/maps/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + const b = ctx.request.body; + const imageId = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) || "" : ""; + await mapsModel.updateMapById(ctx.params.id, b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), imageId || undefined); + ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps'])); + }) + .post("/maps/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + await mapsModel.deleteMapById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps'])); + }) + .post("/maps/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'add')) + .post("/maps/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'remove')) + .post("/maps/generate-invite/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + try { + const code = await mapsModel.generateInvite(ctx.params.id); + ctx.body = `

Map invite code: ${code}

Back

`; + } catch (e) { + ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`); + } + }) + .post("/maps/open-invite/create/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + try { await mapsModel.generateOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/maps/open-invite/remove/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + try { await mapsModel.removeOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/maps/join-code", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || "").trim(); + try { + const mapId = await mapsModel.joinByInvite(code); + ctx.redirect(`/maps/${encodeURIComponent(mapId)}`); + } catch (_) { + ctx.redirect('/maps'); + } + }) + .post("/maps/join/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + try { await mapsModel.joinMap(ctx.params.id); } catch (_) {} + ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/maps/:mapId/marker", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const mapItem = await mapsModel.getMapById(ctx.params.mapId, uid); + if (mapItem.tribeId) { + try { + const t = await tribesModel.getTribeById(mapItem.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } + const b = ctx.request.body; + const imageBlobId = extractBlobId(await handleBlobUpload(ctx, 'image')) || ""; + await mapsModel.addMarker(ctx.params.mapId, b.mkLat, b.mkLng, stripDangerousTags(b.label), imageBlobId); + ctx.redirect(safeReturnTo(ctx, `/maps/${encodeURIComponent(ctx.params.mapId)}`, ['/maps'])); + }) + .post("/audios/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'audios')) + .post("/audios/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'audios')) + .post("/audios/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'audios')) + .post("/audios/opinions/:audioId/:category", koaBody(), async ctx => opinionAction(ctx, 'audios', 'audioId')) + .post("/audios/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'add')) + .post("/audios/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'remove')) + .post("/audios/:audioId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'audios', 'audioId')) + .post("/torrents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } + const { tags, title, description, tribeId } = ctx.request.body; + const cleanTribeId = tribeId ? String(tribeId).trim() : null; + if (cleanTribeId) { + const t = await tribesModel.getTribeById(cleanTribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + const blob = await handleBlobUpload(ctx, 'torrent'); + const fileSize = ctx.request.files?.torrent?.size || 0; + await torrentsModel.createTorrent(blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), fileSize, cleanTribeId); + ctx.redirect(cleanTribeId ? `/tribe/${encodeURIComponent(cleanTribeId)}?section=torrents` : safeReturnTo(ctx, '/torrents?filter=all', ['/torrents'])); + }) + .post("/torrents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } + const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + const { tags, title, description } = ctx.request.body; + const blob = ctx.request.files?.torrent ? await handleBlobUpload(ctx, 'torrent') : null; + await torrentsModel.updateTorrentById(ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description)); + ctx.redirect(safeReturnTo(ctx, '/torrents?filter=mine', ['/torrents'])); + }) + .post("/torrents/delete/:id", koaBody(), async ctx => { + const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + return deleteAction(ctx, 'torrents'); + }) + .post("/torrents/opinions/:torrentId/:category", koaBody(), async ctx => { + const target = await torrentsModel.getTorrentById(ctx.params.torrentId, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + return opinionAction(ctx, 'torrents', 'torrentId'); + }) + .post("/torrents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'add')) + .post("/torrents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'remove')) + .post("/torrents/:torrentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'torrents', 'torrentId')) + .post("/videos/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'videos')) + .post("/videos/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'videos')) + .post("/videos/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'videos')) + .post("/videos/opinions/:videoId/:category", koaBody(), async ctx => opinionAction(ctx, 'videos', 'videoId')) + .post("/videos/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'add')) + .post("/videos/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'remove')) + .post("/videos/:videoId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'videos', 'videoId')) + .post("/documents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const docBlob = await handleBlobUpload(ctx, "document"), b = ctx.request.body; + await documentsModel.createDocument(docBlob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description)); + ctx.redirect(safeReturnTo(ctx, "/documents?filter=all", ["/documents"])); + }) + .post("/documents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const b = ctx.request.body, blob = ctx.request.files?.document ? await handleBlobUpload(ctx, "document") : null; + await documentsModel.updateDocumentById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description)); + ctx.redirect(safeReturnTo(ctx, "/documents?filter=mine", ["/documents"])); + }) + .post("/documents/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'documents')) + .post("/documents/opinions/:documentId/:category", koaBody(), async ctx => opinionAction(ctx, 'documents', 'documentId')) + .post("/documents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'add')) + .post("/documents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'remove')) + .post("/documents/:documentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'documents', 'documentId')) + .post('/cv/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + const photoUrl = await handleBlobUpload(ctx, 'image') + await cvModel.createCV(ctx.request.body, photoUrl) + ctx.redirect('/cv') + }) + .post('/cv/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + const photoUrl = await handleBlobUpload(ctx, 'image') + await cvModel.updateCV(ctx.params.id, ctx.request.body, photoUrl) + ctx.redirect('/cv') + }) + .post('/cv/delete/:id', async ctx => { + await cvModel.deleteCVById(ctx.params.id) + ctx.redirect('/cv') + }) + .post('/cv/visibility/:id', koaBody(), async ctx => { + const cv = await cvModel.getCVByUserId().catch(() => null); + if (!cv || cv.id !== ctx.params.id) { sendErrorPage(ctx, 'CV not found', { status: 404 }); return; } + const next = String(ctx.request.body?.visibility || '').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC'; + await cvModel.updateCV(ctx.params.id, { ...cv, visibility: next }, cv.photo || null); + ctx.redirect('/cv'); + }) + .post('/cipher/encrypt', koaBody(), async (ctx) => { + const { text, password } = ctx.request.body; + if (password.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/cipher'); } + const { encryptedText, iv } = cipherModel.encryptData(text, password); + ctx.body = await cipherView(encryptedText, "", iv, password); + }) + .post('/cipher/decrypt', koaBody(), async (ctx) => { + const { encryptedText, password } = ctx.request.body; + if (password.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/cipher'); } + ctx.body = await cipherView("", cipherModel.decryptData(encryptedText, password), "", password); + }) + .post('/tribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + if (!['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; } + const image = await handleBlobUpload(ctx, 'image'); + const tribeRes = await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isAnonymous === 'true', b.inviteMode, null, 'OPEN', stripDangerousTags(b.mapUrl)); + try { if (tribeRes?.key) await parliamentModel.tribe.publishInitialTerm(tribeRes.key); } catch (e) { console.error('publishInitialTerm failed:', e); } + ctx.redirect('/tribes'); + }) + .post('/tribe/:id/subtribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const parentTribe = await tribesModel.getTribeById(ctx.params.id); + const viewerId = getViewerId(); + const canCreate = parentTribe.inviteMode === 'open' + ? parentTribe.members.includes(viewerId) + : parentTribe.author === viewerId; + if (!canCreate) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + const image = await handleBlobUpload(ctx, 'image'); + const parentEffective = await tribesModel.getEffectiveStatus(ctx.params.id).catch(() => ({ isPrivate: false })); + const effectiveAnonymous = !!(parentEffective.isPrivate || parentTribe.isAnonymous); + await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, effectiveAnonymous, b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl)); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`); + }) + .post('/tribes/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + if (b.inviteMode && !['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; } + const tags = b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []; + const isSub = !!tribe.parentTribeId; + const updateFields = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' }; + if (isSub) { + updateFields.isAnonymous = !!tribe.isAnonymous; + } else { + updateFields.isAnonymous = b.isAnonymous === 'true'; + } + await tribesModel.updateTribeById(ctx.params.id, updateFields); + ctx.redirect('/tribes?filter=mine'); + }) + .post('/tribes/delete/:id', async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesModel.deleteTribeById(ctx.params.id) + ctx.redirect('/tribes?filter=mine') + }) + .post('/tribes/generate-invite', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + ctx.body = await renderInvitePage(await tribesModel.generateInvite(ctx.request.body.tribeId)); + }) + .post('/tribes/join-code', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + await tribesModel.joinByInvite(ctx.request.body.inviteCode) + ctx.redirect('/tribes?filter=membership') + }) + .post('/tribes/leave/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + await tribesModel.leaveTribe(ctx.params.id) + ctx.redirect('/tribes?filter=membership') + }) + .post('/tribes/open-invite/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribeId = ctx.request.body.tribeId; + try { await tribesModel.generateOpenInvite(tribeId); } catch (_) {} + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`); + }) + .post('/tribes/open-invite/remove', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribeId = ctx.request.body.tribeId; + try { await tribesModel.removeOpenInvite(tribeId); } catch (_) {} + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`); + }) + .post('/tribes/open-invite/join', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribeId = ctx.request.body.tribeId; + try { + const oi = await tribesModel.getOpenInvite(tribeId); + if (oi && oi.code) await tribesModel.joinByInvite(oi.code); + } catch (_) {} + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`); + }) + .get('/tribes/open-invite/join/:id', async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribeId = ctx.params.id; + try { + const oi = await tribesModel.getOpenInvite(tribeId); + if (oi && oi.code) await tribesModel.joinByInvite(oi.code); + } catch (_) {} + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}`); + }) + .post('/tribe/:id/message', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + const uid = getViewerId(); + if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; } + if (tooLong(ctx, ctx.request.body.message, MAX_TEXT_LENGTH, 'Text')) return; + const message = stripDangerousTags((ctx.request.body.message || '').trim()); + if (!message || message.length === 0 || message.length > 280) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed`); return; } + await tribesContentModel.create(tribe.id, 'feed', { description: await resolveMentionText(message) }); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed&sent=1`); + }) + .post('/tribe/:id/refeed/:msgId', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + const uid = getViewerId(); + if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesContentModel.toggleRefeed(ctx.params.msgId); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=feed`); + }) + .post('/tribe/:id/events/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + if (b.date && b.date < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events&action=create`); return; } + await tribesContentModel.create(tribe.id, 'event', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), date: b.date, location: stripDangerousTags(b.location), attendees: [getViewerId()] }); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events`); + }) + .post('/tribe/:id/events/attend/:eventId', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesContentModel.toggleAttendee(ctx.params.eventId); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=events`); + }) + .post('/tribe/:id/tasks/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + if (b.deadline && b.deadline < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks&action=create`); return; } + await tribesContentModel.create(tribe.id, 'task', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), priority: b.priority, deadline: b.deadline, assignees: [] }); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`); + }) + .post('/tribe/:id/tasks/assign/:taskId', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesContentModel.toggleAssignee(ctx.params.taskId); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`); + }) + .post('/tribe/:id/tasks/status/:taskId', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const item = await tribesContentModel.getById(ctx.params.taskId); + if (!item || item.author !== getViewerId()) { ctx.status = 403; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`); return; } + await tribesContentModel.updateStatus(ctx.params.taskId, ctx.request.body.status); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`); + }) + .post('/tribe/:id/votations/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + if (b.deadline && b.deadline < new Date().toISOString().split('T')[0]) { ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations&action=create`); return; } + const options = [b.option1, b.option2, b.option3, b.option4].filter(Boolean).map(o => stripDangerousTags(o)); + await tribesContentModel.create(tribe.id, 'votation', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), deadline: b.deadline, options, votes: {} }); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`); + }) + .post('/tribe/:id/votations/:voteId/vote', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesContentModel.castVote(ctx.params.voteId, parseInt(ctx.request.body.optionIndex, 10)); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`); + }) + .post('/tribe/:id/votations/close/:voteId', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const votation = await tribesContentModel.getById(ctx.params.voteId); + if (!votation || votation.author !== getViewerId()) { ctx.status = 403; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`); return; } + await tribesContentModel.updateStatus(ctx.params.voteId, 'CLOSED'); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=votations`); + }) + .post('/tribe/:id/forum/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + await tribesContentModel.create(tribe.id, 'forum', { title: stripDangerousTags(b.title), description: await resolveMentionText(stripDangerousTags(b.description)), category: b.category }); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum`); + }) + .post('/tribe/:id/forum/:forumId/reply', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + await tribesContentModel.create(tribe.id, 'forum-reply', { description: await resolveMentionText(stripDangerousTags(b.description)), parentId: ctx.params.forumId }); + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum&thread=${encodeURIComponent(ctx.params.forumId)}`); + }) + .post('/tribe/:id/forum/:forumId/refeed', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + const uid = getViewerId(); + if (!tribe.members.includes(uid)) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesContentModel.toggleRefeed(ctx.params.forumId); + const thread = ctx.query.thread || ''; + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum${thread ? '&thread=' + encodeURIComponent(thread) : ''}`); + }) + .post('/tribe/:id/media/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const b = ctx.request.body; + if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; + const returnSection = b.returnSection || 'media'; + const mediaType = b.mediaType || 'image'; + let blobRef = null; + if (mediaType === 'bookmark') { + const url = stripDangerousTags(b.url || ''); + await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType: 'bookmark', url }); + } else { + const blobMarkdownMedia = await handleBlobUpload(ctx, 'media'); + blobRef = blobMarkdownMedia ? ((blobMarkdownMedia.match(/\((&[^)]+)\)/) || [])[1] || blobMarkdownMedia) : null; + await tribesContentModel.create(tribe.id, 'media', { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), mediaType, image: blobRef }); + } + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=${returnSection}`); + }) + .post('/tribe/:id/content/delete/:contentId', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribeRedirect = `/tribe/${encodeURIComponent(ctx.params.id)}`; + const item = await tribesContentModel.getById(ctx.params.contentId); + if (!item || item.author !== getViewerId() || item.tribeId !== ctx.params.id) { ctx.status = 403; ctx.redirect(tribeRedirect); return; } + await tribesContentModel.deleteById(ctx.params.contentId); + ctx.redirect(tribeRedirect); + }) + .post('/tribe/:id/content/:contentId/opinion/:category', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + const item = await tribesContentModel.getById(ctx.params.contentId); + if (!item || item.tribeId !== ctx.params.id) { ctx.status = 404; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=opinions`); return; } + try { + await tribesContentModel.castOpinion(ctx.params.contentId, ctx.params.category); + } catch (_) {} + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=opinions`); + }) + .post('/panic/remove', koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const { exec } = require('child_process'); + try { + await panicmodeModel.removeSSB(); + sendErrorPage(ctx, 'Your blockchain has been successfully deleted!'); + if (process.env.OASIS_MOBILE !== '1') exec('pkill -f "node SSB_server.js start"'); + setTimeout(() => process.exit(0), 1000); + } catch (error) { sendErrorPage(ctx, 'Error deleting your blockchain: ' + error.message); } + }) + .post('/export/create', async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + try { + const outputPath = path.join(os.homedir(), 'ssb_exported.zip'); + await exportmodeModel.exportSSB(outputPath); + ctx.set('Content-Type', 'application/zip'); + ctx.set('Content-Disposition', `attachment; filename=ssb_exported.zip`); + ctx.body = fs.createReadStream(outputPath); + ctx.res.on('finish', () => fs.unlinkSync(outputPath)); + } catch (error) { sendErrorPage(ctx, 'Error exporting your blockchain: ' + error.message); } + }) + .post('/tasks/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + const b = ctx.request.body; + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + await tasksModel.createTask(stripDangerousTags(b.title), desc, b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic); + ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); + }) + .post('/tasks/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + const b = ctx.request.body, tags = Array.isArray(b.tags) ? b.tags.filter(Boolean) : (typeof b.tags === 'string' ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []); + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic }); + ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); + }) + .post('/tasks/assign/:id', koaBody(), async ctx => { + await tasksModel.toggleAssignee(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/tasks', ['/tasks'])); + }) + .post('/tasks/delete/:id', koaBody(), async ctx => { + await tasksModel.deleteTaskById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); + }) + .post('/tasks/status/:id', koaBody(), async ctx => { + await tasksModel.updateTaskStatus(ctx.params.id, ctx.request.body.status); + ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); + }) + .post('/tasks/:taskId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'tasks', 'taskId')) + .post('/reports/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image'); + await reportsModel.createReport(stripDangerousTags(b.title), stripDangerousTags(b.description), b.category, image, b.tags, b.severity, { + stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate, + problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria), + whatHappened: stripDangerousTags(b.whatHappened), reportedUser: b.reportedUser, evidenceLinks: stripDangerousTags(b.evidenceLinks), + contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction) + }); + ctx.redirect('/reports'); + }) + .post('/reports/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { + const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image'); + await reportsModel.updateReportById(ctx.params.id, { + title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, image, tags: b.tags, severity: b.severity, + template: { + stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate, + problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria), + whatHappened: stripDangerousTags(b.whatHappened), reportedUser: stripDangerousTags(b.reportedUser), evidenceLinks: stripDangerousTags(b.evidenceLinks), + contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction) + } + }); + ctx.redirect('/reports?filter=mine'); + }) + .post('/reports/delete/:id', async ctx => { + await reportsModel.deleteReportById(ctx.params.id); + ctx.redirect('/reports?filter=mine'); + }) + .post('/reports/confirm/:id', async ctx => { + await reportsModel.confirmReportById(ctx.params.id); + ctx.redirect('/reports'); + }) + .post('/reports/status/:id', koaBody(), async ctx => { + await reportsModel.updateReportById(ctx.params.id, { status: ctx.request.body.status }); + ctx.redirect('/reports?filter=mine'); + }) + .post('/reports/:reportId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'reports', 'reportId')) + .post('/events/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const b = ctx.request.body; + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + const evResult = await eventsModel.createEvent(stripDangerousTags(b.title), desc, b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic, stripDangerousTags(b.mapUrl), b.clearnetPublic); + if ([].concat(b.addToCalendar).includes("1") && evResult && evResult.key) { + try { + await calendarsModel.createCalendar({ + title: stripDangerousTags(b.title), + status: 'OPEN', + deadline: '', + tags: b.tags, + firstDate: b.date, + firstDateLabel: stripDangerousTags(b.title), + firstNote: `/events/${evResult.key}`, + intervalWeekly: 0, + intervalMonthly: 0, + intervalYearly: 0 + }); + } catch (_) {} + } + ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); + }) + .post('/events/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const b = ctx.request.body, existing = await eventsModel.getEventById(ctx.params.id); + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic }); + ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); + }) + .post('/events/attend/:id', koaBody(), async ctx => { + await eventsModel.toggleAttendee(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/events', ['/events'])); + }) + .post('/events/delete/:id', koaBody(), async ctx => { + await eventsModel.deleteEventById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); + }) + .post('/events/generate-invite/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } + try { + const { code } = await eventsModel.generateInvite(ctx.params.id); + ctx.body = renderEventInvitePage(code); + } catch (_) { + ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(ctx.params.id)}`, ['/events'])); + } + }) + .post('/events/open-invite/create/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } + try { await eventsModel.generateOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(ctx.params.id)}`, ['/events'])); + }) + .post('/events/open-invite/remove/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } + try { await eventsModel.removeOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(ctx.params.id)}`, ['/events'])); + }) + .post('/events/join-code', koaBody(), async ctx => { + if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || '').trim(); + try { + const { eventId } = await eventsModel.joinByInvite(code); + ctx.redirect(safeReturnTo(ctx, `/events/${encodeURIComponent(eventId)}`, ['/events'])); + } catch (_) { + ctx.redirect(safeReturnTo(ctx, '/events', ['/events'])); + } + }) + .post('/events/:eventId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'events', 'eventId')) + .post('/votes/create', koaBody(), async ctx => { + const b = ctx.request.body, defaultOptions = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED']; + const parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : defaultOptions; + await votesModel.createVote(stripDangerousTags(b.question), b.deadline, parsedOptions, String(b.tags || '').split(',').map(t => t.trim()).filter(Boolean)); + ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes'])); + }) + .post('/votes/update/:id', koaBody(), async ctx => { + const b = ctx.request.body, parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : undefined; + await votesModel.updateVoteById(ctx.params.id, { question: stripDangerousTags(b.question), deadline: b.deadline, options: parsedOptions, tags: b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : [] }); + ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes'])); + }) + .post('/votes/delete/:id', koaBody(), async ctx => { + await votesModel.deleteVoteById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes'])); + }) + .post('/votes/vote/:id', koaBody(), async ctx => { + await votesModel.voteOnVote(ctx.params.id, ctx.request.body.choice); + ctx.redirect(safeReturnTo(ctx, '/votes?filter=open', ['/votes'])); + }) + .post('/votes/opinions/:voteId/:category', koaBody(), async ctx => { + try { await votesModel.createOpinion(ctx.params.voteId, ctx.params.category); } + catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/votes', ['/votes'])); + }) + .post('/events/opinions/:eventId/:category', koaBody(), async ctx => { + try { await eventsModel.createOpinion(ctx.params.eventId, ctx.params.category); } + catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/events', ['/events'])); + }) + .post('/tasks/opinions/:taskId/:category', koaBody(), async ctx => { + try { await tasksModel.createOpinion(ctx.params.taskId, ctx.params.category); } + catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/tasks', ['/tasks'])); + }) + .post('/reports/opinions/:reportId/:category', koaBody(), async ctx => { + try { await reportsModel.createOpinion(ctx.params.reportId, ctx.params.category); } + catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/reports', ['/reports'])); + }) + .post('/projects/opinions/:projectId/:category', koaBody(), async ctx => { + try { await projectsModel.createOpinion(ctx.params.projectId, ctx.params.category); } + catch (e) { if (!/already/i.test(String(e?.message || ''))) throw e; ctx.flash = { message: "You have already opined." }; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/projects', ['/projects'])); + }) + .post('/votes/:voteId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'votes', 'voteId')) + .post('/parliament/candidatures/propose', koaBody(), async (ctx) => { + const b = ctx.request.body || {}, id = String(b.candidateId || '').trim(), m = String(b.method || '').trim().toUpperCase(); + if (!id) ctx.throw(400, 'Candidate is required.'); + if (!new Set(['DEMOCRACY','MAJORITY','MINORITY','DICTATORSHIP','KARMATOCRACY']).has(m)) ctx.throw(400, 'Invalid method.'); + await parliamentModel.proposeCandidature({ candidateId: id, method: m }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect('/parliament?filter=candidatures'); + }) + .post('/tribe/:id/governance/publish-candidature', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member'); + const globalTerm = await parliamentModel.getCurrentTerm().catch(() => null); + const already = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribeId, globalTerm?.startAt).catch(() => false); + if (already) ctx.throw(400, 'This tribe already has an open candidature in the current global parliament cycle.'); + const term = await parliamentModel.tribe.getCurrentTerm(tribeId).catch(() => null); + const rawMethod = (term?.method && String(term.method).toUpperCase()) || 'DEMOCRACY'; + const method = rawMethod === 'ANARCHY' ? 'DEMOCRACY' : rawMethod; + await parliamentModel.proposeCandidature({ candidateId: tribeId, method }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect('/parliament?filter=candidatures'); + }) + .post('/tribe/:id/governance/candidature/propose', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member'); + const b = ctx.request.body || {}; + const candidateId = String(b.candidateId || '').trim(); + const method = String(b.method || '').trim().toUpperCase(); + if (!candidateId) ctx.throw(400, 'Candidate required'); + await parliamentModel.tribe.publishTribeCandidature({ tribeId, candidateId, method }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`); + }) + .post('/tribe/:id/governance/candidature/vote', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member'); + const candidatureId = String(ctx.request.body?.candidatureId || '').trim(); + if (!candidatureId) ctx.throw(400, 'Missing candidatureId'); + await parliamentModel.tribe.voteTribeCandidature({ tribeId, candidatureId }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`); + }) + .post('/tribe/:id/governance/rule/add', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can add rules'); + const b = ctx.request.body || {}; + await parliamentModel.tribe.publishTribeRule({ tribeId, title: stripDangerousTags(String(b.title || '')), body: stripDangerousTags(String(b.body || '')) }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`); + }) + .post('/tribe/:id/governance/rule/delete', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can delete rules'); + const ruleId = String(ctx.request.body?.ruleId || '').trim(); + if (!ruleId) ctx.throw(400, 'Missing ruleId'); + await parliamentModel.tribe.deleteTribeRule(ruleId).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`); + }) + .post('/parliament/candidatures/:id/vote', koaBody(), async (ctx) => { + await parliamentModel.voteCandidature(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect('/parliament?filter=candidatures'); + }) + .post('/parliament/proposals/create', koaBody(), async (ctx) => { + const b = ctx.request.body || {}, t = String(b.title || '').trim(), d = String(b.description || '').trim(); + if (!t) ctx.throw(400, 'Title is required.'); + if (d.length > 1000) ctx.throw(400, 'Description must be ≤ 1000 chars.'); + await parliamentModel.createProposal({ title: stripDangerousTags(t), description: stripDangerousTags(d) }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect('/parliament?filter=proposals'); + }) + .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => { + const canClose = await parliamentModel.canPropose(); + if (!canClose) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; } + await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect('/parliament?filter=proposals'); + }) + .post('/parliament/resolve', koaBody(), async (ctx) => { + await ensureTerm(); + ctx.redirect('/parliament?filter=government'); + }) + .post('/parliament/revocations/create', koaBody(), async (ctx) => { + const b = ctx.request.body || {}, rawLawId = Array.isArray(b.lawId) ? b.lawId[0] : (b.lawId ?? b['lawId[]'] ?? b.law_id ?? ''); + const lawId = String(rawLawId || '').trim(); + if (!lawId) ctx.throw(400, 'Law required'); + await parliamentModel.createRevocation({ lawId, title: b.title, reasons: b.reasons }); + ctx.redirect('/parliament?filter=revocations'); + }) + .post('/courts/cases/create', koaBody(), async (ctx) => { + const b = ctx.request.body || {}, titleSuffix = String(b.titleSuffix || '').trim(), titlePreset = String(b.titlePreset || '').trim(); + const respondent = String(b.respondentId || '').trim(), method = String(b.method || '').trim().toUpperCase(); + if (!titleSuffix && !titlePreset) { ctx.flash = { message: 'Title is required.' }; return ctx.redirect('/courts?filter=cases'); } + if (!respondent) { ctx.flash = { message: 'Accused / Respondent is required.' }; return ctx.redirect('/courts?filter=cases'); } + if (!/^@[A-Za-z0-9+/]+=*\.ed25519$/.test(respondent)) { ctx.flash = { message: 'Invalid respondent ID. Must be a valid SSB ID (@...ed25519).' }; return ctx.redirect('/courts?filter=cases'); } + if (!new Set(['JUDGE','DICTATOR','POPULAR','MEDIATION','KARMATOCRACY']).has(method)) { ctx.flash = { message: 'Invalid resolution method.' }; return ctx.redirect('/courts?filter=cases'); } + try { await courtsModel.openCase({ titleBase: [titlePreset, titleSuffix].filter(Boolean).join(' - '), respondentInput: respondent, method }); } + catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect('/courts?filter=mycases'); + }) + .post('/courts/cases/:id/evidence/add', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const caseId = ctx.params.id, b = ctx.request.body || {}; + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + try { await courtsModel.addEvidence({ caseId, text: stripDangerousTags(String(b.text || '')), link: String(b.link || ''), imageMarkdown: ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null }); } + catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/answer', koaBody(), async (ctx) => { + const caseId = ctx.params.id, b = ctx.request.body || {}, answer = String(b.answer || ''), stance = String(b.stance || '').toUpperCase(); + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + if (!answer) { ctx.flash = { message: 'Response brief is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + if (!new Set(['DENY','ADMIT','PARTIAL']).has(stance)) { ctx.flash = { message: 'Invalid stance.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + try { await courtsModel.answerCase({ caseId, stance, text: stripDangerousTags(answer) }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/decide', koaBody(), async (ctx) => { + const caseId = ctx.params.id, b = ctx.request.body || {}, result = String(b.outcome || '').trim(), orders = String(b.orders || ''); + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + if (!result) { ctx.flash = { message: 'Result is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + try { await courtsModel.issueVerdict({ caseId, result, orders }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/settlements/propose', koaBody(), async (ctx) => { + const caseId = ctx.params.id, terms = String(ctx.request.body?.terms || ''); + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + if (!terms) { ctx.flash = { message: 'Terms are required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + try { await courtsModel.proposeSettlement({ caseId, terms }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/settlements/accept', koaBody(), async (ctx) => { + const caseId = ctx.params.id; + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + try { await courtsModel.acceptSettlement({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/support', koaBody(), async (ctx) => { + const caseId = ctx.params.id; + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + try { await courtsModel.supportCase({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/mediators/accuser', koaBody(), async (ctx) => { + const caseId = ctx.params.id, mediators = String(ctx.request.body?.mediators || '').split(',').map(s => s.trim()).filter(Boolean); + const uid = getViewerId(); + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + if (!mediators.length) { ctx.flash = { message: 'At least one mediator is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + if (uid && mediators.includes(uid)) { ctx.flash = { message: 'You cannot appoint yourself as mediator.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + try { await courtsModel.setMediators({ caseId, side: 'accuser', mediators }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/mediators/respondent', koaBody(), async (ctx) => { + const caseId = ctx.params.id, mediators = String(ctx.request.body?.mediators || '').split(',').map(s => s.trim()).filter(Boolean); + const uid = getViewerId(); + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + if (!mediators.length) { ctx.flash = { message: 'At least one mediator is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + if (uid && mediators.includes(uid)) { ctx.flash = { message: 'You cannot appoint yourself as mediator.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + try { await courtsModel.setMediators({ caseId, side: 'respondent', mediators }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/judge', koaBody(), async (ctx) => { + const caseId = ctx.params.id, judgeId = String(ctx.request.body?.judgeId || '').trim(), uid = getViewerId(); + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + if (!judgeId) { ctx.flash = { message: 'Judge is required.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + if (uid && judgeId === uid) { ctx.flash = { message: 'You cannot assign yourself as judge.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + try { await courtsModel.assignJudge({ caseId, judgeId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/public', koaBody(), async (ctx) => { + const caseId = ctx.params.id, pref = String(ctx.request.body?.preference || '').toUpperCase(); + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + if (pref !== 'YES' && pref !== 'NO') { ctx.flash = { message: 'Invalid visibility preference.' }; return ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); } + try { await courtsModel.setPublicPreference({ caseId, preference: pref === 'YES' }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/cases/:id/openVote', koaBody(), async (ctx) => { + const caseId = ctx.params.id; + if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } + try { await courtsModel.openPopularVote({ caseId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect(`/courts/cases/${encodeURIComponent(caseId)}`); + }) + .post('/courts/judges/nominate', koaBody(), async (ctx) => { + const judgeId = String(ctx.request.body?.judgeId || '').trim(); + if (!judgeId) { ctx.flash = { message: 'Judge is required.' }; return ctx.redirect('/courts?filter=judges'); } + try { await courtsModel.nominateJudge({ judgeId }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect('/courts?filter=judges'); + }) + .post('/courts/judges/:id/vote', koaBody(), async (ctx) => { + if (!ctx.params.id) { ctx.flash = { message: 'Nomination not found.' }; return ctx.redirect('/courts?filter=judges'); } + try { await courtsModel.voteNomination(ctx.params.id); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } + ctx.redirect('/courts?filter=judges'); + }) + .post("/market/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, image = await handleBlobUpload(ctx, "image"), parsedStock = parseInt(String(b.stock || "0"), 10); + if (!parsedStock || parsedStock <= 0) ctx.throw(400, "Stock must be a positive number."); + const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping); + await marketModel.createItem(b.item_type, stripDangerousTags(b.title), stripDangerousTags(b.description), image, b.price, b.tags, b.item_status, b.deadline, shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", parsedStock, stripDangerousTags(b.mapUrl), {}, b.visibility); + ctx.redirect(safeReturnTo(ctx, "/market", ["/market"])); + }) + .post("/market/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, parsedStock = parseInt(String(b.stock || "0"), 10); + if (parsedStock < 0) ctx.throw(400, "Stock cannot be negative."); + const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping); + const updatedData = { item_type: b.item_type, title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), price: b.price, item_status: b.item_status, deadline: b.deadline, includesShipping: shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", tags: String(b.tags || "").split(",").map(t => t.trim()).filter(Boolean), stock: parsedStock, mapUrl: stripDangerousTags(b.mapUrl), visibility: b.visibility }; + const image = await handleBlobUpload(ctx, "image"); + if (image) updatedData.image = image; + await marketModel.updateItemById(ctx.params.id, updatedData); + ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"])); + }) + .post("/market/delete/:id", koaBody(), async ctx => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + await marketModel.deleteItemById(ctx.params.id) + ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"])) + }) + .post("/market/visibility/:id", koaBody(), async ctx => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const next = String(ctx.request.body?.visibility || '').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC'; + await marketModel.updateItemById(ctx.params.id, { visibility: next }); + ctx.redirect(safeReturnTo(ctx, `/market/${encodeURIComponent(ctx.params.id)}`, ["/market"])); + }) + .post("/market/sold/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const item = await marketModel.getItemById(ctx.params.id); + if (!item) ctx.throw(404, "Item not found"); + if (Number(item.stock || 0) <= 0) ctx.throw(400, "No stock left to mark as sold."); + if (item.status !== "SOLD") { await marketModel.setItemAsSold(ctx.params.id); await marketModel.decrementStock(ctx.params.id); } + ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"])); + }) + .post("/market/buy/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const item = await marketModel.getItemById(ctx.params.id); + if (!item) ctx.throw(404, "Item not found"); + if (String(item.seller) === String(getViewerId())) ctx.throw(403, "You cannot buy your own item"); + if (item.item_type === "auction") ctx.throw(400, "Auction items are acquired via bids"); + if (String(item.status || "").toUpperCase() === "SOLD") ctx.throw(400, "Item already sold"); + if (Number(item.stock || 0) <= 0) ctx.throw(400, "Out of stock"); + try { + await pmModel.sendMessage([item.seller], "MARKET_SOLD", `item "${item.title}" has been sold -> /market/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${item.price} ECO`); + } catch (_) {} + if (item.item_type === "exchange") await marketModel.setItemAsSold(ctx.params.id); + else await marketModel.decrementStock(ctx.params.id); + try { await mediaFavorites.addFavorite('market', item.id || ctx.params.id); } catch (_) {} + if (item.shopProductId && checkMod(ctx, 'shopsMod')) { + try { await shopsModel.buyProduct(item.shopProductId); } catch (_) {} + } + ctx.redirect(safeReturnTo(ctx, "/inbox?filter=sent", ["/inbox", "/market"])); + }) + .post("/market/status/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + const desired = String(ctx.request.body.status || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim(); + if (!["FOR SALE", "SOLD", "DISCARDED"].includes(desired)) ctx.throw(400, "Invalid status."); + const item = await marketModel.getItemById(ctx.params.id); + if (!item) ctx.throw(404, "Item not found"); + const cur = String(item.status || "").toUpperCase().replace(/\s+/g, " ").trim(); + if (cur !== "SOLD" && cur !== "DISCARDED" && desired !== cur && desired !== "FOR SALE") { + if (desired === "SOLD") { + if (Number(item.stock || 0) <= 0) ctx.throw(400, "No stock left to mark as sold."); + await marketModel.setItemAsSold(ctx.params.id); await marketModel.decrementStock(ctx.params.id); + } else if (desired === "DISCARDED") await marketModel.updateItemById(ctx.params.id, { status: "DISCARDED", stock: 0 }); + } + ctx.redirect(safeReturnTo(ctx, "/market?filter=mine", ["/market"])); + }) + .post("/market/bid/:id", koaBody(), async ctx => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + await marketModel.addBidToAuction(ctx.params.id, getViewerId(), ctx.request.body.bidAmount) + ctx.redirect(safeReturnTo(ctx, "/market?filter=auctions", ["/market"])) + }) + .post("/market/:itemId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'market', 'itemId')) + .post('/jobs/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + await jobsModel.createJob({ job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), vacants: b.vacants ? parseInt(b.vacants, 10) : 1, salary: b.salary != null && b.salary !== '' ? parseFloat(String(b.salary).replace(',', '.')) : 0, hoursOffered: b.hoursOffered != null && b.hoursOffered !== '' ? parseFloat(String(b.hoursOffered).replace(',', '.')) : 0, hoursRequested: b.hoursRequested != null && b.hoursRequested !== '' ? parseFloat(String(b.hoursRequested).replace(',', '.')) : 0, exchangeSkill: stripDangerousTags(b.exchangeSkill || ''), tags: b.tags, image: imageBlob, mapUrl: stripDangerousTags(b.mapUrl), visibility: b.visibility, clearnetPublic: b.clearnetPublic }); + ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs'])); + }) + .post('/jobs/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined; + const patch = { job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), tags: b.tags, mapUrl: stripDangerousTags(b.mapUrl), visibility: b.visibility, exchangeSkill: stripDangerousTags(b.exchangeSkill || ''), clearnetPublic: b.clearnetPublic }; + if (b.vacants !== undefined && b.vacants !== '') patch.vacants = parseInt(b.vacants, 10); + if (b.salary !== undefined && b.salary !== '') patch.salary = parseFloat(String(b.salary).replace(',', '.')); + if (b.hoursOffered !== undefined && b.hoursOffered !== '') patch.hoursOffered = parseFloat(String(b.hoursOffered).replace(',', '.')); + if (b.hoursRequested !== undefined && b.hoursRequested !== '') patch.hoursRequested = parseFloat(String(b.hoursRequested).replace(',', '.')); + if (imageBlob !== undefined) patch.image = imageBlob; + await jobsModel.updateJob(ctx.params.id, patch); + ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs'])); + }) + .post('/jobs/delete/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + await jobsModel.deleteJob(ctx.params.id) + ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs'])) + }) + .post('/jobs/status/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + await jobsModel.updateJobStatus(ctx.params.id, String(ctx.request.body.status).toUpperCase()) + ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs'])) + }) + .post('/jobs/visibility/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + const next = String(ctx.request.body?.visibility || '').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC'; + await jobsModel.updateJob(ctx.params.id, { visibility: next }); + ctx.redirect(safeReturnTo(ctx, `/jobs/${encodeURIComponent(ctx.params.id)}`, ['/jobs'])); + }) + .post('/jobs/subscribe/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + const userId = getViewerId(); + let job; + try { job = await jobsModel.getJobById(ctx.params.id, userId); } catch (_) {} + if (!job) { ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); return; } + const alreadySubscribed = Array.isArray(job.subscribers) && job.subscribers.includes(userId); + if (alreadySubscribed || job.author === userId) { + ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); + return; + } + try { await jobsModel.subscribeToJob(ctx.params.id, userId); } catch (_) {} + try { await pmModel.sendMessage([job.author], 'JOB_SUBSCRIBED', `has subscribed to your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); + }) + .post('/jobs/unsubscribe/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } + const userId = getViewerId(); + let job; + try { job = await jobsModel.getJobById(ctx.params.id, userId); } catch (_) {} + if (!job) { ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); return; } + const wasSubscribed = Array.isArray(job.subscribers) && job.subscribers.includes(userId); + if (!wasSubscribed) { + ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); + return; + } + try { await jobsModel.unsubscribeFromJob(ctx.params.id, userId); } catch (_) {} + try { await pmModel.sendMessage([job.author], 'JOB_UNSUBSCRIBED', `has unsubscribed from your job offer "${job.title || ''}" -> /jobs/${encodeURIComponent(job.id)}`); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); + }) + .post('/jobs/:jobId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'jobs', 'jobId')) + .post("/shops/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + await shopsModel.createShop(stripDangerousTags(b.title), stripDangerousTags(b.shortDescription), stripDangerousTags(b.description), imageBlob, stripDangerousTags(b.url), stripDangerousTags(b.location), b.tags, b.visibility, stripDangerousTags(b.mapUrl), b.clearnetPublic); + ctx.redirect(safeReturnTo(ctx, '/shops?filter=mine', ['/shops'])); + }) + .post("/shops/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined; + const patch = { title: stripDangerousTags(b.title), shortDescription: stripDangerousTags(b.shortDescription), description: stripDangerousTags(b.description), url: stripDangerousTags(b.url), location: stripDangerousTags(b.location), tags: b.tags, visibility: b.visibility, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic }; + if (imageBlob !== undefined) patch.image = imageBlob; + await shopsModel.updateShopById(ctx.params.id, patch); + ctx.redirect(safeReturnTo(ctx, '/shops?filter=mine', ['/shops'])); + }) + .post("/shops/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + await shopsModel.deleteShopById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/shops?filter=mine', ['/shops'])); + }) + .post("/shops/visibility/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + await shopsModel.setShopVisibility(ctx.params.id, ctx.request.body.visibility); + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops'])); + }) + .post("/shops/generate-invite/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + try { + const { code } = await shopsModel.generateInvite(ctx.params.id); + ctx.body = renderShopInvitePage(code); + } catch (_) { + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops'])); + } + }) + .post("/shops/open-invite/create/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + try { await shopsModel.generateOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops'])); + }) + .post("/shops/open-invite/remove/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + try { await shopsModel.removeOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.id)}`, ['/shops'])); + }) + .get("/shops/open-invite/join/:id", async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + let dest = ctx.params.id; + try { + const oi = await shopsModel.getOpenInvite(ctx.params.id); + if (oi && oi.code) { const r = await shopsModel.joinByCode(oi.code); if (r && r.shopId) dest = r.shopId; } + } catch (_) {} + ctx.redirect(`/shops/${encodeURIComponent(dest)}`); + }) + .post("/shops/join-code", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || '').trim(); + try { + const { shopId } = await shopsModel.joinByCode(code); + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(shopId)}`, ['/shops'])); + } catch (_) { + ctx.redirect(safeReturnTo(ctx, '/shops', ['/shops'])); + } + }) + .post("/shops/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'shops', 'add')) + .post("/shops/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'shops', 'remove')) + .post("/shops/opinions/:shopId/:category", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + await shopsModel.createOpinion(ctx.params.shopId, ctx.params.category); + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.shopId)}`, ['/shops'])); + }) + .post("/shops/product/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + const productMsg = await shopsModel.createProduct(b.shopId, stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.price, b.stock, [].concat(b.featured).includes("1")); + if ([].concat(b.sendToMarket).includes("1") && checkMod(ctx, 'marketMod')) { + const shop = await shopsModel.getShopById(b.shopId); + const deadline = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(); + const stock = parseInt(String(b.stock || '0'), 10) || 1; + try { + await marketModel.createItem('exchange', stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.price, [], 'NEW', deadline, false, stock, '', { shopProductId: productMsg.key, shopId: b.shopId, shopTitle: shop ? shop.title : '' }); + } catch (e) { console.error("market-from-shop:", e.message) } + } + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(b.shopId)}`, ['/shops'])); + }) + .post("/shops/product/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined; + const patch = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), price: b.price, stock: b.stock, featured: [].concat(b.featured).includes("1") }; + if (imageBlob !== undefined) patch.image = imageBlob; + await shopsModel.updateProductById(ctx.params.id, patch); + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(b.shopId || '')}`, ['/shops'])); + }) + .post("/shops/product/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const product = await shopsModel.getProductById(ctx.params.id); + await shopsModel.deleteProductById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(product?.shopId || '')}`, ['/shops'])); + }) + .post("/shops/product/buy/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + const deliveryAddress = stripDangerousTags(String(b.deliveryAddress || "")).trim(); + if (!deliveryAddress) { + const { i18n: i18nMod } = require('../views/main_views'); + sendErrorPage(ctx, i18nMod.shopBuyDeliveryRequired || "Delivery address is required.", { status: 400 }); + return; + } + await shopsModel.createPurchaseOrder(ctx.params.id, { + deliveryAddress, + contact: stripDangerousTags(String(b.contact || "")).trim(), + notes: stripDangerousTags(String(b.notes || "")).trim() + }); + await shopsModel.buyProduct(ctx.params.id); + try { + const pr = await shopsModel.getProductById(ctx.params.id).catch(() => null); + await mediaFavorites.addFavorite('shopProducts', (pr && pr.rootId) || ctx.params.id); + } catch (_) {} + if (checkMod(ctx, 'marketMod')) { + try { const mi = await marketModel.getItemByShopProductId(ctx.params.id); if (mi) await marketModel.decrementStock(mi.id); } catch (_) {} + } + ctx.redirect(safeReturnTo(ctx, `/shops/product/${encodeURIComponent(ctx.params.id)}`, ['/shops'])); + }) + .post("/shops/product/opinions/:productId/:category", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } + await shopsModel.createOpinion(ctx.params.productId, ctx.params.category); + ctx.redirect(safeReturnTo(ctx, `/shops/product/${encodeURIComponent(ctx.params.productId)}`, ['/shops'])); + }) + .post("/shops/:shopId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'shops', 'shopId')) + .post("/chats/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + const tribeId = b.tribeId || null; + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); + } + const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; + await chatsModel.createChat(stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.category, b.status, b.tags, tribeId); + ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=chats` : safeReturnTo(ctx, '/chats?filter=mine', ['/chats'])); + }) + .post("/chats/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : undefined; + const patch = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, status: b.status, tags: b.tags }; + if (imageBlob !== undefined) patch.image = imageBlob; + await chatsModel.updateChatById(ctx.params.id, patch); + ctx.redirect(safeReturnTo(ctx, '/chats?filter=mine', ['/chats'])); + }) + .post("/chats/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + await chatsModel.deleteChatById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/chats?filter=mine', ['/chats'])); + }) + .post("/chats/close/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + await chatsModel.closeChatById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats'])); + }) + .post("/chats/generate-invite", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const chatId = ctx.request.body.chatId; + const code = await chatsModel.generateInvite(chatId); + ctx.body = renderChatInvitePage(code); + }) + .post("/chats/open-invite/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const chatId = ctx.request.body.chatId; + try { await chatsModel.generateOpenInvite(chatId); } catch (_) {} + ctx.redirect(`/chats/${encodeURIComponent(chatId)}`); + }) + .post("/chats/open-invite/remove", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const chatId = ctx.request.body.chatId; + try { await chatsModel.removeOpenInvite(chatId); } catch (_) {} + ctx.redirect(`/chats/${encodeURIComponent(chatId)}`); + }) + .post("/chats/join-code", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const code = String(ctx.request.body.code || '').trim(); + try { + const chatKey = await chatsModel.joinByInvite(code); + ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(chatKey)}`, ['/chats'])); + } catch (_) { + ctx.redirect(safeReturnTo(ctx, '/chats', ['/chats'])); + } + }) + .post("/chats/join/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const chat = await chatsModel.getChatById(ctx.params.id); + if (!chat) { sendErrorPage(ctx, "Chat not found", { status: 404 }); return; } + if (chat.status === "CLOSED") { sendErrorPage(ctx, "Chat is closed", { status: 403 }); return; } + if (chat.status === "INVITE-ONLY" && !chat.members.includes(uid) && chat.author !== uid) { sendErrorPage(ctx, "Invite-only chat", { status: 403 }); return; } + if (chat.tribeId) { + try { + const t = await tribesModel.getTribeById(chat.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats'])); + return; + } + try { + await chatsModel.joinChat(ctx.params.id); + } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats'])); + }) + .post("/chats/leave/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + try { + await chatsModel.leaveChat(ctx.params.id); + } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/chats?filter=all', ['/chats'])); + }) + .post("/chats/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'add')) + .post("/chats/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'remove')) + .post("/chats/:chatId/message", koaBody({ multipart: true }), async (ctx) => { + if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const chat = await chatsModel.getChatById(ctx.params.chatId); + if (chat && chat.tribeId) { + try { + const t = await tribesModel.getTribeById(chat.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } + const text = stripDangerousTags(String(ctx.request.body.text || '').trim()); + const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; + if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; } + await chatsModel.sendMessage(ctx.params.chatId, text, imageBlob); + ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.chatId)}`, ['/chats'])); + }) + .post("/pads/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + const tribeId = b.tribeId || null; + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); + } + const msg = await padsModel.createPad( + stripDangerousTags(b.title || ""), + b.status || "OPEN", + b.deadline || "", + b.tags || "", + tribeId + ); + ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=pads` : `/pads/${encodeURIComponent(msg.key)}`); + }) + .post("/pads/update/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + await padsModel.updatePadById(ctx.params.id, { + title: stripDangerousTags(b.title || ""), + status: b.status || "OPEN", + deadline: b.deadline || "", + tags: b.tags || "" + }); + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/pads/close/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + try { await padsModel.closePadById(ctx.params.id); } catch (_) {} + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/pads/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + await padsModel.deletePadById(ctx.params.id); + ctx.redirect('/pads'); + }) + .post("/pads/generate-invite/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + const code = await padsModel.generateInvite(ctx.params.id); + ctx.body = renderPadInvitePage(code); + }) + .post("/pads/open-invite/create/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + try { await padsModel.generateOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/pads/open-invite/remove/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + try { await padsModel.removeOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/pads/join-code", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || "").trim(); + try { + const padId = await padsModel.joinByInvite(code); + ctx.redirect(`/pads/${encodeURIComponent(padId)}`); + } catch (_) { + ctx.redirect('/pads'); + } + }) + .post("/pads/join/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const pad = await padsModel.getPadById(ctx.params.id); + if (!pad) { sendErrorPage(ctx, "Pad not found", { status: 404 }); return; } + if (pad.isClosed || pad.status === "CLOSED") { sendErrorPage(ctx, "Pad is closed", { status: 403 }); return; } + if (pad.status === "INVITE-ONLY" && !pad.members.includes(uid) && pad.author !== uid) { sendErrorPage(ctx, "Invite-only pad", { status: 403 }); return; } + if (pad.tribeId) { + try { + const t = await tribesModel.getTribeById(pad.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + return; + } + await padsModel.addMemberToPad(ctx.params.id, uid); + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/pads/entry/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const pad = await padsModel.getPadById(ctx.params.id); + if (pad && pad.tribeId) { + try { + const t = await tribesModel.getTribeById(pad.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } + const b = ctx.request.body || {}; + const text = stripDangerousTags(String(b.text || "").trim()); + if (text) await padsModel.addEntry(ctx.params.id, text); + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/pads/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'add')) + .post("/pads/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'remove')) + .post("/calendars/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + const tribeId = b.tribeId || null; + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + const intervalWeekly = [].concat(b.intervalWeekly).includes("1"); + const intervalMonthly = [].concat(b.intervalMonthly).includes("1"); + const intervalYearly = [].concat(b.intervalYearly).includes("1"); + try { + const msg = await calendarsModel.createCalendar({ + title: stripDangerousTags(b.title || ""), + status: b.status || "OPEN", + deadline: b.deadline || "", + tags: b.tags || "", + firstDate: b.firstDate || "", + firstDateLabel: stripDangerousTags(b.firstDateLabel || ""), + firstNote: stripDangerousTags(b.firstNote || ""), + intervalWeekly, intervalMonthly, intervalYearly, + tribeId + }); + ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : `/calendars/${encodeURIComponent(msg.key)}`); + } catch (e) { + console.error("[calendars/create]", e && e.message ? e.message : e) + ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : '/calendars'); + } + }) + .post("/calendars/update/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + const b = ctx.request.body || {}; + try { + await calendarsModel.updateCalendarById(ctx.params.id, { + title: stripDangerousTags(b.title || ""), + status: b.status || "OPEN", + deadline: b.deadline || "", + tags: b.tags || "" + }); + } catch (_) {} + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/calendars/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + const tribeId = target && target.tribeId; + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + await calendarsModel.deleteCalendarById(ctx.params.id); + ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : '/calendars'); + }) + .post("/calendars/join/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + try { await calendarsModel.joinCalendar(ctx.params.id); } catch (_) {} + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/calendars/generate-invite/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + try { + const code = await calendarsModel.generateInvite(ctx.params.id); + ctx.body = renderCalendarInvitePage(code); + } catch (e) { + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + } + }) + .post("/calendars/open-invite/create/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + try { await calendarsModel.generateOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/calendars/open-invite/remove/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + try { await calendarsModel.removeOpenInvite(ctx.params.id); } catch (_) {} + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/calendars/join-code", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || "").trim(); + try { + const calId = await calendarsModel.joinByInvite(code); + ctx.redirect(`/calendars/${encodeURIComponent(calId)}`); + } catch (_) { + ctx.redirect('/calendars'); + } + }) + .post("/calendars/leave/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + try { await calendarsModel.leaveCalendar(ctx.params.id); } catch (_) {} + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/calendars/add-date/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (calForGate && calForGate.tribeId) { + try { + const t = await tribesModel.getTribeById(calForGate.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } + const b = ctx.request.body || {}; + const intervalWeekly = [].concat(b.intervalWeekly).includes("1"); + const intervalMonthly = [].concat(b.intervalMonthly).includes("1"); + const intervalYearly = [].concat(b.intervalYearly).includes("1"); + try { + const dateMsgs = await calendarsModel.addDate(ctx.params.id, b.date || "", stripDangerousTags(b.label || ""), intervalWeekly, intervalMonthly, intervalYearly, b.intervalDeadline || ""); + const noteText = stripDangerousTags(String(b.text || "").trim()); + if (noteText && Array.isArray(dateMsgs)) { + for (const msg of dateMsgs) { + if (msg && msg.key) { + try { await calendarsModel.addNote(ctx.params.id, msg.key, noteText); } catch (_) {} + } + } + } + } catch (e) { + console.error("[calendars/add-date]", e && e.message ? e.message : e) + } + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/calendars/add-note/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (calForGate && calForGate.tribeId) { + try { + const t = await tribesModel.getTribeById(calForGate.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } + const b = ctx.request.body || {}; + const text = stripDangerousTags(String(b.text || "").trim()); + if (text) { + try { await calendarsModel.addNote(ctx.params.id, b.dateId || "", text); } catch (_) {} + } + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/calendars/delete-note/:noteId", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const calendarId = (ctx.request.body || {}).calendarId || ""; + if (calendarId) { + const target = await calendarsModel.getCalendarById(calendarId).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + } + try { await calendarsModel.deleteNote(ctx.params.noteId); } catch (_) {} + ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars'); + }) + .post("/calendars/delete-date/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const calendarId = (ctx.request.body || {}).calendarId || ""; + if (calendarId) { + const target = await calendarsModel.getCalendarById(calendarId).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + } + try { await calendarsModel.deleteDate(ctx.params.id, calendarId); } catch (_) {} + ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars'); + }) + .post("/calendars/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'add')) + .post("/calendars/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'remove')) + .post("/projects/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}, image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null; + const bounties = b.bountiesInput ? String(b.bountiesInput).split("\n").filter(Boolean).map(l => { const [t,a,d] = String(l).split("|"); return { title: String(t||"").trim(), amount: parseFloat(a||0)||0, description: String(d||"").trim(), milestoneIndex: null }; }) : []; + await projectsModel.createProject({ title: b.title, description: b.description, goal: b.goal != null && b.goal !== "" ? parseFloat(b.goal) : 0, deadline: b.deadline ? new Date(b.deadline).toISOString() : null, progress: b.progress != null && b.progress !== "" ? parseInt(b.progress,10) : 0, bounties, image, milestoneTitle: b.milestoneTitle, milestoneDescription: b.milestoneDescription, milestoneTargetPercent: b.milestoneTargetPercent, milestoneDueDate: b.milestoneDueDate, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic }); + ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"])); + }) + .post("/projects/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {}; + const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined; + const bounties = b.bountiesInput !== undefined ? String(b.bountiesInput).split("\n").filter(Boolean).map(l => { const [t,a,d] = String(l).split("|"); return { title: String(t||"").trim(), amount: parseFloat(a||0)||0, description: String(d||"").trim(), milestoneIndex: null }; }) : undefined; + await projectsModel.updateProject(id, { title: b.title, description: b.description, goal: b.goal !== "" && b.goal != null ? parseFloat(b.goal) : undefined, deadline: b.deadline ? new Date(b.deadline).toISOString() : undefined, progress: b.progress !== "" && b.progress != null ? parseInt(b.progress,10) : undefined, bounties, image, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic }); + ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"])); + }) + .post("/projects/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + await projectsModel.deleteProject(await projectsModel.getProjectTipId(ctx.params.id)); + ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"])); + }) + .post("/projects/status/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id); + await projectsModel.updateProjectStatus(id, String(ctx.request.body?.status || "").toUpperCase()); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/progress/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id); + await projectsModel.updateProjectProgress(id, ctx.request.body?.progress); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/pledge/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const latestId = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {}; + const pledgeAmount = parseFloat(b.amount), uid = getViewerId(); + if (isNaN(pledgeAmount) || pledgeAmount <= 0) ctx.throw(400, "Invalid amount"); + const project = await projectsModel.getProjectById(latestId); + if (String(project.status || "ACTIVE").toUpperCase() !== "ACTIVE") ctx.throw(400, "Project is not active"); + if (project.deadline && moment(project.deadline).isValid() && moment(project.deadline).isBefore(moment())) ctx.throw(400, "Project deadline passed"); + if (project.author === uid) ctx.throw(403, "Authors cannot pledge to their own project"); + let milestoneIndex = null, bountyIndex = null, mob = b.milestoneOrBounty || ""; + if (String(mob).startsWith("milestone:")) milestoneIndex = parseInt(String(mob).split(":")[1], 10); + else if (String(mob).startsWith("bounty:")) bountyIndex = parseInt(String(mob).split(":")[1], 10); + const transfer = await transfersModel.createTransfer(project.author, "Project Pledge", pledgeAmount, moment().add(14, "days").toISOString(), ["backer-pledge", `project:${latestId}`]); + await projectsModel.pledgeToProject(latestId, uid, pledgeAmount, { transferId: transfer.key || transfer.id, milestoneIndex, bountyIndex }); + await pmModel.sendMessage([project.author], "PROJECT_PLEDGE", `has pledged ${pledgeAmount} ECO to your project "${project.title || ''}" -> /projects/${latestId}`); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(latestId)}`, ["/projects"])); + }) + .post("/projects/confirm-transfer/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(), transfer = await transfersModel.getTransferById(ctx.params.id); + if (transfer.to !== uid) ctx.throw(403, "Unauthorized action"); + const tagProject = (Array.isArray(transfer.tags) ? transfer.tags : []).find(t => String(t).startsWith("project:")); + if (!tagProject) ctx.throw(400, "Missing project tag on transfer"); + const projectId = String(tagProject).split(":")[1]; + await transfersModel.confirmTransferById(ctx.params.id); + try { await projectsModel.confirmPledge(projectId, ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(projectId)}`, ["/projects", "/transfers"])); + }) + .post("/projects/follow/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const latestId = await projectsModel.getProjectTipId(ctx.params.id), project = await projectsModel.getProjectById(latestId); + await projectsModel.followProject(ctx.params.id, getViewerId()); + await pmModel.sendMessage([project.author], "PROJECT_FOLLOWED", `has followed your project "${project.title || ''}" -> /projects/${latestId}`); + ctx.redirect(safeReturnTo(ctx, "/projects", ["/projects"])); + }) + .post("/projects/unfollow/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const latestId = await projectsModel.getProjectTipId(ctx.params.id), project = await projectsModel.getProjectById(latestId); + await projectsModel.unfollowProject(ctx.params.id, getViewerId()); + await pmModel.sendMessage([project.author], "PROJECT_UNFOLLOWED", `has unfollowed your project "${project.title || ''}" -> /projects/${latestId}`); + ctx.redirect(safeReturnTo(ctx, "/projects", ["/projects"])); + }) + .post("/projects/milestones/add/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {}; + await projectsModel.addMilestone(id, { title: b.title, description: b.description || "", targetPercent: b.targetPercent != null && b.targetPercent !== "" ? parseInt(b.targetPercent, 10) : 0, dueDate: b.dueDate ? new Date(b.dueDate).toISOString() : null }); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/milestones/update/:id/:index", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id), idx = parseInt(ctx.params.index, 10), b = ctx.request.body || {}; + const patch = { title: b.title, ...(b.description !== undefined ? { description: b.description } : {}), ...(b.targetPercent !== undefined && b.targetPercent !== "" ? { targetPercent: parseInt(b.targetPercent, 10) } : {}), ...(b.dueDate !== undefined ? { dueDate: b.dueDate ? new Date(b.dueDate).toISOString() : null } : {}), ...(b.done !== undefined ? { done: !!b.done } : {}) }; + await projectsModel.updateMilestone(id, idx, patch); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/milestones/complete/:id/:index", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id); + await projectsModel.completeMilestone(id, parseInt(ctx.params.index, 10), getViewerId()); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/bounties/add/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {}; + await projectsModel.addBounty(id, { title: b.title, amount: b.amount, description: b.description, milestoneIndex: b.milestoneIndex === "" || b.milestoneIndex === undefined ? null : parseInt(b.milestoneIndex, 10) }); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/bounties/update/:id/:index", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id), idx = parseInt(ctx.params.index, 10), b = ctx.request.body || {}; + const patch = { ...(b.title !== undefined ? { title: b.title } : {}), ...(b.amount !== undefined && b.amount !== "" ? { amount: parseFloat(b.amount) } : {}), ...(b.description !== undefined ? { description: b.description } : {}), ...(b.milestoneIndex !== undefined ? { milestoneIndex: b.milestoneIndex === "" ? null : parseInt(b.milestoneIndex, 10) } : {}), ...(b.done !== undefined ? { done: !!b.done } : {}) }; + await projectsModel.updateBounty(id, idx, patch); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/bounties/claim/:id/:index", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id); + await projectsModel.claimBounty(id, parseInt(ctx.params.index, 10), getViewerId()); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/bounties/complete/:id/:index", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } + const id = await projectsModel.getProjectTipId(ctx.params.id); + await projectsModel.completeBounty(id, parseInt(ctx.params.index, 10), getViewerId()); + ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); + }) + .post("/projects/:projectId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'projects', 'projectId')) + .post("/banking/claim-ubi", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const userId = getViewerId(); + try { + await bankingModel.claimUBI(userId); + ctx.redirect("/banking?filter=overview&msg=claimed_pending"); + } catch (e) { + ctx.redirect(`/banking?filter=overview&msg=${encodeURIComponent(e.message || "error")}`); + } + }) + .post("/banking/claim/:id", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const { i18n: _i18n } = require("../views/main_views"); + const userId = getViewerId(), allocation = await bankingModel.getAllocationById(ctx.params.id); + if (!allocation) { sendErrorPage(ctx, _i18n.errorNoAllocation); return; } + if (allocation.to !== userId || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) { sendErrorPage(ctx, _i18n.errorInvalidClaim); return; } + if (!bankingModel.isPubNode()) { + ctx.redirect("/banking?filter=overview&msg=claimed_pending"); + return; + } + const { txid } = await bankingModel.claimAllocation({ transferId: ctx.params.id, claimerId: userId }); + await bankingModel.publishBankClaim({ amount: allocation.amount, epochId: allocation.concept, allocationId: allocation.id, txid }); + ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`); + }) + .post("/banking/simulate", koaBody(), async (ctx) => { + if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; } + const { epochId, rules } = ctx.request.body || {}; + ctx.body = await bankingModel.computeEpoch({ epochId, rules }); + }) + .post("/banking/run", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; } + const { epochId, rules } = ctx.request.body || {}; + ctx.body = await bankingModel.executeEpoch({ epochId, rules }); + }) + .post("/banking/addresses", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const b = ctx.request.body || {}; + const viewerId = getViewerId(); + const submittedId = (b.userId || "").trim(); + if (submittedId && submittedId !== viewerId) { + ctx.redirect(`/banking?filter=addresses&msg=forbidden`); + return; + } + const res = await bankingModel.addAddress({ userId: viewerId, address: (b.address || "").trim() }); + ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`); + }) + .post("/banking/addresses/delete", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const res = await bankingModel.removeAddress({ userId: getViewerId() }); + ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`); + }) + .post("/favorites/remove/:kind/:id", koaBody(), async (ctx) => { + await favoritesModel.removeFavorite(ctx.params.kind, ctx.params.id); + const fallback = `/favorites?filter=${encodeURIComponent(ctx.query.filter || "all")}`; + ctx.redirect(safeReturnTo(ctx, fallback, ["/favorites"])); + }) + .post("/update", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const exec = require("node:util").promisify(require("node:child_process").exec); + const repoRoot = path.resolve(__dirname, '..', '..'); + const { stdout, stderr } = await exec("git reset --hard && git pull", { cwd: repoRoot }); + console.log("oasis@version: updating Oasis...", stdout, stderr); + const { stdout: shOut, stderr: shErr } = await exec("sh install.sh", { cwd: repoRoot }); + console.log("oasis@version: running install.sh...", shOut, shErr); + safeRefererRedirect(ctx, '/settings'); + }) + .post("/settings/theme", koaBody(), async (ctx) => { + const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig(); + cfg.themes.current = theme || "Dark-SNH"; + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); + ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict', secure: ctx.secure }); + ctx.redirect("/settings"); + }) + .post("/language", koaBody(), async (ctx) => { + const lang = String(ctx.request.body.language || "en"); + const cfg = getConfig(); + cfg.language = lang; + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); + ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict', secure: ctx.secure }); + safeRefererRedirect(ctx, '/settings'); + }) + .post("/settings/conn/start", koaBody(), async ctx => { await meta.connStart(); ctx.redirect("/peers"); }) + .post("/settings/conn/stop", koaBody(), async ctx => { await meta.connStop(); ctx.redirect("/peers"); }) + .post("/settings/conn/sync", koaBody(), async ctx => { await meta.sync(); ctx.redirect("/peers"); }) + .post("/settings/conn/restart", koaBody(), async ctx => { await meta.connRestart(); ctx.redirect("/peers"); }) + .post("/settings/invite/accept", koaBody(), async ctx => { + const invite = String(ctx.request.body.invite || ''); + const pubKey = (invite.match(/@[A-Za-z0-9+/=_-]{43,}\.ed25519/) || [])[0] || null; + if (pubKey) { + try { + const os = require('os'), fsx = require('fs'), px = require('path'); + const gossip = JSON.parse(fsx.readFileSync(px.join(os.homedir(), '.ssb', 'gossip.json'), 'utf8') || '[]'); + let unfollowed = []; + try { unfollowed = JSON.parse(fsx.readFileSync(px.join(os.homedir(), '.ssb', 'gossip_unfollowed.json'), 'utf8') || '[]'); } catch (_) {} + const activePub = Array.isArray(gossip) && gossip.some(p => p && p.key === pubKey) && !unfollowed.some(u => u && u.key === pubKey); + if (activePub) { ctx.redirect('/invites?flash=alreadyFederated'); return; } + } catch (_) {} + } + try { await meta.acceptInvite(invite); } catch (_) {} + ctx.redirect("/invites"); + }) + .post("/invites/inhabitant/follow", koaBody(), async (ctx) => { + const feedId = String(ctx.request.body?.feedId || '').trim(); + if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(feedId)) { + ctx.redirect(`/search?query=${encodeURIComponent(feedId)}`); + return; + } + try { + const name = await about.name(feedId); + if (!name || name === feedId.slice(1, 9)) { + ctx.redirect(`/search?query=${encodeURIComponent(feedId)}`); + return; + } + const ssb = await cooler.open(); + await new Promise((res, rej) => ssb.publish({ type: 'contact', contact: feedId, following: true }, (e) => e ? rej(e) : res())); + ctx.redirect(`/author/${encodeURIComponent(feedId)}`); + } catch (_) { + ctx.redirect(`/search?query=${encodeURIComponent(feedId)}`); + } + }) + .post("/settings/invite/unfollow", koaBody(), async (ctx) => { + const { key } = ctx.request.body || {}; + if (!key) return ctx.redirect("/invites"); + const pubs = readJSON(gossipPath), kcanon = canonicalKey(key); + const idx = pubs.findIndex(x => x && canonicalKey(x.key) === kcanon); + const removed = idx >= 0 ? (pubs.splice(idx, 1)[0], writeJSON(gossipPath, pubs), pubs[idx-1] !== undefined ? pubs.splice(idx,1)[0] : null) : null; + const ssb = await cooler.open(), addr = removed?.host ? msAddrFrom(removed.host, removed.port, removed.key) : null; + if (addr) { try { await new Promise(res => ssb.conn.disconnect(addr, res)); } catch {} try { ssb.conn.forget(addr); } catch {} } + try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, following: false, blocking: true }, e => e ? rej(e) : res())); } catch {} + const unf = readJSON(unfollowedPath); + if (!unf.find(x => x && canonicalKey(x.key) === kcanon)) { unf.push(removed || { key: kcanon }); writeJSON(unfollowedPath, unf); } + ctx.redirect("/invites"); + }) + .post("/settings/invite/follow", koaBody(), async (ctx) => { + const { key, host, port } = ctx.request.body || {}; + if (!key || !host) return ctx.redirect("/invites"); + const pubs = readJSON(gossipPath), kcanon = canonicalKey(key); + if (pubs.find(p => p.host === host)?.error) return ctx.redirect("/invites"); + const ssb = await cooler.open(), unf = readJSON(unfollowedPath); + const rec = unf.find(x => x && canonicalKey(x.key) === kcanon) || { host, port: Number(port) || 8008, key: kcanon }; + if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) { pubs.push({ host: rec.host, port: Number(rec.port) || 8008, key: kcanon }); writeJSON(gossipPath, pubs); } + const addr = msAddrFrom(rec.host, rec.port, kcanon); + try { ssb.conn.remember(addr, { type: "pub", autoconnect: true, key: kcanon }); } catch {} + try { await new Promise(res => ssb.conn.connect(addr, { type: "pub" }, res)); } catch {} + try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, blocking: false }, e => e ? rej(e) : res())); } catch {} + writeJSON(unfollowedPath, unf.filter(x => !(x && canonicalKey(x.key) === kcanon))); + ctx.redirect("/invites"); + }) + .post("/peers/connect", koaBody(), async (ctx) => { + const { key, host, port } = ctx.request.body || {}; + if (!key || !host) { sendErrorPage(ctx, "Missing IP or public key.", { status: 400 }); return; } + const hostStr = String(host).trim().toLowerCase(); + const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostStr); + const isHostname = /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$/.test(hostStr); + if ((!isIPv4 && !isHostname) || hostStr.length > 253) { sendErrorPage(ctx, `Invalid IP/hostname: ${hostStr}`, { status: 400 }); return; } + if (isIPv4 && hostStr.split('.').some(o => Number(o) > 255)) { sendErrorPage(ctx, `Invalid IPv4: ${hostStr}`, { status: 400 }); return; } + const prt = Number(port) || 8008; + if (!Number.isInteger(prt) || prt < 1 || prt > 65535) { sendErrorPage(ctx, `Invalid port: ${port}`, { status: 400 }); return; } + const keyStr = String(key).trim(); + if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) { sendErrorPage(ctx, `Invalid public key. Expected @<44 chars>=.ed25519`, { status: 400 }); return; } + const kcanon = canonicalKey(keyStr); + const pubs = readJSON(gossipPath); + if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) { + pubs.push({ host: hostStr, port: prt, key: kcanon }); + writeJSON(gossipPath, pubs); + } + const ssb = await cooler.open(); + const addr = msAddrFrom(hostStr, prt, kcanon); + try { ssb.conn.remember(addr, { type: "peer", autoconnect: true, key: kcanon }); } catch (e) { console.error('[peers/connect] remember failed:', e.message || e); } + try { await new Promise((res, rej) => ssb.conn.connect(addr, { type: "peer" }, (err) => err ? rej(err) : res())); } catch (_) {} + try { await new Promise((res, rej) => ssb.publish({ type: "contact", contact: kcanon, following: true }, e => e ? rej(e) : res())); } catch (_) {} + const unf = readJSON(unfollowedPath); + writeJSON(unfollowedPath, unf.filter(x => !(x && canonicalKey(x.key) === kcanon))); + ctx.redirect("/peers"); + }) + .post("/peers/disconnect", koaBody(), async (ctx) => { + const { key, host, port } = ctx.request.body || {}; + if (!key) return ctx.redirect("/peers"); + const keyStr = String(key).trim(); + if (!/^@[A-Za-z0-9+/_\-]{43}=\.ed25519$/.test(keyStr)) return ctx.redirect("/peers"); + const kcanon = canonicalKey(keyStr); + const ssb = await cooler.open(); + const candidates = new Set(); + if (host && Number(port)) candidates.add(msAddrFrom(String(host), Number(port), kcanon)); + try { + const snapshot = (ssb.conn && typeof ssb.conn.dbPeers === 'function') ? await ssb.conn.dbPeers() : []; + for (const entry of (snapshot || [])) { + const addr = Array.isArray(entry) ? entry[0] : null; + const data = Array.isArray(entry) ? entry[1] : entry; + if (data && canonicalKey(data.key || '') === kcanon && addr) candidates.add(addr); + } + } catch (_) {} + try { + const staged = (ssb.conn && typeof ssb.conn.stagedPeers === 'function') ? ssb.conn.stagedPeers() : []; + for (const entry of (staged || [])) { + const addr = Array.isArray(entry) ? entry[0] : null; + const data = Array.isArray(entry) ? entry[1] : entry; + if (data && canonicalKey(data.key || '') === kcanon && addr) candidates.add(addr); + } + } catch (_) {} + try { + const livePeers = (ssb.peers && typeof ssb.peers === 'object') ? ssb.peers : {}; + const rpcs = livePeers[kcanon]; + if (Array.isArray(rpcs)) { + for (const r of rpcs) { + const addrLive = r?.stream?.address || null; + if (addrLive) candidates.add(addrLive); + } + } + } catch (_) {} + for (const addr of candidates) { + try { await new Promise(res => ssb.conn.disconnect(addr, res)); } catch {} + try { ssb.conn.forget(addr); } catch {} + } + ctx.redirect("/peers"); + }) + .post("/invites/refresh-pubs", koaBody(), async (ctx) => { + try { + const ssb = await cooler.open(); + const pubs = readJSON(gossipPath); + if (Array.isArray(pubs)) { + for (const p of pubs) { + if (!p || !p.key || !p.host) continue; + let addr; + try { addr = msAddrFrom(p.host, p.port, p.key); } catch (_) { continue; } + try { ssb.conn.connect(addr, { type: "pub" }, () => {}); } catch (_) {} + } + } + } catch (_) {} + ctx.redirect("/invites"); + }) + .post("/invites/clear-unreachable", koaBody(), async (ctx) => { + try { + const pubs = readJSON(gossipPath); + if (Array.isArray(pubs)) { + const kept = pubs.filter(p => p && !p.error && !(typeof p.failure === 'number' && p.failure > 0)); + if (kept.length !== pubs.length) writeJSON(gossipPath, kept); + } + } catch (_) {} + ctx.redirect("/invites"); + }) + .get("/invites/export-pubs", async (ctx) => { + const lines = []; + lines.push('# Oasis pubs — multiserver addresses, one per line'); + lines.push('# Generated ' + new Date().toISOString()); + try { + const pubs = readJSON(gossipPath); + if (Array.isArray(pubs)) { + const seen = new Set(); + for (const p of pubs) { + if (!p || !p.key || !p.host) continue; + try { + const addr = msAddrFrom(p.host, p.port, p.key); + if (seen.has(addr)) continue; + seen.add(addr); + lines.push(addr); + } catch (_) {} + } + } + } catch (_) {} + ctx.type = 'text/plain'; + ctx.set('Content-Disposition', 'attachment; filename="oasis-pubs.txt"'); + ctx.body = lines.join('\n') + '\n'; + }) + .post("/invites/import-pubs", koaBody({ multipart: true, formidable: { maxFileSize: 1 * 1024 * 1024 } }), async (ctx) => { + let raw = String((ctx.request.body && ctx.request.body.peerList) || ''); + try { + const f = ctx.request.files && (ctx.request.files.peerFile || ctx.request.files.file); + const file = Array.isArray(f) ? f[0] : f; + if (file && file.filepath) { + const buf = await promisesFs.readFile(file.filepath, 'utf8'); + raw = raw ? (raw + '\n' + buf) : buf; + } + } catch (_) {} + if (!raw.trim()) return ctx.redirect("/invites"); + let ssb = null; + try { ssb = await cooler.open(); } catch (_) {} + const pubs = readJSON(gossipPath); + let writeBack = false; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const msMatch = trimmed.match(/^net:([^:]+):(\d+)~shs:([A-Za-z0-9+/_\-]{43}=?)(?:\.ed25519)?$/); + if (msMatch) { + const host = msMatch[1]; + const port = Number(msMatch[2]); + const keyCore = msMatch[3].endsWith('=') ? msMatch[3] : (msMatch[3] + '='); + const kcanon = canonicalKey('@' + keyCore + '.ed25519'); + const addr = msAddrFrom(host, port, kcanon); + if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) { + pubs.push({ host, port, key: kcanon }); + writeBack = true; + } + if (ssb && ssb.conn && typeof ssb.conn.remember === 'function') { + try { ssb.conn.remember(addr, { type: 'pub', autoconnect: true, key: kcanon }); } catch (_) {} + } + continue; + } + const inviteMatch = trimmed.match(/^[^:]+:\d+:@[A-Za-z0-9+/_\-]{43}=?\.ed25519~/); + if (inviteMatch) { + try { await meta.acceptInvite(trimmed); } catch (_) {} + continue; + } + } + if (writeBack) writeJSON(gossipPath, pubs); + ctx.redirect("/invites"); + }) + .post("/peers/refresh", koaBody(), async (ctx) => { + try { + const ssb = await cooler.open(); + try { if (ssb.lan && typeof ssb.lan.stop === 'function') ssb.lan.stop(); } catch (_) {} + try { if (ssb.lan && typeof ssb.lan.start === 'function') ssb.lan.start(); } catch (_) {} + } catch (_) {} + const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || ''); + ctx.redirect(['/peers', '/graphos'].includes(returnTo) ? returnTo : '/peers'); + }) + .post("/peers/prune", koaBody(), async (ctx) => { + try { + const ssb = await cooler.open(); + const connectedKeys = new Set(); + try { + const livePeers = (ssb.peers && typeof ssb.peers === 'object') ? ssb.peers : {}; + for (const k of Object.keys(livePeers)) { + const rpcs = livePeers[k]; + if (Array.isArray(rpcs) && rpcs.length > 0) connectedKeys.add(canonicalKey(k)); + } + } catch (_) {} + try { + const snapshot = (ssb.conn && typeof ssb.conn.dbPeers === 'function') ? await ssb.conn.dbPeers() : []; + for (const entry of (snapshot || [])) { + const data = Array.isArray(entry) ? entry[1] : entry; + const addr = Array.isArray(entry) ? entry[0] : null; + if (!data || !addr) continue; + const kc = data.key ? canonicalKey(data.key) : null; + if (kc && connectedKeys.has(kc)) continue; + try { ssb.conn.forget(addr); } catch (_) {} + } + } catch (_) {} + try { + const pubs = readJSON(gossipPath); + if (Array.isArray(pubs)) { + const kept = pubs.filter(g => g && g.key && connectedKeys.has(canonicalKey(g.key))); + if (kept.length !== pubs.length) writeJSON(gossipPath, kept); + } + } catch (_) {} + } catch (_) {} + const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || ''); + ctx.redirect(['/peers', '/graphos'].includes(returnTo) ? returnTo : '/peers'); + }) + .get("/peers/export", async (ctx) => { + const lines = []; + lines.push('# Oasis peers — multiserver addresses, one per line'); + lines.push('# Generated ' + new Date().toISOString()); + const seen = new Set(); + const writePeer = (host, port, key) => { + if (!host || !key) return; + let addr; + try { addr = msAddrFrom(host, port || 8008, key); } catch (_) { return; } + if (seen.has(addr)) return; + seen.add(addr); + lines.push(addr); + }; + try { + const pubs = readJSON(gossipPath); + if (Array.isArray(pubs)) for (const g of pubs) if (g && g.key && g.host) writePeer(g.host, g.port, g.key); + } catch (_) {} + try { + const ssb = await cooler.open(); + try { + const snapshot = (ssb.conn && typeof ssb.conn.dbPeers === 'function') ? await ssb.conn.dbPeers() : []; + for (const entry of (snapshot || [])) { + const data = Array.isArray(entry) ? entry[1] : entry; + const addr = Array.isArray(entry) ? entry[0] : null; + if (!data || !data.key) continue; + let host = data.host, port = data.port; + if ((!host || !port) && addr) { + const m = String(addr).match(/^net:([^:]+):(\d+)/); + if (m) { host = host || m[1]; port = port || Number(m[2]); } + } + writePeer(host, port, data.key); + } + } catch (_) {} + try { + if (ssb.conn && typeof ssb.conn.stagedPeers === 'function') { + const staged = await new Promise((resolve) => { + try { + pull( + ssb.conn.stagedPeers(), + pull.take(1), + pull.collect((err, results) => { + if (err || !results || !results[0]) return resolve([]); + resolve(Array.isArray(results[0]) ? results[0] : []); + }) + ); + } catch (_) { resolve([]); } + }); + for (const entry of staged) { + const data = Array.isArray(entry) ? entry[1] : entry; + const addr = Array.isArray(entry) ? entry[0] : null; + if (!data || !data.key) continue; + let host = data.host, port = data.port; + if ((!host || !port) && addr) { + const m = String(addr).match(/^net:([^:]+):(\d+)/); + if (m) { host = host || m[1]; port = port || Number(m[2]); } + } + writePeer(host, port, data.key); + } + } + } catch (_) {} + } catch (_) {} + ctx.type = 'text/plain'; + ctx.set('Content-Disposition', 'attachment; filename="oasis-peers.txt"'); + ctx.body = lines.join('\n') + '\n'; + }) + .post("/peers/import", koaBody({ multipart: true, formidable: { maxFileSize: 1 * 1024 * 1024 } }), async (ctx) => { + let raw = String((ctx.request.body && ctx.request.body.peerList) || ''); + try { + const f = ctx.request.files && (ctx.request.files.peerFile || ctx.request.files.file); + const file = Array.isArray(f) ? f[0] : f; + if (file && file.filepath) { + const buf = await promisesFs.readFile(file.filepath, 'utf8'); + raw = raw ? (raw + '\n' + buf) : buf; + } + } catch (_) {} + if (!raw.trim()) return ctx.redirect("/peers"); + let ssb = null; + try { ssb = await cooler.open(); } catch (_) {} + const pubs = readJSON(gossipPath); + let added = 0; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const m = trimmed.match(/^net:([^:]+):(\d+)~shs:([A-Za-z0-9+/_\-]{43}=?)(?:\.ed25519)?$/); + if (!m) continue; + const host = m[1]; + const port = Number(m[2]); + const keyCore = m[3].endsWith('=') ? m[3] : (m[3] + '='); + const kcanon = canonicalKey('@' + keyCore + '.ed25519'); + const addr = msAddrFrom(host, port, kcanon); + if (!pubs.find(x => x && canonicalKey(x.key) === kcanon)) { + pubs.push({ host, port, key: kcanon }); + } + if (ssb && ssb.conn && typeof ssb.conn.remember === 'function') { + try { ssb.conn.remember(addr, { type: 'peer', autoconnect: true, key: kcanon }); added++; } catch (_) {} + } else { + added++; + } + } + writeJSON(gossipPath, pubs); + ctx.redirect("/peers"); + }) + .post("/settings/ssb-logstream", koaBody(), async (ctx) => { + const logLimit = parseInt(ctx.request.body.ssb_log_limit, 10); + if (!isNaN(logLimit) && logLimit > 0 && logLimit <= 100000) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.ssbLogStream = { ...(config.ssbLogStream || {}), limit: logLimit }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + } + ctx.redirect("/settings"); + }) + .post("/settings/replication", koaBody(), async (ctx) => { + const hops = parseInt(ctx.request.body.hops, 10); + if (Number.isFinite(hops) && hops >= 0 && hops <= 6) { + const serverConfigPath = path.join(__dirname, '..', 'configs', 'server-config.json'); + try { + const cfg = JSON.parse(fs.readFileSync(serverConfigPath, 'utf8')); + cfg.friends = { ...(cfg.friends || {}), hops }; + fs.writeFileSync(serverConfigPath, JSON.stringify(cfg, null, 2)); + } catch (_) {} + } + ctx.redirect("/settings"); + }) + .post("/settings/home-page", koaBody(), async (ctx) => { + const cfg = getConfig(); + cfg.homePage = String(ctx.request.body.homePage || "").trim() || "activity"; + saveConfig(cfg); + ctx.redirect("/settings"); + }) + .post("/settings/ux", koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String(ctx.request.body.ux || "").trim().toLowerCase(); + const aiNavEnabled = cfg.modules && cfg.modules.aiNavMod === 'on'; + const next = (v === "ainav" && aiNavEnabled) ? "ainav" : "blocks"; + cfg.ux = { ...(cfg.ux && typeof cfg.ux === 'object' ? cfg.ux : {}), current: next }; + saveConfig(cfg); + ctx.redirect(next === "ainav" ? "/" : "/settings"); + }) + .post("/settings/lan-broadcasting", koaBody(), async (ctx) => { + const enabled = !!(ctx.request.body && (ctx.request.body.lanBroadcasting === 'on' || ctx.request.body.lanBroadcasting === '1' || ctx.request.body.lanBroadcasting === 'true')); + const cfg = getConfig(); + cfg.lanBroadcasting = enabled; + saveConfig(cfg); + try { + const ssb = await cooler.open(); + if (ssb && ssb.lan) { + if (enabled && typeof ssb.lan.start === 'function') { try { ssb.lan.start(); } catch (_) {} } + if (!enabled && typeof ssb.lan.stop === 'function') { try { ssb.lan.stop(); } catch (_) {} } + } + } catch (_) {} + ctx.redirect("/settings"); + }) + .post("/inhabitants/follow/accept", koaBody(), async (ctx) => { + const b = ctx.request.body || {}; + const followerId = String(b.followerId || '').trim(); + if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; } + if (viewerFilters.canAutoAcceptNow()) viewerFilters.markAutoAccept(); + viewerFilters.addAccepted(followerId); + viewerFilters.removePending(followerId); + ctx.redirect('/inhabitants?filter=pending'); + }) + .post("/inhabitants/follow/reject", koaBody(), async (ctx) => { + const b = ctx.request.body || {}; + const followerId = String(b.followerId || '').trim(); + if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; } + viewerFilters.removeAccepted(followerId); + viewerFilters.removePending(followerId); + ctx.redirect('/inhabitants?filter=pending'); + }) + .post("/settings/wish", koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String(ctx.request.body.wish || '').trim(); + cfg.wish = ['mutuals', 'only-lan'].includes(v) ? v : 'whole'; + saveConfig(cfg); + ctx.redirect("/settings"); + }) + .post("/settings/pm-visibility", koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String(ctx.request.body.pmVisibility || '').trim(); + cfg.pmVisibility = v === 'mutuals' ? 'mutuals' : 'whole'; + saveConfig(cfg); + const returnTo = String((ctx.query && ctx.query.returnTo) || (ctx.request.body && ctx.request.body.returnTo) || ''); + ctx.redirect(['/settings', '/inbox'].includes(returnTo) ? returnTo : '/settings'); + }) + .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); }) + .post("/modules/preset", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; + const PRESETS = { + minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'], + social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'], + economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'], + full: ALL_MODULES + }; + const preset = String(ctx.request.body.preset || ''); + const enabledMods = PRESETS[preset]; + if (!enabledMods) { ctx.redirect('/modules'); return; } + const cfg = getConfig(); + ALL_MODULES.forEach(mod => cfg.modules[`${mod}Mod`] = enabledMods.includes(mod) ? 'on' : 'off'); + saveConfig(cfg); + ctx.redirect('/modules'); + }) + .post("/save-modules", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'graphos', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; + const cfg = getConfig(); + modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off'); + saveConfig(cfg); + ctx.redirect(`/modules`); + }) + .post("/settings/ai", koaBody(), async (ctx) => { + const aiPrompt = String(ctx.request.body.ai_prompt || "").trim(); + if (aiPrompt.length > 128) { sendErrorPage(ctx, "Prompt too long. Must be 128 characters or fewer.", { status: 400 }); return; } + const cfg = getConfig(); + cfg.ai = { ...(cfg.ai || {}), prompt: aiPrompt }; + saveConfig(cfg); + ctx.redirect("/settings"); + }) + .post("/settings/pub-id", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const b = ctx.request.body, cfg = getConfig(); + cfg.walletPub = { pubId: String(b.pub_id || "").trim() }; + saveConfig(cfg); + ctx.redirect("/settings"); + }) + .post('/transfers/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + await transfersModel.createTransfer(b.to, b.concept, b.amount, b.deadline, b.tags, b.category); + ctx.redirect(safeReturnTo(ctx, '/transfers?filter=all', ['/transfers'])); + }) + .post('/transfers/update/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + await transfersModel.updateTransferById(ctx.params.id, b.to, b.concept, b.amount, b.deadline, b.tags, b.category); + ctx.redirect(safeReturnTo(ctx, '/transfers?filter=mine', ['/transfers'])); + }) + .post('/transfers/confirm/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + await transfersModel.confirmTransferById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/transfers', ['/transfers'])); + }) + .post('/transfers/delete/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + await transfersModel.deleteTransferById(ctx.params.id); + ctx.redirect(safeReturnTo(ctx, '/transfers?filter=mine', ['/transfers'])); + }) + .post('/transfers/opinions/:transferId/:category', koaBody(), async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + await transfersModel.createOpinion(ctx.params.transferId, ctx.params.category); + ctx.redirect(safeReturnTo(ctx, '/transfers', ['/transfers'])); + }) + .post("/settings/wallet", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const b = ctx.request.body, cfg = getConfig(); + if (b.wallet_url) cfg.wallet.url = String(b.wallet_url); + if (b.wallet_user) cfg.wallet.user = String(b.wallet_user); + if (b.wallet_pass) cfg.wallet.pass = String(b.wallet_pass); + if (b.wallet_fee) cfg.wallet.fee = String(b.wallet_fee); + saveConfig(cfg); + const res = await bankingModel.ensureSelfAddressPublished(); + ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`); + }) + .post("/wallet/send", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + if (!checkMod(ctx, 'walletMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body, action = String(b.action), dest = String(b.destination), amt = Number(b.amount), fee = Number(b.fee); + const { url, user, pass } = getConfig().wallet; + let balance = null; + try { balance = await walletModel.getBalance(url, user, pass); } catch (error) { ctx.body = await walletErrorView(error); return; } + if (action === 'confirm') { + const v = await walletModel.validateSend(url, user, pass, dest, amt, fee); + try { ctx.body = v.isValid ? await walletSendConfirmView(balance, dest, amt, fee) : await walletSendFormView(balance, dest, amt, fee, { type: 'error', title: 'validation_errors', messages: v.errors }); } + catch (error) { ctx.body = await walletErrorView(error); } + } else if (action === 'send') { + try { ctx.body = await walletSendResultView(balance, dest, amt, await walletModel.sendToAddress(url, user, pass, dest, amt)); } + catch (error) { ctx.body = await walletErrorView(error); } + } + }); +const routes = router.routes(); +const middleware = [ + async (ctx, next) => { + if (config.public && ctx.method !== "GET") { sendErrorPage(ctx, "Sorry, many actions are unavailable when Oasis is running in public mode. Please run Oasis in the default mode and try again.", { status: 403 }); return; } + await next(); + }, + async (ctx, next) => { setLanguage(ctx.cookies.get("language") || getConfig().language || "en"); await next(); }, + async (ctx, next) => { + const isBinary = ctx.path.startsWith('/qr') || ctx.path.startsWith('/image/') || ctx.path.startsWith('/blob/') || ctx.path.startsWith('/assets/'); + if (isBinary) { + try { await next(); } catch (err) { + ctx.status = err.status || 500; + ctx.body = ''; + } + return; + } + const allowDuringSync = + ctx.path === '/profile/edit' || + ctx.path === '/profile' || + ctx.path === '/modules' || + ctx.path.startsWith('/c/') || + ctx.path.startsWith('/settings'); + if (allowDuringSync) { + try { await next(); } catch (err) { + const { i18n } = require('../views/main_views'); + sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 }); + } + return; + } + const ssb = await cooler.open(), status = await ssb.status(), values = Object.values(status.sync.plugins); + const totalCurrent = values.reduce((acc, cur) => acc + cur, 0), totalTarget = status.sync.since * values.length; + if (totalTarget - totalCurrent > 1024 * 1024) ctx.response.body = indexingView({ percent: Math.floor((totalCurrent / totalTarget) * 1000) / 10 }); + else { try { await next(); } catch (err) { + const { i18n } = require('../views/main_views'); + if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) { + sendErrorPage(ctx, i18n.fileTooLargeMessage, { title: i18n.fileTooLargeTitle, status: 413 }); + } else { + sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 }); + } + } } + }, + async (ctx, next) => { + if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/') && !ctx.path.startsWith('/qr') && !ctx.path.startsWith('/c/')) { + const now = Date.now(); + if (now - sharedState.getLastRefresh() > 60000) { + sharedState.setLastRefresh(now); + try { + const stats = await statsModel.getStats('ALL'); + const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize); + const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2)); + const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1; + const hcH = inhabitants > 0 ? parseFloat((hcT / inhabitants).toFixed(2)) : 0; + sharedState.setCarbonHcT(hcT); + sharedState.setCarbonHcH(hcH); + } catch (_) {} + try { await refreshInboxCount(); } catch (_) {} + try { await calendarsModel.checkDueReminders(); } catch (_) {} + try { await tasksModel.checkDueReminders(); } catch (_) {} + try { + const peers = await meta.connectedPeers(); + sharedState.setOnlinePeerCount(Array.isArray(peers) ? peers.length : 0); + } catch (_) {} + try { + sharedState.setInboxUnreadCount(sharedState.getInboxCount()); + sharedState.setLastSyncTs(now); + } catch (_) {} + try { + const ex = await bankingModel.calculateEcoinValue(); + if (ex && ex.isSynced) sharedState.setEcoValue(Number(ex.ecoValue).toFixed(4)); + } catch (_) {} + try { + const me = getViewerId(); + const actions = await activityModel.listFeed('all').catch(() => []); + const mine = (actions || []).filter(a => a && a.author === me && a.type !== 'tombstone' && a.type !== 'post'); + mine.sort((a, b) => (b.ts || 0) - (a.ts || 0)); + const prev = mine[1] || mine[0]; + if (prev) { + const hrefByType = { + vote: prev.content?.vote?.link ? `/thread/${encodeURIComponent(prev.content.vote.link)}#${encodeURIComponent(prev.content.vote.link)}` : null, + transfer: `/transfers/${encodeURIComponent(prev.id)}`, + tribe: `/tribe/${encodeURIComponent(prev.id)}`, + shop: `/shops/${encodeURIComponent(prev.id)}`, + job: `/jobs/${encodeURIComponent(prev.id)}`, + event: `/events/${encodeURIComponent(prev.id)}`, + project: `/projects/${encodeURIComponent(prev.id)}`, + image: `/images/${encodeURIComponent(prev.id)}`, + audio: `/audios/${encodeURIComponent(prev.id)}`, + video: `/videos/${encodeURIComponent(prev.id)}`, + document: `/documents/${encodeURIComponent(prev.id)}`, + torrent: `/torrents/${encodeURIComponent(prev.id)}`, + forum: `/forum/${encodeURIComponent(prev.content?.key || prev.id)}`, + bookmark: `/bookmarks/${encodeURIComponent(prev.id)}`, + task: `/tasks/${encodeURIComponent(prev.id)}`, + event_: `/events/${encodeURIComponent(prev.id)}`, + about: `/author/${encodeURIComponent(prev.author)}`, + map: `/maps/${encodeURIComponent(prev.id)}`, + chat: `/chats/${encodeURIComponent(prev.id)}`, + pad: `/pads/${encodeURIComponent(prev.id)}`, + pixelia: `/pixelia` + }; + sharedState.setLastActivity({ + id: prev.id, + type: prev.type, + ts: prev.ts, + href: hrefByType[prev.type] || `/activity?filter=mine` + }); + } + } catch (_) {} + } + } + await next(); + }, + routes, +]; +const app = http({ host, port, middleware, allowHost: config.allowHost }); + +let pubEngineTimer = null; +async function runPubEngineTick() { + if (!bankingModel.isPubNode()) return; + try { await bankingModel.executeEpoch({}); } catch (_) {} + try { await bankingModel.processPendingClaims(); } catch (_) {} + try { await bankingModel.publishPubAvailability(); } catch (_) {} +} +if (bankingModel.isPubNode()) { + setTimeout(() => { runPubEngineTick(); }, 15000); + pubEngineTimer = setInterval(runPubEngineTick, 30 * 60 * 1000); +} + +setTimeout(() => { larpModel.init().catch(() => {}); }, 10000); + +let welcomePmAttempted = false; +async function sendWelcomePmIfFirstLaunch() { + if (welcomePmAttempted) return; + welcomePmAttempted = true; + const flagPath = path.join(ssbConfig.path, 'oasis-first-contact'); + try { + const ssbClient = await cooler.open(); + if (!ssbClient || !ssbClient.id) { welcomePmAttempted = false; return; } + const ownId = ssbClient.id; + + if (fs.existsSync(flagPath)) { + let prev = ''; + try { prev = fs.readFileSync(flagPath, 'utf8'); } catch (_) {} + if (prev.includes(ownId)) return; + try { fs.unlinkSync(flagPath); } catch (_) {} + } + + const i18nAll = require('../client/assets/translations/i18n'); + const lang = (getConfig() && getConfig().language) || 'en'; + const t = i18nAll[lang] || i18nAll.en; + const subject = t.welcomePmSubject || 'Hello.'; + const text = t.welcomePmBody || 'Hello.'; + try { + await pmModel.sendMessage([], subject, text); + } catch (err) { + console.error('[welcome-pm] publish failed:', err && err.message ? err.message : err); + welcomePmAttempted = false; + return; + } + try { fs.writeFileSync(flagPath, ownId + '\n' + new Date().toISOString() + '\n'); } catch (e) { + console.error('[welcome-pm] flag write failed:', e && e.message ? e.message : e); + } + } catch (err) { + console.error('[welcome-pm] unexpected error:', err && err.message ? err.message : err); + welcomePmAttempted = false; + } +} +setTimeout(() => { sendWelcomePmIfFirstLaunch(); }, 20000); + +async function logClearnetStatus() { + try { + const ssbClient = await cooler.open(); + if (!ssbClient || !ssbClient.id) return; + const prefs = await about.visibilityPrefs(ssbClient.id).catch(() => null); + const modules = [ + ['Shops', 'clearnetShops'], + ['Jobs', 'clearnetJobs'], + ['Events', 'clearnetEvents'], + ['Projects', 'clearnetProjects'], + ['Blogs', 'clearnetPosts'], + ['Audios', 'clearnetAudios'], + ['Videos', 'clearnetVideos'], + ['Images', 'clearnetImages'], + ['Documents', 'clearnetDocuments'], + ['Torrents', 'clearnetTorrents'] + ]; + const active = prefs ? modules.filter(([_, k]) => prefs[k] === true).map(([label]) => label) : []; + try { + const { setClearnetModules } = require('../server/ssb_metadata'); + setClearnetModules(active); + } catch (_) {} + } catch (_) { + try { + const { setClearnetModules } = require('../server/ssb_metadata'); + setClearnetModules([]); + } catch (_) {} + } +} +setTimeout(() => { logClearnetStatus(); }, 8000); + +app._close = () => { + if (pubEngineTimer) clearInterval(pubEngineTimer); + nameWarmup.close(); + cooler.close(); +}; +module.exports = app; +if (config.open === true && process.env.OASIS_MOBILE !== '1') open(url); diff --git a/src/backend/blobHandler.js b/src/backend/blobHandler.js new file mode 100644 index 0000000..95c00b8 --- /dev/null +++ b/src/backend/blobHandler.js @@ -0,0 +1,279 @@ +const pull = require('../server/node_modules/pull-stream'); +const FileType = require("../server/node_modules/file-type"); +const promisesFs = require('fs').promises; +const ssb = require("../client/gui"); +const config = require("../server/SSB_server").config; +const cooler = ssb({ offline: config.offline }); + +let sharp; +try { + sharp = require("sharp"); +} catch (e) { +} + +const stripImageMetadata = async (buffer) => { + if (typeof sharp !== "function") return buffer; + try { + return await sharp(buffer).rotate().toBuffer(); + } catch { + return buffer; + } +}; + +const PDF_METADATA_KEYS = [ + '/Title', '/Author', '/Subject', '/Keywords', + '/Creator', '/Producer', '/CreationDate', '/ModDate' +]; + +const stripPdfMetadata = (buffer) => { + try { + let str = buffer.toString('binary'); + for (const key of PDF_METADATA_KEYS) { + const keyBytes = key; + const regex = new RegExp( + keyBytes.replace(/\//g, '\\/') + '\\s*\\([^)]*\\)', + 'g' + ); + str = str.replace(regex, keyBytes + ' ()'); + const hexRegex = new RegExp( + keyBytes.replace(/\//g, '\\/') + '\\s*<[^>]*>', + 'g' + ); + str = str.replace(hexRegex, keyBytes + ' <>'); + } + return Buffer.from(str, 'binary'); + } catch { + return buffer; + } +}; + +const MAX_BLOB_SIZE = 50 * 1024 * 1024; + +class FileTooLargeError extends Error { + constructor(fileName, fileSize) { + super(`File too large: ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`); + this.name = 'FileTooLargeError'; + this.fileName = fileName; + this.fileSize = fileSize; + } +} + +const handleBlobUpload = async function (ctx, fileFieldName) { + if (!ctx.request.files || !ctx.request.files[fileFieldName]) { + 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 || fileFieldName, data.length); + } + + const EXTENSION_MIME_MAP = { + '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg', + '.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', + '.svg': 'image/svg+xml', '.bmp': 'image/bmp', + '.torrent': 'application/x-bittorrent' + }; + + const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' }; + + try { + const fileType = await FileType.fromBuffer(data); + blob.mime = (fileType && fileType.mime) ? fileType.mime : null; + } catch { + blob.mime = null; + } + + if (!blob.mime && blob.name) { + const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase(); + blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream'; + } + + if (!blob.mime) { + blob.mime = 'application/octet-stream'; + } + + if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') { + data = await stripImageMetadata(data); + } else if (blob.mime === 'application/pdf') { + data = stripPdfMetadata(data); + } + + 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![image:${blob.name}](${blob.id})`; + if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`; + if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`; + if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`; + if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`; + + return `\n[${blob.name}](${blob.id})`; +}; + +function waitForBlob(ssbClient, blobId, timeoutMs = 8000) { + return new Promise((resolve, reject) => { + let done = false; + + const finishOk = () => { + if (done) return; + done = true; + clearTimeout(timer); + resolve(); + }; + + const finishErr = (err) => { + if (done) return; + done = true; + clearTimeout(timer); + reject(err); + }; + + const timer = setTimeout(() => { + finishErr(new Error(`Timeout waiting for blob ${blobId}`)); + }, timeoutMs); + + if (!ssbClient.blobs || typeof ssbClient.blobs.has !== 'function') { + return finishErr(new Error('ssb.blobs.has is not available')); + } + + ssbClient.blobs.has(blobId, (err, has) => { + if (err) return finishErr(err); + if (has) return finishOk(); + + if (typeof ssbClient.blobs.want !== 'function') { + return finishErr(new Error('ssb.blobs.want is not available')); + } + + ssbClient.blobs.want(blobId, (err2) => { + if (err2) return finishErr(err2); + finishOk(); + }); + }); + }); +} + +const serveBlob = async function (ctx) { + const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim(); + const raw = decodeURIComponent(encodedParam); + + if (!raw) { + ctx.status = 400; + ctx.body = 'Invalid blob id'; + return; + } + + const blobId = raw.startsWith('&') ? raw : `&${raw}`; + + const ssbClient = await cooler.open(); + + try { + await waitForBlob(ssbClient, blobId, 8000); + } catch (err) { + ctx.status = 504; + ctx.body = 'Blob not available'; + return; + } + + let buffer; + try { + buffer = await new Promise((resolve, reject) => { + pull( + ssbClient.blobs.get(blobId), + pull.collect((err, chunks) => { + if (err) return reject(err); + resolve(Buffer.concat(chunks)); + }) + ); + }); + } catch (err) { + ctx.status = 500; + ctx.body = 'Error reading blob'; + return; + } + + const size = buffer.length; + + let mime = 'application/octet-stream'; + try { + const ft = await FileType.fromBuffer(buffer); + if (ft && ft.mime) mime = ft.mime; + } catch {} + + if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) { + const head = buffer.slice(0, 128).toString('ascii'); + if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent'; + } + + if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') { + let head = buffer.slice(0, 512).toString('utf8'); + if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1); + const trimmed = head.replace(/^\s+/, '').toLowerCase(); + if (trimmed.startsWith('= 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, serveBlob, FileTooLargeError }; + diff --git a/src/backend/collab_content.js b/src/backend/collab_content.js new file mode 100644 index 0000000..7699ba7 --- /dev/null +++ b/src/backend/collab_content.js @@ -0,0 +1,66 @@ +const { mergeDuplicatesBy, norm } = require('./dedupe'); + +const identitySignature = (x) => (x && x.author && x.createdAt) ? norm(x.author) + '|' + norm(x.createdAt) : null; + +const openInviteOf = (item) => { + if (!item || !Array.isArray(item.invites)) return null; + const pub = item.invites.find((inv) => inv && typeof inv === 'object' && inv.public === true && inv.code); + return pub ? { code: pub.code } : null; +}; + +const collabContent = (opts = {}) => { + const membersField = opts.membersField || 'members'; + const undecField = opts.undecField || 'undecryptable'; + const contentFields = Array.isArray(opts.contentFields) ? opts.contentFields : []; + const listFields = Array.isArray(opts.listFields) ? opts.listFields : []; + const arr = (v) => (Array.isArray(v) ? v : []); + + const freshestMembers = (base, dup) => { + const bt = new Date(base.updatedAt || base.createdAt || 0).getTime(); + const dt = new Date(dup.updatedAt || dup.createdAt || 0).getTime(); + const fresh = dt >= bt ? dup : base; + return arr(fresh[membersField]).length ? fresh[membersField] + : (arr(base[membersField]).length ? base[membersField] : arr(dup[membersField])); + }; + + const mergePair = (base, dup) => { + const out = { ...base, [membersField]: freshestMembers(base, dup) }; + for (const f of contentFields) out[f] = base[f] || dup[f]; + for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(dup[f]); + out[undecField] = !!(base[undecField] && dup[undecField]); + return out; + }; + + const orderForMerge = (a, b) => { + const c = new Date(a.createdAt || 0) - new Date(b.createdAt || 0); + if (c !== 0) return c; + return ((a[undecField] || !a.title) ? 1 : 0) - ((b[undecField] || !b.title) ? 1 : 0); + }; + + const collapse = (items) => + mergeDuplicatesBy(arr(items).slice().sort(orderForMerge), identitySignature, mergePair); + + const fold = (base, all) => { + const sig = identitySignature(base); + if (!sig) return base; + const group = arr(all).filter((x) => identitySignature(x) === sig); + if (group.length <= 1) return base; + const canonical = collapse(group)[0]; + const out = { ...base, [membersField]: canonical[membersField] }; + for (const f of contentFields) out[f] = base[f] || canonical[f]; + for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(canonical[f]); + return out; + }; + + const isVisible = (item, viewerId) => { + if (!item[undecField]) return true; + return item.author === viewerId || arr(item[membersField]).includes(viewerId); + }; + + const visibleThenCollapsed = (items, viewerId) => + collapse(arr(items).filter((x) => isVisible(x, viewerId))); + + return { identitySignature, mergePair, collapse, fold, isVisible, visibleThenCollapsed }; +}; + +module.exports = { collabContent, identitySignature, openInviteOf }; diff --git a/src/backend/dedupe.js b/src/backend/dedupe.js new file mode 100644 index 0000000..c5d9e97 --- /dev/null +++ b/src/backend/dedupe.js @@ -0,0 +1,52 @@ +const norm = (v) => String(v == null ? '' : v).trim().toLowerCase(); + +const dedupeBy = (items, keyFn) => { + const seen = new Set(); + const out = []; + for (const it of (Array.isArray(items) ? items : [])) { + let k = null; + try { k = keyFn(it); } catch (_) { k = null; } + if (k == null || k === '') { out.push(it); continue; } + if (seen.has(k)) continue; + seen.add(k); + out.push(it); + } + return out; +}; + +const mergeDuplicatesBy = (items, keyFn, mergeFn) => { + const slots = new Map(); + const out = []; + for (const it of (Array.isArray(items) ? items : [])) { + let k = null; + try { k = keyFn(it); } catch (_) { k = null; } + if (k == null || k === '') { out.push(it); continue; } + if (!slots.has(k)) { + const slot = { i: out.length, v: it }; + slots.set(k, slot); + out.push(it); + } else { + const slot = slots.get(k); + slot.v = mergeFn(slot.v, it); + out[slot.i] = slot.v; + } + } + return out; +}; + +const dedupeByPreferring = (items, keyFn, scoreFn) => { + const best = new Map(); + const order = []; + for (const it of (Array.isArray(items) ? items : [])) { + let k = null; + try { k = keyFn(it); } catch (_) { k = null; } + if (k == null || k === '') { order.push({ solo: it }); continue; } + let s = 0; + try { s = Number(scoreFn(it)) || 0; } catch (_) { s = 0; } + if (!best.has(k)) { const slot = { v: it, s }; best.set(k, slot); order.push({ slot }); } + else { const slot = best.get(k); if (s > slot.s) { slot.v = it; slot.s = s; } } + } + return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v)); +}; + +module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm }; diff --git a/src/backend/logsPdf.js b/src/backend/logsPdf.js new file mode 100644 index 0000000..ca25ec3 --- /dev/null +++ b/src/backend/logsPdf.js @@ -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 }; diff --git a/src/backend/media-favorites.js b/src/backend/media-favorites.js new file mode 100644 index 0000000..78a8923 --- /dev/null +++ b/src/backend/media-favorites.js @@ -0,0 +1,126 @@ +const fs = require("fs"); +const path = require("path"); + +const FILE = path.join(__dirname, "../configs/media-favorites.json"); + +const DEFAULT = { + audios: [], + bookmarks: [], + calendars: [], + chats: [], + documents: [], + images: [], + maps: [], + pads: [], + shops: [], + market: [], + shopProducts: [], + torrents: [], + videos: [] +}; + +const safeArr = (v) => (Array.isArray(v) ? v : []); + +let queue = Promise.resolve(); + +const withLock = (fn) => { + queue = queue.then(fn, fn); + return queue; +}; + +const normalize = (raw) => { + const out = {}; + for (const k of Object.keys(DEFAULT)) { + const list = safeArr(raw?.[k]).map((x) => String(x || "").trim()).filter(Boolean); + out[k] = Array.from(new Set(list)); + } + return out; +}; + +const ensureFile = async () => { + try { + await fs.promises.access(FILE); + } catch (e) { + const dir = path.dirname(FILE); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8"); + } +}; + +const readAll = async () => { + await ensureFile(); + try { + const txt = await fs.promises.readFile(FILE, "utf8"); + return normalize(JSON.parse(txt || "{}")); + } catch (e) { + const fixed = normalize(DEFAULT); + await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8"); + return fixed; + } +}; + +const writeAll = async (data) => { + 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, FILE); +}; + +const assertKind = (kind) => { + const k = String(kind || "").trim(); + if (!Object.prototype.hasOwnProperty.call(DEFAULT, k)) throw new Error("Invalid favorites kind"); + return k; +}; + +const lastArg = (args, n) => args[args.length - n]; + +const kindFromArgs = (args) => { + const k = String(lastArg(args, 1) || "").trim(); + return assertKind(k); +}; + +const idFromArgs = (args) => String(lastArg(args, 1) || "").trim(); + +exports.getFavoriteSet = async (kind) => { + const k = assertKind(kind); + const data = await readAll(); + return new Set(safeArr(data[k]).map(String)); +}; + +exports.addFavorite = async (kind, id) => + withLock(async () => { + const k = assertKind(kind); + const favId = String(id || "").trim(); + if (!favId) return; + const data = await readAll(); + const set = new Set(safeArr(data[k]).map(String)); + set.add(favId); + data[k] = Array.from(set); + await writeAll(data); + }); + +exports.removeFavorite = async (kind, id) => + withLock(async () => { + const k = assertKind(kind); + const favId = String(id || "").trim(); + if (!favId) return; + const data = await readAll(); + const set = new Set(safeArr(data[k]).map(String)); + set.delete(favId); + data[k] = Array.from(set); + await writeAll(data); + }); + +exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args)); +exports.addToFavorites = async (...args) => { + const kind = kindFromArgs(args.slice(0, -1)); + const id = idFromArgs(args); + return exports.addFavorite(kind, id); +}; +exports.removeFromFavorites = async (...args) => { + const kind = kindFromArgs(args.slice(0, -1)); + const id = idFromArgs(args); + return exports.removeFavorite(kind, id); +}; + diff --git a/src/backend/nameCache.js b/src/backend/nameCache.js new file mode 100644 index 0000000..46c4fdd --- /dev/null +++ b/src/backend/nameCache.js @@ -0,0 +1,20 @@ +const cache = new Map() + +const get = (feedId) => { + if (!feedId) return null + const entry = cache.get(String(feedId)) + return entry ? entry.name : null +} + +const set = (feedId, name, ts) => { + if (!feedId || typeof name !== 'string' || !name) return + const id = String(feedId) + const t = Number(ts) || 0 + const prev = cache.get(id) + if (!prev || (prev.ts || 0) <= t) cache.set(id, { name, ts: t }) +} + +const has = (feedId) => !!feedId && cache.has(String(feedId)) +const size = () => cache.size + +module.exports = { get, set, has, size } diff --git a/src/backend/opinion_categories.js b/src/backend/opinion_categories.js new file mode 100644 index 0000000..a6e314d --- /dev/null +++ b/src/backend/opinion_categories.js @@ -0,0 +1,50 @@ +const positive = [ + "interesting", + "necessary", + "useful", + "informative", + "wellResearched", + "accurate", + "insightful", + "actionable", + "creative", + "inspiring", + "love", + "funny", + "clear", + "uplifting" +]; + +const constructive = [ + "unnecessary", + "rejected", + "needsSources", + "wrong", + "lowQuality", + "confusing", + "misleading", + "offTopic", + "duplicate", + "clickbait", + "propaganda" +]; + +const moderation = [ + "spam", + "troll", + "adultOnly", + "nsfw", + "violent", + "toxic", + "harassment", + "hate", + "scam", + "triggering" +]; + +const all = [...positive, ...constructive, ...moderation]; +all.positive = positive; +all.constructive = constructive; +all.moderation = moderation; + +module.exports = all; diff --git a/src/backend/renderTextWithStyles.js b/src/backend/renderTextWithStyles.js new file mode 100644 index 0000000..c403a45 --- /dev/null +++ b/src/backend/renderTextWithStyles.js @@ -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, '>') + + html = html + .replace(/```([\s\S]*?)```/gim, '
$1
') + .replace(/^> (.*)$/gim, '
$1
') + .replace(/^---$/gim, '
') + .replace(/^### (.*)$/gim, '

$1

') + .replace(/^## (.*)$/gim, '

$1

') + .replace(/^# (.*)$/gim, '

$1

') + + html = html + .replace(/\*\*(.*?)\*\*/gim, '$1') + .replace(/\*(.*?)\*/gim, '$1') + .replace(/`([^`]+)`/gim, '$1') + + html = html + .replace(/!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) => + `${alt}` + ) + .replace(/\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) => + `` + ) + .replace(/\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) => + `` + ) + .replace(/\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, name, blob) => + `${name || i18n.pdfFallbackLabel || 'PDF'}` + ) + + html = html + .replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) => + `@${name}` + ) + .replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) => + `@${id}` + ) + + const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''') + html = html + .replace(/#(\w+)/g, (_, tag) => + `#${escAttr(tag)}` + ) + .replace(/(https?:\/\/[^\s"'<>]+)/g, url => + `${escAttr(url)}` + ) + .replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email => + `${escAttr(email)}` + ) + + const lines = html.split('\n') + let result = '' + let inUL = false + let inOL = false + + for (let line of lines) { + if (/^- /.test(line)) { + if (!inUL) { + result += '
    ' + inUL = true + } + result += `
  • ${line.replace(/^- /, '')}
  • ` + continue + } + + if (/^\d+\. /.test(line)) { + if (!inOL) { + result += '
      ' + inOL = true + } + result += `
    1. ${line.replace(/^\d+\. /, '')}
    2. ` + continue + } + + if (inUL) { + result += '
' + inUL = false + } + + if (inOL) { + result += '' + inOL = false + } + + result += line + '
' + } + + if (inUL) result += '' + if (inOL) result += '' + + return result +} + +module.exports = { renderTextWithStyles, renderTextPreview } diff --git a/src/backend/renderUrl.js b/src/backend/renderUrl.js new file mode 100644 index 0000000..9267768 --- /dev/null +++ b/src/backend/renderUrl.js @@ -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 }; diff --git a/src/backend/sanitizeHtml.js b/src/backend/sanitizeHtml.js new file mode 100644 index 0000000..85bb4b4 --- /dev/null +++ b/src/backend/sanitizeHtml.js @@ -0,0 +1,45 @@ +"use strict"; + +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 purify.sanitize(input, { + USE_PROFILES: { html: true }, + ALLOWED_TAGS: [ + 'p', 'br', + 'b', 'strong', 'i', 'em', 'u', + 'ul', 'ol', 'li', + 'blockquote', + 'code', 'pre' + ], + 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 purify.sanitize(input, { + USE_PROFILES: { html: true }, + ALLOWED_TAGS: [ + 'p', 'br', 'hr', + 'b', 'strong', 'i', 'em', 'u', 's', 'del', + 'ul', 'ol', 'li', + 'blockquote', 'code', 'pre', + 'a', 'span', 'div', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'img', 'video', 'audio', + 'table', 'thead', 'tbody', 'tr', 'th', 'td' + ], + 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 }; diff --git a/src/backend/smartContractPdf.js b/src/backend/smartContractPdf.js new file mode 100644 index 0000000..66b8baf --- /dev/null +++ b/src/backend/smartContractPdf.js @@ -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 }; diff --git a/src/backend/updater.js b/src/backend/updater.js new file mode 100644 index 0000000..bb2d660 --- /dev/null +++ b/src/backend/updater.js @@ -0,0 +1,121 @@ +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'; // Official SNH-Oasis +const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis + +let printed = false; + +async function extractVersionFromText(text) { + try { + const versionMatch = text.match(/"version":\s*"([^"]+)"/); + if (versionMatch) { + return versionMatch[1]; + } else { + throw new Error('Version not found in the response.'); + } + } catch (error) { + console.error("Error extracting version:", error.message); + return null; + } +} + +async function diffVersion(body, callback) { + try { + const remoteData = JSON.parse(body); + const remoteVersion = remoteData.version; + + const localData = JSON.parse(readFileSync(localpackage, 'utf8')); + const localVersion = localData.version; + + const updateFlagPath = join(__dirname, "../server/.update_required"); + + if (remoteVersion !== localVersion) { + writeFileSync(updateFlagPath, JSON.stringify({ required: true })); + callback("required"); + } else { + if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath); + callback(""); // no updates required + } + } catch (error) { + console.error("Error comparing versions:", error.message); + callback("error"); + } +} + +async function checkMirror(callback) { + try { + const response = await fetch(remoteUrl2, { + method: 'GET', + 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) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.text(); + callback(null, data); + } catch (error) { + console.error("\noasis@version: no updates requested.\n"); + callback(error); + } +} + +exports.getRemoteVersion = async () => { + if (existsSync('../../.git')) { + try { + const response = await fetch(remoteUrl, { + method: 'GET', + 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) { + throw new Error(`Request failed with status ${response.status}`); + } + const data = await response.text(); + diffVersion(data, (status) => { + if (status === "required" && !printed) { + printed = true; + console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n"); + } else if (status === "") { + console.log("\noasis@version: no updates requested.\n"); + } + }); + } catch (error) { + checkMirror((err, data) => { + if (err) { + console.error("\noasis@version: no updates requested.\n"); + } else { + diffVersion(data, (status) => { + if (status === "required" && !printed) { + printed = true; + console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n"); + } else { + console.log("oasis@version: no updates requested.\n"); + } + }); + } + }); + } + } +}; + diff --git a/src/backend/vote_tally.js b/src/backend/vote_tally.js new file mode 100644 index 0000000..54f02af --- /dev/null +++ b/src/backend/vote_tally.js @@ -0,0 +1,116 @@ +const buildVoteTally = (messages) => { + const nodes = new Map(); + const ballots = []; + + for (const m of messages) { + const v = m && m.value; + const c = v && v.content; + if (!c) 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: v.timestamp || m.timestamp || 0 }); + } + + const prev = new Map(); + for (const [k, n] of nodes) { + const t = n.content.replaces; + 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 tallyByKey = new Map(); + for (const [root, keys] of groups.entries()) { + const rootNode = nodes.get(root); + if (!rootNode) continue; + 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; + } + if (!advanced) break; + } + + 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; + voterSet.add(b.author); + voters.push(b.author); + votesMap[b.choice] = (votesMap[b.choice] || 0) + 1; + totalVotes += 1; + } + + const tally = { votes: votesMap, voters, totalVotes }; + for (const k of keys) tallyByKey.set(k, tally); + } + + return tallyByKey; +}; + +module.exports = { buildVoteTally }; diff --git a/src/backend/wallet_addresses.js b/src/backend/wallet_addresses.js new file mode 100644 index 0000000..ada6f20 --- /dev/null +++ b/src/backend/wallet_addresses.js @@ -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 }; diff --git a/src/client/assets/images/anarchy.png b/src/client/assets/images/anarchy.png new file mode 100644 index 0000000000000000000000000000000000000000..faec19d8e6e5dea02518df62d7f9660ff46acea2 GIT binary patch literal 33926 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 DBdahh literal 0 HcmV?d00001 diff --git a/src/client/assets/images/default-avatar.png b/src/client/assets/images/default-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..e451d959db5884894e5e3a40b83cc3dfb98ed1bd GIT binary patch literal 354997 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�wq1u|49Jx>*GR1icMl0~ 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]rZj17ku+dEAPU;Ey>?FB&1K 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�}vz` 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

AO85QthODWKYcFWU9R!w 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

9UBJvqG%Hx5MM{5K$8G!K)7Qu^VpW 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%0AMpzoO*mqp} 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�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 literal 0 HcmV?d00001 diff --git a/src/client/assets/images/default-tribe.png b/src/client/assets/images/default-tribe.png new file mode 100644 index 0000000000000000000000000000000000000000..bebe9762fd043bedffa239cc1cf5ed2d36268496 GIT binary patch literal 244056 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!xO}!*y$Y&*AIDKHS6Wr{>}AS) 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

+ ← Back to Games + 8BALL POOL +
+
+ SCORE: 0 + SHOTS: 0 + BEST: - +
+ +
Click to aim & shoot. Hold = more power.
+
Click on table to aim cue ball  |  Hold longer = more power  |  SPACE — new game
+ + + + diff --git a/src/games/8ball/thumbnail.svg b/src/games/8ball/thumbnail.svg new file mode 100644 index 0000000..6e551fa --- /dev/null +++ b/src/games/8ball/thumbnail.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + 8 + + + + + + + diff --git a/src/games/arkanoid/index.html b/src/games/arkanoid/index.html new file mode 100644 index 0000000..40f312d --- /dev/null +++ b/src/games/arkanoid/index.html @@ -0,0 +1,209 @@ + + + + + +Arkanoid + + + +
+ ← Back to Games + ARKANOID +
+
+ SCORE: 0 + LIVES: 3 + LEVEL: 1 +
+ +
Press SPACE or CLICK to start
+
←→ or MOUSE — Move paddle  |  SPACE / CLICK — Launch ball
+ + + + diff --git a/src/games/arkanoid/thumbnail.svg b/src/games/arkanoid/thumbnail.svg new file mode 100644 index 0000000..62e4569 --- /dev/null +++ b/src/games/arkanoid/thumbnail.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SCORE: 0 + diff --git a/src/games/artillery/index.html b/src/games/artillery/index.html new file mode 100644 index 0000000..0e51427 --- /dev/null +++ b/src/games/artillery/index.html @@ -0,0 +1,272 @@ + + + + + +Artillery + + + +
+ ← Back to Games + ARTILLERY +
+
+ ROUND: 1/5 + SHOTS: 0 + SCORE: 0 + WIND: 0 + BEST: - +
+ +
+ + + +
+
Adjust angle and power, then fire!
+ + + + diff --git a/src/games/artillery/thumbnail.svg b/src/games/artillery/thumbnail.svg new file mode 100644 index 0000000..800d9bc --- /dev/null +++ b/src/games/artillery/thumbnail.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + ~ + diff --git a/src/games/asteroids/index.html b/src/games/asteroids/index.html new file mode 100644 index 0000000..613f1c2 --- /dev/null +++ b/src/games/asteroids/index.html @@ -0,0 +1,250 @@ + + + + + +Asteroids + + + +
+ ← Back to Games + ASTEROIDS +
+
+ SCORE: 0 + LIVES: 3 + LEVEL: 1 +
+ +
Press SPACE to start
+
←→ Rotate  |  ↑ Thrust  |  SPACE Shoot
+ + + + diff --git a/src/games/asteroids/thumbnail.svg b/src/games/asteroids/thumbnail.svg new file mode 100644 index 0000000..0d3c5e4 --- /dev/null +++ b/src/games/asteroids/thumbnail.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + SCORE: 0 + diff --git a/src/games/audiopendulum/index.html b/src/games/audiopendulum/index.html new file mode 100644 index 0000000..c741379 --- /dev/null +++ b/src/games/audiopendulum/index.html @@ -0,0 +1,693 @@ + + + + + + Musical Double Pendulum + + + +
+ ← Back to Games + AUDIO PENDULUM +
+
+
+
Status: Stopped | Energy: 0.00 J | Time: 0.00 s
+
Tonal: Disabled | Percussion: Disabled | ♪: -- Hz | 🥁: 0 hits
+
+ + + + + +
+ +
+ Physical State:
+ Initial position: vertical up (12 o'clock)
+ State: Unstable equilibrium
+ ✓ NEAR EQUILIBRIUM + In motion +
✓ AT REST
+
🎵 TONAL AUDIO ACTIVE
+
🥁 PERCUSSION ACTIVE
+
+
+ Energy Conservation:
+ Initial: -- J | Current: -- J
+ Energy drift: 0.000% | Status: CORRECT +
Singularities: 0
+
+
+ +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+ +
+
+ + + + + + + + +
+
+ + + + + + +
+
+ +
+ + +
+
+
+ + + + diff --git a/src/games/audiopendulum/thumbnail.svg b/src/games/audiopendulum/thumbnail.svg new file mode 100644 index 0000000..7c60c4e --- /dev/null +++ b/src/games/audiopendulum/thumbnail.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AUDIOPENDULUM + diff --git a/src/games/cocoland/index.html b/src/games/cocoland/index.html new file mode 100644 index 0000000..31e357a --- /dev/null +++ b/src/games/cocoland/index.html @@ -0,0 +1,236 @@ + + + + + +Cocoland + + + +
+ ← Back to Games + COCOLAND +
+
+ SCORE: 0 + BEST: 0 +
+ +
Press SPACE or TAP to start
+
SPACE / TAP — Jump  |  Double jump allowed
+ + + + diff --git a/src/games/cocoland/thumbnail.svg b/src/games/cocoland/thumbnail.svg new file mode 100644 index 0000000..38a5bc0 --- /dev/null +++ b/src/games/cocoland/thumbnail.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + SCORE: 0 + diff --git a/src/games/cocoman/index.html b/src/games/cocoman/index.html new file mode 100644 index 0000000..475c4f5 --- /dev/null +++ b/src/games/cocoman/index.html @@ -0,0 +1,323 @@ + + + + + +Cocoman + + + +
+ ← Back to Games + COCOMAN +
+
+ SCORE: 0 + LIVES: 3 + BEST: - +
+ +
Press SPACE or tap arrow to start
+
+
+
+
+
Arrow keys / WASD  |  SPACE — new game
+ + + + diff --git a/src/games/cocoman/thumbnail.svg b/src/games/cocoman/thumbnail.svg new file mode 100644 index 0000000..6c23836 --- /dev/null +++ b/src/games/cocoman/thumbnail.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/games/ecoinflow/index.html b/src/games/ecoinflow/index.html new file mode 100644 index 0000000..c1276ce --- /dev/null +++ b/src/games/ecoinflow/index.html @@ -0,0 +1,793 @@ + + + + + +ECOINFLOW + + + +
+ ← Back to Games + ECOINFLOW +
+
+ LEVEL: 1/8 + TURN: 0 + PAR: 2 + SCORE: 0 + BEST: - +
+
+
+ +
+
+
+
+
+ + ENTER — advance  |  Backspace — undo  |  I — info +
+
+
+

LEVEL 1 — Your first PUB

+

+

+ ECOin ↗ + Oasis ↗ + SolarNET.HuB ↗ +

+

Click a node to start a pipe, then click adjacent cells to extend it, and finish on another node. Click an existing pipe to remove it. Press ENTER (or ADVANCE TURN button) to process one flow cycle.

+
+
+
+ + + + + + diff --git a/src/games/ecoinflow/thumbnail.svg b/src/games/ecoinflow/thumbnail.svg new file mode 100644 index 0000000..424a5f7 --- /dev/null +++ b/src/games/ecoinflow/thumbnail.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PUB + + + + HAB + + + + VAL + + + + SHP + + + + ACU + + + + CBD + + + + + PUB + + + + HAB + + + + HAB + + + + SHP + + + + CHK + + + + HAB + + + + CBD + + + + SHP + + + + + + + + + + + + + ECOINFLOW + diff --git a/src/games/flipflop/index.html b/src/games/flipflop/index.html new file mode 100644 index 0000000..c7dff8f --- /dev/null +++ b/src/games/flipflop/index.html @@ -0,0 +1,238 @@ + + + + + +FlipFlop + + + +
+ ← Back to Games + FLIPFLOP +
+

FLIPFLOP

+
Flip the ECOin — Heads or Tails?
+
+ WINS: 0 + LOSSES: 0 + STREAK: 0 + BEST: 0 +
+
+ +
+ + +
+ +
 
+
+ +
+ + + diff --git a/src/games/flipflop/thumbnail.svg b/src/games/flipflop/thumbnail.svg new file mode 100644 index 0000000..2869ad6 --- /dev/null +++ b/src/games/flipflop/thumbnail.svg @@ -0,0 +1,44 @@ + + + + + + + + + HEADS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TAILS + + Flip the ECOin! + \ No newline at end of file diff --git a/src/games/labyrinth/index.html b/src/games/labyrinth/index.html new file mode 100644 index 0000000..ab3d722 --- /dev/null +++ b/src/games/labyrinth/index.html @@ -0,0 +1,229 @@ + + + + + +Labyrinth + + + +
+ ← Back to Games + LABYRINTH + +
+
+ LEVEL: 1 + MOVES: 150 + SCORE: 0 + BEST: - +
+ +
Press SPACE or tap to start
+
+
+
+
+
Arrow keys / WASD  |  SPACE — new game
+ + + + diff --git a/src/games/labyrinth/thumbnail.svg b/src/games/labyrinth/thumbnail.svg new file mode 100644 index 0000000..6103255 --- /dev/null +++ b/src/games/labyrinth/thumbnail.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/games/neoninfiltrator/index.html b/src/games/neoninfiltrator/index.html new file mode 100644 index 0000000..fdf7c81 --- /dev/null +++ b/src/games/neoninfiltrator/index.html @@ -0,0 +1,351 @@ + + + + + +Neon Infiltrator + + + +
+ ← Back to Games + NEON INFILTRATOR +
+
+ DATA: 0/0 + LEVEL: 1 + SCORE: 0 + BEST: - + ENEMIES: 0 +
+ +
Press any arrow key or tap to start
+
+
+
+ + + +
+
+
Arrow keys / WASD  |  SPACE — new game
+ + + + diff --git a/src/games/neoninfiltrator/thumbnail.svg b/src/games/neoninfiltrator/thumbnail.svg new file mode 100644 index 0000000..6bfa891 --- /dev/null +++ b/src/games/neoninfiltrator/thumbnail.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NEON INFILTRATOR + diff --git a/src/games/pingpong/index.html b/src/games/pingpong/index.html new file mode 100644 index 0000000..7e67fc1 --- /dev/null +++ b/src/games/pingpong/index.html @@ -0,0 +1,192 @@ + + + + + +PingPong + + + +
+ ← Back to Games + PINGPONG +
+ +
Press SPACE to start
+
↑↓ or W/S — Move paddle  |  First to 5 wins
+ + + + diff --git a/src/games/pingpong/thumbnail.svg b/src/games/pingpong/thumbnail.svg new file mode 100644 index 0000000..f52189f --- /dev/null +++ b/src/games/pingpong/thumbnail.svg @@ -0,0 +1,11 @@ + + + + + + + 3 + 2 + YOU + AI + diff --git a/src/games/rockpaperscissors/index.html b/src/games/rockpaperscissors/index.html new file mode 100644 index 0000000..81216ef --- /dev/null +++ b/src/games/rockpaperscissors/index.html @@ -0,0 +1,182 @@ + + + + + +Rock Paper Scissors + + + +
+ ← Back to Games + ROCK PAPER SCISSORS +
+

Rock · Paper · Scissors

+
+ YOU: 0 + AI: 0 + DRAWS: 0 +
+
+
+ + + +
+
+ ? + VS + ? +
+
 
+
+ +
 
+
Best of 5 rounds
+ +
+ + + diff --git a/src/games/rockpaperscissors/thumbnail.svg b/src/games/rockpaperscissors/thumbnail.svg new file mode 100644 index 0000000..a91b3a6 --- /dev/null +++ b/src/games/rockpaperscissors/thumbnail.svg @@ -0,0 +1,17 @@ + + + + + + + + + ROCK + + + PAPER + + ✌️ + SCISSORS + Rock · Paper · Scissors + diff --git a/src/games/spaceinvaders/index.html b/src/games/spaceinvaders/index.html new file mode 100644 index 0000000..6df7f9a --- /dev/null +++ b/src/games/spaceinvaders/index.html @@ -0,0 +1,234 @@ + + + + + +Space Invaders + + + +
+ ← Back to Games + SPACE INVADERS +
+
+ SCORE: 0 + LIVES: 3 + WAVE: 1 +
+ +
Press SPACE to start
+
←→ Move  |  SPACE — Shoot
+ + + + diff --git a/src/games/spaceinvaders/thumbnail.svg b/src/games/spaceinvaders/thumbnail.svg new file mode 100644 index 0000000..bb4f29c --- /dev/null +++ b/src/games/spaceinvaders/thumbnail.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + SCORE: 0 + diff --git a/src/games/tetris/index.html b/src/games/tetris/index.html new file mode 100644 index 0000000..e542c2f --- /dev/null +++ b/src/games/tetris/index.html @@ -0,0 +1,242 @@ + + + + + +Tetris + + + +
+ ← Back to Games + TETRIS +
+
+ + +
+
Press SPACE to start
+
+ + + + +
+
Arrow keys  |  SPACE — hard drop / new game
+ + + + diff --git a/src/games/tetris/thumbnail.svg b/src/games/tetris/thumbnail.svg new file mode 100644 index 0000000..b062bbb --- /dev/null +++ b/src/games/tetris/thumbnail.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/games/tiktaktoe/index.html b/src/games/tiktaktoe/index.html new file mode 100644 index 0000000..494d5a3 --- /dev/null +++ b/src/games/tiktaktoe/index.html @@ -0,0 +1,190 @@ + + + + + +TikTakToe + + + +
+ ← Back to Games + TIKTAKTOE +
+

Tic · Tac · Toe

+
+ YOU (X): 0 + AI (O): 0 + DRAWS: 0 +
+
+
 
+ +
+
+ + + +
+
+ + + diff --git a/src/games/tiktaktoe/thumbnail.svg b/src/games/tiktaktoe/thumbnail.svg new file mode 100644 index 0000000..48a7e71 --- /dev/null +++ b/src/games/tiktaktoe/thumbnail.svg @@ -0,0 +1,18 @@ + + + + + + + X + O + X + O + X + O + O + X + X + + Tic · Tac · Toe + diff --git a/src/maps/map_renderer.js b/src/maps/map_renderer.js new file mode 100644 index 0000000..0a63299 --- /dev/null +++ b/src/maps/map_renderer.js @@ -0,0 +1,223 @@ +const { execFileSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); + +const BASE_MAP = path.join(__dirname, "..", "client", "assets", "images", "worldmap-z2.png"); +const CACHE_DIR = path.join(__dirname, "cache"); +const TILES_DIR = path.join(__dirname, "tiles"); +const MAP_W = 1024; +const MAP_H = 1024; + +const latLngToPx = (lat, lng) => { + const latRad = lat * Math.PI / 180; + const x = Math.round((lng + 180) / 360 * MAP_W); + const y = Math.round((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * MAP_H); + return { x: Math.max(12, Math.min(MAP_W - 12, x)), y: Math.max(12, Math.min(MAP_H - 12, y)) }; +}; + +const pxToLatLng = (px, py) => { + const lng = px / MAP_W * 360 - 180; + const n = Math.PI - 2 * Math.PI * py / MAP_H; + const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); + return { lat: Math.round(lat * 100) / 100, lng: Math.round(lng * 100) / 100 }; +}; + +const getMaxTileZoom = () => { + try { + const dirs = fs.readdirSync(TILES_DIR).filter(d => /^\d+$/.test(d) && fs.existsSync(path.join(TILES_DIR, d, '0_0.png'))); + return dirs.length ? Math.max(...dirs.map(Number)) : 0; + } catch (_) { return 0; } +}; + +const getViewportBounds = (centerLat, centerLng, zoom) => { + const effectiveZ = Math.min(zoom, getMaxTileZoom()); + const n = Math.pow(2, effectiveZ); + const tileSize = 256; + const worldPx = n * tileSize; + const scale = Math.pow(2, zoom - effectiveZ); + const vw = MAP_W / scale; + const vh = MAP_H / scale; + + const latRad = centerLat * Math.PI / 180; + const cx = (centerLng + 180) / 360 * worldPx; + const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx; + + const x0 = cx - vw / 2; + const y0 = cy - vh / 2; + const x1 = cx + vw / 2; + const y1 = cy + vh / 2; + + const pxToLng = (px) => px / worldPx * 360 - 180; + const pxToLat = (py) => { const nn = Math.PI - 2 * Math.PI * py / worldPx; return 180 / Math.PI * Math.atan(0.5 * (Math.exp(nn) - Math.exp(-nn))); }; + + return { + latMin: Math.max(-85, pxToLat(Math.min(y1, worldPx - 1))), + latMax: Math.min(85, pxToLat(Math.max(y0, 0))), + lngMin: Math.max(-180, pxToLng(Math.max(x0, 0))), + lngMax: Math.min(180, pxToLng(Math.min(x1, worldPx - 1))) + }; +}; + +const renderMapWithPins = (markers, mainIdx) => { + const pins = (Array.isArray(markers) ? markers : []) + .filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number") + .map((m, i) => ({ ...latLngToPx(m.lat, m.lng), main: i === (mainIdx || 0) })); + + const hash = crypto.createHash("md5") + .update(pins.map((p) => `${p.x},${p.y},${p.main}`).join(";")) + .digest("hex") + .slice(0, 12); + + const outFile = path.join(CACHE_DIR, `map_${hash}.png`); + + if (fs.existsSync(outFile)) return `map_${hash}.png`; + + const script = ` +from PIL import Image, ImageDraw +import sys, json + +pins = json.loads(sys.argv[1]) +im = Image.open(sys.argv[2]).copy() +draw = ImageDraw.Draw(im) + +for p in pins: + x, y, main = p['x'], p['y'], p.get('main', False) + sw = 3 if main else 2 + sh = 18 if main else 13 + clr = '#e74c3c' if main else '#3498db' + dark = '#c0392b' if main else '#2980b9' + draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr) + draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1) + +im.save(sys.argv[3], optimize=True) +`; + + try { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + execFileSync("python3", [ + "-c", script, + JSON.stringify(pins), + BASE_MAP, + outFile + ], { timeout: 10000 }); + } catch (e) { + return null; + } + + return `map_${hash}.png`; +}; + +const renderZoomedMapWithPins = (centerLat, centerLng, zoom, markers, mainIdx) => { + const maxZ = getMaxTileZoom(); + if (!maxZ || zoom <= 2) return renderMapWithPins(markers, mainIdx); + + const effectiveZ = Math.min(zoom, maxZ); + const scale = Math.pow(2, zoom - effectiveZ); + const n = Math.pow(2, effectiveZ); + const tileSize = 256; + const worldPx = n * tileSize; + + const latRad = centerLat * Math.PI / 180; + const cx = (centerLng + 180) / 360 * worldPx; + const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx; + + const vw = MAP_W / scale; + const vh = MAP_H / scale; + const x0 = cx - vw / 2; + const y0 = cy - vh / 2; + + const pinData = (Array.isArray(markers) ? markers : []) + .filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number") + .map((m, i) => { + const latR = m.lat * Math.PI / 180; + const wx = (m.lng + 180) / 360 * worldPx; + const wy = (1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * worldPx; + return { px: (wx - x0) * scale, py: (wy - y0) * scale, main: i === (mainIdx || 0) }; + }); + + const hashInput = `z${zoom}_${Math.round(centerLat * 100)}_${Math.round(centerLng * 100)}_` + pinData.map(p => `${Math.round(p.px)},${Math.round(p.py)},${p.main}`).join(";"); + const hash = crypto.createHash("md5").update(hashInput).digest("hex").slice(0, 12); + const outFile = path.join(CACHE_DIR, `map_${hash}.png`); + + if (fs.existsSync(outFile)) return `map_${hash}.png`; + + const txMin = Math.max(0, Math.floor(x0 / tileSize)); + const txMax = Math.min(n - 1, Math.floor((x0 + vw) / tileSize)); + const tyMin = Math.max(0, Math.floor(y0 / tileSize)); + const tyMax = Math.min(n - 1, Math.floor((y0 + vh) / tileSize)); + + const tiles = []; + for (let tx = txMin; tx <= txMax; tx++) { + for (let ty = tyMin; ty <= tyMax; ty++) { + const tp = path.join(TILES_DIR, String(effectiveZ), `${tx}_${ty}.png`); + tiles.push({ tx, ty, tp, exists: fs.existsSync(tp) }); + } + } + + const script = ` +from PIL import Image, ImageDraw +import sys, json, os + +args = json.loads(sys.argv[1]) +out_file = sys.argv[2] + +tile_size = 256 +tx_min = args['txMin'] +ty_min = args['tyMin'] +tx_max = args['txMax'] +ty_max = args['tyMax'] +x0 = args['x0'] +y0 = args['y0'] +vw = args['vw'] +vh = args['vh'] +scale = args['scale'] +pins = args['pins'] +tiles = args['tiles'] + +canvas_w = (tx_max - tx_min + 1) * tile_size +canvas_h = (ty_max - ty_min + 1) * tile_size +canvas = Image.new('RGB', (canvas_w, canvas_h), (170, 211, 223)) + +for t in tiles: + if t['exists']: + try: + tile = Image.open(t['tp']).convert('RGB') + canvas.paste(tile, ((t['tx'] - tx_min) * tile_size, (t['ty'] - ty_min) * tile_size)) + except: + pass + +crop_x = x0 - tx_min * tile_size +crop_y = y0 - ty_min * tile_size +cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y + vh))) +result = cropped.resize((1024, 1024), Image.LANCZOS) + +draw = ImageDraw.Draw(result) +for p in pins: + px, py, main = p['px'], p['py'], p.get('main', False) + if -20 <= px <= 1044 and -20 <= py <= 1044: + sw = 3 if main else 2 + sh = 18 if main else 13 + clr = '#e74c3c' if main else '#3498db' + dark = '#c0392b' if main else '#2980b9' + draw.polygon([(px, py + 2), (px - sw, py - sh + sw * 2), (px + sw, py - sh + sw * 2)], fill=clr) + draw.ellipse([px - sw - 1, py - sh - sw, px + sw + 1, py - sh + sw], fill=dark, outline='white', width=1) + +result.save(out_file, optimize=True) +`; + + try { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + execFileSync("python3", [ + "-c", script, + JSON.stringify({ txMin, tyMin, txMax, tyMax, x0, y0, vw, vh, scale, pins: pinData, tiles }), + outFile + ], { timeout: 15000 }); + } catch (e) { + return renderMapWithPins(markers, mainIdx); + } + + return `map_${hash}.png`; +}; + +module.exports = { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, getMaxTileZoom, latLngToPx, pxToLatLng, MAP_W, MAP_H }; diff --git a/src/models/activity_model.js b/src/models/activity_model.js new file mode 100644 index 0000000..6b51862 --- /dev/null +++ b/src/models/activity_model.js @@ -0,0 +1,686 @@ +const pull = require('../server/node_modules/pull-stream'); +const ssbRef = require('../server/node_modules/ssb-ref'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildVoteTally } = require('../backend/vote_tally'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const safeFeedId = (v) => { + if (typeof v === 'string' && ssbRef.isFeed(v)) return v; + if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link; + return null; +}; + +const isContentSane = (c) => { + if (!c || typeof c !== 'object') return false; + if (c.type === 'about') { + if (c.about === undefined) return true; + if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true; + return false; + } + if (c.type === 'pub') { + const addr = c.address; + if (!addr || typeof addr !== 'object') return false; + return typeof addr.key === 'string' && ssbRef.isFeed(addr.key); + } + return true; +}; + +const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_'); +const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD']; +const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED']; +const SCORE_MARKET = s => { const i = ORDER_MARKET.indexOf(N(s)); return i < 0 ? -1 : i }; +const SCORE_PROJECT = s => { const i = ORDER_PROJECT.indexOf(N(s)); return i < 0 ? -1 : i }; + +function inferType(c = {}) { + if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet'; + if (c.type === 'bankClaim') return 'bankClaim'; + if (c.type === 'karmaScore') return 'karmaScore'; + if (c.type === 'courts_case') return 'courtsCase'; + if (c.type === 'courts_evidence') return 'courtsEvidence'; + if (c.type === 'courts_answer') return 'courtsAnswer'; + if (c.type === 'courts_verdict') return 'courtsVerdict'; + if (c.type === 'courts_settlement') return 'courtsSettlement'; + if (c.type === 'courts_nomination') return 'courtsNomination'; + if (c.type === 'courts_nom_vote') return 'courtsNominationVote'; + if (c.type === 'courts_public_pref') return 'courtsPublicPref'; + if (c.type === 'courts_mediators') return 'courtsMediators'; + if (c.type === 'map') return 'map'; + if (c.type === 'mapMarker') return 'mapMarker'; + if (c.type === 'chat') return 'chat'; + if (c.type === 'chatMessage') return 'chatMessage'; + if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') { + const br = Array.isArray(c.branch) ? c.branch : []; + if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread'; + } + return c.type || ''; +} + +const HIDDEN_ENVELOPE_TYPES = new Set([ + 'tribe-keys-distrib', + 'tribe-keys', + 'tribe-invite-msg', + 'tribe-invite-tombstone', + 'aiExchangeVote', + 'event-invite', + 'forum-invite', + 'larpJoinHouse', + 'larpLeaveLarp', + 'larpTestAttempt', + 'larpHousePost', + 'larpHouseInvite', + 'larpHouseInviteRedeem', + 'larpHouseTribeAnchor', + 'larpHouseTribeAnchorTombstone', + 'larpAutoInvite', + 'karmaScore', + 'pubAvailability', + 'calendarReminderSent', + 'taskReminderSent', + 'ubiClaimResult', + 'ubiAllocation', + 'courts-key' +]); + +module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }; + + let _feedCache = null; + let _feedCacheInflight = null; + const FEED_CACHE_MS = 15 * 1000; + + const buildAccessibleTribeIds = async () => { + const set = new Set(); + if (!tribesModel) return set; + try { + const list = await tribesModel.listAll(); + for (const t of list) { + if (!t || !t.id) continue; + set.add(t.id); + try { + const chain = await tribesModel.getChainIds(t.id); + for (const cid of chain) set.add(cid); + } catch (_) {} + } + } catch (_) {} + return set; + }; + + const tryDecryptPublicInviteKey = (invites) => { + if (!tribeCrypto || !Array.isArray(invites)) return null; + for (const inv of invites) { + if (!inv || typeof inv !== 'object' || inv.public !== true) continue; + if (typeof inv.code !== 'string') continue; + if (typeof inv.ek === 'string') { + try { + const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt); + if (k) return k; + } catch (_) {} + } + if (typeof inv.ekChain === 'string') { + try { + const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt); + if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key; + } catch (_) {} + } + } + return null; + }; + const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has))); + const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg))); + const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const stripHtml = (s) => normNL(s) + .replace(//gi, '\n') + .replace(/<\/p\s*>/gi, '\n\n') + .replace(/<[^>]*>/g, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + const excerpt = (s, max = 900) => { + const t = stripHtml(s); + if (!t) return ''; + return t.length > max ? t.slice(0, max - 1) + '…' : t; + }; + + return { + invalidateCache() { + _feedCache = null; + _feedCacheInflight = null; + }, + async listFeed(filter = 'all') { + const cacheKey = filter || 'all'; + const now = Date.now(); + if (!_feedCache) _feedCache = new Map(); + const entry = _feedCache.get(cacheKey); + if (entry && now - entry.ts < FEED_CACHE_MS) return entry.value; + if (!_feedCacheInflight) _feedCacheInflight = new Map(); + if (_feedCacheInflight.has(cacheKey)) return _feedCacheInflight.get(cacheKey); + const promise = (async () => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const results = await new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ reverse: true, limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ); + }); + + if (typeof ssbClient.createUserStream === 'function') { + const ownResults = await new Promise((resolve) => { + pull( + ssbClient.createUserStream({ id: userId, reverse: true }), + pull.collect((err, msgs) => resolve(err ? [] : msgs)) + ); + }); + const seenKeys = new Set(results.map(m => m && m.key)); + for (const m of ownResults) { + if (m && m.key && !seenKeys.has(m.key)) results.push(m); + } + } + + const tombstoned = buildValidatedTombstoneSet(results); + const parentOf = new Map(); + const idToAction = new Map(); + const rawById = new Map(); + const voteTally = buildVoteTally(results); + const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; + const accessibleTribeIds = await buildAccessibleTribeIds(); + + for (const msg of results) { + const k = msg.key; + const v = msg.value; + let c = v?.content; + if (!c) continue; + if (typeof c === 'string' && c.endsWith('.box')) continue; + if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) continue; + if (typeof c.type === 'string' && /e2ee/i.test(c.type)) continue; + if (c.type === 'contact') continue; + if (tribeCrypto && tribeCrypto.isTribeMsg(c)) { + const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null; + if (!r || !r.body) continue; + const inner = r.body; + if (inner.k !== 'tribe' || inner.op !== 'create') continue; + c = { ...inner, type: 'tribe', _decrypted: true, _rootId: r.rootId }; + } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) { + continue; + } + if (!c.type) continue; + if (c.type === 'tombstone') continue; + if (c.encryptedPayload && tribeCrypto && !c.tribeId) { + const pubKey = tryDecryptPublicInviteKey(c.invites); + if (pubKey) { + try { + const dec = tribeCrypto.decryptContent(c, [[pubKey]]); + if (dec && !dec._undecryptable) c = dec; + } catch (_) {} + } + } + if (c.type === 'pad' && c.encrypted === true && !c.tribeId && padsModel && typeof padsModel.decryptContentPublicSync === 'function') { + try { + const dec = padsModel.decryptContentPublicSync(c); + if (dec) c = { ...c, title: dec.title, deadline: dec.deadline, tags: dec.tags, encrypted: false }; + } catch (_) {} + } + if (!isContentSane(c)) continue; + const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0; + idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c }); + rawById.set(k, msg); + if (c.replaces) parentOf.set(k, c.replaces); + } + + const forgedParentOf = new Map(); + const forgedFeedContent = new Map(); + for (const [child, parent] of Array.from(parentOf.entries())) { + const ca = idToAction.get(child); + const pa = idToAction.get(parent); + if (!pa) { parentOf.delete(child); continue; } + if (!ca || String(ca.author) !== String(pa.author)) { + forgedParentOf.set(child, parent); + if (ca && ca.type === 'feed') forgedFeedContent.set(child, ca.content || {}); + parentOf.delete(child); + idToAction.delete(child); + rawById.delete(child); + } + } + + const replacedIds = new Set(parentOf.values()); + const spreadVoteState = new Map(); + const aiHelpfulCounts = new Map(); + const aiHelpfulSeen = new Map(); + for (const msg of results) { + const v = msg.value; + const c = v && v.content; + if (!c || c.type !== 'aiExchangeVote') continue; + const target = String(c.target || ''); + const author = v.author; + if (!target || !author) continue; + const key = `${target}${author}`; + const prev = aiHelpfulSeen.get(key); + const curTs = v.timestamp || 0; + if (!prev || curTs > prev.ts) aiHelpfulSeen.set(key, { target, ts: curTs, helpful: c.helpful !== false }); + } + for (const { target, helpful } of aiHelpfulSeen.values()) { + if (!helpful) continue; + aiHelpfulCounts.set(target, (aiHelpfulCounts.get(target) || 0) + 1); + } + + for (const a of idToAction.values()) { + const c = a.content || {}; + if (c.type !== 'vote' || !c.vote || typeof c.vote.link !== 'string') continue; + + const link = c.vote.link; + const br = Array.isArray(c.branch) ? c.branch : []; + if (!br.includes(link)) continue; + + if (tombstoned.has(a.id)) continue; + if (replacedIds.has(a.id)) continue; + if (tombstoned.has(link)) continue; + + const author = a.author; + if (!author) continue; + + const value = Number(c.vote.value); + const key = `${link}:${author}`; + const prev = spreadVoteState.get(key); + const curTs = a.ts || 0; + + if (!prev || curTs > prev.ts || (curTs === prev.ts && String(a.id || '').localeCompare(String(prev.id || '')) > 0)) { + spreadVoteState.set(key, { ts: curTs, id: a.id, value, link }); + } + } + + const spreadCountByTarget = new Map(); + for (const v of spreadVoteState.values()) { + if (Number(v.value) !== 1) continue; + spreadCountByTarget.set(v.link, (spreadCountByTarget.get(v.link) || 0) + 1); + } + + const fetchedTargetCache = new Map(); + + for (const a of idToAction.values()) { + if (a.type !== 'spread') continue; + const c = a.content || {}; + const link = c.vote?.link || ''; + const totalSpreads = link ? (spreadCountByTarget.get(link) || 0) : 0; + + let targetMsg = link ? rawById.get(link) : null; + if (!targetMsg && link) { + if (fetchedTargetCache.has(link)) targetMsg = fetchedTargetCache.get(link); + else { + const got = await getMsg(ssbClient, link); + if (got) { + const wrapped = { key: link, value: got }; + fetchedTargetCache.set(link, wrapped); + targetMsg = wrapped; + } else { + fetchedTargetCache.set(link, null); + targetMsg = null; + } + } + } + + const targetContent = targetMsg?.value?.content || null; + const title = + (typeof targetContent?.title === 'string' && targetContent.title.trim()) + ? targetContent.title.trim() + : (typeof targetContent?.name === 'string' && targetContent.name.trim()) + ? targetContent.name.trim() + : ''; + const rawText = + (typeof targetContent?.text === 'string' && targetContent.text.trim()) + ? targetContent.text + : (typeof targetContent?.description === 'string' && targetContent.description.trim()) + ? targetContent.description + : ''; + const text = rawText ? excerpt(rawText, 700) : ''; + const cw = + (typeof targetContent?.contentWarning === 'string' && targetContent.contentWarning.trim()) + ? targetContent.contentWarning + : ''; + a.content = { + ...c, + spreadTargetId: link, + spreadTotalSpreads: totalSpreads, + spreadOriginalAuthor: targetMsg?.value?.author || '', + spreadTitle: title, + spreadContentWarning: cw, + spreadText: text + }; + } + + const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur }; + + const groups = new Map(); + for (const [id, action] of idToAction.entries()) { + const root = rootOf(id); + if (!groups.has(root)) groups.set(root, []); + groups.get(root).push(action); + } + + const idToTipId = new Map(); + + for (const [root, arr] of groups.entries()) { + if (!arr.length) continue; + const type = arr[0].type; + + if (type !== 'project') { + const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]); + for (const a of arr) idToTipId.set(a.id, tip.id); + + if (type === 'task' && tip && tip.content && tip.content.isPublic !== 'PRIVATE') { + const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length))); + const sorted = arr + .filter(a => a.type === 'task' && a.content && typeof a.content === 'object') + .sort((a, b) => (a.ts || 0) - (b.ts || 0)); + + let prev = null; + + for (const ev of sorted) { + const cur = uniq(ev.content.assignees); + if (prev) { + const prevSet = new Set(prev); + const curSet = new Set(cur); + const added = cur.filter(x => !prevSet.has(x)); + const removed = prev.filter(x => !curSet.has(x)); + + if (added.length || removed.length) { + const overlayId = `${ev.id}:assignees:${added.join(',')}:${removed.join(',')}`; + idToAction.set(overlayId, { + id: overlayId, + author: ev.author, + ts: ev.ts, + type: 'taskAssignment', + content: { + taskId: tip.id, + title: tip.content.title || ev.content.title || '', + added, + removed, + isPublic: tip.content.isPublic + } + }); + idToTipId.set(overlayId, overlayId); + } + } + prev = cur; + } + } + + continue; + } + + let tip = arr[0]; + let bestScore = SCORE_PROJECT(tip.content.status); + for (const a of arr) { + const s = SCORE_PROJECT(a.content.status); + if (s > bestScore || (s === bestScore && a.ts > tip.ts)) { tip = a; bestScore = s } + } + for (const a of arr) idToTipId.set(a.id, tip.id); + + const baseTitle = (tip.content && tip.content.title) || ''; + const overlays = arr + .filter(a => a.type === 'project' && (a.content.followersOp || a.content.backerPledge)) + .sort((a, b) => (a.ts || 0) - (b.ts || 0)); + + for (const ev of overlays) { + if (tombstoned.has(ev.id)) continue; + + let kind = null; + let amount = null; + + if (ev.content.followersOp === 'follow') kind = 'follow'; + else if (ev.content.followersOp === 'unfollow') kind = 'unfollow'; + if (ev.content.backerPledge && typeof ev.content.backerPledge.amount !== 'undefined') { + const amt = Math.max(0, parseFloat(ev.content.backerPledge.amount || 0) || 0); + if (amt > 0) { kind = kind || 'pledge'; amount = amt } + } + if (!kind) continue; + const augmented = { + ...ev, + type: 'project', + content: { + ...ev.content, + title: baseTitle, + projectId: tip.id, + activity: { kind, amount }, + activityActor: ev.author + } + }; + idToAction.set(ev.id, augmented); + idToTipId.set(ev.id, ev.id); + } + } + + const feedOpinionsByRoot = new Map(); + for (const msg of results) { + const v = msg && msg.value; + const c = v && v.content; + if (!c || typeof c !== 'object') continue; + const isOpinion = c.type === 'feedOpinion' && typeof c.target === 'string'; + const isVoteAction = c.type === 'feed-action' && c.action === 'vote' && (typeof c.root === 'string' || typeof c.target === 'string'); + if (!isOpinion && !isVoteAction) continue; + const target = isOpinion ? c.target : String(c.root || c.target); + let cur = target; + let g = 0; + while (g++ < 1000) { + if (forgedParentOf.has(cur)) { cur = forgedParentOf.get(cur); continue; } + if (parentOf.has(cur)) { cur = parentOf.get(cur); continue; } + break; + } + const r = cur; + if (!feedOpinionsByRoot.has(r)) feedOpinionsByRoot.set(r, []); + feedOpinionsByRoot.get(r).push({ author: v.author, category: String(c.category || '') }); + } + + const forgedFeedAggByRoot = new Map(); + for (const [child, fc] of forgedFeedContent.entries()) { + let cur = child; + let g = 0; + while (g++ < 1000) { + if (forgedParentOf.has(cur)) { cur = forgedParentOf.get(cur); continue; } + if (parentOf.has(cur)) { cur = parentOf.get(cur); continue; } + break; + } + if (!forgedFeedAggByRoot.has(cur)) forgedFeedAggByRoot.set(cur, []); + forgedFeedAggByRoot.get(cur).push(fc); + } + + const latest = []; + for (const a of idToAction.values()) { + if (tombstoned.has(a.id)) continue; + if (a.type === 'tribe' && parentOf.has(a.id)) continue; + const c = a.content || {}; + if (c.root && tombstoned.has(c.root)) continue; + if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue; + if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue; + if (c.key && tombstoned.has(c.key)) continue; + if (c.branch && tombstoned.has(c.branch)) continue; + if (c.target && tombstoned.has(c.target)) continue; + if (a.type === 'document') { + const url = c.url; + const ok = await hasBlob(ssbClient, url); + if (!ok) continue; + } + if (a.type === 'forum' && c.root) { + const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || ''); + const rootAction = idToAction.get(rootId); + a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || ''; + a.content.rootKey = rootId || a.content.rootKey || ''; + } + const actionRoot = rootOf(a.id); + const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {}; + const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null; + let content = voteAgg ? { ...c, ...voteAgg } : a.content; + if (a.type === 'feed') { + const base = a.content || {}; + const opinions = { ...(base.opinions || {}) }; + const voters = Array.isArray(base.opinions_inhabitants) ? base.opinions_inhabitants.slice() : []; + const voterSet = new Set(voters); + for (const fc of (forgedFeedAggByRoot.get(actionRoot) || [])) { + for (const [cat, n] of Object.entries(fc.opinions || {})) { + opinions[cat] = (Number(opinions[cat]) || 0) + (Number(n) || 0); + } + for (const vt of (Array.isArray(fc.opinions_inhabitants) ? fc.opinions_inhabitants : [])) { + if (voterSet.has(vt)) continue; + voterSet.add(vt); + voters.push(vt); + } + } + for (const op of (feedOpinionsByRoot.get(actionRoot) || [])) { + if (!op.author || voterSet.has(op.author)) continue; + voterSet.add(op.author); + voters.push(op.author); + if (op.category) opinions[op.category] = (Number(opinions[op.category]) || 0) + 1; + } + content = { ...content, opinions, opinions_inhabitants: voters }; + } + latest.push({ ...a, content, ...extra, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null }); + } + + let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id))); + + const mediaTypes = new Set(['image','video','audio','document','bookmark','map']); + const perAuthorUnique = new Set(); + const byKey = new Map(); + const norm = s => String(s || '').trim().toLowerCase(); + + for (const a of deduped) { + const c = a.content || {}; + const effTs = + (c.updatedAt && Date.parse(c.updatedAt)) || + (c.createdAt && Date.parse(c.createdAt)) || + (a.ts || 0); + + if (mediaTypes.has(a.type)) { + const u = c.url || c.title || `${a.type}:${a.id}`; + const key = `${a.type}:${u}`; + const prev = byKey.get(key); + if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs }); + } else if (perAuthorUnique.has(a.type)) { + const key = `${a.type}:${a.author}`; + const prev = byKey.get(key); + if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs }); + } else if (a.type === 'about') { + const target = c.about || a.author; + if (String(target) !== String(a.author)) { + byKey.set(`id:${a.id}`, { ...a, __effTs: effTs }); + continue; + } + const key = `about:${target}`; + const prev = byKey.get(key); + const prevContent = prev && (prev.content || {}); + const prevHasImage = !!(prevContent && prevContent.image); + const newHasImage = !!c.image; + + if (!prev) { + byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage }); + } else if (!prevHasImage && newHasImage) { + byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage }); + } else if (prevHasImage === newHasImage && effTs > prev.__effTs) { + byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage }); + } + } else if (a.type === 'tribe') { + const t = norm(c.title); + if (t) { + const key = `tribe:${t}::${a.author}`; + const prev = byKey.get(key); + if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs }); + } else { + const key = `id:${a.id}`; + byKey.set(key, { ...a, __effTs: effTs }); + } + } else { + const key = `id:${a.id}`; + byKey.set(key, { ...a, __effTs: effTs }); + } + } + + deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x }); + + const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']); + const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']); + const isAllowedTribeActivity = (a) => { + if (tribeInternalTypes.has(a.type)) return false; + const c = a.content || {}; + if (c.tribeId) return false; + if (a.type === 'tribe') { + const isInitial = !c.replaces; + if (!isInitial) return false; + if (c.isAnonymous === true && !c._decrypted) return false; + const st = String(c.status || '').toUpperCase(); + if ((st === 'PRIVATE' || st === 'INVITE-ONLY') && !(Array.isArray(c.members) && c.members.includes(userId))) return false; + } + return true; + }; + const itemVisible = (a) => { + if (hiddenTypes.has(a.type)) return false; + const c = a.content || {}; + if (c.encryptedPayload) return false; + if (a.type === 'pad' && c.status !== 'OPEN') return false; + if (a.type === 'chat' && c.status !== 'OPEN') return false; + if (a.type === 'calendar' && c.status !== 'OPEN') return false; + if (a.type === 'event' && String(c.isPublic || '').toLowerCase() === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false; + if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false; + if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false; + if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false; + if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false; + if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false; + if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false; + if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false; + return true; + }; + const isVisible = (a) => { + if (!itemVisible(a)) return false; + if (a.type === 'post' || a.type === 'opinion') { + const c = a.content || {}; + const ref = (typeof c.root === 'string' && c.root) + || (typeof c.branch === 'string' && c.branch) + || (typeof c.target === 'string' && c.target) + || null; + if (ref) { + const parent = idToAction.get(ref); + if (parent && !itemVisible(parent)) return false; + } + } + return true; + }; + + let out; + if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a)); + else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) } + else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a)); + else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim'); + else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe')); + else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread'); + else if (filter === 'parliament') + out = deduped.filter(a => + ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type) + ); + else if (filter === 'courts') + out = deduped.filter(a => { + const t = String(a.type || '').toLowerCase(); + return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote'; + }); + else if (filter === 'task') + out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment'); + else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN'); + else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN'); + else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN'); + else out = deduped.filter(a => a.type === filter); + + out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); + return out; + })(); + _feedCacheInflight.set(cacheKey, promise); + try { + const value = await promise; + _feedCache.set(cacheKey, { value, ts: Date.now() }); + return value; + } finally { + _feedCacheInflight.delete(cacheKey); + } + } + }; +}; + diff --git a/src/models/agenda_model.js b/src/models/agenda_model.js new file mode 100644 index 0000000..863b8a5 --- /dev/null +++ b/src/models/agenda_model.js @@ -0,0 +1,340 @@ +const fs = require('fs'); +const path = require('path'); +const pull = require('../server/node_modules/pull-stream'); +const moment = require('../server/node_modules/moment'); + +const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +function readAgendaConfig() { + if (!fs.existsSync(agendaConfigPath)) { + fs.writeFileSync(agendaConfigPath, JSON.stringify({ discardedItems: [] })); + } + return JSON.parse(fs.readFileSync(agendaConfigPath)); +} + +function writeAgendaConfig(cfg) { + fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2)); +} + +module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD']; + const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase()); + + const fetchItems = (targetType) => + new Promise((resolve, reject) => { + openSsb().then((ssbClient) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => { + if (err) return reject(err); + + const tomb = buildValidatedTombstoneSet(msgs); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + + for (const m of msgs) { + const k = m.key; + const v = m.value; + const c = v?.content; + if (!c) continue; + if (c.type === 'tombstone') continue; + if (c.type !== targetType) continue; + if (c.encryptedPayload) continue; + nodes.set(k, { key: k, ts: v.timestamp || 0, author: v.author, content: c }); + if (c.replaces) parent.set(k, c.replaces); + } + + for (const [k, p] of Array.from(parent.entries())) { + const cn = nodes.get(k); + const pn = nodes.get(p); + if (!pn) { parent.delete(k); continue; } + if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); nodes.delete(k); } + } + for (const [k, p] of parent.entries()) child.set(p, k); + + const rootOf = (id) => { let cur = id; while (parent.has(cur) && nodes.has(parent.get(cur))) cur = parent.get(cur); return cur; }; + + const groups = new Map(); + for (const id of nodes.keys()) { + const r = rootOf(id); + if (!groups.has(r)) groups.set(r, new Set()); + groups.get(r).add(id); + } + + const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD']; + const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase()); + + const out = []; + + for (const [root, ids] of groups.entries()) { + const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key)); + if (!items.length) continue; + + let tipId = Array.from(ids).find(id => !child.has(id)); + let tip = tipId ? nodes.get(tipId) : items.reduce((a, b) => a.ts > b.ts ? a : b); + + if (targetType === 'market') { + let chosen = items[0]; + for (const n of items) { + const a = sIdx(n.content.status); + const b = sIdx(chosen.content.status); + if (a > b || (a === b && n.ts > chosen.ts)) chosen = n; + } + const c = chosen.content; + let status = c.status; + if (c.deadline) { + const dl = moment(c.deadline); + if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED'; + } + if (status === 'FOR SALE' && (c.stock || 0) === 0) continue; + + out.push({ + ...c, + status, + author: chosen.author, + id: chosen.key, + tipId: chosen.key, + createdAt: c.createdAt || chosen.ts + }); + continue; + } + + if (targetType === 'job') { + const latest = items.sort((a, b) => b.ts - a.ts)[0]; + const withSubsNode = items + .filter(n => Array.isArray(n.content.subscribers)) + .sort((a, b) => b.ts - a.ts)[0]; + const subscribers = withSubsNode ? withSubsNode.content.subscribers : []; + const latestWithStatus = items + .filter(n => typeof n.content.status !== 'undefined') + .sort((a, b) => b.ts - a.ts)[0]; + const resolvedStatus = latestWithStatus + ? latestWithStatus.content.status + : latest.content.status; + + const c = { ...latest.content, status: resolvedStatus, subscribers }; + + out.push({ + ...c, + author: latest.author, + id: latest.key, + tipId: latest.key, + createdAt: c.createdAt || latest.ts + }); + continue; + } + + out.push({ + ...tip.content, + author: tip.author, + id: tip.key, + tipId: tip.key, + createdAt: tip.content.createdAt || tip.ts + }); + } + + resolve(out); + }) + ); + }).catch(reject); + }); + + return { + async listAgenda(filter = 'all') { + const agendaConfig = readAgendaConfig(); + const discardedItems = agendaConfig.discardedItems || []; + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const normalize = (arr) => arr.map(it => ({ + ...it, + key: it.id || it.key, + createdAt: it.createdAt || new Date().toISOString(), + tipId: it.tipId || it.id || it.key + })); + + const eventsViaModel = eventsModel && typeof eventsModel.listAll === 'function' + ? eventsModel.listAll(null, 'all').then(normalize).catch(() => []) + : fetchItems('event'); + + const calendarsViaModel = calendarsModel && typeof calendarsModel.listAll === 'function' + ? calendarsModel.listAll({ filter: 'all', viewerId: userId }).then(normalize).catch(() => []) + : fetchItems('calendar'); + + const tasksViaModel = tasksModel && typeof tasksModel.listAll === 'function' + ? tasksModel.listAll().then(normalize).catch(() => []) + : fetchItems('task'); + + const marketViaModel = marketModel && typeof marketModel.listAllItems === 'function' + ? marketModel.listAllItems('all').then(normalize).catch(() => []) + : fetchItems('market'); + + const jobsViaModel = jobsModel && typeof jobsModel.listJobs === 'function' + ? jobsModel.listJobs('ALL', userId).then(normalize).catch(() => []) + : fetchItems('job'); + + const projectsViaModel = projectsModel && typeof projectsModel.listProjects === 'function' + ? projectsModel.listProjects('all').then(normalize).catch(() => []) + : fetchItems('project'); + + const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll] = await Promise.all([ + tasksViaModel, + eventsViaModel, + fetchItems('transfer'), + fetchItems('tribe'), + marketViaModel, + fetchItems('report'), + jobsViaModel, + projectsViaModel, + calendarsViaModel + ]); + + const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' })); + const events = eventsAll.filter(c => Array.isArray(c.attendees) && c.attendees.includes(userId)).map(e => ({ ...e, type: 'event' })); + const transfers = transfersAll.filter(c => c.from === userId || c.to === userId).map(tr => ({ ...tr, type: 'transfer' })); + const tribes = tribesAll.filter(c => Array.isArray(c.members) && c.members.includes(userId)).map(t => ({ ...t, type: 'tribe', title: t.title })); + const marketItems = marketAll.filter(c => + c.seller === userId || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => String(b).split(':')[0] === userId)) + ).map(m => ({ ...m, type: 'market' })); + const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' })); + const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title })); + const projects = projectsAll.map(p => ({ ...p, type: 'project' })); + const myCalendars = calendarsAll + .filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId))); + const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' })); + const calendarDates = []; + if (calendarsModel && typeof calendarsModel.getDatesForCalendar === 'function') { + for (const cal of myCalendars) { + try { + const dates = await calendarsModel.getDatesForCalendar(cal.id || cal.key); + for (const d of (dates || [])) { + calendarDates.push({ + type: 'calendarDate', + id: d.key || d.id, + title: d.label || cal.title || 'Calendar date', + calendarTitle: cal.title || '', + calendarId: cal.id || cal.key, + date: d.date || d.createdAt, + label: d.label || '', + author: cal.author, + createdAt: d.createdAt || cal.createdAt, + status: 'OPEN' + }); + } + } catch (_) {} + } + } + + let combined = [ + ...tasks, + ...events, + ...transfers, + ...tribes, + ...marketItems, + ...reports, + ...jobs, + ...projects, + ...calendars, + ...calendarDates + ]; + + let filtered; + if (filter === 'discarded') { + filtered = combined.filter(i => discardedItems.includes(i.id)); + } else { + filtered = combined.filter(i => !discardedItems.includes(i.id)); + if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task'); + else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event'); + else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer'); + else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe'); + else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market'); + else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report'); + else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN'); + else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED'); + else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job'); + else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project'); + else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate'); + else if (filter === 'today') { + const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); + filtered = filtered.filter(i => { + const d = new Date(i.date || i.startTime || i.deadline || 0).getTime(); + return d >= startOfDay.getTime() && d <= endOfDay.getTime(); + }); + } + else if (filter === 'upcoming') { + const now = Date.now(); + filtered = filtered.filter(i => { + const d = new Date(i.date || i.startTime || i.deadline || 0).getTime(); + return d > now; + }); + } + else if (filter === 'overdue') { + const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); + filtered = filtered.filter(i => { + const d = new Date(i.date || i.startTime || i.deadline || 0).getTime(); + const open = String(i.status || 'OPEN').toUpperCase() === 'OPEN'; + return d > 0 && d < startOfDay.getTime() && open; + }); + } + } + + filtered.sort((a, b) => { + const dateA = a.date || a.startTime || a.deadline || a.createdAt || 0; + const dateB = b.date || b.startTime || b.deadline || b.createdAt || 0; + return new Date(dateA) - new Date(dateB); + }); + + const mainItems = combined.filter(i => !discardedItems.includes(i.id)); + const discarded = combined.filter(i => discardedItems.includes(i.id)); + + const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); + const now = Date.now(); + const itemTs = (i) => new Date(i.date || i.startTime || i.deadline || 0).getTime(); + return { + items: filtered, + counts: { + all: mainItems.length, + open: mainItems.filter(i => String(i.status).toUpperCase() === 'OPEN').length, + closed: mainItems.filter(i => String(i.status).toUpperCase() === 'CLOSED').length, + tasks: mainItems.filter(i => i.type === 'task').length, + events: mainItems.filter(i => i.type === 'event').length, + transfers: mainItems.filter(i => i.type === 'transfer').length, + tribes: mainItems.filter(i => i.type === 'tribe').length, + market: mainItems.filter(i => i.type === 'market').length, + reports: mainItems.filter(i => i.type === 'report').length, + jobs: mainItems.filter(i => i.type === 'job').length, + projects: mainItems.filter(i => i.type === 'project').length, + calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length, + today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length, + upcoming: mainItems.filter(i => itemTs(i) > now).length, + overdue: mainItems.filter(i => { const d = itemTs(i); return d > 0 && d < startOfDay.getTime() && String(i.status || 'OPEN').toUpperCase() === 'OPEN'; }).length, + discarded: discarded.length + } + }; + }, + + async discardItem(itemId) { + const agendaConfig = readAgendaConfig(); + if (!agendaConfig.discardedItems.includes(itemId)) { + agendaConfig.discardedItems.push(itemId); + writeAgendaConfig(agendaConfig); + } + }, + + async restoreItem(itemId) { + const agendaConfig = readAgendaConfig(); + agendaConfig.discardedItems = agendaConfig.discardedItems.filter(id => id !== itemId); + writeAgendaConfig(agendaConfig); + } + }; +}; + diff --git a/src/models/audios_model.js b/src/models/audios_model.js new file mode 100644 index 0000000..13f66d6 --- /dev/null +++ b/src/models/audios_model.js @@ -0,0 +1,387 @@ +const pull = require("../server/node_modules/pull-stream"); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe'); +const { getConfig } = require("../configs/config-manager.js"); +const categories = require("../backend/opinion_categories"); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const safeArr = (v) => (Array.isArray(v) ? v : []); + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return undefined; + if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean); + return String(raw).split(",").map((t) => t.trim()).filter(Boolean); +}; + +const parseBlobId = (blobMarkdown) => { + const s = String(blobMarkdown || ""); + const match = s.match(/\(([^)]+)\)/); + return match ? match[1] : s || null; +}; + +const voteSum = (opinions = {}) => + Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); + +module.exports = ({ cooler }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))); + }); + + const getMsg = async (ssbClient, key) => + new Promise((resolve, reject) => { + ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg))); + }); + + const buildIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const opinionMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === "tombstone") continue; + if (c.type === "audioOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + if (c.type !== "audio") continue; + + const ts = v.timestamp || m.timestamp || 0; + let sizeBytes = 0; + try { sizeBytes = Buffer.byteLength(JSON.stringify(v), 'utf8'); } catch (_) { sizeBytes = 0; } + nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author }); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const opinionsByRoot = new Map(); + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const forward = new Map(); + for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId); + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + const lc = contentNode ? contentNode.c : {}; + const opinions = { ...(lc.opinions || {}) }; + const voters = safeArr(lc.opinions_inhabitants).slice(); + const voterSet = new Set(voters); + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); voters.push(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + return { contentTip, contentNode, opinions, voters }; + }; + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup }; + }; + + const buildAudio = (node, rootId, viewerId, agg) => { + const c = node.c || {}; + const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants); + const opinions = agg ? agg.opinions : (c.opinions || {}); + const composition = Array.isArray(c.bcsComposition) ? c.bcsComposition : null; + const tagsArr = safeArr(c.tags); + const isBcs = (composition && composition.length > 0) || tagsArr.some(t => String(t).toLowerCase() === 'bcs'); + return { + key: node.key, + rootId, + url: c.url, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + tags: tagsArr, + author: c.author, + title: c.title || "", + description: c.description || "", + mapUrl: c.mapUrl || "", + opinions, + opinions_inhabitants: voters, + hasVoted: viewerId ? voters.includes(viewerId) : false, + bcsComposition: composition, + isBcs, + sizeBytes: node.sizeBytes || 0 + }; + }; + + return { + type: "audio", + + async resolveCurrentId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const tip = idx.tipByRoot.get(idx.rootOf(id)) || id; + if (idx.tomb.has(tip)) throw new Error("Audio not found"); + return tip; + }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + let tip = id; + while (idx.forward.has(tip)) tip = idx.forward.get(tip); + if (idx.tomb.has(tip)) throw new Error("Audio not found"); + + let root = tip; + while (idx.parent.has(root)) root = idx.parent.get(root); + return root; + }, + + async createAudio(blobMarkdown, tagsRaw, title, description, mapUrl) { + const ssbClient = await openSsb(); + const blobId = parseBlobId(blobMarkdown); + const tags = normalizeTags(tagsRaw) || []; + const now = new Date().toISOString(); + + const content = { + type: "audio", + url: blobId, + createdAt: now, + updatedAt: null, + author: ssbClient.id, + tags, + title: title || "", + description: description || "", + mapUrl: mapUrl || "", + opinions: {}, + opinions_inhabitants: [] + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async createBcsAudio(blobId, title, description, composition) { + const ssbClient = await openSsb(); + const now = new Date().toISOString(); + const cleanComposition = safeArr(composition) + .map(n => ({ + t: String(n.type || n.t || ''), + n: String(n.name || n.n || ''), + d: Number(n.durMs || n.d || 0), + id: typeof n.id === 'string' ? n.id : (typeof n.key === 'string' ? n.key : null) + })) + .filter(n => n.t && n.n); + + const baseContent = { + type: "audio", + url: blobId, + createdAt: now, + updatedAt: null, + author: ssbClient.id, + tags: ["bcs"], + title: title || "", + description: description || "", + mapUrl: "", + bcsComposition: [], + opinions: {}, + opinions_inhabitants: [] + }; + + const SSB_MAX_BYTES = 8000; + const wrapEnvelope = (content) => ({ + previous: "%0000000000000000000000000000000000000000000=.sha256", + sequence: 999999, + author: ssbClient.id, + timestamp: Date.now(), + hash: "sha256", + content, + signature: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000==.sig.ed25519" + }); + const fitted = []; + for (const item of cleanComposition) { + fitted.push(item); + const probe = wrapEnvelope({ ...baseContent, bcsComposition: fitted }); + if (Buffer.byteLength(JSON.stringify(probe, null, 2), 'utf8') > SSB_MAX_BYTES) { + fitted.pop(); + break; + } + } + + const content = { ...baseContent, bcsComposition: fitted }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const oldMsg = await getMsg(ssbClient, tipId); + + if (!oldMsg || oldMsg.content?.type !== "audio") throw new Error("Audio not found"); + if (oldMsg.content.author !== userId) throw new Error("Not the author"); + const aggAudio = await this.getAudioById(id, userId); + if (aggAudio && Object.keys(aggAudio.opinions || {}).some(k => (aggAudio.opinions[k] || 0) > 0)) throw new Error("Cannot edit audio after it has received opinions."); + + const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags); + const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null; + const now = new Date().toISOString(); + + const updated = { + ...oldMsg.content, + replaces: tipId, + url: blobId || oldMsg.content.url, + tags, + title: title !== undefined ? title || "" : oldMsg.content.title || "", + description: description !== undefined ? description || "" : oldMsg.content.description || "", + mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "", + createdAt: oldMsg.content.createdAt, + updatedAt: now + }; + + const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId }; + await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); + + return new Promise((resolve, reject) => { + ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result))); + }); + }, + + async deleteAudioById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const msg = await getMsg(ssbClient, tipId); + + if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found"); + if (msg.content.author !== userId) throw new Error("Not the author"); + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }; + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async listAll(filterOrOpts = "all", maybeOpts = {}) { + const ssbClient = await openSsb(); + + const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {}; + const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all"; + const q = String(opts.q || "").trim().toLowerCase(); + const sort = String(opts.sort || "recent").trim(); + const viewerId = opts.viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const items = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + items.push(buildAudio(node, rootId, viewerId, idx.resolveGroup(rootId))); + } + + let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null); + const now = Date.now(); + + if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId)); + else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000); + else if (filter === "top") { + list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } else if (filter === "bcs") { + list = list.filter((a) => a.isBcs === true); + } + + if (q) { + list = list.filter((a) => { + const title = String(a.title || "").toLowerCase(); + const desc = String(a.description || "").toLowerCase(); + const tags = safeArr(a.tags).join(" ").toLowerCase(); + const author = String(a.author || "").toLowerCase(); + return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q); + }); + } + + if (sort === "top") { + list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } else if (sort === "oldest") { + list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + } else { + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + return list; + }, + + async getAudioById(id, viewerId = null) { + const ssbClient = await openSsb(); + const viewer = viewerId || ssbClient.id; + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const rootId = idx.rootOf(id); + const agg = idx.resolveGroup(rootId); + if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Audio not found"); + + return buildAudio(agg.contentNode, rootId, viewer, agg); + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error("Invalid voting category"); + + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const audio = await this.getAudioById(id, userId); + if (!audio) throw new Error("Audio not found"); + if (safeArr(audio.opinions_inhabitants).includes(userId)) throw new Error("Already voted"); + + const content = { type: "audioOpinion", target: audio.rootId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result))); + }); + } + }; +}; + diff --git a/src/models/banking_model.js b/src/models/banking_model.js new file mode 100644 index 0000000..4837168 --- /dev/null +++ b/src/models/banking_model.js @@ -0,0 +1,1394 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const pull = require("../server/node_modules/pull-stream"); +const { getConfig } = require("../configs/config-manager.js"); +const { config } = require("../server/SSB_server.js"); +const sharedState = require("../configs/shared-state.js"); + +const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x)); + +const MAX_PENDING_EPOCHS = 12; + +const DEFAULT_RULES = { + epochKind: "MONTHLY", + alpha: 0.2, + reserveMin: 500, + capPerEpoch: 2000, + caps: { cap_user_epoch: 50, floor_user: 1, w_min: 0.2, w_max: 6 }, + graceDays: 30 +}; + +const STORAGE_DIR = process.env.OASIS_BANKING_DIR || path.join(__dirname, "..", "configs"); +const EPOCHS_PATH = path.join(STORAGE_DIR, "banking-epochs.json"); +const TRANSFERS_PATH = path.join(STORAGE_DIR, "banking-allocations.json"); +const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json"); +const ECO_HISTORY_PATH = path.join(STORAGE_DIR, "banking-eco-history.json"); +const ECO_HISTORY_MAX = 500; +const ECO_HISTORY_MIN_GAP_MS = 5 * 60 * 1000; + +const ECOIN_PER_GRAM_CO2 = 0.1; +const ECOIN_PER_DAY_OF_HISTORY = 0.001; +const ONE_DAY_MS = 86400000; +const ONE_MIB = 1024 * 1024; +const carbonGramsFromBytes = (b) => (Number(b) || 0) / ONE_MIB * 0.095; +const ecoinTaxFromGrams = (g) => (Number(g) || 0) * ECOIN_PER_GRAM_CO2; +const ecoinTaxFromBytes = (b) => ecoinTaxFromGrams(carbonGramsFromBytes(b)); +const archTaxFromDays = (days) => Math.max(0, Number(days) || 0) * ECOIN_PER_DAY_OF_HISTORY; + +function ensureStoreFiles() { + if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true }); + if (!fs.existsSync(EPOCHS_PATH)) fs.writeFileSync(EPOCHS_PATH, "[]"); + if (!fs.existsSync(TRANSFERS_PATH)) fs.writeFileSync(TRANSFERS_PATH, "[]"); + if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}"); + if (!fs.existsSync(ECO_HISTORY_PATH)) fs.writeFileSync(ECO_HISTORY_PATH, "[]"); +} + +function readEcoHistory() { + ensureStoreFiles(); + try { return JSON.parse(fs.readFileSync(ECO_HISTORY_PATH, "utf8")) || []; } catch (_) { return []; } +} + +function appendEcoHistory(sample) { + ensureStoreFiles(); + let arr = readEcoHistory(); + if (!Array.isArray(arr)) arr = []; + const last = arr[arr.length - 1]; + if (last && Number(sample.ts) - Number(last.ts) < ECO_HISTORY_MIN_GAP_MS) return arr; + arr.push(sample); + if (arr.length > ECO_HISTORY_MAX) arr = arr.slice(arr.length - ECO_HISTORY_MAX); + try { fs.writeFileSync(ECO_HISTORY_PATH, JSON.stringify(arr)); } catch (_) {} + return arr; +} + +function epochIdNow() { + const d = new Date(); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + return `${yyyy}-${mm}`; +} + +async function getAnyWalletAddress() { + const tryOne = async (method, params = []) => { + const r = await rpcCall(method, params, "user"); + if (!r) return null; + if (typeof r === "string" && isValidEcoinAddress(r)) return r; + if (Array.isArray(r) && r.length && isValidEcoinAddress(r[0])) return r[0]; + if (r && typeof r === "object") { + const keys = Object.keys(r); + if (keys.length && isValidEcoinAddress(keys[0])) return keys[0]; + if (r.address && isValidEcoinAddress(r.address)) return r.address; + } + return null; + }; + return await tryOne("getnewaddress") + || await tryOne("getaddress") + || await tryOne("getaccountaddress", [""]) + || await tryOne("getaddressesbyaccount", [""]) + || await tryOne("getaddressesbylabel", [""]) + || await tryOne("getaddressesbylabel", ["default"]); +} + +async function ensureSelfAddressPublished() { + const me = config.keys.id; + const local = readAddrMap(); + const current = typeof local[me] === "string" ? local[me] : (local[me] && local[me].address) || null; + if (current && isValidEcoinAddress(current)) return { status: "present", address: current }; + const cfg = getWalletCfg("user") || {}; + if (!cfg.url) return { status: "skipped" }; + const addr = await getAnyWalletAddress(); + if (addr && isValidEcoinAddress(addr)) { + const m = readAddrMap(); + m[me] = addr; + writeAddrMap(m); + let ssb = null; + try { + if (services?.cooler?.open) ssb = await services.cooler.open(); + else if (global.ssb) ssb = global.ssb; + else { + try { + const srv = require("../server/SSB_server.js"); + ssb = srv?.ssb || srv?.server || srv?.default || null; + } catch (_) {} + } + } catch (_) {} + if (ssb && ssb.publish) { + await new Promise((resolve, reject) => + ssb.publish( + { type: "wallet", coin: "ECO", address: addr, timestamp: Date.now(), updatedAt: new Date().toISOString() }, + (err) => err ? reject(err) : resolve() + ) + ); + } + return { status: "published", address: addr }; + } + return { status: "error" }; +} + +function readJson(p, d) { + try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return d; } +} + +function writeJson(p, v) { + fs.writeFileSync(p, JSON.stringify(v, null, 2)); +} + +async function rpcCall(method, params, kind = "user") { + const cfg = getWalletCfg(kind); + if (!cfg?.url) { + return null; + } + const headers = { + "Content-Type": "application/json", + }; + if (cfg.user || cfg.pass) { + headers.authorization = "Basic " + Buffer.from(`${cfg.user}:${cfg.pass}`).toString("base64"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + try { + const res = await fetch(cfg.url, { + method: "POST", + headers: headers, + body: JSON.stringify({ + jsonrpc: "1.0", + id: "oasis", + method: method, + params: params, + }), + signal: controller.signal, + }); + if (!res.ok) { + return null; + } + const data = await res.json(); + if (data.error) { + return null; + } + return data.result; + } catch (err) { + return null; + } finally { + clearTimeout(timer); + } +} + +async function safeGetBalance(kind = "user") { + try { + const r = await rpcCall("getbalance", [], kind); + return Number(r) || 0; + } catch { + return 0; + } +} + +function readAddrMap() { + ensureStoreFiles(); + const raw = readJson(ADDR_PATH, {}); + return raw && typeof raw === "object" ? raw : {}; +} + +function writeAddrMap(m) { + ensureStoreFiles(); + writeJson(ADDR_PATH, m || {}); +} + +function getLogLimit() { + return getConfig().ssbLogStream?.limit || 1000; +} + +function isValidEcoinAddress(addr) { + return typeof addr === "string" && /^E[1-9A-HJ-NP-Za-km-z]{32,34}$/.test(addr); +} + +function getWalletCfg(kind) { + const cfg = getConfig() || {}; + if (kind === "pub") { + if (!isPubNode()) return null; + return cfg.wallet || null; + } + return cfg.wallet || null; +} + +function isPubNode() { + const pubId = (getConfig() || {}).walletPub?.pubId || ""; + const myId = config?.keys?.id || ""; + return !!pubId && !!myId && pubId === myId; +} + +function getConfiguredPubId() { + return (getConfig() || {}).walletPub?.pubId || ""; +} + +function resolveUserId(maybeId) { + const s = String(maybeId || "").trim(); + if (s) return s; + return config?.keys?.id || ""; +} + +let FEED_SRC = "none"; + +module.exports = ({ services } = {}) => { + const transfersRepo = { + listAll: async () => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []); }, + listByTag: async (tag) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).filter(t => (t.tags || []).includes(tag)); }, + findById: async (id) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).find(t => t.id === id) || null; }, + create: async (t) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); all.push(t); writeJson(TRANSFERS_PATH, all); }, + markClosed: async (id, txid) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); const i = all.findIndex(x => x.id === id); if (i >= 0) { all[i].status = "CLOSED"; all[i].txid = txid; writeJson(TRANSFERS_PATH, all); } } + }; + + const epochsRepo = { + list: async () => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []); }, + save: async (epoch) => { ensureStoreFiles(); const all = readJson(EPOCHS_PATH, []); const i = all.findIndex(e => e.id === epoch.id); if (i >= 0) all[i] = epoch; else all.push(epoch); writeJson(EPOCHS_PATH, all); }, + get: async (id) => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []).find(e => e.id === id) || null; } + }; + + let ssbInstance; + async function openSsb() { + if (ssbInstance) return ssbInstance; + if (services?.cooler?.open) ssbInstance = await services.cooler.open(); + else if (cooler?.open) ssbInstance = await cooler.open(); + else if (global.ssb) ssbInstance = global.ssb; + else { + try { + const srv = require("../server/SSB_server.js"); + ssbInstance = srv?.ssb || srv?.server || srv?.default || null; + } catch (_) { + ssbInstance = null; + } + } + return ssbInstance; + } + + async function scanLogStream() { + const ssb = await openSsb(); + if (!ssb) return []; + return new Promise((resolve, reject) => + pull( + ssb.createLogStream({ limit: getLogLimit(), reverse: true }), + pull.collect((err, arr) => err ? reject(err) : resolve(arr)) + ) + ); + } + + async function getWalletFromSSB(userId) { + const msgs = await scanLogStream(); + for (const m of msgs) { + const v = m.value || {}; + const c = v.content || {}; + if (v.author === userId && c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") { + return c.address; + } + } + return null; + } + + async function scanAllWalletsSSB() { + const latest = {}; + const msgs = await scanLogStream(); + for (const m of msgs) { + const v = m.value || {}; + const c = v.content || {}; + if (c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") { + if (!latest[v.author]) latest[v.author] = c.address; + } + } + return latest; + } + + async function publishSelfAddress(address) { + const ssb = await openSsb(); + if (!ssb) return false; + const msg = { type: "wallet", coin: "ECO", address, updatedAt: new Date().toISOString() }; + await new Promise((resolve, reject) => ssb.publish(msg, (err, val) => err ? reject(err) : resolve(val))); + return true; + } + + async function listUsers() { + const addrLocal = readAddrMap(); + const ids = Object.keys(addrLocal); + if (ids.length > 0) return ids.map(id => ({ id })); + return [{ id: config.keys.id }]; + } + + async function getUserAddress(userId) { + const v = readAddrMap()[userId]; + if (v === "__removed__") return null; + const local = typeof v === "string" ? v : (v && v.address) || null; + if (local) return local; + const ssbAddr = await getWalletFromSSB(userId); + return ssbAddr; + } + + async function setUserAddress(userId, address, publishIfSelf) { + if (!userId || !isValidEcoinAddress(address)) return false; + const m = readAddrMap(); + m[userId] = address; + writeAddrMap(m); + if (publishIfSelf && idsEqual(userId, config.keys.id)) await publishSelfAddress(address); + return true; + } + + async function addAddress({ userId, address }) { + if (!userId || !address || !isValidEcoinAddress(address)) return { status: "invalid" }; + const m = readAddrMap(); + const prev = m[userId]; + m[userId] = address; + writeAddrMap(m); + if (idsEqual(userId, config.keys.id)) await publishSelfAddress(address); + return { status: prev ? (prev === address || (prev && prev.address === address) ? "exists" : "updated") : "added" }; + } + + async function removeAddress({ userId }) { + if (!userId) return { status: "invalid" }; + const m = readAddrMap(); + const hadLocal = !!m[userId] && m[userId] !== "__removed__"; + const ssbAll = await scanAllWalletsSSB(); + if (ssbAll[userId]) { + m[userId] = "__removed__"; + writeAddrMap(m); + return { status: "deleted" }; + } + if (hadLocal) { + delete m[userId]; + writeAddrMap(m); + return { status: "deleted" }; + } + return { status: "not_found" }; + } + + async function listAddressesMerged() { + const local = readAddrMap(); + const ssbAll = await scanAllWalletsSSB(); + const keys = new Set([...Object.keys(local), ...Object.keys(ssbAll)]); + const out = []; + for (const id of keys) { + if (local[id] === "__removed__") continue; + if (local[id]) out.push({ id, address: typeof local[id] === "string" ? local[id] : local[id].address, source: "local" }); + else if (ssbAll[id]) out.push({ id, address: ssbAll[id], source: "ssb" }); + } + return out; + } + + function idsEqual(a, b) { + if (!a || !b) return false; + const A = String(a).trim(); + const B = String(b).trim(); + if (A === B) return true; + const strip = s => s.replace(/^@/, "").replace(/\.ed25519$/, ""); + return strip(A) === strip(B); + } + + function inferType(c = {}) { + if (c.vote) return "vote"; + if (c.votes) return "votes"; + if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet"; + if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult"; + if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim"; + if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market"; + if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project"; + if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe"; + if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event"; + if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task"; + if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report"; + if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job"; + if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio"; + if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video"; + if (typeof c.url !== "undefined" && c.title && c.key) return "document"; + if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed"; + if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post"; + if (typeof c.contact !== "undefined") return "contact"; + if (typeof c.about !== "undefined") return "about"; + if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer"; + return ""; + } + + function normalizeType(a) { + const t = a.type || a.content?.type || inferType(a.content) || ""; + return String(t).toLowerCase(); + } + + function priorityBump(p) { + const s = String(p || "").toUpperCase(); + if (s === "HIGH") return 3; + if (s === "MEDIUM") return 1; + return 0; + } + + function severityBump(s) { + const x = String(s || "").toUpperCase(); + if (x === "CRITICAL") return 6; + if (x === "HIGH") return 4; + if (x === "MEDIUM") return 2; + return 0; + } + + function scoreMarket(c) { + const st = String(c.status || "").toUpperCase(); + let s = 5; + if (st === "SOLD") s += 8; + else if (st === "ACTIVE") s += 3; + const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0; + s += Math.min(10, bids); + return s; + } + + function scoreProject(c) { + const st = String(c.status || "ACTIVE").toUpperCase(); + const prog = Number(c.progress || 0); + let s = 8 + Math.min(10, prog / 10); + if (st === "FUNDED") s += 10; + return s; + } + + function calculateOpinionScore(content) { + const cats = content?.opinions || {}; + let s = 0; + for (const k in cats) { + if (!Object.prototype.hasOwnProperty.call(cats, k)) continue; + if (k === "interesting" || k === "inspiring") s += 5; + else if (k === "boring" || k === "spam" || k === "propaganda") s -= 3; + else s += 1; + } + return s; + } + + async function listAllActions() { + if (services?.feed?.listAll) { + const arr = await services.feed.listAll(); + FEED_SRC = "services.feed.listAll"; + return normalizeFeedArray(arr); + } + if (services?.activity?.list) { + const arr = await services.activity.list(); + FEED_SRC = "services.activity.list"; + return normalizeFeedArray(arr); + } + if (typeof global.listFeed === "function") { + const arr = await global.listFeed("all"); + FEED_SRC = "global.listFeed('all')"; + return normalizeFeedArray(arr); + } + const ssb = await openSsb(); + if (!ssb || !ssb.createLogStream) { + FEED_SRC = "none"; + return []; + } + const msgs = await scanLogStream(); + FEED_SRC = "ssb.createLogStream"; + return msgs.map(m => { + const v = m.value || {}; + const c = v.content || {}; + return { + id: v.key || m.key, + author: v.author, + type: (c.type || "").toLowerCase(), + value: v, + content: c + }; + }); + } + + function normalizeFeedArray(arr) { + if (!Array.isArray(arr)) return []; + return arr.map(x => { + const value = x.value || {}; + const content = x.content || value.content || {}; + const author = x.author || value.author || content.author || null; + const type = (content.type || "").toLowerCase(); + return { id: x.id || value.key || x.key, author, type, value, content }; + }); + } + +async function publishKarmaScore(userId, karmaScore) { + const ssb = await openSsb(); + if (!ssb || !ssb.publish) return false; + const timestamp = new Date().toISOString(); + const content = { type: "karmaScore", karmaScore, userId, timestamp }; + return new Promise((resolve, reject) => { + ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg)); + }); +} + +async function fetchUserActions(userId) { + const me = resolveUserId(userId); + const actions = await listAllActions(); + const authored = actions.filter(a => + (a.author && a.author === me) || (a.value?.author && a.value.author === me) + ); + if (authored.length) return authored; + return actions.filter(a => { + const c = a.content || {}; + const fields = [c.author, c.organizer, c.seller, c.about, c.contact]; + return fields.some(f => f && f === me); + }); +} + +function scoreFromActions(actions) { + let score = 0; + const nowMs = Date.now(); + for (const action of actions) { + const t = normalizeType(action); + const c = action.content || {}; + const rawType = String(c.type || "").toLowerCase(); + const ts = action.value?.timestamp; + const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity; + const decay = ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25; + if (t === "post") score += 10 * decay; + else if (t === "comment") score += 5 * decay; + else if (t === "like") score += 2 * decay; + else if (t === "image") score += 8 * decay; + else if (t === "video") score += 12 * decay; + else if (t === "audio") score += 8 * decay; + else if (t === "document") score += 6 * decay; + else if (t === "bookmark") score += 2 * decay; + else if (t === "feed") score += 6 * decay; + else if (t === "forum") score += (c.root ? 5 : 10) * decay; + else if (t === "vote") score += (3 + calculateOpinionScore(c)) * decay; + else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0)) * decay; + else if (t === "market") score += scoreMarket(c) * decay; + else if (t === "project") score += scoreProject(c) * decay; + else if (t === "tribe") score += (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0)) * decay; + else if (t === "event") score += (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0)) * decay; + else if (t === "task") score += (3 + priorityBump(c.priority)) * decay; + else if (t === "report") score += (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity)) * decay; + else if (t === "curriculum") score += 5 * decay; + else if (t === "aiexchange") score += (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0) * decay; + else if (t === "job") score += (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0)) * decay; + else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5) * decay; + else if (t === "bankwallet") score += 2 * decay; + else if (t === "transfer") score += 1 * decay; + else if (t === "about") score += (1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0)) * decay; + else if (t === "contact") score += 1 * decay; + else if (t === "pub") score += 1 * decay; + else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") score += 12 * decay; + else if (t === "parliamentterm" || rawType === "parliamentterm") score += 25 * decay; + else if (t === "parliamentproposal" || rawType === "parliamentproposal") score += 8 * decay; + else if (t === "parliamentlaw" || rawType === "parliamentlaw") score += 16 * decay; + else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") score += 10 * decay; + else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") score += 4 * decay; + else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") score += 3 * decay; + else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") score += 4 * decay; + else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") score += 10 * decay; + else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") score += 8 * decay; + else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") score += 6 * decay; + else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") score += 3 * decay; + else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") score += 1 * decay; + else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") score += 6 * decay; + else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") score += 2 * decay; + else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") score += 3 * decay; + else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") score += 5 * decay; + else if (t === "larpjoinhouse") score += 15 * decay; + else if (t === "larphousepost") score += 6 * decay; + else if (t === "larptestattempt") score += 3 * decay; + else if (t === "torrent") score += 6 * decay; + else if (t === "shop" || t === "shopproduct") score += 6 * decay; + else if (t === "shop-purchase") score += 2 * decay; + else if (t === "pad" || t === "padentry") score += 3 * decay; + else if (t === "calendar" || t === "calendarnote" || t === "calendardate") score += 3 * decay; + else if (t === "chat") score += 1 * decay; + else if (t === "gamescore") score += 2 * decay; + else if (t === "pixelia") score += 2 * decay; + } + return Math.max(0, Math.round(score)); +} + +async function getCarbonGramsForUser(userId) { + const ssb = await openSsb(); + if (!ssb || !userId) return 0; + return new Promise((resolve) => { + let bytes = 0; + pull( + ssb.createUserStream({ id: userId }), + pull.drain( + (m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} }, + () => resolve((bytes / (1024 * 1024)) * 0.095) + ) + ); + }); +} + +async function getBytesForUser(userId) { + const ssb = await openSsb(); + if (!ssb || !userId) return 0; + return new Promise((resolve) => { + let bytes = 0; + pull( + ssb.createUserStream({ id: userId }), + pull.drain( + (m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} }, + () => resolve(bytes) + ) + ); + }); +} + +let _ecoTaxStatsCache = null; +const ECO_TAX_STATS_TTL_MS = 60 * 1000; + +async function calculateEcoTaxStatsInternal() { + const ssb = await (async () => { + try { return await openSsb(); } catch (_) { return null; } + })(); + if (!ssb) return null; + return new Promise((resolve) => { + let totalBytes = 0; + let totalBlocks = 0; + let maxBlockBytes = 0; + let oldestTs = Infinity; + let newestTs = 0; + pull( + ssb.createLogStream({}), + pull.drain( + (m) => { + try { + const v = m && m.value; + if (!v) return; + totalBlocks += 1; + const size = Buffer.byteLength(JSON.stringify(v), 'utf8'); + totalBytes += size; + if (size > maxBlockBytes) maxBlockBytes = size; + const ts = Number(v.timestamp || 0); + if (ts && ts < oldestTs) oldestTs = ts; + if (ts && ts > newestTs) newestTs = ts; + } catch (_) {} + }, + () => { + const totalGramsCO2 = carbonGramsFromBytes(totalBytes); + const totalEcoinTax = ecoinTaxFromGrams(totalGramsCO2); + const spanMs = (newestTs && oldestTs && oldestTs !== Infinity) ? Math.max(1, newestTs - oldestTs) : 1; + const spanDays = spanMs / 86400000; + const annualEcoinTax = spanDays > 0 ? totalEcoinTax * (365 / spanDays) : totalEcoinTax; + const monthlyEcoinTax = annualEcoinTax / 12; + try { sharedState.setMaxBlockBytes(maxBlockBytes); } catch (_) {} + const ecoTaxes = { + lifetime: Number(totalEcoinTax.toFixed(6)), + annual: Number(annualEcoinTax.toFixed(6)), + monthly: Number(monthlyEcoinTax.toFixed(6)) + }; + const archLifetime = archTaxFromDays(spanDays); + const archAnnual = archTaxFromDays(365); + const archMonthly = archTaxFromDays(365 / 12); + const archTaxes = { + lifetime: Number(archLifetime.toFixed(6)), + annual: Number(archAnnual.toFixed(6)), + monthly: Number(archMonthly.toFixed(6)) + }; + const byType = { eco: ecoTaxes, arch: archTaxes }; + const totals = { + lifetimeEcoinTax: Number(Object.values(byType).reduce((s, t) => s + (t.lifetime || 0), 0).toFixed(6)), + annualEcoinTax: Number(Object.values(byType).reduce((s, t) => s + (t.annual || 0), 0).toFixed(6)), + monthlyEcoinTax: Number(Object.values(byType).reduce((s, t) => s + (t.monthly || 0), 0).toFixed(6)) + }; + resolve({ + totalBlocks, + totalBytes, + maxBlockBytes, + totalGramsCO2: Number(totalGramsCO2.toFixed(6)), + totalEcoinTax: ecoTaxes.lifetime, + annualEcoinTax: ecoTaxes.annual, + monthlyEcoinTax: ecoTaxes.monthly, + byType, + totals, + spanDays: Number(spanDays.toFixed(3)), + oldestTs: oldestTs === Infinity ? 0 : oldestTs, + newestTs, + ecoinPerGramCO2: ECOIN_PER_GRAM_CO2, + ecoinPerDayOfHistory: ECOIN_PER_DAY_OF_HISTORY + }); + } + ) + ); + }); +} + +async function calculateEcoTaxStats() { + const now = Date.now(); + if (_ecoTaxStatsCache && (now - _ecoTaxStatsCache.ts) < ECO_TAX_STATS_TTL_MS) { + return _ecoTaxStatsCache.value; + } + const value = await calculateEcoTaxStatsInternal().catch(() => null); + if (value) _ecoTaxStatsCache = { ts: now, value }; + return value || { + totalBlocks: 0, totalBytes: 0, totalGramsCO2: 0, + totalEcoinTax: 0, annualEcoinTax: 0, monthlyEcoinTax: 0, + byType: { + eco: { lifetime: 0, annual: 0, monthly: 0 }, + arch: { lifetime: 0, annual: 0, monthly: 0 } + }, + totals: { lifetimeEcoinTax: 0, annualEcoinTax: 0, monthlyEcoinTax: 0 }, + spanDays: 0, oldestTs: 0, newestTs: 0, + ecoinPerGramCO2: ECOIN_PER_GRAM_CO2, + ecoinPerDayOfHistory: ECOIN_PER_DAY_OF_HISTORY + }; +} + +async function getUserEcoinTax(userId) { + const bytes = await getBytesForUser(userId).catch(() => 0); + return ecoinTaxFromBytes(bytes); +} + +async function getUserFirstBlockTs(userId) { + const ssb = await openSsb(); + if (!ssb || !userId) return 0; + return new Promise((resolve) => { + let first = 0; + pull( + ssb.createUserStream({ id: userId, limit: 1 }), + pull.drain( + (m) => { if (m && m.value && m.value.timestamp && !first) first = Number(m.value.timestamp); }, + () => resolve(first || 0) + ) + ); + }); +} + +async function getUserArchTax(userId) { + const firstTs = await getUserFirstBlockTs(userId).catch(() => 0); + if (!firstTs) return 0; + const stats = await calculateEcoTaxStats().catch(() => null); + const newestTs = stats && Number.isFinite(stats.newestTs) && stats.newestTs > 0 + ? stats.newestTs + : Date.now(); + const ageDays = Math.max(0, (newestTs - firstTs) / ONE_DAY_MS); + return archTaxFromDays(ageDays); +} + +async function getUserEngagementScore(userId) { + const ssb = await openSsb(); + const uid = resolveUserId(userId); + const actions = await fetchUserActions(uid); + const rawKarma = scoreFromActions(actions); + const carbonGrams = await getCarbonGramsForUser(uid).catch(() => 0); + const karmaScore = Math.max(0, Math.round(rawKarma - carbonGrams)); + + const prev = await getLastKarmaScore(uid); + const lastPublishedTimestamp = await getLastPublishedTimestamp(uid); + + const isSelf = idsEqual(uid, ssb.id); + const hasSSB = !!(ssb && ssb.publish); + + const changed = (prev === null) || (karmaScore !== prev); + const nowMs = Date.now(); + const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0; + const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000; + + if (isSelf && hasSSB && changed && cooldownOk) { + await publishKarmaScore(uid, karmaScore); + } + return karmaScore; +} + +async function getLastKarmaScore(userId) { + const ssb = await openSsb(); + if (!ssb) return null; + return new Promise((resolve) => { + const source = ssb.messagesByType + ? ssb.messagesByType({ type: "karmaScore", reverse: true }) + : ssb.createLogStream && ssb.createLogStream({ reverse: true }); + if (!source) return resolve(null); + pull( + source, + pull.filter(msg => { + const v = msg.value || msg; + const c = v.content || {}; + return c && c.type === "karmaScore" && c.userId === userId; + }), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(null); + const v = arr[0].value || arr[0]; + const c = v.content || {}; + resolve(Number(c.karmaScore) || 0); + }) + ); + }); +} + +async function getLastPublishedTimestamp(userId) { + const ssb = await openSsb(); + if (!ssb) return new Date(0).toISOString(); + const fallback = new Date(0).toISOString(); + return new Promise((resolve) => { + const source = ssb.messagesByType + ? ssb.messagesByType({ type: "karmaScore", reverse: true }) + : ssb.createLogStream && ssb.createLogStream({ reverse: true }); + if (!source) return resolve(fallback); + pull( + source, + pull.filter(msg => { + const v = msg.value || msg; + const c = v.content || {}; + return c && c.type === "karmaScore" && c.userId === userId; + }), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(fallback); + const v = arr[0].value || arr[0]; + const c = v.content || {}; + resolve(c.timestamp || fallback); + }) + ); + }); +} + + function computePoolVars(pubBal, rules) { + const alphaCap = (rules.alpha ?? DEFAULT_RULES.alpha) * pubBal; + const available = Math.max(0, pubBal - (rules.reserveMin ?? DEFAULT_RULES.reserveMin)); + const rawMin = Math.min(available, (rules.capPerEpoch ?? DEFAULT_RULES.capPerEpoch), alphaCap); + const pool = clamp(rawMin, 0, Number.MAX_SAFE_INTEGER); + return { pubBal, alphaCap, available, rawMin, pool }; + } + + async function computeEpoch({ epochId, userId, rules = DEFAULT_RULES }) { + const pubBal = await safeGetBalance("pub"); + const pv = computePoolVars(pubBal, rules); + const addresses = await listAddressesMerged(); + const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address)); + const capUser = rules.caps?.cap_user_epoch ?? DEFAULT_RULES.caps.cap_user_epoch; + const wMin = rules.caps?.w_min ?? DEFAULT_RULES.caps.w_min; + const wMax = rules.caps?.w_max ?? DEFAULT_RULES.caps.w_max; + const floorUbi = rules.caps?.floor_user ?? DEFAULT_RULES.caps.floor_user ?? 1; + const weights = []; + for (const entry of eligible) { + const score = await getUserEngagementScore(entry.id); + weights.push({ user: entry.id, w: clamp(1 + score / 100, wMin, wMax) }); + } + if (!weights.length && userId) { + const score = await getUserEngagementScore(userId); + weights.push({ user: userId, w: clamp(1 + score / 100, wMin, wMax) }); + } + const W = weights.reduce((acc, x) => acc + x.w, 0) || 1; + const allocations = []; + for (const { user, w } of weights) { + const gross = Math.max(floorUbi, Math.min(pv.pool * w / W, capUser)); + const surplus = Math.max(0, gross - floorUbi); + const userEcoTax = await getUserEcoinTax(user).catch(() => 0); + const userArchTax = await getUserArchTax(user).catch(() => 0); + const userTotalTax = userEcoTax + userArchTax; + const taxedSurplus = Math.max(0, surplus - userTotalTax); + const amount = floorUbi + taxedSurplus; + allocations.push({ + id: `alloc:${epochId}:${user}`, + epoch: epochId, + user, + weight: Number(w.toFixed(6)), + gross: Number(gross.toFixed(6)), + ecoTax: Number((userEcoTax || 0).toFixed(6)), + archTax: Number((userArchTax || 0).toFixed(6)), + totalTax: Number((userTotalTax || 0).toFixed(6)), + amount: Number(amount.toFixed(6)) + }); + } + const snapshot = JSON.stringify({ epochId, pool: pv.pool, weights, allocations, rules }, null, 2); + const hash = crypto.createHash("sha256").update(snapshot).digest("hex"); + return { epoch: { id: epochId, pool: Number(pv.pool.toFixed(6)), weightsSum: Number(W.toFixed(6)), rules, hash }, allocations }; + } + + async function executeEpoch({ epochId, rules = DEFAULT_RULES } = {}) { + const eid = epochId || epochIdNow(); + await expireOldAllocations(); + const existing = await epochsRepo.get(eid); + if (existing) return { epoch: existing, allocations: await transfersRepo.listByTag(`epoch:${eid}`) }; + const { epoch, allocations } = await computeEpoch({ epochId: eid, userId: config.keys.id, rules }); + await epochsRepo.save(epoch); + for (const a of allocations) { + if (a.amount <= 0) continue; + const record = { + id: a.id, + from: config.keys.id, + to: a.user, + amount: a.amount, + concept: `UBI ${eid}`, + status: "UNCLAIMED", + createdAt: new Date().toISOString(), + deadline: new Date(Date.now() + ((rules.graceDays ?? DEFAULT_RULES.graceDays) * 86400000)).toISOString(), + tags: ["UBI", `epoch:${eid}`], + opinions: {} + }; + await transfersRepo.create(record); + try { await publishUbiAllocation(record); } catch (_) {} + } + return { epoch, allocations }; + } + + async function publishBankClaim({ amount, epochId, allocationId, txid }) { + const ssbClient = await openSsb(); + const content = { type: "bankClaim", amount, epochId, allocationId, txid, timestamp: Date.now() }; + return new Promise((resolve, reject) => ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))); + } + + async function claimAllocation({ transferId, claimerId, forcePub = false }) { + const allocation = await transfersRepo.findById(transferId); + if (!allocation || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) throw new Error("Invalid allocation or already claimed."); + if (claimerId && allocation.to !== claimerId) throw new Error("This allocation is not for you."); + const addr = await getUserAddress(allocation.to); + if (!addr || !isValidEcoinAddress(addr)) throw new Error("No valid ECOin address registered."); + const txid = await rpcCall("sendtoaddress", [addr, allocation.amount, `UBI ${allocation.concept || "claim"}`], "pub"); + if (!txid) throw new Error("RPC sendtoaddress failed. Check PUB wallet configuration."); + await transfersRepo.markClosed(transferId, txid); + return { txid }; + } + + async function claimUBI(userId) { + const uid = resolveUserId(userId); + const epochId = epochIdNow(); + const pubId = getConfiguredPubId(); + if (!pubId) throw new Error("no_pub_configured"); + const alreadyClaimed = await hasClaimedThisMonth(uid); + if (alreadyClaimed) throw new Error("already_claimed"); + const ssb = await openSsb(); + if (!ssb || !ssb.publish) throw new Error("ssb_unavailable"); + const now = new Date().toISOString(); + const claimContent = { type: "ubiClaim", pubId, epochId, claimedAt: now }; + await new Promise((resolve, reject) => ssb.publish(claimContent, (err, res) => err ? reject(err) : resolve(res))); + return { status: "claimed_pending", epochId }; + } + + async function updateAllocationStatus(allocationId, status, txid) { + if (status === "CLOSED") { + await transfersRepo.markClosed(allocationId, txid); + return; + } + ensureStoreFiles(); + const all = readJson(TRANSFERS_PATH, []); + const idx = all.findIndex(t => t.id === allocationId); + if (idx >= 0) { + all[idx].status = status; + if (txid) all[idx].txid = txid; + writeJson(TRANSFERS_PATH, all); + } + } + + async function hasClaimedThisMonth(userId) { + const epochId = epochIdNow(); + const msgs = await scanLogStream(); + for (const m of msgs) { + const v = m.value || {}; + const c = v.content || {}; + if (c.type === "ubiClaimResult" && c.userId === userId && c.epochId === epochId) return true; + if (c.type === "ubiClaim" && v.author === userId && c.epochId === epochId) return true; + } + return false; + } + + async function getUbiClaimHistory(userId) { + const msgs = await scanLogStream(); + let lastClaimedDate = null; + let totalClaimed = 0; + let claimCount = 0; + for (const m of msgs) { + const v = m.value || {}; + const c = v.content || {}; + if (c.type === "ubiClaimResult" && c.userId === userId) { + totalClaimed += Number(c.amount) || 0; + claimCount += 1; + const d = c.processedAt || null; + if (d && (!lastClaimedDate || d > lastClaimedDate)) lastClaimedDate = d; + } + } + return { lastClaimedDate, totalClaimed: Number(totalClaimed.toFixed(6)), claimCount }; + } + + async function getUbiAllocationsFromSSB() { + const pubId = getConfiguredPubId(); + if (!pubId) return []; + const msgs = await scanLogStream(); + const out = []; + for (const m of msgs) { + const v = m.value || {}; + const c = v.content || {}; + if (v.author === pubId && c && c.type === "ubiAllocation") { + out.push({ + id: c.allocationId, + from: pubId, + to: c.to, + amount: c.amount, + concept: c.concept, + epochId: c.epochId, + status: c.status || "UNCLAIMED", + createdAt: c.createdAt || new Date().toISOString() + }); + } + } + return out; + } + + async function publishPubAvailability() { + if (!isPubNode()) return; + const balance = await safeGetBalance("pub"); + const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user ?? 1); + const available = Number(balance) >= floor; + const ssb = await openSsb(); + if (!ssb || !ssb.publish) return; + const content = { type: "pubAvailability", available, coin: "ECO", timestamp: Date.now() }; + await new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res))); + return available; + } + + async function getPubAvailabilityFromSSB() { + const pubId = getConfiguredPubId(); + if (!pubId) return false; + const msgs = await scanLogStream(); + let latest = null; + for (const m of msgs) { + const v = m.value || {}; + const c = v.content || {}; + if (v.author === pubId && c && c.type === "pubAvailability" && c.coin === "ECO") { + if (!latest || (Number(c.timestamp) || 0) > (Number(latest.timestamp) || 0)) latest = c; + } + } + return !!(latest && latest.available); + } + + async function listBanking(filter = "overview", userId) { + const uid = resolveUserId(userId); + const epochId = epochIdNow(); + let pubBalance = 0; + let ubiAvailable = false; + let allocations; + if (isPubNode()) { + pubBalance = await safeGetBalance("pub"); + const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user ?? 1); + ubiAvailable = Number(pubBalance) >= floor; + try { await publishPubAvailability(); } catch (_) {} + const all = await transfersRepo.listByTag("UBI"); + allocations = all.map(t => ({ + id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, + createdAt: t.createdAt || t.deadline || new Date().toISOString(), txid: t.txid + })); + } else { + ubiAvailable = await getPubAvailabilityFromSSB(); + allocations = await getUbiAllocationsFromSSB(); + } + const userBalance = await safeGetBalance("user"); + const epochs = await epochsRepo.list(); + let computed = null; + try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {} + const pv = computePoolVars(pubBalance, DEFAULT_RULES); + const actions = await fetchUserActions(uid); + const rawScore = scoreFromActions(actions); + const carbonGramsForScore = await getCarbonGramsForUser(uid).catch(() => 0); + const engagementScore = Math.max(0, Math.round(rawScore - carbonGramsForScore)); + const poolForEpoch = computed?.epoch?.pool || pv.pool || 0; + const futureUBI = Number(((engagementScore / 100) * poolForEpoch).toFixed(6)); + const addresses = await listAddressesMerged(); + const alreadyClaimed = await hasClaimedThisMonth(uid); + const pubId = getConfiguredPubId(); + const userAddress = await getUserAddress(uid); + const userWalletCfg = getWalletCfg("user") || {}; + const hasValidWallet = !!(userAddress && isValidEcoinAddress(userAddress) && userWalletCfg.url); + const summary = { + userBalance, + epochId, + pool: poolForEpoch, + weightsSum: computed?.epoch?.weightsSum || 0, + userEngagementScore: engagementScore, + futureUBI, + alreadyClaimed, + pubId, + hasValidWallet, + ubiAvailability: ubiAvailable ? "OK" : "NO_FUNDS" + }; + const exchange = await calculateEcoinValue(); + const exchangeHistory = readEcoHistory(); + let taxStats = null; + let userEcoinTax = 0; + if (filter === 'taxes' || filter === 'exchange' || filter === 'overview') { + try { taxStats = await calculateEcoTaxStats(); } catch (_) { taxStats = null; } + try { userEcoinTax = await getUserEcoinTax(uid); } catch (_) { userEcoinTax = 0; } + } + const taxRules = { ecoinPerGramCO2: ECOIN_PER_GRAM_CO2, gramsCO2PerMiB: 0.095, ecoinPerDayOfHistory: ECOIN_PER_DAY_OF_HISTORY }; + let userArchTax = 0; + if (filter === 'taxes' || filter === 'exchange' || filter === 'overview') { + try { userArchTax = await getUserArchTax(uid); } catch (_) { userArchTax = 0; } + } + const userTotalTax = (userEcoinTax || 0) + (userArchTax || 0); + return { + summary, allocations, epochs, rules: DEFAULT_RULES, taxRules, addresses, exchange, exchangeHistory, taxStats, + userEcoinTax: Number((userEcoinTax || 0).toFixed(6)), + userArchTax: Number((userArchTax || 0).toFixed(6)), + userTotalTax: Number(userTotalTax.toFixed(6)) + }; + } + + async function getAllocationById(id) { + const t = await transfersRepo.findById(id); + if (!t) return null; + return { id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid }; + } + + async function getEpochById(id) { + const existing = await epochsRepo.get(id); + if (existing) return existing; + const all = await transfersRepo.listAll(); + const filtered = all.filter(t => (t.tags || []).includes(`epoch:${id}`)); + const pool = filtered.reduce((s, t) => s + Number(t.amount || 0), 0); + return { id, pool, weightsSum: 0, rules: DEFAULT_RULES, hash: "-" }; + } + + async function listEpochAllocations(id) { + const all = await transfersRepo.listAll(); + return all.filter(t => (t.tags || []).includes(`epoch:${id}`)).map(t => ({ + id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid + })); + } + + let genesisTimeCache = null; + + async function getAvgBlockSeconds(blocks) { + if (!blocks || blocks < 2) return 0; + try { + if (!genesisTimeCache) { + const h1 = await rpcCall("getblockhash", [1]); + if (!h1) return 0; + const b1 = await rpcCall("getblock", [h1]); + genesisTimeCache = b1?.time || null; + if (!genesisTimeCache) return 0; + } + const hCur = await rpcCall("getblockhash", [blocks]); + if (!hCur) return 0; + const bCur = await rpcCall("getblock", [hCur]); + const curTime = bCur?.time || 0; + if (!curTime) return 0; + const elapsed = curTime - genesisTimeCache; + return elapsed > 0 ? elapsed / (blocks - 1) : 0; + } catch (_) { return 0; } + } + + async function calculateEcoinValue() { + const totalSupply = 25500000; + let circulatingSupply = 0; + let blocks = 0; + let blockValueEco = 0; + let isSynced = false; + try { + const info = await rpcCall("getinfo", []); + circulatingSupply = info?.moneysupply || 0; + blocks = info?.blocks || 0; + isSynced = circulatingSupply > 0; + const mining = await rpcCall("getmininginfo", []); + blockValueEco = (mining?.blockvalue || 0) / 1e8; + } catch (_) {} + const avgSec = await getAvgBlockSeconds(blocks); + const ecoValuePerHour = avgSec > 0 ? (3600 / avgSec) * blockValueEco : 0; + const maturity = totalSupply > 0 ? circulatingSupply / totalSupply : 0; + const ecoTimeMs = maturity * 3600 * 1000; + const annualIssuance = ecoValuePerHour * 24 * 365; + const inflationFactor = circulatingSupply > 0 ? (annualIssuance / circulatingSupply) * 100 : 0; + const inflationMonthly = inflationFactor / 12; + const result = { + ecoValue: Number(ecoValuePerHour.toFixed(6)), + ecoTimeMs: Number(ecoTimeMs.toFixed(3)), + totalSupply, + inflationFactor: Number(inflationFactor.toFixed(2)), + inflationMonthly: Number(inflationMonthly.toFixed(2)), + currentSupply: circulatingSupply, + isSynced + }; + if (isSynced) { + appendEcoHistory({ + ts: Date.now(), + ecoValue: result.ecoValue, + currentSupply: result.currentSupply, + inflationFactor: result.inflationFactor, + blockValueEco: Number(blockValueEco.toFixed(8)), + avgBlockSec: Number((avgSec || 0).toFixed(2)), + blocks + }); + } + return result; + } + + async function getBankingData(userId) { + const ecoValue = await calculateEcoinValue(); + const karmaScore = await getUserEngagementScore(userId); + let estimatedUBI = 0; + try { + const pubBal = isPubNode() ? await safeGetBalance("pub") : 0; + const pv = computePoolVars(pubBal, DEFAULT_RULES); + const pool = pv.pool || 0; + const addresses = await listAddressesMerged(); + const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address)); + const wMin = DEFAULT_RULES.caps?.w_min ?? 0.2; + const wMax = DEFAULT_RULES.caps?.w_max ?? 6; + const userW = clamp(1 + karmaScore / 100, wMin, wMax); + const otherUsers = Math.max(0, eligible.length - 1); + const totalW = Math.max(1, userW + otherUsers); + const cap = DEFAULT_RULES.caps?.cap_user_epoch ?? 50; + const floor = DEFAULT_RULES.caps?.floor_user ?? 1; + estimatedUBI = eligible.length > 0 + ? Math.max(floor, Math.min(pool * (userW / totalW), cap)) + : 0; + } catch (_) {} + const uid = resolveUserId(userId); + const userEcoinTax = await getUserEcoinTax(uid).catch(() => 0); + const userArchTax = await getUserArchTax(uid).catch(() => 0); + const userTotalTax = userEcoinTax + userArchTax; + const estimatedUBIBeforeTax = estimatedUBI; + const baseFloor = DEFAULT_RULES.caps?.floor_user ?? 1; + const surplus = Math.max(0, estimatedUBI - baseFloor); + estimatedUBI = baseFloor + Math.max(0, surplus - userTotalTax); + const claimHistory = await getUbiClaimHistory(userId).catch(() => ({ lastClaimedDate: null, totalClaimed: 0 })); + return { + ecoValue, + karmaScore, + estimatedUBI, + estimatedUBIBeforeTax: Number(estimatedUBIBeforeTax.toFixed(6)), + userEcoinTax: Number(userEcoinTax.toFixed(6)), + userArchTax: Number(userArchTax.toFixed(6)), + userTotalTax: Number(userTotalTax.toFixed(6)), + lastClaimedDate: claimHistory.lastClaimedDate, + totalClaimed: claimHistory.totalClaimed + }; + } + + async function expireOldAllocations() { + const cutoffMs = MAX_PENDING_EPOCHS * 30 * 86400000; + const now = Date.now(); + const allocs = await transfersRepo.listAll(); + for (const a of allocs) { + if ((a.status === "UNCLAIMED" || a.status === "UNCONFIRMED") && + (now - new Date(a.createdAt).getTime()) > cutoffMs) { + await updateAllocationStatus(a.id, "EXPIRED"); + } + } + } + + async function publishUbiAllocation(allocation) { + const ssb = await openSsb(); + if (!ssb) return; + const epochTag = (allocation.tags || []).find(t => t.startsWith("epoch:")); + const content = { + type: "ubiAllocation", + allocationId: allocation.id, + to: allocation.to, + amount: allocation.amount, + concept: allocation.concept, + epochId: epochTag ? epochTag.slice(6) : "", + status: "UNCLAIMED", + createdAt: allocation.createdAt + }; + return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res))); + } + + async function publishUbiClaim(allocationId, epochId) { + const ssb = await openSsb(); + if (!ssb) return; + const content = { type: "ubiClaim", allocationId, epochId, claimedAt: new Date().toISOString() }; + return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res))); + } + + async function publishUbiClaimResult(allocationId, epochId, txid, userId, amount) { + const ssb = await openSsb(); + if (!ssb) return; + const content = { type: "ubiClaimResult", allocationId, epochId, txid, userId, amount, processedAt: new Date().toISOString() }; + return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res))); + } + + async function processPendingClaims() { + if (!isPubNode()) return; + const ssb = await openSsb(); + if (!ssb) return; + const claims = []; + const results = []; + await new Promise((resolve, reject) => { + pull(ssb.messagesByType({ type: "ubiClaim", reverse: false }), + pull.drain(msg => { + if (msg.value?.content?.type === "ubiClaim") { + claims.push({ ...msg.value.content, _author: msg.value.author }); + } + }, + err => err ? reject(err) : resolve())); + }); + await new Promise((resolve, reject) => { + pull(ssb.messagesByType({ type: "ubiClaimResult", reverse: false }), + pull.drain(msg => { if (msg.value?.content?.type === "ubiClaimResult") results.push(msg.value.content); }, + err => err ? reject(err) : resolve())); + }); + const processedEpochUser = new Set(results.map(r => `${r.epochId}:${r.userId}`)); + const epochId = epochIdNow(); + for (const claim of claims) { + const claimantId = claim._author; + if (!claimantId) continue; + const claimEpoch = claim.epochId || epochId; + if (processedEpochUser.has(`${claimEpoch}:${claimantId}`)) continue; + try { + const addr = await getUserAddress(claimantId); + if (!addr || !isValidEcoinAddress(addr)) continue; + const pubBal = await safeGetBalance("pub"); + if (pubBal <= 0) continue; + const pv = computePoolVars(pubBal, DEFAULT_RULES); + const addresses = await listAddressesMerged(); + const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address)); + const karmaScore = await getUserEngagementScore(claimantId); + const wMin = DEFAULT_RULES.caps.w_min; + const wMax = DEFAULT_RULES.caps.w_max; + const capUser = DEFAULT_RULES.caps.cap_user_epoch; + const userW = clamp(1 + karmaScore / 100, wMin, wMax); + const totalW = eligible.reduce((acc) => acc + clamp(1, wMin, wMax), 0) || 1; + const amount = Number(Math.max(1, Math.min(pv.pool * userW / totalW, capUser)).toFixed(6)); + const txid = await rpcCall("sendtoaddress", [addr, amount, `UBI ${claimEpoch}`], "pub"); + if (!txid) continue; + await publishUbiClaimResult(claim.allocationId || `claim:${claimEpoch}:${claimantId}`, claimEpoch, txid, claimantId, amount); + await publishBankClaim({ amount, epochId: claimEpoch, allocationId: claim.allocationId || `claim:${claimEpoch}:${claimantId}`, txid }); + const now = new Date().toISOString(); + await new Promise((resolve, reject) => ssb.publish({ + type: "transfer", + from: config.keys.id, + to: claimantId, + concept: `UBI ${claimEpoch} ${claimantId}`, + amount: String(amount), + createdAt: now, + updatedAt: now, + deadline: null, + confirmedBy: [config.keys.id], + status: "UNCONFIRMED", + tags: ["UBI"], + opinions: {}, + opinions_inhabitants: [], + txid + }, (err, msg) => err ? reject(err) : resolve(msg))); + } catch (_) {} + } + } + + return { + DEFAULT_RULES, + isPubNode, + getConfiguredPubId, + computeEpoch, + executeEpoch, + getUserEngagementScore, + publishBankClaim, + publishUbiAllocation, + publishUbiClaim, + publishUbiClaimResult, + publishPubAvailability, + getPubAvailabilityFromSSB, + hasClaimedThisMonth, + getUbiClaimHistory, + claimUBI, + processPendingClaims, + expireOldAllocations, + claimAllocation, + listBanking, + getAllocationById, + getEpochById, + listEpochAllocations, + addAddress, + removeAddress, + ensureSelfAddressPublished, + getUserAddress, + setUserAddress, + listAddressesMerged, + calculateEcoinValue, + calculateEcoTaxStats, + getUserEcoinTax, + getUserArchTax, + getUserFirstBlockTs, + getBankingData, + ECOIN_PER_GRAM_CO2, + ECOIN_PER_DAY_OF_HISTORY + }; +}; diff --git a/src/models/blockchain_model.js b/src/models/blockchain_model.js new file mode 100644 index 0000000..39fe868 --- /dev/null +++ b/src/models/blockchain_model.js @@ -0,0 +1,397 @@ +const pull = require('../server/node_modules/pull-stream'); +const config = require('../server/ssb_config'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler, tribeCrypto, tribesModel }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const HIDDEN_ENVELOPE_TYPES = new Set([ + 'tribe-keys-distrib', + 'tribe-invite-msg', + 'tribe-invite-tombstone' + ]); + + const isHiddenBoxedContent = (rawContent) => + typeof rawContent === 'string' && rawContent.endsWith('.box'); + + const buildAccessibleTribeIds = async () => { + const set = new Set(); + if (!tribesModel) return set; + try { + const list = await tribesModel.listAll(); + for (const t of list) { + if (!t || !t.id) continue; + set.add(t.id); + try { + const chain = await tribesModel.getChainIds(t.id); + for (const cid of chain) set.add(cid); + } catch (_) {} + } + } catch (_) {} + return set; + }; + + const hasBlob = async (ssbClient, url) => + new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has))); + + const isClosedSold = s => + String(s || '').toUpperCase() === 'SOLD' || String(s || '').toUpperCase() === 'CLOSED'; + + const projectRank = (status) => { + const S = String(status || '').toUpperCase(); + if (S === 'COMPLETED') return 3; + if (S === 'ACTIVE') return 2; + if (S === 'PAUSED') return 1; + if (S === 'CANCELLED') return 0; + return -1; + }; + + const safeDecode = (s) => { + try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); } + }; + + const parseTs = (s) => { + const raw = String(s || '').trim(); + if (!raw) return null; + const ts = new Date(raw).getTime(); + return Number.isFinite(ts) ? ts : null; + }; + + const matchBlockId = (blockId, q) => { + const a = String(blockId || ''); + const b = String(q || ''); + if (!a || !b) return true; + const al = a.toLowerCase(); + const bl = b.toLowerCase(); + if (al.includes(bl)) return true; + const ad = safeDecode(a).toLowerCase(); + const bd = safeDecode(b).toLowerCase(); + return ad.includes(bd) || ad.includes(bl) || al.includes(bd); + }; + + const matchAuthorOrName = (authorId, authorName, query) => { + const q0 = String(query || '').trim().toLowerCase(); + if (!q0) return true; + + const qNoAt = q0.replace(/^@/, ''); + const aid = String(authorId || '').toLowerCase(); + if (aid.includes(q0)) return true; + if (qNoAt && aid.includes('@' + qNoAt)) return true; + + const nm0 = String(authorName || '').trim().toLowerCase(); + const nmNoAt = nm0.replace(/^@/, ''); + if (!nmNoAt) return false; + + if (nm0.includes(q0)) return true; + if (qNoAt && nmNoAt.includes(qNoAt)) return true; + + return false; + }; + + const buildNameIndexFromAbout = async (ssbClient, minLimit = 5000) => { + const nameByFeedId = new Map(); + if (!ssbClient?.query?.read) return nameByFeedId; + + const limit = Math.max(minLimit, logLimit); + + const source = await ssbClient.query.read({ + query: [{ $filter: { value: { content: { type: 'about' } } } }], + reverse: true, + limit + }); + + const aboutMsgs = await new Promise((resolve, reject) => { + pull( + source, + pull.take(limit), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs || []))) + ); + }); + + for (const msg of aboutMsgs) { + const c = msg?.value?.content; + if (!c || c.type !== 'about') continue; + const aboutId = String(c.about || msg?.value?.author || '').trim(); + const nm = typeof c.name === 'string' ? c.name.trim() : ''; + if (!aboutId || !nm) continue; + if (!nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm); + } + + return nameByFeedId; + }; + + return { + async listBlockchain(filter = 'all', userId, search = {}) { + const ssbClient = await openSsb(); + + const results = await new Promise((resolve, reject) => + pull( + ssbClient.createLogStream({ reverse: true, limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ) + ); + + const tombstoned = buildValidatedTombstoneSet(results); + const idToBlock = new Map(); + const referencedAsReplaces = new Set(); + + const nameByFeedId = new Map(); + + const showLogs = (filter === 'logs' || filter === 'LOGS'); + const me = userId || config.keys.id; + const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; + const accessibleTribeIds = await buildAccessibleTribeIds(); + for (const msg of results) { + const k = msg.key; + let c = msg.value?.content; + const author = msg.value?.author; + if (isHiddenBoxedContent(c)) continue; + if (showLogs && typeof c === 'string' && author === me) { + try { + const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 }); + c = dec?.value?.content; + } catch { c = null; } + } + if (!c?.type) continue; + if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue; + if (tribeCrypto && tribeCrypto.isTribeMsg(c)) { + const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null; + if (!r || !r.body) continue; + const inner = r.body; + const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg'); + c = { type: innerType, _decrypted: true, _rootId: r.rootId, ...inner }; + } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) { + continue; + } + + if (c.type === 'about') { + const aboutId = String(c.about || author || '').trim(); + const nm = typeof c.name === 'string' ? c.name.trim() : ''; + if (aboutId && nm && !nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm); + } + + if (c.type === 'tombstone' && c.target) { + idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); + continue; + } + if (c.replaces) referencedAsReplaces.add(c.replaces); + idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); + } + + const tipBlocks = []; + for (const [id, block] of idToBlock.entries()) { + if (!referencedAsReplaces.has(id) && block.content.replaces) tipBlocks.push(block); + } + for (const [id, block] of idToBlock.entries()) { + if (!block.content.replaces && !referencedAsReplaces.has(id)) tipBlocks.push(block); + } + + const groups = {}; + for (const block of tipBlocks) { + const ancestor = block.content.replaces || block.id; + if (!groups[ancestor]) groups[ancestor] = []; + groups[ancestor].push(block); + } + + const liveTipIds = new Set(); + for (const groupBlocks of Object.values(groups)) { + let best = groupBlocks[0]; + for (const block of groupBlocks) { + if (block.type === 'market') { + if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) { + best = block; + } else if ((block.content.status === best.content.status) && block.ts > best.ts) { + best = block; + } + } else if (block.type === 'project') { + const br = projectRank(best.content.status); + const cr = projectRank(block.content.status); + if (cr > br || (cr === br && block.ts > best.ts)) best = block; + } else if (block.type === 'job' || block.type === 'forum') { + if (block.ts > best.ts) best = block; + } else { + if (block.ts > best.ts) best = block; + } + } + liveTipIds.add(best.id); + } + + const blockData = Array.from(idToBlock.values()).map(block => { + const c = block.content; + const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root); + return { + ...block, + isTombstoned: tombstoned.has(block.id), + isReplaced: c.replaces + ? (!liveTipIds.has(block.id) || tombstoned.has(block.id)) + : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted + }; + }); + + let filtered = blockData; + + if (filter === 'RECENT' || filter === 'recent') { + const now = Date.now(); + filtered = filtered.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000); + } + if (filter === 'MINE' || filter === 'mine') { + const me = userId || config.keys.id; + filtered = filtered.filter(b => b && b.author === me); + } + if (filter === 'LOGS' || filter === 'logs') { + filtered = filtered.filter(b => b && b.type === 'log' && b.author === me); + } + if (filter === 'PARLIAMENT' || filter === 'parliament') { + const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']); + filtered = filtered.filter(b => b && pset.has(b.type)); + } + if (filter === 'COURTS' || filter === 'courts') { + const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']); + filtered = filtered.filter(b => b && cset.has(b.type)); + } + + const s = search || {}; + const authorQ = String(s.author || '').trim(); + const idQ = String(s.id || '').trim(); + const fromTs = parseTs(s.from); + const toTs = parseTs(s.to); + + let aboutIndex = null; + const needsNameSearch = !!authorQ && !authorQ.toLowerCase().includes('.ed25519'); + + if (needsNameSearch) { + aboutIndex = await buildNameIndexFromAbout(ssbClient, 10000); + for (const [fid, nm] of aboutIndex.entries()) { + if (!nameByFeedId.has(fid)) nameByFeedId.set(fid, nm); + } + } + + filtered = filtered.filter(b => { + if (!b) return false; + if (fromTs != null && b.ts < fromTs) return false; + if (toTs != null && b.ts > toTs) return false; + + if (authorQ) { + const nm = nameByFeedId.get(b.author) || ''; + if (!matchAuthorOrName(b.author, nm, authorQ)) return false; + } + + if (idQ && !matchBlockId(b.id, idQ)) return false; + + return true; + }); + + return filtered.filter(Boolean); + }, + + async getBlockById(id, userId) { + const ssbClient = await openSsb(); + const results = await new Promise((resolve, reject) => + pull( + ssbClient.createLogStream({ reverse: true, limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ) + ); + + const me = userId || config.keys.id; + const tombstoned = buildValidatedTombstoneSet(results); + const idToBlock = new Map(); + const referencedAsReplaces = new Set(); + const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; + const accessibleTribeIds = await buildAccessibleTribeIds(); + + for (const msg of results) { + const k = msg.key; + let c = msg.value?.content; + const author = msg.value?.author; + if (isHiddenBoxedContent(c)) continue; + if (typeof c === 'string' && author === me) { + try { + const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 }); + c = dec?.value?.content; + } catch { c = null; } + } + if (!c?.type) continue; + if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue; + if (tribeCrypto && tribeCrypto.isTribeMsg(c)) { + const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null; + if (!r || !r.body) continue; + const inner = r.body; + const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg'); + c = { type: innerType, _decrypted: true, _rootId: r.rootId, ...inner }; + } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) { + continue; + } + if (c.type === 'tombstone' && c.target) { + idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); + continue; + } + if (c.replaces) referencedAsReplaces.add(c.replaces); + idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); + } + + const tipBlocks = []; + for (const [bid, block] of idToBlock.entries()) { + if (!referencedAsReplaces.has(bid) && block.content.replaces) tipBlocks.push(block); + } + for (const [bid, block] of idToBlock.entries()) { + if (!block.content.replaces && !referencedAsReplaces.has(bid)) tipBlocks.push(block); + } + + const groups = {}; + for (const block of tipBlocks) { + const ancestor = block.content.replaces || block.id; + if (!groups[ancestor]) groups[ancestor] = []; + groups[ancestor].push(block); + } + + const liveTipIds = new Set(); + for (const groupBlocks of Object.values(groups)) { + let best = groupBlocks[0]; + for (const block of groupBlocks) { + if (block.type === 'market') { + if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) { + best = block; + } else if ((block.content.status === best.content.status) && block.ts > best.ts) { + best = block; + } + } else if (block.type === 'project') { + const br = projectRank(best.content.status); + const cr = projectRank(block.content.status); + if (cr > br || (cr === br && block.ts > best.ts)) best = block; + } else if (block.type === 'job' || block.type === 'forum') { + if (block.ts > best.ts) best = block; + } else { + if (block.ts > best.ts) best = block; + } + } + liveTipIds.add(best.id); + } + + const block = idToBlock.get(id); + if (!block) return null; + + if (block.type === 'document') { + const valid = await hasBlob(ssbClient, block.content.url); + if (!valid) return null; + } + + const c = block.content; + const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root); + const isTombstoned = tombstoned.has(block.id); + const isReplaced = c.replaces + ? (!liveTipIds.has(block.id) || tombstoned.has(block.id)) + : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted; + + return { ...block, isTombstoned, isReplaced }; + } + }; +}; + diff --git a/src/models/bookmarking_model.js b/src/models/bookmarking_model.js new file mode 100644 index 0000000..144fb96 --- /dev/null +++ b/src/models/bookmarking_model.js @@ -0,0 +1,350 @@ +const pull = require("../server/node_modules/pull-stream"); +const moment = require("../server/node_modules/moment"); +const { getConfig } = require("../configs/config-manager.js"); +const categories = require("../backend/opinion_categories"); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe'); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return []; + if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean); + return String(raw).split(",").map((t) => t.trim()).filter(Boolean); +}; + +const coerceLastVisit = (lastVisit) => { + const now = moment(); + if (!lastVisit) return now.toISOString(); + const m = moment(lastVisit, moment.ISO_8601, true); + if (!m.isValid()) return now.toISOString(); + if (m.isAfter(now)) return now.toISOString(); + return m.toISOString(); +}; + +const voteSum = (opinions = {}) => + Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); + +module.exports = ({ cooler }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) + ); + }); + + const getMsg = async (ssbClient, key) => + new Promise((resolve, reject) => { + ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg))); + }); + + const buildIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const opinionMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === 'tombstone') continue; + if (c.type === 'bookmarkOpinion') { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + if (c.type !== "bookmark") continue; + + const ts = v.timestamp || m.timestamp || 0; + let sizeBytes = 0; + try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; } + nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author }); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, t] of Array.from(parent.entries())) { + const cn = nodes.get(k); + const pn = nodes.get(t); + if (!pn) { parent.delete(k); if (child.get(t) === k) child.delete(t); continue; } + if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); if (child.get(t) === k) child.delete(t); nodes.delete(k); } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const opinionsByRoot = new Map(); + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const forward = new Map(); + for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId); + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + const lc = contentNode ? contentNode.c : {}; + const opinions = { ...(lc.opinions || {}) }; + const voters = safeArr(lc.opinions_inhabitants).slice(); + const voterSet = new Set(voters); + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); voters.push(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + return { contentTip, contentNode, opinions, voters }; + }; + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup }; + }; + + const buildBookmark = (node, rootId, viewerId, agg) => { + const c = node.c || {}; + const opinions = agg ? agg.opinions : (c.opinions || {}); + const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants); + return { + id: node.key, + rootId, + url: c.url || "", + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + lastVisit: c.lastVisit || null, + tags: safeArr(c.tags), + category: c.category || "", + description: c.description || "", + opinions, + opinions_inhabitants: voters, + author: node.author, + hasVoted: viewerId ? voters.includes(viewerId) : false, + sizeBytes: node.sizeBytes || 0 + }; + }; + + return { + type: "bookmark", + + async resolveCurrentId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const tip = idx.tipByRoot.get(idx.rootOf(id)) || id; + if (idx.tomb.has(tip)) throw new Error("Bookmark not found"); + return tip; + }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + let tip = id; + while (idx.forward.has(tip)) tip = idx.forward.get(tip); + if (idx.tomb.has(tip)) throw new Error("Bookmark not found"); + + let root = tip; + while (idx.parent.has(root)) root = idx.parent.get(root); + return root; + }, + + async createBookmark(url, tagsRaw, description, category, lastVisit) { + const ssbClient = await openSsb(); + const now = new Date().toISOString(); + + const u = safeText(url); + if (!u) throw new Error("URL is required"); + + const content = { + type: "bookmark", + author: ssbClient.id, + url: u, + tags: normalizeTags(tagsRaw), + description: description || "", + category: category || "", + createdAt: now, + updatedAt: now, + lastVisit: coerceLastVisit(lastVisit), + opinions: {}, + opinions_inhabitants: [] + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async updateBookmarkById(id, updatedData) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const tipId = await this.resolveCurrentId(id); + const oldMsg = await getMsg(ssbClient, tipId); + + if (!oldMsg || oldMsg.content?.type !== "bookmark") throw new Error("Bookmark not found"); + if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author"); + const aggBm = await this.getBookmarkById(id, userId); + if (aggBm && Object.keys(aggBm.opinions || {}).some(k => (aggBm.opinions[k] || 0) > 0)) throw new Error("Cannot edit bookmark after it has received opinions."); + + const url = safeText(updatedData.url || oldMsg.content.url); + if (!url) throw new Error("URL is required"); + + const now = new Date().toISOString(); + + const updated = { + ...oldMsg.content, + replaces: tipId, + url, + tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags), + description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "", + category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "", + lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit), + createdAt: oldMsg.content.createdAt, + updatedAt: now + }; + + const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId }; + await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); + + return new Promise((resolve, reject) => { + ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result))); + }); + }, + + async deleteBookmarkById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const tipId = await this.resolveCurrentId(id); + const msg = await getMsg(ssbClient, tipId); + + if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found"); + if (String(msg.content.author) !== String(userId)) throw new Error("Not the author"); + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }; + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async listAll(filterOrOpts = "all", maybeOpts = {}) { + const ssbClient = await openSsb(); + + const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {}; + const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all"; + const q = safeText(opts.q || "").toLowerCase(); + const sort = safeText(opts.sort || "recent"); + const viewerId = opts.viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const items = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + items.push(buildBookmark(node, rootId, viewerId, idx.resolveGroup(rootId))); + } + + let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null); + const now = Date.now(); + + if (filter === "mine") list = list.filter((b) => String(b.author) === String(viewerId)); + else if (filter === "recent") list = list.filter((b) => new Date(b.createdAt).getTime() >= now - 86400000); + else if (filter === "top") { + list = list + .slice() + .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } + + if (q) { + list = list.filter((b) => { + const url = String(b.url || "").toLowerCase(); + const cat = String(b.category || "").toLowerCase(); + const desc = String(b.description || "").toLowerCase(); + const tags = safeArr(b.tags).join(" ").toLowerCase(); + const author = String(b.author || "").toLowerCase(); + return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q); + }); + } + + if (sort === "top") { + list = list + .slice() + .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } else if (sort === "oldest") { + list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + } else { + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + return list; + }, + + async getBookmarkById(id, viewerId = null) { + const ssbClient = await openSsb(); + const viewer = viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + const rootId = idx.rootOf(id); + const agg = idx.resolveGroup(rootId); + if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Bookmark not found"); + + return buildBookmark(agg.contentNode, rootId, viewer, agg); + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error("Invalid voting category"); + + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const bm = await this.getBookmarkById(id, userId); + if (!bm) throw new Error("Bookmark not found"); + if (safeArr(bm.opinions_inhabitants).includes(userId)) throw new Error("Already voted"); + + const content = { type: "bookmarkOpinion", target: bm.rootId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result))); + }); + } + }; +}; + diff --git a/src/models/calendars_model.js b/src/models/calendars_model.js new file mode 100644 index 0000000..5eb49ce --- /dev/null +++ b/src/models/calendars_model.js @@ -0,0 +1,1084 @@ +const pull = require("../server/node_modules/pull-stream") +const crypto = require("crypto") +const { getConfig } = require("../configs/config-manager.js") +const { buildValidatedTombstoneSet } = require('./tombstone_validator') +const { collabContent, openInviteOf } = require('../backend/collab_content') +const calCollab = collabContent({ membersField: 'participants', undecField: 'encrypted', contentFields: ['title', 'deadline', 'status'], listFields: ['tags', 'invites'] }) +const logLimit = getConfig().ssbLogStream?.limit || 1000 +const INVITE_CODE_BYTES = 16 + +const safeText = (v) => String(v || "").trim() +const normalizeTags = (raw) => { + if (!raw) return [] + if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) + return String(raw).split(",").map(t => t.trim()).filter(Boolean) +} +const hasAnyInterval = (w, m, y) => !!(w || m || y) +const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => { + const start = new Date(firstDate) + const out = [start] + if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out + const end = new Date(deadline).getTime() + const seen = new Set([start.getTime()]) + const walk = (mutate) => { + const n = new Date(start) + mutate(n) + while (n.getTime() <= end) { + const t = n.getTime() + if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) } + mutate(n) + } + } + if (weekly) walk((d) => d.setDate(d.getDate() + 7)) + if (monthly) walk((d) => d.setMonth(d.getMonth() + 1)) + if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1)) + return out.sort((a, b) => a.getTime() - b.getTime()) +} + +module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => { + let ssb + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } + + const ownCrypto = calendarCrypto || tribeCrypto + const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null + const lookupKeys = (rid) => { + const a = (ownCrypto && ownCrypto.getKeys(rid)) || [] + if (a.length) return a + return (tribeCrypto && tribeCrypto.getKeys(rid)) || [] + } + const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0) + + const rotateCalendarKey = async (rootId, remainingMembers) => { + if (!ownCrypto || !tribeCrypto || !rootId) return + const existing = lookupKey(rootId) + if (!existing) return + const newKey = ownCrypto.generateTribeKey() + const newGen = ownCrypto.addNewKey(rootId, newKey) + if (!Array.isArray(remainingMembers) || !remainingMembers.length) return + const ssbClient = await openSsb() + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + for (const m of remainingMembers) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve()) + }) + } + } + + const ingestOwnTribeKeys = async () => { + if (!ownCrypto) return + try { + const ssbClient = await openSsb() + const ssbKeys = require("../server/node_modules/ssb-keys") + const config = require("../server/ssb_config") + const msgs = await readAll(ssbClient) + for (const m of msgs) { + const c = m.value && m.value.content + if (!c || c.type !== "tribe-keys") continue + const memberKeys = c.memberKeys + if (!memberKeys || typeof memberKeys !== "object") continue + const boxed = memberKeys[ssbClient.id] + if (!boxed) continue + try { + const unboxed = ssbKeys.unbox(boxed, config.keys) + const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null) + if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key) + } catch (_) {} + } + } catch (_) {} + } + + const readAll = async (ssbClient) => + new Promise((resolve, reject) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))) + ) + + const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null + const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c + const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c + const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {} + + const encryptStandalone = (content, rootId) => { + if (!tribeCrypto || !rootId) return content + const key = lookupKey(rootId) + if (!key) return content + return tribeCrypto.encryptContent(content, [key], true) + } + + const decryptCalendarRoot = (content, rootId) => { + if (!content || !content.encryptedPayload) return content + if (!tribeCrypto) return content + const keys = lookupKeys(rootId) + if (!keys || !keys.length) return { ...content, _undecryptable: true } + return tribeCrypto.decryptContent(content, keys.map(k => [k])) + } + + const tryDecryptPublicInviteKey = (invites) => { + if (!tribeCrypto || !Array.isArray(invites)) return null + for (const inv of invites) { + if (!inv || typeof inv !== "object" || inv.public !== true) continue + if (typeof inv.code !== "string") continue + if (typeof inv.ek === "string") { + try { const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt); if (k) return k } catch (_) {} + } + if (typeof inv.ekChain === "string") { + try { const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt); if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key } catch (_) {} + } + } + return null + } + + const decryptIndexNodes = async (idx) => { + if (!tribeCrypto) return + for (const [k, n] of idx.nodes.entries()) { + if (!n.c || !n.c.encryptedPayload) continue + let root = k + while (idx.parent.has(root)) root = idx.parent.get(root) + let dec = null + if (n.c.tribeId && tribesModel) { + try { + const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel) + if (r && !r._undecryptable) dec = r + } catch (_) {} + } + if (!dec) { + const r = decryptCalendarRoot(n.c, root) + if (r && !r._undecryptable) dec = r + } + if (!dec) { + const pubKey = tryDecryptPublicInviteKey(n.c.invites) + if (pubKey) { + try { const r = tribeCrypto.decryptContent(n.c, [[pubKey]]); if (r && !r._undecryptable) dec = r } catch (_) {} + } + } + if (dec) { + idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } }) + } else { + idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } }) + } + } + } + + const buildIndex = (messages) => { + const tomb = new Set() + const nodes = new Map() + const parent = new Map() + const child = new Map() + const strictChild = new Map() + const authorByKey = new Map() + const tombRequests = [] + const participantMsgs = [] + + for (const m of messages) { + const k = m.key + const v = m.value || {} + const c = v.content + if (!c) continue + if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue } + if (c.type === "calendarParticipant" && c.target) { participantMsgs.push({ target: c.target, author: v.author, on: c.on !== false, ts: v.timestamp || m.timestamp || 0 }); continue } + if (c.type === "calendar") { + nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + authorByKey.set(k, v.author) + if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) } + } + } + + for (const t of tombRequests) { + const targetAuthor = authorByKey.get(t.target) + if (targetAuthor && t.author === targetAuthor) tomb.add(t.target) + } + + for (const [k, node] of nodes.entries()) { + const t = node.c.replaces + if (!t) continue + const orig = nodes.get(t) + if (orig && orig.author === node.author) strictChild.set(t, k) + } + + const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur } + const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur } + const contentTipOf = (root) => { + const rn = nodes.get(root) + if (!rn) return root + let cur = root + let best = root + const seen = new Set() + while (child.has(cur) && !seen.has(cur)) { + seen.add(cur) + const next = child.get(cur) + const n = nodes.get(next) + if (!n) break + if (n.author === rn.author && !tomb.has(next)) best = next + cur = next + } + return best + } + + const roots = new Set() + for (const id of nodes.keys()) roots.add(rootOf(id)) + + const participantsByRoot = new Map() + for (const pm of participantMsgs) { + if (!nodes.has(pm.target)) continue + const r = rootOf(pm.target) + if (!participantsByRoot.has(r)) participantsByRoot.set(r, new Map()) + const m2 = participantsByRoot.get(r) + const prev = m2.get(pm.author) + if (!prev || pm.ts >= prev.ts) m2.set(pm.author, { on: pm.on, ts: pm.ts }) + } + + const resolveParticipants = (root) => { + const ownerNode = nodes.get(root) + const oc = ownerNode ? ownerNode.c : {} + const set = new Set(Array.isArray(oc.participants) ? oc.participants : []) + for (const [au, st] of (participantsByRoot.get(root) || new Map())) { + if (st.on) set.add(au); else set.delete(au) + } + return [...set] + } + + const tipByRoot = new Map() + for (const r of roots) tipByRoot.set(r, tipOf(r)) + const contentTipByRoot = new Map() + for (const r of roots) contentTipByRoot.set(r, contentTipOf(r)) + + return { tomb, nodes, parent, child, rootOf, tipOf, contentTipOf, tipByRoot, contentTipByRoot, resolveParticipants } + } + + const buildCalendar = (node, rootId, participants) => { + const c = node.c || {} + if (c.type !== "calendar") return null + const undec = c.encryptedPayload && c._decrypted === false + return { + key: node.key, + rootId, + title: undec ? "" : safeText(c.title), + status: c.status || "OPEN", + deadline: undec ? "" : (c.deadline || ""), + tags: Array.isArray(c.tags) ? c.tags : [], + author: c.author || node.author, + participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []), + invites: Array.isArray(c.invites) ? c.invites : [], + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + tribeId: c.tribeId || null, + encrypted: !!undec + } + } + + + const isClosed = (calendar) => { + if (calendar.status === "CLOSED") return true + if (!calendar.deadline) return false + return new Date(calendar.deadline).getTime() <= Date.now() + } + + const collectCalendars = async () => { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + await decryptIndexNodes(idx) + const items = [] + for (const rootId of idx.contentTipByRoot.keys()) { + const contentTip = idx.contentTipByRoot.get(rootId) || rootId + if (idx.tomb.has(contentTip)) continue + const node = idx.nodes.get(contentTip) + if (!node || node.c.type !== "calendar") continue + const cal = buildCalendar(node, rootId, idx.resolveParticipants(rootId)) + if (!cal) continue + cal.isClosed = isClosed(cal) + items.push(cal) + } + return items + } + + return { + type: "calendar", + + async ingestKeys() { await ingestOwnTribeKeys() }, + + async pruneOrphanKeys() { + if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0 + try { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const live = new Set() + const tomb = buildValidatedTombstoneSet(messages) + for (const m of messages) { + const c = m.value && m.value.content + if (!c) continue + if (c.type === "calendar") live.add(m.key) + } + const all = ownCrypto.getAllRootIds() + let removed = 0 + for (const rid of all) { + if (!live.has(rid) || tomb.has(rid)) { + try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {} + } + } + return removed + } catch (_) { return 0 } + }, + + async resolveRootId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + let tip = id + while (idx.child.has(tip)) tip = idx.child.get(tip) + if (idx.tomb.has(tip)) throw new Error("Not found") + let root = tip + while (idx.parent.has(root)) root = idx.parent.get(root) + return root + }, + + async resolveCurrentId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + let tip = id + while (idx.child.has(tip)) tip = idx.child.get(tip) + if (idx.tomb.has(tip)) throw new Error("Not found") + return tip + }, + + async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const now = new Date().toISOString() + const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN" + + if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future") + if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future") + + let plainContent = { + type: "calendar", + title: safeText(title), + status: validStatus, + deadline: deadline || "", + tags: normalizeTags(tags), + author: userId, + participants: [userId], + invites: [], + createdAt: now, + updatedAt: now, + ...(tribeId ? { tribeId } : {}) + } + + let calKey = null + let content = plainContent + if (tribeId) { + content = await encryptIfTribe(plainContent) + } else if (tribeCrypto) { + calKey = ownCrypto.generateTribeKey() + content = tribeCrypto.encryptContent(plainContent, [calKey], true) + } + + const calMsg = await new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + + const calendarId = calMsg.key + + if (calKey && tribeCrypto) { + ownCrypto.setKey(calendarId, calKey, 1) + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const boxedKey = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: calendarId, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve()) + }) + } catch (_) {} + if (validStatus === "OPEN") { + try { + const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") + const inviteSalt = tribeCrypto.generateInviteSalt() + const ek = tribeCrypto.encryptForInvite(calKey, pubCode, inviteSalt) + const tipId = await this.resolveCurrentId(calendarId) + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + const dec = decryptCalendarRoot(item.content, calendarId) + let updated = { + type: "calendar", + title: dec.title || "", + status: validStatus, + deadline: dec.deadline || "", + tags: Array.isArray(dec.tags) ? dec.tags : [], + author: userId, + participants: [userId], + invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }], + createdAt: dec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + updated = encryptStandalone(updated, calendarId) + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve())) + } catch (_) {} + } + } + + let dateContent = { + type: "calendarDate", + calendarId, + date: new Date(firstDate).toISOString(), + label: safeText(firstDateLabel), + author: userId, + createdAt: new Date().toISOString(), + ...(intervalWeekly ? { intervalWeekly: true } : {}), + ...(intervalMonthly ? { intervalMonthly: true } : {}), + ...(intervalYearly ? { intervalYearly: true } : {}), + ...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}), + ...(tribeId ? { tribeId } : {}) + } + if (tribeId) dateContent = await encryptIfTribe(dateContent) + else if (calKey) dateContent = tribeCrypto.encryptContent(dateContent, [calKey], true) + const dateMsg = await new Promise((resolve, reject) => { + ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg)) + }) + + if (firstNote && safeText(firstNote)) { + let noteContent = { + type: "calendarNote", + calendarId, + dateId: dateMsg.key, + text: safeText(firstNote), + author: userId, + createdAt: new Date().toISOString(), + ...(tribeId ? { tribeId } : {}) + } + if (tribeId) noteContent = await encryptIfTribe(noteContent) + else if (calKey) noteContent = tribeCrypto.encryptContent(noteContent, [calKey], true) + await new Promise((resolve, reject) => { + ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) + }) + } + + return calMsg + }, + + async updateCalendarById(id, data) { + const tipId = await this.resolveCurrentId(id) + const rootId = await this.resolveRootId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + const item = await new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it)) + }) + if (!item || !item.content) throw new Error("Calendar not found") + const oldDec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, rootId) + assertReadable(oldDec, "Calendar") + if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author") + let updated = { + type: "calendar", + title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""), + status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"), + deadline: data.deadline !== undefined ? data.deadline : (oldDec.deadline || ""), + tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []), + author: oldDec.author || userId, + participants: oldDec.participants || [userId], + invites: Array.isArray(oldDec.invites) ? oldDec.invites : [], + ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), + createdAt: oldDec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, rootId) + const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) + return result + }, + + async deleteCalendarById(id) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + if (!item || !item.content) throw new Error("Calendar not found") + const dec = await decryptIfTribe(item.content) + assertReadable(dec, "Calendar") + const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author) + if (contentAuthor !== userId) throw new Error("Not the author") + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) + }, + + async joinCalendar(calendarId) { + const rootId = await this.resolveRootId(calendarId) + const ssbClient = await openSsb() + const userId = ssbClient.id + const cal = await this.getCalendarById(rootId) + if (!cal) throw new Error("Calendar not found") + const participants = Array.isArray(cal.participants) ? cal.participants : [] + if (participants.includes(userId)) return + if (tribeCrypto && Array.isArray(cal.invites)) { + const pub = cal.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain)) + if (pub) return await this.joinByInvite(pub.code) + } + const content = { type: "calendarParticipant", target: rootId, on: true, createdAt: new Date().toISOString() } + return new Promise((resolve, reject) => ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res))) + }, + + async leaveCalendar(calendarId) { + const rootId = await this.resolveRootId(calendarId) + const ssbClient = await openSsb() + const userId = ssbClient.id + const cal = await this.getCalendarById(rootId) + if (!cal) throw new Error("Calendar not found") + if (cal.author === userId) throw new Error("Author cannot leave") + const participants = Array.isArray(cal.participants) ? cal.participants : [] + if (!participants.includes(userId)) return + const content = { type: "calendarParticipant", target: rootId, on: false, createdAt: new Date().toISOString() } + const result = await new Promise((resolve, reject) => ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res))) + try { await rotateCalendarKey(rootId, participants.filter(p => p !== userId)) } catch (_) {} + return result + }, + + async findCalendarByLinkText(linkSubstring) { + if (!linkSubstring) return null + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + await decryptIndexNodes(idx) + for (const node of idx.nodes.values()) { + const c = node && node.c + if (!c || c.type !== "calendarNote") continue + if (typeof c.text === "string" && c.text.includes(linkSubstring)) { + return c.calendarId || null + } + } + return null + }, + + async getCalendarById(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + await decryptIndexNodes(idx) + let root = id + while (idx.parent.has(root)) root = idx.parent.get(root) + const contentTip = idx.contentTipOf(root) + if (idx.tomb.has(contentTip)) return null + const node = idx.nodes.get(contentTip) + if (!node || node.c.type !== "calendar") return null + const cal = buildCalendar(node, root, idx.resolveParticipants(root)) + if (!cal) return null + cal.isClosed = isClosed(cal) + return calCollab.fold(cal, await collectCalendars()) + }, + + async listAll({ filter = "all", viewerId } = {}) { + const ssbClient = await openSsb() + const uid = viewerId || ssbClient.id + let list = calCollab.visibleThenCollapsed(await collectCalendars(), uid) + if (filter === "mine") list = list.filter(c => c.author === uid) + else if (filter === "recent") { + const now = Date.now() + list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000) + } + else if (filter === "open") list = list.filter(c => !c.isClosed) + else if (filter === "closed") list = list.filter(c => c.isClosed) + return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + }, + + async addDate(calendarId, date, label, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const rootId = await this.resolveRootId(calendarId) + const cal = await this.getCalendarById(rootId) + if (!cal) throw new Error("Calendar not found") + if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar") + if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future") + + const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) + const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : "" + let dateContent = { + type: "calendarDate", + calendarId: rootId, + date: new Date(date).toISOString(), + label: safeText(label), + author: userId, + createdAt: new Date().toISOString(), + ...(intervalWeekly ? { intervalWeekly: true } : {}), + ...(intervalMonthly ? { intervalMonthly: true } : {}), + ...(intervalYearly ? { intervalYearly: true } : {}), + ...(ruleDeadline ? { intervalDeadline: ruleDeadline } : {}), + ...(cal.tribeId ? { tribeId: cal.tribeId } : {}) + } + if (cal.tribeId) dateContent = await encryptIfTribe(dateContent) + else dateContent = encryptStandalone(dateContent, rootId) + const msg = await new Promise((resolve, reject) => { + ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m)) + }) + return [msg] + }, + + async getDatesForCalendar(calendarId) { + const rootId = await this.resolveRootId(calendarId) + const cal = await this.getCalendarById(rootId) + const calDeadline = cal && cal.deadline ? cal.deadline : "" + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const authorByKey = new Map() + for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) + const tombstoned = new Set() + for (const m of messages) { + const c = (m.value || {}).content + if (c && c.type === "tombstone" && c.target) { + const targetAuthor = authorByKey.get(c.target) + if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) + } + } + const dates = [] + for (const m of messages) { + if (tombstoned.has(m.key)) continue + const v = m.value || {} + const c = v.content + if (!c || c.type !== "calendarDate") continue + if (c.calendarId !== rootId) continue + let dec = c + if (c.encryptedPayload && tribeCrypto) { + if (c.tribeId && tribesModel) { + const r = await tribeCrypto.decryptFromTribe(c, tribesModel) + dec = r && !r._undecryptable ? r : c + if (r && r._undecryptable) continue + } else { + const keys = lookupKeys(c.calendarId) + if (keys && keys.length) { + const r = tribeCrypto.decryptContent(c, keys.map(k => [k])) + dec = r && !r._undecryptable ? r : c + if (r && r._undecryptable) continue + } + } + } + const baseEntry = { + key: m.key, + calendarId: dec.calendarId || c.calendarId, + label: dec.label || "", + author: dec.author || v.author, + createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString() + } + const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly) + const ruleDeadline = dec.intervalDeadline || calDeadline + if (hasInterval && ruleDeadline) { + const occurrences = expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly) + for (const occ of occurrences) { + dates.push({ ...baseEntry, date: occ.toISOString() }) + } + } else { + dates.push({ ...baseEntry, date: dec.date }) + } + } + dates.sort((a, b) => new Date(a.date) - new Date(b.date)) + return dates + }, + + async deleteDate(dateId, calendarId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const rootId = await this.resolveRootId(calendarId) + const cal = await this.getCalendarById(rootId) + if (!cal) throw new Error("Calendar not found") + const messages = await readAll(ssbClient) + const authorByKey = new Map() + for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) + const tombstoned = new Set() + for (const m of messages) { + const c = (m.value || {}).content + if (c && c.type === "tombstone" && c.target) { + const targetAuthor = authorByKey.get(c.target) + if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) + } + } + let dateAuthor = null + for (const m of messages) { + if (m.key !== dateId) continue + const c = (m.value || {}).content + if (!c || c.type !== "calendarDate") continue + if (tombstoned.has(m.key)) break + let dec = c + if (c.encryptedPayload && tribeCrypto && tribesModel) { + const r = await tribeCrypto.decryptFromTribe(c, tribesModel) + if (r && !r._undecryptable) dec = r + } + dateAuthor = dec.author || (m.value || {}).author + break + } + if (!dateAuthor) throw new Error("Date not found") + if (dateAuthor !== userId && cal.author !== userId) throw new Error("Not authorized") + for (const m of messages) { + const c = (m.value || {}).content + if (!c || c.type !== "calendarNote") continue + if (tombstoned.has(m.key)) continue + if (c.dateId !== dateId) continue + await new Promise((resolve, reject) => { + ssbClient.publish({ type: "tombstone", target: m.key, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve()) + }) + } + return new Promise((resolve, reject) => { + ssbClient.publish({ type: "tombstone", target: dateId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve()) + }) + }, + + async addNote(calendarId, dateId, text) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const rootId = await this.resolveRootId(calendarId) + const cal = await this.getCalendarById(rootId) + if (!cal) throw new Error("Calendar not found") + if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes") + let noteContent = { + type: "calendarNote", + calendarId: rootId, + dateId, + text: safeText(text), + author: userId, + createdAt: new Date().toISOString(), + ...(cal.tribeId ? { tribeId: cal.tribeId } : {}) + } + if (cal.tribeId) noteContent = await encryptIfTribe(noteContent) + else noteContent = encryptStandalone(noteContent, rootId) + return new Promise((resolve, reject) => { + ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) + }) + }, + + async deleteNote(noteId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it))) + if (!item || !item.content) throw new Error("Note not found") + const dec = await decryptIfTribe(item.content) + if ((dec.author || item.content.author) !== userId) throw new Error("Not the author") + return new Promise((resolve, reject) => { + ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg)) + }) + }, + + async getNotesForDate(calendarId, dateId) { + const rootId = await this.resolveRootId(calendarId) + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const authorByKey = new Map() + for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) + const tombstoned = new Set() + for (const m of messages) { + const c = (m.value || {}).content + if (c && c.type === "tombstone" && c.target) { + const targetAuthor = authorByKey.get(c.target) + if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) + } + } + const notes = [] + for (const m of messages) { + const v = m.value || {} + const c = v.content + if (!c || c.type !== "calendarNote") continue + if (tombstoned.has(m.key)) continue + if (c.calendarId !== rootId || c.dateId !== dateId) continue + let dec = c + if (c.encryptedPayload && tribeCrypto && tribesModel) { + const r = await tribeCrypto.decryptFromTribe(c, tribesModel) + if (r && !r._undecryptable) dec = r + else continue + } + notes.push({ + key: m.key, + calendarId: dec.calendarId || c.calendarId, + dateId: dec.dateId || c.dateId, + text: dec.text || "", + author: dec.author || v.author, + createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString() + }) + } + notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + return notes + }, + + async checkDueReminders() { + if (!pmModel) return + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const now = Date.now() + + const sentMarkers = new Set() + for (const m of messages) { + const c = (m.value || {}).content + if (!c || c.type !== "calendarReminderSent") continue + const sig = c.occurrence ? `${c.calendarId}::${c.dateId}::${c.occurrence}` : `${c.calendarId}::${c.dateId}` + sentMarkers.add(sig) + } + + const authorByKey = new Map() + for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) + const tombstoned = new Set() + for (const m of messages) { + const c = (m.value || {}).content + if (c && c.type === "tombstone" && c.target) { + const targetAuthor = authorByKey.get(c.target) + if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) + } + } + + const calendarDeadlines = new Map() + const dueByCalendar = new Map() + for (const m of messages) { + if (tombstoned.has(m.key)) continue + const v = m.value || {} + const c = v.content + if (!c || c.type !== "calendarDate") continue + let dec = c + if (c.encryptedPayload && tribeCrypto && tribesModel) { + const r = await tribeCrypto.decryptFromTribe(c, tribesModel) + if (!r || r._undecryptable) continue + dec = r + } + if (!dec.date) continue + const calId = c.calendarId + let calDeadline = calendarDeadlines.get(calId) + if (calDeadline === undefined) { + try { + const cc = await this.getCalendarById(calId) + calDeadline = (cc && cc.deadline) || "" + } catch (_) { calDeadline = "" } + calendarDeadlines.set(calId, calDeadline) + } + const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly) + const ruleDeadline = dec.intervalDeadline || calDeadline + const occurrences = (hasInterval && ruleDeadline) + ? expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly) + : [new Date(dec.date)] + for (const occ of occurrences) { + if (occ.getTime() > now) continue + const occIso = occ.toISOString() + const sig = hasInterval ? `${calId}::${m.key}::${occIso}` : `${calId}::${m.key}` + if (sentMarkers.has(sig)) continue + const entry = { key: m.key, calendarId: calId, date: occIso, label: dec.label || "", recurring: hasInterval } + const list = dueByCalendar.get(calId) || [] + list.push(entry) + dueByCalendar.set(calId, list) + } + } + + const publishMarker = (calendarId, dateId, occurrence) => new Promise((resolve, reject) => { + const payload = { + type: "calendarReminderSent", + calendarId, + dateId, + sentAt: new Date().toISOString() + } + if (occurrence) payload.occurrence = occurrence + ssbClient.publish(payload, (err) => err ? reject(err) : resolve()) + }) + + for (const [calendarId, list] of dueByCalendar.entries()) { + try { + list.sort((a, b) => new Date(b.date) - new Date(a.date)) + const primary = list[0] + const cal = await this.getCalendarById(calendarId) + if (!cal) continue + const participants = cal.participants.filter(p => typeof p === "string" && p.length > 0) + if (participants.length > 0) { + const notesForDay = await this.getNotesForDate(calendarId, primary.key) + const notesBlock = notesForDay.length > 0 + ? notesForDay.map(n => ` - ${n.text}`).join("\n\n") + : " (no notes)" + const subject = `Calendar Reminder: ${cal.title}` + const text = + `Reminder from: ${cal.author}\n` + + `Title: ${cal.title}\n` + + `Date: ${primary.label || primary.date}\n\n` + + `Notes for this day:\n\n${notesBlock}\n\n` + + `Visit Calendar: /calendars/${cal.rootId}` + const chunkSize = 6 + for (let i = 0; i < participants.length; i += chunkSize) { + await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text) + } + } + for (const dd of list) { + try { await publishMarker(calendarId, dd.key, dd.recurring ? dd.date : null) } catch (_) {} + } + } catch (_) {} + } + }, + + async generateInvite(calendarId, opts = {}) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const cal = await this.getCalendarById(calendarId) + if (!cal) throw new Error("Calendar not found") + if (cal.author !== userId) throw new Error("Only the author can generate invites") + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") + let invite = code + const pubFlag = opts.public ? { public: true } : {} + if (tribeCrypto && !cal.tribeId) { + const inviteSalt = tribeCrypto.generateInviteSalt() + const ekChain = tribeCrypto.encryptChainForInvite([cal.rootId], code, inviteSalt) + if (ekChain) invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(cal.rootId), ...pubFlag } + } + if (opts.public && typeof invite !== "object") invite = { code, public: true } + const tipId = await this.resolveCurrentId(calendarId) + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + const dec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, cal.rootId) + const invites = [...(Array.isArray(dec.invites) ? dec.invites : []), invite] + let updated = { + type: "calendar", + title: dec.title || "", + status: dec.status || "OPEN", + deadline: dec.deadline || "", + tags: Array.isArray(dec.tags) ? dec.tags : [], + author: dec.author, + participants: Array.isArray(dec.participants) ? dec.participants : [userId], + invites, + ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), + createdAt: dec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, cal.rootId) + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) + return code + }, + + async getOpenInvite(calendarId) { + return openInviteOf(await this.getCalendarById(calendarId).catch(() => null)) + }, + + async generateOpenInvite(calendarId) { + const cal = await this.getCalendarById(calendarId) + if (!cal) throw new Error("Calendar not found") + const existing = (Array.isArray(cal.invites) ? cal.invites : []).find(inv => typeof inv === "object" && inv.public === true) + if (existing) throw new Error("An open invitation already exists") + return this.generateInvite(calendarId, { public: true }) + }, + + async removeOpenInvite(calendarId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const cal = await this.getCalendarById(calendarId) + if (!cal) throw new Error("Calendar not found") + if (cal.author !== userId) throw new Error("Only the author can remove invites") + const tipId = await this.resolveCurrentId(calendarId) + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + const dec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, cal.rootId) + const invites = (Array.isArray(dec.invites) ? dec.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true)) + let updated = { + type: "calendar", + title: dec.title || "", + status: dec.status || "OPEN", + deadline: dec.deadline || "", + tags: Array.isArray(dec.tags) ? dec.tags : [], + author: dec.author, + participants: Array.isArray(dec.participants) ? dec.participants : [userId], + invites, + ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), + createdAt: dec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, cal.rootId) + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) + }, + + async joinByInvite(code) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const calendars = await collectCalendars() + let matched = null + let matchedInvite = null + for (const cal of calendars) { + const invs = Array.isArray(cal.invites) ? cal.invites : [] + for (const inv of invs) { + if (typeof inv === "string" && inv === code) { matched = cal; matchedInvite = inv; break } + if (typeof inv === "object" && inv.code === code) { matched = cal; matchedInvite = inv; break } + } + if (matched) break + } + if (!matched) throw new Error("Invalid or expired invite code") + if (matched.participants.includes(userId)) throw new Error("Already a participant") + let calKey = null + if (tribeCrypto && typeof matchedInvite === "object") { + if (matchedInvite.ekChain) { + const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt) + if (Array.isArray(chain) && chain.length) { + for (const entry of chain) { + if (Array.isArray(entry.keys) && entry.keys.length) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length) + } else if (entry.key) { + tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1) + } + } + calKey = chain[0].key + } + } else if (matchedInvite.ek) { + calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt) + ownCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1) + } + } + const tipId = await this.resolveCurrentId(matched.rootId) + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + const dec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, matched.rootId) + const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true + const invites = isPublicInvite + ? (Array.isArray(dec.invites) ? dec.invites : []) + : (Array.isArray(dec.invites) ? dec.invites : []).filter(inv => { + if (typeof inv === "string") return inv !== code + return inv.code !== code + }) + let updated = { + type: "calendar", + title: dec.title || "", + status: dec.status || "OPEN", + deadline: dec.deadline || "", + tags: Array.isArray(dec.tags) ? dec.tags : [], + author: dec.author, + participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId], + invites, + ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), + createdAt: dec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, matched.rootId) + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) + if (tribeCrypto && calKey) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) } catch (_) {} + if (matched.author && matched.author !== userId) { + try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(calKey, matched.author, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: matched.rootId, generation: lookupGen(matched.rootId) || 1, memberKeys }, () => resolve()) + }) + } + } catch (_) {} + } + await new Promise((resolve, reject) => ssbClient.publish({ type: "calendarParticipant", target: matched.rootId, on: true, createdAt: new Date().toISOString() }, (e, res) => e ? reject(e) : resolve(res))) + return matched.rootId + } + } +} diff --git a/src/models/chats_model.js b/src/models/chats_model.js new file mode 100644 index 0000000..f4abc89 --- /dev/null +++ b/src/models/chats_model.js @@ -0,0 +1,875 @@ +const pull = require("../server/node_modules/pull-stream") +const crypto = require("crypto") +const { getConfig } = require("../configs/config-manager.js") +const { buildValidatedTombstoneSet } = require('./tombstone_validator') +const { collabContent, openInviteOf } = require('../backend/collab_content') +const chatCollab = collabContent({ membersField: 'members', undecField: 'undecryptable', contentFields: ['title', 'description', 'image', 'category', 'status'], listFields: ['tags', 'invites'] }) +const logLimit = getConfig().ssbLogStream?.limit || 1000 + +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return [] + if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) + return String(raw).split(",").map(t => t.trim()).filter(Boolean) +} + +const INVITE_CODE_BYTES = 16 +const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"] + +module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => { + let ssb + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } + + const ownCrypto = chatCrypto || tribeCrypto + const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null + const lookupKeys = (rid) => { + const a = (ownCrypto && ownCrypto.getKeys(rid)) || [] + if (a.length) return a + return (tribeCrypto && tribeCrypto.getKeys(rid)) || [] + } + const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0) + + const rotateChatKey = async (rootId, remainingMembers) => { + if (!ownCrypto || !tribeCrypto || !rootId) return + const existing = lookupKey(rootId) + if (!existing) return + const newKey = ownCrypto.generateTribeKey() + const newGen = ownCrypto.addNewKey(rootId, newKey) + if (!Array.isArray(remainingMembers) || !remainingMembers.length) return + const ssbClient = await openSsb() + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + for (const m of remainingMembers) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve()) + }) + } + } + + const ingestOwnTribeKeys = async () => { + if (!ownCrypto) return + try { + const ssbClient = await openSsb() + const ssbKeys = require("../server/node_modules/ssb-keys") + const config = require("../server/ssb_config") + const msgs = await readAll(ssbClient) + for (const m of msgs) { + const c = m.value && m.value.content + if (!c || c.type !== "tribe-keys") continue + const memberKeys = c.memberKeys + if (!memberKeys || typeof memberKeys !== "object") continue + const boxed = memberKeys[ssbClient.id] + if (!boxed) continue + try { + const unboxed = ssbKeys.unbox(boxed, config.keys) + const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null) + if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key) + } catch (_) {} + } + } catch (_) {} + } + + const getTribeKeysFor = async (tribeId) => { + if (!tribeCrypto || !tribesModel || !tribeId) return [] + try { + const rootId = await tribesModel.getRootId(tribeId) + return tribeCrypto.getKeys(rootId) || [] + } catch (_) { return [] } + } + + const getTribeFirstKeyFor = async (tribeId) => { + const ks = await getTribeKeysFor(tribeId) + return ks.length ? ks[0] : null + } + + const readAll = async (ssbClient) => + new Promise((resolve, reject) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))) + ) + + const buildIndex = (messages) => { + const tomb = new Set() + const nodes = new Map() + const parent = new Map() + const child = new Map() + const msgNodes = new Map() + const authorByKey = new Map() + const tombRequests = [] + const memberMsgs = [] + + for (const m of messages) { + const k = m.key + const v = m.value || {} + const c = v.content + if (!c) continue + if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue } + if (c.type === "chatMember" && c.target && c.member) { + memberMsgs.push({ target: c.target, member: c.member, on: c.on !== false, code: typeof c.code === "string" ? c.code : "", author: v.author, ts: v.timestamp || m.timestamp || 0 }) + continue + } + if (c.type === "chat") { + nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + authorByKey.set(k, v.author) + if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) } + } else if (c.type === "chatMessage") { + msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + authorByKey.set(k, v.author) + } + } + + const strictParent = new Map() + const strictChild = new Map() + for (const [c1, p1] of parent.entries()) { + const childNode = nodes.get(c1) + const parentNode = nodes.get(p1) + if (childNode && parentNode && childNode.author === parentNode.author) { + strictParent.set(c1, p1) + strictChild.set(p1, c1) + } + } + + for (const t of tombRequests) { + const targetAuthor = authorByKey.get(t.target) + if (targetAuthor && t.author === targetAuthor) tomb.add(t.target) + } + + const rootOf = (id) => { let cur = id; while (strictParent.has(cur)) cur = strictParent.get(cur); return cur } + const tipOf = (id) => { let cur = id; while (strictChild.has(cur)) cur = strictChild.get(cur); return cur } + + const roots = new Set() + for (const id of nodes.keys()) roots.add(rootOf(id)) + const tipByRoot = new Map() + for (const r of roots) tipByRoot.set(r, tipOf(r)) + + const memberByRoot = new Map() + const consumedByRoot = new Map() + for (const mm of memberMsgs) { + if (!nodes.has(mm.target)) continue + const r = rootOf(mm.target) + if (!memberByRoot.has(r)) memberByRoot.set(r, new Map()) + const perMember = memberByRoot.get(r) + const prev = perMember.get(mm.member) + if (!prev || mm.ts >= prev.ts) perMember.set(mm.member, { on: mm.on, ts: mm.ts }) + if (mm.on && mm.code) { + if (!consumedByRoot.has(r)) consumedByRoot.set(r, new Set()) + consumedByRoot.get(r).add(mm.code) + } + } + + const isCodeConsumed = (rootId, code) => !!(code && consumedByRoot.has(rootId) && consumedByRoot.get(rootId).has(code)) + + const rawRootOf = (id) => { let cur = id, g = 0; while (parent.has(cur) && g++ < 100000) cur = parent.get(cur); return cur } + + return { tomb, nodes, parent: strictParent, child: strictChild, rawParent: parent, rawChild: child, rawRootOf, rootOf, tipOf, tipByRoot, msgNodes, memberByRoot, consumedByRoot, isCodeConsumed } + } + + const resolveKeyChainSets = (chatRootId) => { + if (!tribeCrypto) return [] + const keys = lookupKeys(chatRootId) + return keys.map(k => [k]) + } + + const tryDecryptPublicInviteKey = (invites) => { + if (!tribeCrypto || !Array.isArray(invites)) return null + for (const inv of invites) { + if (!inv || typeof inv !== "object" || inv.public !== true) continue + if (typeof inv.code !== "string") continue + if (typeof inv.ek === "string") { + try { + const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt) + if (k) return k + } catch (_) {} + } + if (typeof inv.ekChain === "string") { + try { + const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt) + if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key + } catch (_) {} + } + } + return null + } + + const decryptChatContent = (rawC, rootId) => { + let c = rawC + let undecryptable = false + if (tribeCrypto && c.encryptedPayload) { + const keyChainSets = resolveKeyChainSets(rootId) + c = tribeCrypto.decryptContent(c, keyChainSets) + undecryptable = !!c._undecryptable + if (undecryptable) { + const pubKey = tryDecryptPublicInviteKey(rawC.invites) + if (pubKey) { + const retry = tribeCrypto.decryptContent(rawC, [[pubKey]]) + if (retry && !retry._undecryptable) { + c = retry + undecryptable = false + } + } + } + } + return { c, undecryptable } + } + + const aggregateMembers = (ownerContent, ownerAuthor, memberToggles) => { + const set = new Set() + if (ownerContent) for (const m of safeArr(ownerContent.members)) set.add(m) + if (ownerAuthor) set.add(ownerAuthor) + if (memberToggles) { + for (const [member, st] of memberToggles.entries()) { + if (member === ownerAuthor) continue + if (st.on) set.add(member) + else set.delete(member) + } + } + return [...set] + } + + const buildChat = (node, rootId, idx) => { + const rawC = node.c || {} + if (rawC.type !== "chat") return null + + const { c, undecryptable } = decryptChatContent(rawC, rootId) + + let ownerContent = c + let ownerAuthor = c.author || node.author + if (idx) { + const ownerNode = idx.nodes.get(rootId) + if (ownerNode) { + const decOwner = decryptChatContent(ownerNode.c || {}, rootId) + ownerContent = decOwner.c + ownerAuthor = decOwner.c.author || ownerNode.author + } + } + const memberToggles = idx && idx.memberByRoot ? idx.memberByRoot.get(rootId) : null + const members = aggregateMembers(ownerContent, ownerAuthor, memberToggles) + + const invites = safeArr(c.invites) + const hasPublicInvite = invites.some(inv => typeof inv === "object" && inv && inv.public === true) + const inferredStatus = c.status || (undecryptable ? (hasPublicInvite ? "OPEN" : "INVITE-ONLY") : "OPEN") + + return { + key: node.key, + rootId, + title: c.title || "", + description: c.description || "", + image: c.image || null, + category: c.category || "", + status: inferredStatus, + tags: safeArr(c.tags), + members, + invites, + author: c.author || node.author, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + encrypted: !!c.encrypted, + tribeId: c.tribeId || null, + undecryptable + } + } + + const buildMessage = (node, chatRootId, tribeKeys = []) => { + const c = node.c || {} + if (c.type !== "chatMessage") return null + + let text = c.text || "" + if (tribeCrypto && c.encryptedText) { + const candidateKeys = [...tribeKeys, ...lookupKeys(chatRootId)] + for (const keyHex of candidateKeys) { + try { + text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex) + break + } catch (_) {} + } + } + + return { + key: node.key, + chatId: c.chatId || "", + text, + image: c.image || null, + author: c.author || node.author, + createdAt: c.createdAt || new Date(node.ts).toISOString() + } + } + + const publishTombstone = async (ssbClient, tipId) => + new Promise((resolve, reject) => { + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id } + ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve()) + }) + + const ensureMemberKeys = async (ssbClient, messages, chats) => { + if (!tribeCrypto) return + const distributed = new Map() + for (const m of messages) { + const c = m.value && m.value.content + if (!c || c.type !== "tribe-keys" || !c.tribeId) continue + const mk = c.memberKeys + if (!mk || typeof mk !== "object") continue + if (!distributed.has(c.tribeId)) distributed.set(c.tribeId, new Set()) + for (const id of Object.keys(mk)) distributed.get(c.tribeId).add(id) + } + const ssbKeys = require("../server/node_modules/ssb-keys") + for (const chat of safeArr(chats)) { + if (!chat || chat.undecryptable) continue + const rootId = chat.rootId + if (!rootId) continue + const key = lookupKey(rootId) + if (!key) continue + const have = distributed.get(rootId) || new Set() + const missing = safeArr(chat.members).filter(m => m && m !== ssbClient.id && !have.has(m)) + if (!missing.length) continue + const memberKeys = {} + for (const m of missing) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {} + } + if (!Object.keys(memberKeys).length) continue + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve()) + }) + } + } + + const publishMemberToggle = async (ssbClient, rootId, member, on, code) => + new Promise((resolve, reject) => { + const content = { type: "chatMember", target: rootId, member, on: !!on, createdAt: new Date().toISOString() } + if (typeof code === "string" && code) content.code = code + ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res)) + }) + + const collectChats = (idx) => { + const out = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "chat") continue + const chat = buildChat(node, rootId, idx) + if (chat) out.push(chat) + } + return out + } + + return { + type: "chat", + + async resolveRootId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + let tip = id + while (idx.child.has(tip)) tip = idx.child.get(tip) + if (idx.tomb.has(tip)) throw new Error("Not found") + let root = tip + while (idx.parent.has(root)) root = idx.parent.get(root) + return root + }, + + async resolveCurrentId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + let tip = id + while (idx.child.has(tip)) tip = idx.child.get(tip) + if (idx.tomb.has(tip)) throw new Error("Not found") + return tip + }, + + async createChat(title, description, image, category, status, tagsRaw, tribeId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const blobId = image ? String(image).trim() || null : null + const tags = normalizeTags(tagsRaw) + const st = VALID_STATUS.includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN" + const now = new Date().toISOString() + + let content = { + type: "chat", + title: safeText(title), + description: safeText(description), + image: blobId, + category: safeText(category), + status: st, + tags, + members: [userId], + invites: [], + author: userId, + createdAt: now, + updatedAt: now, + ...(tribeId ? { tribeId } : {}) + } + + if (!tribeCrypto) { + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + } + + if (tribeId) { + try { + const ancestryIds = await tribesModel.getAncestryChain(tribeId) + const chain = [] + for (const rid of ancestryIds || []) { + const k = tribeCrypto.getKey(rid) + if (!k) { chain.length = 0; break } + chain.push(k) + } + if (chain.length) content = tribeCrypto.encryptContent(content, chain, true) + } catch (_) {} + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + } + + if (st === "OPEN") { + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + } + + const chatKey = ownCrypto.generateTribeKey() + content = tribeCrypto.encryptContent(content, [chatKey], true) + const result = await new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + ownCrypto.setKey(result.key, chatKey, 1) + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve()) + }) + } catch (_) {} + return result + }, + + async updateChatById(id, data) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + + const item = await new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item)) + }) + const c = item.content + const rawAuthor = c.author || (c.encryptedPayload ? null : undefined) + if (rawAuthor && rawAuthor !== userId) throw new Error("Not the author") + + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + let rootId = tipId + while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId) + const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 } + const chat = buildChat(node, rootId, idx) + if (!chat) throw new Error("Invalid chat") + + const ownerNode = idx.nodes.get(rootId) + const ownerContent = ownerNode ? decryptChatContent(ownerNode.c || {}, rootId).c : {} + const persistedMembers = safeArr(ownerContent.members) + + let updated = { + type: "chat", + replaces: tipId, + title: data.title !== undefined ? safeText(data.title) : chat.title, + description: data.description !== undefined ? safeText(data.description) : chat.description, + image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image, + category: data.category !== undefined ? safeText(data.category) : chat.category, + status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status, + tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags, + members: persistedMembers, + invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites, + author: chat.author, + createdAt: chat.createdAt, + updatedAt: new Date().toISOString(), + ...(chat.tribeId ? { tribeId: chat.tribeId } : {}) + } + + if (tribeCrypto) { + if (chat.tribeId) { + try { + const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId) + const chain = [] + for (const rid of ancestryIds || []) { + const k = tribeCrypto.getKey(rid) + if (!k) { chain.length = 0; break } + chain.push(k) + } + if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true) + } catch (_) {} + } else { + const chatKey = lookupKey(rootId) + if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true) + } + } + + return new Promise((resolve, reject) => { + ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => { + if (e1) return reject(e1) + ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res)) + }) + }) + }, + + async deleteChatById(id) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item?.content) return reject(new Error("Chat not found")) + if (item.content.author && item.content.author !== userId) return reject(new Error("Not the author")) + ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve()) + }) + }) + }, + + async closeChatById(id) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + let tip = id + while (idx.child.has(tip)) tip = idx.child.get(tip) + if (idx.tomb.has(tip)) throw new Error("Not found") + let root = tip + while (idx.parent.has(root)) root = idx.parent.get(root) + + const node = idx.nodes.get(tip) + if (!node) throw new Error("Not found") + const chat = buildChat(node, root, idx) + if (!chat) throw new Error("Invalid chat") + if (chat.author !== userId) throw new Error("Not the author") + + const updated = { + type: "chat", + replaces: tip, + title: chat.title, + description: chat.description, + image: chat.image, + category: chat.category, + status: "CLOSED", + tags: chat.tags, + members: chat.members, + invites: chat.invites, + author: chat.author, + createdAt: chat.createdAt, + updatedAt: new Date().toISOString() + } + + await publishTombstone(ssbClient, tip) + return new Promise((resolve, reject) => { + ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)) + }) + }, + + async getChatById(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + let tip = id + while (idx.child.has(tip)) tip = idx.child.get(tip) + if (idx.tomb.has(tip)) return null + + const node = idx.nodes.get(tip) + if (!node || node.c.type !== "chat") return null + + let root = tip + while (idx.parent.has(root)) root = idx.parent.get(root) + + const chat = buildChat(node, root, idx) + if (!chat) return null + return chatCollab.fold(chat, collectChats(idx)) + }, + + async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) { + const ssbClient = await openSsb() + const uid = viewerId || ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const now = Date.now() + + let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid) + + if (filter === "mine") list = list.filter(c => c.author === uid) + else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000) + else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY") + else if (filter === "closed") list = list.filter(c => c.status === "CLOSED") + + if (q) { + const qq = q.toLowerCase() + list = list.filter(c => { + const t = String(c.title || "").toLowerCase() + const d = String(c.description || "").toLowerCase() + const cat = String(c.category || "").toLowerCase() + const tags = safeArr(c.tags).join(" ").toLowerCase() + return t.includes(qq) || d.includes(qq) || cat.includes(qq) || tags.includes(qq) + }) + } + + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + try { await ensureMemberKeys(ssbClient, messages, list) } catch (_) {} + return list + }, + + async generateInvite(chatId, opts = {}) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const chat = await this.getChatById(chatId) + if (!chat) throw new Error("Chat not found") + if (chat.author !== userId) throw new Error("Only the author can generate invites") + + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") + let invite = code + const pubFlag = opts.public ? { public: true } : {} + + if (tribeCrypto) { + const inviteSalt = tribeCrypto.generateInviteSalt() + const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code, inviteSalt) + if (ekChain) { + invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId), ...pubFlag } + } else { + const chatKey = lookupKey(chat.rootId) + if (chatKey) { + const ek = tribeCrypto.encryptForInvite(chatKey, code, inviteSalt) + invite = { code, ek, salt: inviteSalt, gen: lookupGen(chat.rootId), ...pubFlag } + } + } + } + if (opts.public && typeof invite !== "object") invite = { code, public: true } + + const invites = [...chat.invites, invite] + await this.updateChatById(chatId, { invites, members: chat.members, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }) + return code + }, + + async getOpenInvite(chatId) { + return openInviteOf(await this.getChatById(chatId).catch(() => null)) + }, + + async generateOpenInvite(chatId) { + const chat = await this.getChatById(chatId) + if (!chat) throw new Error("Chat not found") + const existing = (Array.isArray(chat.invites) ? chat.invites : []).find(inv => typeof inv === "object" && inv.public === true) + if (existing) throw new Error("An open invitation already exists") + return this.generateInvite(chatId, { public: true }) + }, + + async removeOpenInvite(chatId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const chat = await this.getChatById(chatId) + if (!chat) throw new Error("Chat not found") + if (chat.author !== userId) throw new Error("Only the author can remove invites") + const invites = (Array.isArray(chat.invites) ? chat.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true)) + await this.updateChatById(chatId, { invites, members: chat.members, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }) + }, + + async joinByInvite(code) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + let matchedChat = null + let matchedInvite = null + + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "chat") continue + const chat = buildChat(node, rootId, idx) + if (!chat || !chat.invites.length) continue + + for (const inv of chat.invites) { + if (typeof inv === "string" && inv === code) { + matchedChat = chat; matchedInvite = inv; break + } + if (typeof inv === "object" && inv.code === code) { + matchedChat = chat; matchedInvite = inv; break + } + } + if (matchedChat) break + } + + if (!matchedChat) throw new Error("Invalid or expired invite code") + if (matchedChat.members.includes(userId)) throw new Error("Already a participant") + + const isPublic = typeof matchedInvite === "object" && matchedInvite.public === true + if (!isPublic && idx.isCodeConsumed(matchedChat.rootId, code)) throw new Error("Invite already used") + + let chatKey = null + if (tribeCrypto && typeof matchedInvite === "object") { + if (matchedInvite.ekChain) { + const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt) + if (Array.isArray(chain) && chain.length) { + for (const entry of chain) { + if (Array.isArray(entry.keys) && entry.keys.length) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length) + } else if (entry.key) { + tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1) + } + } + chatKey = chain[0].key + } + } else if (matchedInvite.ek) { + chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt) + ownCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1) + } + } + + await publishMemberToggle(ssbClient, matchedChat.rootId, userId, true, isPublic ? undefined : code) + + if (tribeCrypto && chatKey) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {} + if (matchedChat.author && matchedChat.author !== userId) { + try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: lookupGen(matchedChat.rootId) || 1, memberKeys }, () => resolve()) + }) + } + } catch (_) {} + } + + return matchedChat.key + }, + + async joinChat(chatId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const chat = await this.getChatById(chatId) + if (!chat) throw new Error("Chat not found") + if (chat.status === "CLOSED") throw new Error("Chat is closed") + if (chat.members.includes(userId)) return chat.key + + if (tribeCrypto && Array.isArray(chat.invites)) { + const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain)) + if (pub) return await this.joinByInvite(pub.code) + } + + await publishMemberToggle(ssbClient, chat.rootId, userId, true) + return chat.key + }, + + async leaveChat(chatId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const chat = await this.getChatById(chatId) + if (!chat) throw new Error("Chat not found") + if (chat.author === userId) throw new Error("Author cannot leave their own chat") + const members = chat.members.filter(m => m !== userId) + await publishMemberToggle(ssbClient, chat.rootId, userId, false) + try { await rotateChatKey(chat.rootId, members) } catch (_) {} + }, + + async ingestKeys() { await ingestOwnTribeKeys() }, + + async pruneOrphanKeys() { + if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0 + try { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const live = new Set() + for (const m of messages) { + const c = m.value && m.value.content + if (!c) continue + if (c.type === "chat") live.add(m.key) + } + const tomb = buildValidatedTombstoneSet(messages) + const all = ownCrypto.getAllRootIds() + let removed = 0 + for (const rid of all) { + if (!live.has(rid) || tomb.has(rid)) { + try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {} + } + } + return removed + } catch (_) { return 0 } + }, + + async sendMessage(chatId, text, image = null) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const chat = await this.getChatById(chatId) + if (!chat) throw new Error("Chat not found") + if (chat.status === "CLOSED") throw new Error("Chat is closed") + if (!chat.members.includes(userId)) { + if (chat.status === "OPEN") await this.joinChat(chatId) + else throw new Error("Not a participant") + } + + const messages = await readAll(ssbClient) + const oneHourAgo = Date.now() - 60 * 60 * 1000 + const recentCount = messages.filter(m => { + const c = m.value?.content + return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo + }).length + if (recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour") + + const now = new Date().toISOString() + let content = { + type: "chatMessage", + chatId: chat.rootId, + author: userId, + createdAt: now + } + if (image) content.image = image + + const chatIsEncrypted = !!(chat.tribeId) || !!lookupKey(chat.rootId) + if (chatIsEncrypted && tribeCrypto) { + let encKey = null + if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId) + if (!encKey) encKey = lookupKey(chat.rootId) + if (!encKey) throw new Error(`Missing chat key for ${chat.rootId} — cannot send message`) + content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey) + if (chat.tribeId) content.tribeId = chat.tribeId + } else { + content.text = safeText(text) + } + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + }, + + async listMessages(chatRootId) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + let tribeId = null + const tipId = idx.tipByRoot.get(chatRootId) || chatRootId + const chatNode = idx.nodes.get(tipId) || idx.nodes.get(chatRootId) + if (chatNode?.c?.tribeId) tribeId = chatNode.c.tribeId + const tribeKeys = tribeId ? await getTribeKeysFor(tribeId) : [] + + const wantRoot = idx.rawRootOf(chatRootId) + const result = [] + for (const [k, node] of idx.msgNodes.entries()) { + const cid = node.c.chatId + if (cid !== chatRootId && idx.rawRootOf(cid) !== wantRoot) continue + const msg = buildMessage(node, chatRootId, tribeKeys) + if (msg) result.push(msg) + } + + result.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + return result + }, + + async getParticipants(chatRootId) { + const chat = await this.getChatById(chatRootId) + if (!chat) return [] + return chat.members + } + } +} diff --git a/src/models/cipher_model.js b/src/models/cipher_model.js new file mode 100644 index 0000000..40ec6f5 --- /dev/null +++ b/src/models/cipher_model.js @@ -0,0 +1,52 @@ +const crypto = require('crypto'); + +function generateKeyFromPassword(password, salt) { + return crypto.scryptSync(password, salt, 32); +} + +function encryptText(text, password) { + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const key = generateKeyFromPassword(password, salt); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + let encryptedText = cipher.update(text, 'utf-8', 'hex'); + encryptedText += cipher.final('hex'); + const authTag = cipher.getAuthTag().toString('hex'); + const ivHex = iv.toString('hex'); + const saltHex = salt.toString('hex'); + return { encryptedText, iv: ivHex, salt: saltHex, authTag }; +} + +function decryptText(encryptedText, password, ivHex, saltHex, authTagHex) { + const salt = Buffer.from(saltHex, 'hex'); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const key = generateKeyFromPassword(password, salt); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + let decryptedText = decipher.update(encryptedText, 'hex', 'utf-8'); + decryptedText += decipher.final('utf-8'); + return decryptedText; +} + +function extractComponents(encryptedText) { + const iv = encryptedText.slice(0, 24); + const salt = encryptedText.slice(24, 56); + const authTag = encryptedText.slice(56, 88); + const encrypted = encryptedText.slice(88); + return { iv, salt, authTag, encrypted }; +} + +module.exports = { + encryptData: (text, password) => { + const { encryptedText, iv, salt, authTag } = encryptText(text, password); + return { encryptedText: iv + salt + authTag + encryptedText, iv, salt, authTag }; + }, + decryptData: (encryptedText, password) => { + const { iv, salt, authTag, encrypted } = extractComponents(encryptedText); + const decryptedText = decryptText(encrypted, password, iv, salt, authTag); + return decryptedText; + }, + generateKey: (length = 32) => crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length), + extractComponents +}; diff --git a/src/models/courts_model.js b/src/models/courts_model.js new file mode 100644 index 0000000..13b8fde --- /dev/null +++ b/src/models/courts_model.js @@ -0,0 +1,964 @@ +const pull = require('../server/node_modules/pull-stream'); +const moment = require('../server/node_modules/moment'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; +const CASE_ANSWER_DAYS = 7; +const CASE_EVIDENCE_DAYS = 14; +const CASE_DECISION_DAYS = 21; +const POPULAR_DAYS = 14; +const FEED_ID_RE = /^@.+\.ed25519$/; + +const CASE_FIELDS = ['title', 'accuser', 'respondentId', 'mediatorsAccuser', 'mediatorsRespondent']; +const MEDIATORS_FIELDS = ['mediators']; +const EVIDENCE_FIELDS = ['text', 'link', 'imageUrl']; +const ANSWER_FIELDS = ['stance', 'text']; +const VERDICT_FIELDS = ['result', 'orders']; +const SETTLEMENT_FIELDS = ['terms']; + +module.exports = ({ cooler, services = {}, tribeCrypto }) => { + let ssb; + let userId; + + const openSsb = async () => { + if (!ssb) { + ssb = await cooler.open(); + userId = ssb.id; + } + return ssb; + }; + + const nowISO = () => new Date().toISOString(); + const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []); + + const encryptFields = (content, keyHex, fields) => { + const payload = {}; + for (const f of fields) { + if (content[f] !== undefined) payload[f] = content[f]; + } + const enc = tribeCrypto.encryptWithKey(JSON.stringify(payload), keyHex); + const result = { ...content }; + for (const f of fields) delete result[f]; + result.encryptedPayload = enc; + return result; + }; + + const decryptFields = (content, keyHex) => { + if (!content || !content.encryptedPayload) return content; + try { + const plain = tribeCrypto.decryptWithKey(content.encryptedPayload, keyHex); + const payload = JSON.parse(plain); + const result = { ...content }; + delete result.encryptedPayload; + return { ...result, ...payload }; + } catch (e) { + return { ...content, encrypted: true }; + } + }; + + const getCaseKey = (obj) => { + if (!tribeCrypto || !obj) return null; + return tribeCrypto.getKey(obj.rootCaseId || obj.id); + }; + + const distributeKey = async (caseKey, caseRootId, recipientId) => { + if (!tribeCrypto || !recipientId) return; + const ssbClient = await openSsb(); + const ssbKeys = require('../server/node_modules/ssb-keys'); + const boxed = tribeCrypto.boxKeyForMember(caseKey, recipientId, ssbKeys); + const content = { type: 'courts-key', caseRootId, for: recipientId, memberKey: boxed }; + await new Promise((res, rej) => ssbClient.publish(content, (e) => e ? rej(e) : res())); + }; + + async function readLog() { + const ssbClient = await openSsb(); + return new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, arr) => (err ? reject(err) : resolve(arr))) + ); + }); + } + + async function listByType(type) { + const msgs = await readLog(); + const tomb = buildValidatedTombstoneSet(msgs); + const rep = new Map(); + const map = new Map(); + for (const m of msgs) { + const k = m.key || m.id; + const c = m.value?.content || m.content; + if (!c) continue; +if (c.type === type) { + if (c.replaces) rep.set(c.replaces, k); + map.set(k, { id: k, ...c }); + } + } + for (const oldId of rep.keys()) map.delete(oldId); + for (const tId of tomb) map.delete(tId); + return [...map.values()]; + } + + async function getCurrentUserId() { + await openSsb(); + return userId; + } + + async function resolveRespondent(candidateInput) { + const s = String(candidateInput || '').trim(); + if (!s) return null; + if (FEED_ID_RE.test(s)) { + return { type: 'inhabitant', id: s }; + } + if (services.tribes && services.tribes.getTribeById) { + try { + const t = await services.tribes.getTribeById(s); + if (t && t.id) return { type: 'tribe', id: t.id }; + } catch {} + } + return null; + } + + function computeDeadlines(openedAt) { + const answerBy = moment(openedAt).add(CASE_ANSWER_DAYS, 'days').toISOString(); + const evidenceBy = moment(openedAt).add(CASE_EVIDENCE_DAYS, 'days').toISOString(); + const decisionBy = moment(openedAt).add(CASE_DECISION_DAYS, 'days').toISOString(); + return { answerBy, evidenceBy, decisionBy }; + } + + async function openCase({ titleBase, respondentInput, method }) { + const ssbClient = await openSsb(); + const rawTitle = String(titleBase || '').trim(); + if (!rawTitle) throw new Error('Title is required.'); + const resp = await resolveRespondent(respondentInput); + if (!resp) throw new Error('Accused / Respondent not found.'); + const m = String(method || '').trim().toUpperCase(); + const ALLOWED = new Set(['JUDGE', 'DICTATOR', 'POPULAR', 'MEDIATION', 'KARMATOCRACY']); + if (!ALLOWED.has(m)) throw new Error('Invalid resolution method.'); + let dictatorId = null; + if (m === 'DICTATOR') { + if (!(services.parliament && services.parliament.getGovernmentCard)) throw new Error('DICTATOR method requires DICTATORSHIP government.'); + try { + var gov = await services.parliament.getGovernmentCard(); + } catch (e) { + throw new Error('Unable to verify government method for DICTATOR.'); + } + const gm = String(gov && gov.method ? gov.method : '').toUpperCase(); + if (gm !== 'DICTATORSHIP') throw new Error('DICTATOR method requires DICTATORSHIP government.'); + dictatorId = gov && gov.powerType === 'inhabitant' ? gov.powerId : null; + if (!dictatorId) throw new Error('No ruling dictator to assign.'); + } + const openedAt = nowISO(); + const prefix = moment(openedAt).format('MM/YYYY') + '_'; + const title = prefix + rawTitle; + const { answerBy, evidenceBy, decisionBy } = computeDeadlines(openedAt); + const content = { + type: 'courtsCase', + title, + accuser: userId, + respondentType: resp.type, + respondentId: resp.id, + method: m, + status: 'OPEN', + openedAt, + answerBy, + evidenceBy, + decisionBy, + mediatorsAccuser: [], + mediatorsRespondent: [], + ...(dictatorId ? { dictatorId } : {}), + createdAt: openedAt + }; + + if (tribeCrypto) { + const stub = { ...content }; + for (const f of CASE_FIELDS) delete stub[f]; + const initialMsg = await new Promise((res, rej) => + ssbClient.publish(stub, (err, msg) => (err ? rej(err) : res(msg))) + ); + const caseRootId = initialMsg.key; + const caseKey = tribeCrypto.generateTribeKey(); + tribeCrypto.setKey(caseRootId, caseKey, 1); + const encrypted = encryptFields({ ...content, rootCaseId: caseRootId }, caseKey, CASE_FIELDS); + const update = { ...encrypted, replaces: caseRootId, updatedAt: openedAt }; + const finalMsg = await new Promise((res, rej) => + ssbClient.publish(update, (err, msg) => (err ? rej(err) : res(msg))) + ); + await distributeKey(caseKey, caseRootId, resp.id); + if (dictatorId) await distributeKey(caseKey, caseRootId, dictatorId); + return finalMsg; + } + + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + } + + async function loadAllCases() { + const msgs = await readLog(); + const idx = collectCaseNodes(msgs); + const out = []; + for (const [k, n] of idx.nodes) { + if (n.c.replaces) continue; + const base = strictCaseBase(msgs, k, idx); + if (base) out.push(await assembleCase(msgs, base)); + } + return out; + } + + async function listCases(filter = 'open') { + const decrypted = await loadAllCases(); + const sorted = decrypted.sort((a, b) => { + const ta = new Date(a.openedAt || a.createdAt || 0).getTime(); + const tb = new Date(b.openedAt || b.createdAt || 0).getTime(); + return tb - ta; + }); + if (filter === 'open') { + return sorted.filter((c) => { + const s = String(c.status || '').toUpperCase(); + return s !== 'DECIDED' && s !== 'CLOSED' && s !== 'SOLVED' && s !== 'UNSOLVED' && s !== 'DISCARDED'; + }); + } + if (filter === 'history') { + return sorted.filter((c) => { + const s = String(c.status || '').toUpperCase(); + return s === 'DECIDED' || s === 'CLOSED' || s === 'SOLVED' || s === 'UNSOLVED' || s === 'DISCARDED'; + }); + } + return sorted; + } + + async function listCasesForUser(uid) { + const all = await loadAllCases(); + const id = String(uid || userId || ''); + const supportsMap = await countSupportsByCase(); + const settlementsMap = await countSettlementsByCase(); + const rows = []; + for (const c of all) { + const isAccuser = String(c.accuser || '') === id; + const isRespondent = String(c.respondentId || '') === id; + const ma = ensureArray(c.mediatorsAccuser || []); + const mr = ensureArray(c.mediatorsRespondent || []); + const isMediator = ma.includes(id) || mr.includes(id); + const isJudge = String(c.judgeId || '') === id; + const isDictator = String(c.dictatorId || '') === id; + const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator; + if (!mine) continue; + let myPublicPreference = null; + if (isAccuser && typeof c.publicPrefAccuser === 'boolean') { + myPublicPreference = c.publicPrefAccuser; + } else if (isRespondent && typeof c.publicPrefRespondent === 'boolean') { + myPublicPreference = c.publicPrefRespondent; + } + const rootId = c.rootCaseId || c.id; + const tally = c.voteId ? await voteTally(c.voteId) : null; + rows.push({ + ...c, + respondent: c.respondentId || c.respondent, + isAccuser, + isRespondent, + isMediator, + isJudge, + isDictator, + mine, + myPublicPreference, + supportCount: supportsMap.get(String(rootId)) || supportsMap.get(String(c.id)) || 0, + hasSettlement: (settlementsMap.get(String(rootId)) || settlementsMap.get(String(c.id)) || 0) > 0, + yes: tally ? tally.yes : 0, + no: tally ? tally.no : 0, + total: tally ? tally.total : 0, + needed: tally ? tally.needed : 0 + }); + } + rows.sort((a, b) => { + const ta = new Date(a.openedAt || a.createdAt || 0).getTime(); + const tb = new Date(b.openedAt || b.createdAt || 0).getTime(); + return tb - ta; + }); + return rows; + } + + function collectCaseNodes(msgs) { + const nodes = new Map(); + for (const m of msgs) { + const c = m.value?.content || m.content; + if (!c || c.type !== 'courtsCase') continue; + nodes.set(m.key || m.id, { key: m.key || m.id, author: m.value?.author, c }); + } + const strictNext = new Map(), strictPrev = new Map(); + for (const [k, n] of nodes) { + const t = n.c.replaces; + if (t) { const o = nodes.get(t); if (o && o.author === n.author) { strictNext.set(t, k); strictPrev.set(k, t); } } + } + return { nodes, strictNext, strictPrev }; + } + + function strictCaseBase(msgs, id, idxIn) { + const idx = idxIn || collectCaseNodes(msgs); + const { nodes, strictNext, strictPrev } = idx; + const tomb = buildValidatedTombstoneSet(msgs); + let startKey = nodes.has(id) ? id : null; + if (!startKey) { + for (const [k, n] of nodes) { if (String(n.c.rootCaseId || '') === id) { startKey = k; break; } } + } + if (!startKey) return null; + let root = startKey, g = 0; while (strictPrev.has(root) && g++ < 100000) root = strictPrev.get(root); + let tip = root; g = 0; while (strictNext.has(tip) && g++ < 100000) tip = strictNext.get(tip); + if (tomb.has(tip) || tomb.has(root)) return null; + const node = nodes.get(tip); + if (!node) return null; + let c = { id: tip, ...node.c }; + if (tribeCrypto) { const key = getCaseKey(c); if (key) c = decryptFields(c, key); } + if (!c.rootCaseId) c.rootCaseId = root; + return c; + } + + async function assembleCase(msgs, base) { + const accuser = String(base.accuser || ''); + const respondentId = String(base.respondentId || ''); + const rootCaseId = String(base.rootCaseId || base.id || ''); + const idStr = String(base.id || ''); + const method = String(base.method || '').toUpperCase(); + const dictatorId = String(base.dictatorId || ''); + const caseKey = getCaseKey(base); + const isParty = (a) => a === accuser || a === respondentId; + const matchC = (c) => { const cid = String(c.caseId || ''); return cid === rootCaseId || cid === idStr; }; + + let medA = ensureArray(base.mediatorsAccuser || []), medR = ensureArray(base.mediatorsRespondent || []); + let medATs = 0, medRTs = 0, judgeId = '', judgeTs = 0, voteId = String(base.voteId || ''), voteTs = 0; + let prefA = base.publicPrefAccuser, prefATs = 0, prefR = base.publicPrefRespondent, prefRTs = 0; + let answered = false, settled = false, verdict = null, verdictTs = 0, verdictAt = null, settledAt = null; + + for (const m of msgs) { + const c = m.value?.content || m.content; if (!c) continue; + const author = String(m.value?.author || ''); + const ts = m.value?.timestamp || m.timestamp || 0; + if (c.type === 'courtsMediators' && matchC(c)) { + let dec = c; if (tribeCrypto && caseKey) dec = decryptFields(c, caseKey); + const list = ensureArray(dec.mediators || []).map(x => String(x || '').trim()).filter(Boolean).filter(x => x !== accuser && x !== respondentId); + if (c.side === 'accuser' && author === accuser && ts >= medATs) { medA = list; medATs = ts; } + else if (c.side === 'respondent' && author === respondentId && ts >= medRTs) { medR = list; medRTs = ts; } + } else if (c.type === 'courtsJudge' && matchC(c) && isParty(author) && ts >= judgeTs) { + const jid = String(c.judgeId || '').trim(); + if (jid && jid !== accuser && jid !== respondentId) { judgeId = jid; judgeTs = ts; } + } else if (c.type === 'courtsVoteLink' && matchC(c) && isParty(author) && ts >= voteTs) { + voteId = String(c.voteId || ''); voteTs = ts; + } else if (c.type === 'courtsVisibility' && matchC(c)) { + if (c.side === 'accuser' && author === accuser && ts >= prefATs) { prefA = !!c.pref; prefATs = ts; } + else if (c.side === 'respondent' && author === respondentId && ts >= prefRTs) { prefR = !!c.pref; prefRTs = ts; } + } else if (c.type === 'courtsAnswer' && matchC(c) && author === respondentId) { + answered = true; + } else if (c.type === 'courtsSettlementAccepted' && matchC(c) && isParty(author)) { + settled = true; settledAt = c.createdAt || new Date(ts).toISOString(); + } else if (c.type === 'courtsVerdict' && matchC(c) && ts >= verdictTs) { + verdict = author; verdictTs = ts; verdictAt = c.createdAt || new Date(ts).toISOString(); + } + } + + let verdictValid = false; + if (verdict) { + if (method === 'JUDGE') verdictValid = (verdict === judgeId); + else if (method === 'MEDIATION') verdictValid = (medA.includes(verdict) || medR.includes(verdict)); + else if (method === 'DICTATOR') verdictValid = (verdict === dictatorId); + } + let voteDecided = false; + if ((method === 'POPULAR' || method === 'KARMATOCRACY') && voteId) { + try { const t = await voteTally(voteId); if (t && t.closed && t.total > 0) voteDecided = true; } catch (_) {} + } + + base.mediatorsAccuser = medA; + base.mediatorsRespondent = medR; + if (judgeId) base.judgeId = judgeId; else delete base.judgeId; + if (voteId) base.voteId = voteId; + if (typeof prefA === 'boolean') base.publicPrefAccuser = prefA; + if (typeof prefR === 'boolean') base.publicPrefRespondent = prefR; + base.status = (verdictValid || voteDecided) ? 'DECIDED' : settled ? 'CLOSED' : answered ? 'IN_PROGRESS' : 'OPEN'; + if (verdictValid) { base.verdictAt = verdictAt; base.decidedAt = verdictAt; } + else if (voteDecided) { base.decidedAt = base.decidedAt || nowISO(); } + else if (settled) { base.closedAt = settledAt; base.decidedAt = settledAt; } + return base; + } + + async function getCaseById(caseId) { + const id = String(caseId || '').trim(); + if (!id) return null; + const msgs = await readLog(); + const base = strictCaseBase(msgs, id); + if (!base) return null; + return await assembleCase(msgs, base); + } + + function getCaseRole(caseObj, uid) { + const id = String(uid || ''); + if (!id) return 'OTHER'; + if (String(caseObj.accuser || '') === id) return 'ACCUSER'; + if (String(caseObj.respondentId || '') === id) return 'DEFENCE'; + const ma = ensureArray(caseObj.mediatorsAccuser || []); + const mr = ensureArray(caseObj.mediatorsRespondent || []); + if (ma.includes(id) || mr.includes(id)) return 'MEDIATOR'; + if (String(caseObj.judgeId || '') === id) return 'JUDGE'; + return 'OTHER'; + } + + async function setMediators({ caseId, side, mediators }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const role = side === 'accuser' ? 'ACCUSER' : side === 'respondent' ? 'DEFENCE' : null; + if (!role) throw new Error('Invalid side.'); + const myRole = getCaseRole(c, userId); + if (role === 'ACCUSER' && myRole !== 'ACCUSER') throw new Error('Only accuser can set these mediators.'); + if (role === 'DEFENCE' && myRole !== 'DEFENCE') throw new Error('Only defence can set these mediators.'); + const list = Array.from( + new Set( + ensureArray(mediators || []) + .map((x) => String(x || '').trim()) + .filter(Boolean) + ) + ); + const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId); + const caseRootId = c.rootCaseId || c.id; + if (tribeCrypto) { + const caseKey = getCaseKey(c); + if (caseKey) { + for (const mediatorId of clean) { + await distributeKey(caseKey, caseRootId, mediatorId); + } + } + } + const ssbClient = await openSsb(); + let content = { type: 'courtsMediators', caseId: caseRootId, side, mediators: clean, author: userId, createdAt: nowISO() }; + if (tribeCrypto) { + const caseKey = getCaseKey(c); + if (caseKey) content = encryptFields(content, caseKey, MEDIATORS_FIELDS); + } + await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve()))); + if (side === 'accuser') c.mediatorsAccuser = clean; + else c.mediatorsRespondent = clean; + return c; + } + + async function assignJudge({ caseId, judgeId }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const m = String(c.method || '').toUpperCase(); + if (m !== 'JUDGE') throw new Error('This case does not use a judge.'); + const myRole = getCaseRole(c, userId); + if (myRole !== 'ACCUSER' && myRole !== 'DEFENCE') throw new Error('Only parties can assign a judge.'); + const id = String(judgeId || '').trim(); + if (!id) throw new Error('Judge ID is required.'); + if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.'); + if (id === String(c.accuser || '') || id === String(c.respondentId || '')) { + throw new Error('Judge cannot be a party of the case.'); + } + const caseRootId = c.rootCaseId || c.id; + if (tribeCrypto) { + const caseKey = getCaseKey(c); + if (caseKey) { + await distributeKey(caseKey, caseRootId, id); + } + } + const ssbClient = await openSsb(); + const content = { type: 'courtsJudge', caseId: caseRootId, judgeId: id, author: userId, createdAt: nowISO() }; + await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve()))); + c.judgeId = id; + return c; + } + + async function addEvidence({ caseId, text, link, imageMarkdown }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const role = getCaseRole(c, userId); + if (role === 'OTHER') throw new Error('You are not involved in this case.'); + const t = String(text || '').trim(); + const l = String(link || '').trim(); + let imageUrl = null; + if (imageMarkdown) { + const match = imageMarkdown.match(/\(([^)]+)\)/); + imageUrl = match ? match[1] : imageMarkdown; + } + if (!t && !l && !imageUrl) throw new Error('Text, link or image is required.'); + const ssbClient = await openSsb(); + let content = { + type: 'courtsEvidence', + caseId: c.rootCaseId || c.id, + author: userId, + role, + text: t, + link: l, + imageUrl, + createdAt: nowISO() + }; + if (tribeCrypto) { + const caseKey = getCaseKey(c); + if (caseKey) content = encryptFields(content, caseKey, EVIDENCE_FIELDS); + } + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + } + + async function answerCase({ caseId, stance, text }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + if (String(c.respondentId || '') !== String(userId || '')) throw new Error('Only the respondent can answer.'); + const s = String(stance || '').trim().toUpperCase(); + const ALLOWED = new Set(['DENY', 'ADMIT', 'PARTIAL']); + if (!ALLOWED.has(s)) throw new Error('Invalid stance.'); + const t = String(text || '').trim(); + if (!t) throw new Error('Response text is required.'); + const ssbClient = await openSsb(); + let content = { + type: 'courtsAnswer', + caseId: c.rootCaseId || c.id, + respondent: userId, + stance: s, + text: t, + createdAt: nowISO() + }; + if (tribeCrypto) { + const caseKey = getCaseKey(c); + if (caseKey) content = encryptFields(content, caseKey, ANSWER_FIELDS); + } + await new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + c.status = 'IN_PROGRESS'; + return c; + } + + async function issueVerdict({ caseId, result, orders }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const me = String(userId || ''); + const isParty = String(c.accuser || '') === me || String(c.respondentId || '') === me; + if (isParty) throw new Error('A party cannot issue the verdict.'); + const method = String(c.method || '').toUpperCase(); + const isMediator = ensureArray(c.mediatorsAccuser || []).includes(me) || ensureArray(c.mediatorsRespondent || []).includes(me); + if (method === 'JUDGE') { + if (String(c.judgeId || '') !== me) throw new Error('Only the assigned judge can issue the verdict.'); + } else if (method === 'MEDIATION') { + if (!isMediator) throw new Error('Only an appointed mediator can issue the verdict.'); + } else if (method === 'DICTATOR') { + if (String(c.dictatorId || '') !== me) throw new Error('Only the dictator can issue the verdict.'); + } else { + throw new Error('This method is resolved by public vote, not by a verdict.'); + } + const r = String(result || '').trim(); + if (!r) throw new Error('Result is required.'); + const o = String(orders || '').trim(); + const ssbClient = await openSsb(); + let content = { + type: 'courtsVerdict', + caseId: c.rootCaseId || c.id, + judgeId: userId, + result: r, + orders: o, + createdAt: nowISO() + }; + if (tribeCrypto) { + const caseKey = getCaseKey(c); + if (caseKey) content = encryptFields(content, caseKey, VERDICT_FIELDS); + } + await new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + c.status = 'DECIDED'; + c.verdictAt = nowISO(); + c.ruledBy = userId; + return c; + } + + async function proposeSettlement({ caseId, terms }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const role = getCaseRole(c, userId); + if (role === 'OTHER') throw new Error('You are not involved in this case.'); + const t = String(terms || '').trim(); + if (!t) throw new Error('Terms are required.'); + const ssbClient = await openSsb(); + let content = { + type: 'courtsSettlementProposal', + caseId: c.rootCaseId || c.id, + proposer: userId, + terms: t, + createdAt: nowISO() + }; + if (tribeCrypto) { + const caseKey = getCaseKey(c); + if (caseKey) content = encryptFields(content, caseKey, SETTLEMENT_FIELDS); + } + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + } + + async function acceptSettlement({ caseId }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const role = getCaseRole(c, userId); + if (role !== 'ACCUSER' && role !== 'DEFENCE') throw new Error('Only parties can accept a settlement.'); + const ssbClient = await openSsb(); + const content = { + type: 'courtsSettlementAccepted', + caseId: c.rootCaseId || c.id, + by: userId, + createdAt: nowISO() + }; + await new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + c.status = 'CLOSED'; + return c; + } + + async function supportCase({ caseId }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + if (getCaseRole(c, userId) !== 'OTHER') throw new Error('Parties cannot support their own case.'); + const rootId = c.rootCaseId || c.id; + const supports = await listByType('courtsSupport'); + const already = supports.find(s => String(s.caseId || '') === String(rootId) && String(s.supporter || '') === String(userId || '')); + if (already) throw new Error('You already support this case.'); + const ssbClient = await openSsb(); + const content = { type: 'courtsSupport', caseId: rootId, supporter: userId, createdAt: nowISO() }; + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + } + + async function countSupportsByCase() { + const supports = await listByType('courtsSupport'); + const map = new Map(); + for (const s of supports) { + const k = String(s.caseId || ''); + if (!k) continue; + map.set(k, (map.get(k) || 0) + 1); + } + return map; + } + + async function countSettlementsByCase() { + const items = await listByType('courtsSettlementProposal'); + const map = new Map(); + for (const s of items) { + const k = String(s.caseId || ''); + if (!k) continue; + map.set(k, (map.get(k) || 0) + 1); + } + return map; + } + + async function voteTally(voteId) { + if (!voteId || !services.votes || !services.votes.getVoteById) return null; + try { + const v = await services.votes.getVoteById(voteId); + const vm = v && v.votes ? v.votes : {}; + const yes = Number(vm.YES ?? vm.Yes ?? vm.yes ?? 0); + const no = Number(vm.NO ?? vm.No ?? vm.no ?? 0); + const sum = Object.values(vm).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + const deadline = v.deadline || v.endAt || v.expiresAt || null; + const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + const needed = Math.floor(total / 2) + 1; + return { yes, no, total, needed, closed, deadline }; + } catch { return null; } + } + + async function sweepCases() { + return; + } + + async function setPublicPreference({ caseId, preference }) { + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const id = String(userId || ''); + const pref = !!preference; + let side = null; + if (String(c.accuser || '') === id) { side = 'accuser'; c.publicPrefAccuser = pref; } + else if (String(c.respondentId || '') === id) { side = 'respondent'; c.publicPrefRespondent = pref; } + else throw new Error('Only parties can set visibility preference.'); + const ssbClient = await openSsb(); + const content = { type: 'courtsVisibility', caseId: c.rootCaseId || c.id, side, pref, author: id, createdAt: nowISO() }; + await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve()))); + return c; + } + + async function openPopularVote({ caseId }) { + if (!services.votes || !services.votes.createVote) throw new Error('Votes service not available.'); + const c = await getCaseById(caseId); + if (!c) throw new Error('Case not found.'); + const m = String(c.method || '').toUpperCase(); + if (m !== 'POPULAR' && m !== 'KARMATOCRACY') throw new Error('This case does not use public voting.'); + if (c.voteId) throw new Error('Vote already opened.'); + const myRole = getCaseRole(c, userId); + if (myRole !== 'ACCUSER' && myRole !== 'DEFENCE') throw new Error('Only parties can open the vote.'); + const question = c.title || `Case ${caseId}`; + const deadline = moment().add(POPULAR_DAYS, 'days').toISOString(); + const voteMsg = await services.votes.createVote( + question, + deadline, + ['YES', 'NO', 'ABSTENTION'], + [`courtsCase:${caseId}`, `courtsMethod:${m}`] + ); + const voteId = voteMsg.key || voteMsg.id; + const ssbClient = await openSsb(); + const content = { type: 'courtsVoteLink', caseId: c.rootCaseId || c.id, voteId, author: userId, createdAt: nowISO() }; + await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve()))); + c.voteId = voteId; + return c; + } + + async function getInhabitantKarma(feedId) { + if (services.banking && services.banking.getUserEngagementScore) { + try { + const v = await services.banking.getUserEngagementScore(feedId); + return Number(v || 0) || 0; + } catch { + return 0; + } + } + return 0; + } + + async function getFirstUserTimestamp(feedId) { + const ssbClient = await openSsb(); + return new Promise((resolve) => { + pull( + ssbClient.createUserStream({ id: feedId, reverse: false }), + pull.filter((m) => m && m.value && m.value.content && m.value.content.type !== 'tombstone'), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(Date.now()); + const m = arr[0]; + const ts = (m.value && m.value.timestamp) || m.timestamp || Date.now(); + resolve(ts < 1e12 ? ts * 1000 : ts); + }) + ); + }); + } + + async function nominateJudge({ judgeId }) { + const id = String(judgeId || '').trim(); + if (!id) throw new Error('Judge ID is required.'); + if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.'); + const ssbClient = await openSsb(); + const content = { + type: 'courtsNomination', + judgeId: id, + createdAt: nowISO() + }; + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + } + + async function voteNomination(nominationId) { + const id = String(nominationId || '').trim(); + if (!id) throw new Error('Nomination not found.'); + const nominations = await listByType('courtsNomination'); + const nomination = nominations.find((n) => n.id === id); + if (!nomination) throw new Error('Nomination not found.'); + if (String(nomination.judgeId || '') === String(userId || '')) { + throw new Error('You cannot vote for yourself.'); + } + const votes = await listByType('courtsNominationVote'); + const already = votes.find( + (v) => + String(v.nominationId || '') === id && + String(v.voter || '') === String(userId || '') + ); + if (already) throw new Error('You have already voted.'); + const ssbClient = await openSsb(); + const content = { + type: 'courtsNominationVote', + nominationId: id, + voter: userId, + createdAt: nowISO() + }; + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))) + ); + } + + async function listNominations() { + const nominations = await listByType('courtsNomination'); + const votes = await listByType('courtsNominationVote'); + const byId = new Map(); + for (const n of nominations) { + byId.set(n.id, { ...n, supports: 0, karma: 0, profileSince: 0 }); + } + for (const v of votes) { + const rec = byId.get(v.nominationId); + if (rec) rec.supports = (rec.supports || 0) + 1; + } + const rows = []; + for (const rec of byId.values()) { + const karma = await getInhabitantKarma(rec.judgeId); + const since = await getFirstUserTimestamp(rec.judgeId); + rows.push({ ...rec, karma, profileSince: since }); + } + rows.sort((a, b) => { + if ((b.supports || 0) !== (a.supports || 0)) return (b.supports || 0) - (a.supports || 0); + if ((b.karma || 0) !== (a.karma || 0)) return (b.karma || 0) - (a.karma || 0); + if ((a.profileSince || 0) !== (b.profileSince || 0)) return (a.profileSince || 0) - (b.profileSince || 0); + const ta = new Date(a.createdAt || 0).getTime(); + const tb = new Date(b.createdAt || 0).getTime(); + if (ta !== tb) return ta - tb; + return String(a.judgeId || '').localeCompare(String(b.judgeId || '')); + }); + return rows; + } + + async function getCaseDetails({ caseId }) { + const id = String(caseId || '').trim(); + if (!id) return null; + const base = await getCaseById(id); + if (!base) return null; + const currentUser = await getCurrentUserId(); + const me = String(currentUser || ''); + const accuserId = String(base.accuser || ''); + const respondentId = String(base.respondentId || ''); + const ma = ensureArray(base.mediatorsAccuser || []); + const mr = ensureArray(base.mediatorsRespondent || []); + const judgeId = String(base.judgeId || ''); + const dictatorId = String(base.dictatorId || ''); + const isAccuser = accuserId === me; + const isRespondent = respondentId === me; + const isMediator = ma.includes(me) || mr.includes(me); + const isJudge = judgeId === me; + const isDictator = dictatorId === me; + const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator; + let myPublicPreference = null; + if (isAccuser && typeof base.publicPrefAccuser === 'boolean') { + myPublicPreference = base.publicPrefAccuser; + } else if (isRespondent && typeof base.publicPrefRespondent === 'boolean') { + myPublicPreference = base.publicPrefRespondent; + } + const publicDetails = base.publicPrefAccuser === true && base.publicPrefRespondent === true; + + const caseKey = getCaseKey(base); + const caseRootId = base.rootCaseId || base.id; + + const matchCase = (e) => { + const eCaseId = String(e.caseId || ''); + return eCaseId === id || eCaseId === caseRootId; + }; + + const tryDecrypt = (item) => { + if (!caseKey) return item; + return decryptFields(item, caseKey); + }; + + const evidencesAll = await listByType('courtsEvidence'); + const answersAll = await listByType('courtsAnswer'); + const settlementsAll = await listByType('courtsSettlementProposal'); + const verdictsAll = await listByType('courtsVerdict'); + const acceptedAll = await listByType('courtsSettlementAccepted'); + + const evidences = evidencesAll + .filter(matchCase) + .map(tryDecrypt) + .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const answers = answersAll + .filter(matchCase) + .map(tryDecrypt) + .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const settlements = settlementsAll + .filter(matchCase) + .map(tryDecrypt) + .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const verdicts = verdictsAll + .filter(matchCase) + .map(tryDecrypt) + .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const verdict = verdicts.length ? verdicts[verdicts.length - 1] : null; + const acceptedSettlements = acceptedAll + .filter(matchCase) + .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const decidedAt = + base.verdictAt || + base.closedAt || + (verdict && verdict.createdAt) || + base.decidedAt; + const hasVerdict = !!verdict; + const supportsMap = await countSupportsByCase(); + const supportCount = supportsMap.get(String(caseRootId)) || supportsMap.get(String(id)) || 0; + const tally = base.voteId ? await voteTally(base.voteId) : null; + return { + ...base, + id, + respondent: base.respondentId || base.respondent, + evidences, + answers, + settlements, + acceptedSettlements, + verdict, + decidedAt, + isAccuser, + isRespondent, + isMediator, + isJudge, + isDictator, + mine, + publicDetails, + myPublicPreference, + supportCount, + hasSettlement: settlements.length > 0, + yes: tally ? tally.yes : 0, + no: tally ? tally.no : 0, + total: tally ? tally.total : 0, + needed: tally ? tally.needed : 0, + voteClosed: tally ? tally.closed : false, + hasVerdict + }; + } + + async function processIncomingCourtsKeys() { + if (!tribeCrypto) return; + const ssbKeys = require('../server/node_modules/ssb-keys'); + const ssbConfig = require('../server/ssb_config'); + const ssbClient = await openSsb(); + const msgs = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, arr) => (err ? rej(err) : res(arr))) + ); + }); + for (const m of msgs) { + const c = m.value?.content; + if (!c || c.type !== 'courts-key') continue; + if (c.for !== ssbClient.id) continue; + if (!c.memberKey || !c.caseRootId) continue; + if (tribeCrypto.getKey(c.caseRootId)) continue; + try { + const key = tribeCrypto.unboxKeyFromMember(c.memberKey, ssbConfig.keys, ssbKeys); + if (key) tribeCrypto.setKey(c.caseRootId, key, 1); + } catch (e) {} + } + } + + return { + getCurrentUserId, + openCase, + listCases, + listCasesForUser, + getCaseById, + setMediators, + assignJudge, + addEvidence, + answerCase, + issueVerdict, + proposeSettlement, + acceptSettlement, + supportCase, + sweepCases, + setPublicPreference, + openPopularVote, + nominateJudge, + voteNomination, + listNominations, + getCaseDetails, + processIncomingCourtsKeys + }; +}; diff --git a/src/models/crypto.js b/src/models/crypto.js new file mode 100644 index 0000000..bdd9321 --- /dev/null +++ b/src/models/crypto.js @@ -0,0 +1,530 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const SENSITIVE_FIELDS = [ + 'title', 'description', 'location', 'price', 'salary', 'options', 'votes', + 'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline', + 'goal', 'funded', 'refeeds', 'refeeds_inhabitants', 'opinions', + 'opinions_inhabitants', 'status', 'priority', 'date', 'mediaType' +]; + +const ENVELOPE_PRESERVE = new Set([ + 'type', 'tribeId', 'contentType', 'replaces', 'target', 'author', + 'createdAt', 'updatedAt', 'encryptedPayload', + 'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId', + 'members', 'invites', 'participants', + '_decrypted', '_undecryptable' +]); + +const INVITE_SALT_LEGACY = 'SolarNET.HuB'; +const INVITE_SCRYPT = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }; + +const FP_INFO = Buffer.from('v1-fp', 'utf8'); +const ENVELOPE_TYPE = 'tribe-msg'; +const ENVELOPE_VERSION = 1; +const KEY_DISTRIB_TYPE = 'tribe-keys-distrib'; +const KEY_DISTRIB_BATCH = 7; + +module.exports = (configPath, namespace = 'tribes') => { + const keysDir = path.join(configPath, 'keys'); + try { fs.mkdirSync(keysDir, { recursive: true, mode: 0o700 }); } catch (_) {} + const keyringPath = path.join(keysDir, `${namespace}-keys.json`); + + if (namespace === 'tribes') { + const legacyPath = path.join(configPath, 'tribe-keys.json'); + try { + if (fs.existsSync(legacyPath) && !fs.existsSync(keyringPath)) { + fs.renameSync(legacyPath, keyringPath); + try { fs.chmodSync(keyringPath, 0o600); } catch (_) {} + } + } catch (_) {} + } + + let keyring = {}; + + const loadKeyring = () => { + try { + keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8')); + try { fs.chmodSync(keyringPath, 0o600); } catch (_) {} + } catch (e) { + if (e.code !== 'ENOENT') throw e; + keyring = {}; + } + return keyring; + }; + + const saveKeyring = () => { + const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now(); + fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tmp, keyringPath); + try { fs.chmodSync(keyringPath, 0o600); } catch (_) {} + }; + + const generateTribeKey = () => crypto.randomBytes(32).toString('hex'); + + const getKey = (rid) => { + const e = keyring[rid]; + return e && Array.isArray(e.keys) && e.keys[0] ? e.keys[0] : null; + }; + + const getKeys = (rid) => { + const e = keyring[rid]; + return e && Array.isArray(e.keys) ? e.keys : []; + }; + + const getGen = (rid) => { + const e = keyring[rid]; + return e ? e.gen || 1 : 0; + }; + + const getAllRootIds = () => Object.keys(keyring); + + const setKey = (rid, kHex, gen) => { + keyring[rid] = { keys: [kHex], gen: gen || 1 }; + saveKeyring(); + }; + + const setKeys = (rid, ks, topGen) => { + if (!Array.isArray(ks) || !ks.length) return; + const seen = new Set(); + const dedup = []; + for (const k of ks) if (k && !seen.has(k)) { seen.add(k); dedup.push(k); } + keyring[rid] = { keys: dedup, gen: topGen || dedup.length }; + saveKeyring(); + }; + + const mergeKeys = (rid, incoming, topGen) => { + const e = keyring[rid] || { keys: [], gen: 0 }; + const seen = new Set(e.keys); + const merged = [...e.keys]; + for (const k of incoming) if (k && !seen.has(k)) { seen.add(k); merged.push(k); } + keyring[rid] = { keys: merged, gen: Math.max(e.gen || 0, topGen || merged.length) }; + saveKeyring(); + return keyring[rid].gen; + }; + + const addNewKey = (rid, kHex) => { + const e = keyring[rid] || { keys: [], gen: 0 }; + if (e.keys.includes(kHex)) return e.gen; + e.keys.unshift(kHex); + e.gen = (e.gen || 0) + 1; + keyring[rid] = e; + saveKeyring(); + return e.gen; + }; + + const dropKey = (rid) => { + if (keyring[rid]) { + delete keyring[rid]; + saveKeyring(); + } + }; + + const fingerprint = (kHex) => + crypto.createHmac('sha256', Buffer.from(kHex, 'hex')).update(FP_INFO).digest('hex').slice(0, 32); + + const buildFingerprintIndex = () => { + const m = new Map(); + for (const [rid, e] of Object.entries(keyring)) { + if (!e || !Array.isArray(e.keys)) continue; + for (let i = 0; i < e.keys.length; i++) { + const k = e.keys[i]; + if (!k) continue; + m.set(fingerprint(k), { rootId: rid, keyHex: k, isCurrent: i === 0 }); + } + } + return m; + }; + + const buildAad = (fp) => Buffer.from(`${ENVELOPE_TYPE}|v${ENVELOPE_VERSION}|${fp}`, 'utf8'); + + const encryptWithKey = (plaintext, kHex, aad) => { + const key = Buffer.from(kHex, 'hex'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + if (aad) cipher.setAAD(aad); + const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return iv.toString('hex') + tag.toString('hex') + enc.toString('hex'); + }; + + const decryptWithKey = (encrypted, kHex, aad) => { + const key = Buffer.from(kHex, 'hex'); + const iv = Buffer.from(encrypted.slice(0, 24), 'hex'); + const tag = Buffer.from(encrypted.slice(24, 56), 'hex'); + const ct = Buffer.from(encrypted.slice(56), 'hex'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + if (aad) decipher.setAAD(aad); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); + }; + + const isTribeMsg = (c) => + !!c && c.type === ENVELOPE_TYPE && c.v === ENVELOPE_VERSION && typeof c.fp === 'string' && typeof c.p === 'string'; + + const wrapMsg = (body, kHex) => { + if (!kHex) throw new Error('wrapMsg: missing key'); + const fp = fingerprint(kHex); + const aad = buildAad(fp); + const p = encryptWithKey(JSON.stringify(body), kHex, aad); + return { type: ENVELOPE_TYPE, v: ENVELOPE_VERSION, fp, p }; + }; + + const unwrapMsg = (envelope, fpIdx) => { + if (!isTribeMsg(envelope)) return null; + const aad = buildAad(envelope.fp); + const tryKey = (rootId, keyHex) => { + try { + const pt = decryptWithKey(envelope.p, keyHex, aad); + return { body: JSON.parse(pt), rootId, keyHex }; + } catch (_) { return null; } + }; + const entry = fpIdx.get(envelope.fp); + if (entry) { + const r = tryKey(entry.rootId, entry.keyHex); + if (r) return r; + for (const [, e] of fpIdx) { + if (e.rootId !== entry.rootId || e.keyHex === entry.keyHex) continue; + const r2 = tryKey(e.rootId, e.keyHex); + if (r2) return r2; + } + return null; + } + for (const [, e] of fpIdx) { + const r = tryKey(e.rootId, e.keyHex); + if (r) return r; + } + return null; + }; + + const generateInviteSalt = () => crypto.randomBytes(16).toString('hex'); + + const deriveInviteKey = (code, salt) => { + const s = (salt === undefined || salt === null || salt === '') ? INVITE_SALT_LEGACY : salt; + return crypto.scryptSync(code, s, 32, INVITE_SCRYPT); + }; + + const hashInviteCode = (code, salt) => { + const s = (salt === undefined || salt === null || salt === '') ? INVITE_SALT_LEGACY : salt; + return crypto.createHmac('sha256', s).update(String(code), 'utf8').digest('hex'); + }; + + const inviteAad = (code, salt) => + Buffer.from(`tribe-invite|v1|${hashInviteCode(code, salt)}`, 'utf8'); + + const encryptForInvite = (tribeKeyHex, inviteCode, salt) => { + const derived = deriveInviteKey(inviteCode, salt); + return encryptWithKey(tribeKeyHex, derived.toString('hex'), inviteAad(inviteCode, salt)); + }; + + const decryptFromInvite = (encryptedKey, inviteCode, salt) => { + const derived = deriveInviteKey(inviteCode, salt); + return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt)); + }; + + const encryptChainForInvite = (ancestryRootIds, code, salt) => { + const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) })); + if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null; + const k = deriveInviteKey(code, salt); + return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt)); + }; + + const decryptChainFromInvite = (encryptedPayload, code, salt) => { + const k = deriveInviteKey(code, salt); + try { + const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt)); + const parsed = JSON.parse(json); + if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && Array.isArray(e.keys) && e.keys.length)) { + return parsed.map(e => ({ + rootId: e.rootId, + keys: e.keys.slice(), + key: e.keys[0], + gen: e.gen || e.keys.length + })); + } + } catch (_) {} + return null; + }; + + const inviteMatchesCode = (inv, code) => { + if (!inv || typeof inv !== 'object' || !inv.codeHash) return false; + return inv.codeHash === hashInviteCode(code, inv.salt); + }; + + const buildKeyDistribPayload = (rootId, keys, gen) => ({ + type: KEY_DISTRIB_TYPE, + rootId, + keys: Array.isArray(keys) ? keys.slice() : [], + gen: gen || (Array.isArray(keys) ? keys.length : 1), + distributedAt: new Date().toISOString() + }); + + const isKeyDistribContent = (decoded) => + !!decoded && decoded.type === KEY_DISTRIB_TYPE && typeof decoded.rootId === 'string' && + Array.isArray(decoded.keys) && decoded.keys.length > 0; + + const tryUnboxKeyDistrib = (rawContent, localKeypair, ssbKeys) => { + if (typeof rawContent !== 'string' || !rawContent.endsWith('.box')) return null; + let decoded; + try { decoded = ssbKeys.unbox(rawContent, localKeypair); } catch (_) { return null; } + if (!decoded) return null; + if (typeof decoded === 'string') { + try { decoded = JSON.parse(decoded); } catch (_) { return null; } + } + return isKeyDistribContent(decoded) ? decoded : null; + }; + + const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) => + ssbKeys.box(tribeKeyHex, [memberFeedId]); + + const unboxKeyFromMember = (boxed, localKeypair, ssbKeys) => + ssbKeys.unbox(boxed, localKeypair); + + const canonicalAad = (envelope) => { + if (!envelope) return null; + const fields = ['type', 'tribeId', 'contentType', 'replaces', 'author', 'createdAt']; + const obj = {}; + for (const f of fields) if (envelope[f] !== undefined && envelope[f] !== null) obj[f] = envelope[f]; + return Buffer.from(JSON.stringify(obj), 'utf8'); + }; + + const encryptChain = (plaintext, keyChain, aad) => { + let data = plaintext; + const last = keyChain.length - 1; + for (let i = 0; i < keyChain.length; i++) { + data = encryptWithKey(data, keyChain[i], i === last ? aad : undefined); + } + return data; + }; + + const decryptChain = (encrypted, keyChain, aad) => { + const reversed = [...keyChain].reverse(); + let data = encrypted; + for (let i = 0; i < reversed.length; i++) { + data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined); + } + return data; + }; + + const encryptContent = (content, keyChain, customFields) => { + const payload = {}; + if (customFields) { + for (const [k, v] of Object.entries(content)) { + if (ENVELOPE_PRESERVE.has(k)) continue; + payload[k] = v; + } + } else { + for (const field of SENSITIVE_FIELDS) { + if (content[field] !== undefined) payload[field] = content[field]; + } + } + const plaintext = JSON.stringify(payload); + const result = {}; + for (const [k, v] of Object.entries(content)) { + if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) { + result[k] = v; + } + } + const aad = canonicalAad(result); + const encryptedPayload = encryptChain(plaintext, keyChain, aad); + result.encryptedPayload = encryptedPayload; + return result; + }; + + const decryptContent = (content, keyChainSets) => { + if (!content || !content.encryptedPayload) return content; + const envelope = { ...content }; + delete envelope.encryptedPayload; + const aad = canonicalAad(envelope); + for (const keyChain of keyChainSets) { + try { + const plaintext = decryptChain(content.encryptedPayload, keyChain, aad); + const payload = JSON.parse(plaintext); + const result = { ...content }; + delete result.encryptedPayload; + Object.assign(result, payload); + return result; + } catch (_) {} + } + return { ...content, _undecryptable: true }; + }; + + const buildKeyChainSets = (ancestryRootIds) => { + if (!Array.isArray(ancestryRootIds) || ancestryRootIds.length === 0) return []; + if (ancestryRootIds.length === 1) { + const keys = getKeys(ancestryRootIds[0]); + return keys.map(k => [k]); + } + const ownKeys = getKeys(ancestryRootIds[0]); + const parentSets = buildKeyChainSets(ancestryRootIds.slice(1)); + const sets = []; + for (const ownKey of ownKeys) { + for (const parentChain of parentSets) { + sets.push([ownKey, ...parentChain]); + } + } + return sets; + }; + + const resolveKeyChain = async (tribeId, tribesModel) => { + if (!tribeId || !tribesModel) return null; + let ancestryIds; + try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; } + if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null; + const chain = []; + for (const rid of ancestryIds) { + const k = getKey(rid); + if (!k) return null; + chain.push(k); + } + return chain.length ? chain : null; + }; + + const resolveKeyChainSets = async (tribeId, tribesModel) => { + if (!tribeId || !tribesModel) return null; + let ancestryIds; + try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; } + if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null; + return buildKeyChainSets(ancestryIds); + }; + + const encryptForTribe = async (content, tribeId, tribesModel) => { + const chain = await resolveKeyChain(tribeId, tribesModel); + if (!chain) throw new Error('Missing tribe key chain — cannot encrypt content for this tribe'); + return encryptContent(content, chain, true); + }; + + const decryptFromTribe = async (content, tribesModel) => { + if (!content) return content; + if (isTribeMsg(content)) { + const fpIdx = buildFingerprintIndex(); + const r = unwrapMsg(content, fpIdx); + if (!r) return { ...content, _undecryptable: true }; + return { ...r.body, _decrypted: true }; + } + if (!content.encryptedPayload) return content; + const tid = content.tribeId; + if (tid && tribesModel) { + let sets = null; + try { sets = await resolveKeyChainSets(tid, tribesModel); } catch (_) {} + if (sets && sets.length) { + const r = decryptContent(content, sets); + if (r && !r._undecryptable) return r; + } + const directKeys = getKeys(tid); + if (directKeys && directKeys.length) { + const r = decryptContent(content, directKeys.map(k => [k])); + if (r && !r._undecryptable) return r; + } + } + const candidateRoots = [ + content.calendarId, content.chatId, content.padId, + content.mapId, content.roomId, content.parentId, content.dateId + ].filter(Boolean); + for (const rid of candidateRoots) { + const keys = getKeys(rid); + if (keys && keys.length) { + const r = decryptContent(content, keys.map(k => [k])); + if (r && !r._undecryptable) return r; + } + } + return { ...content, _undecryptable: true }; + }; + + const createHelpers = (tribesModel) => ({ + async encryptIfTribe(content) { + if (!content || !content.tribeId || !tribesModel) return content; + const rootId = await tribesModel.getRootId(content.tribeId); + const key = getKey(rootId); + if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted content`); + const body = { k: content.type, ...content }; + return wrapMsg(body, key); + }, + async decryptIfTribe(content) { + if (!content || !tribesModel) return content; + if (isTribeMsg(content)) { + const fpIdx = buildFingerprintIndex(); + const r = unwrapMsg(content, fpIdx); + if (!r) return { ...content, _undecryptable: true }; + const flat = { ...r.body, _decrypted: true, _rootId: r.rootId }; + if (r.body.k === 'tombstone') flat.type = 'tombstone'; + else if (r.body.k && !flat.type) flat.type = r.body.k; + delete flat.k; + return flat; + } + if (!content.encryptedPayload) return content; + return await decryptFromTribe(content, tribesModel); + }, + assertReadable(decrypted, what) { + if (decrypted && decrypted._undecryptable) throw new Error(`${what} is tribe-encrypted and cannot be decrypted with available keys`); + }, + async decryptIndexNodes(idx) { + if (!tribesModel) return; + for (const [k, n] of idx.nodes.entries()) { + if (!n.c) continue; + if (!n.c.encryptedPayload && !isTribeMsg(n.c)) continue; + const dec = await decryptFromTribe(n.c, tribesModel); + if (dec && !dec._undecryptable) { + idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } }); + } else { + idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } }); + } + } + }, + unwrapMessagesForKind(messages, kindOrKinds) { + const kinds = Array.isArray(kindOrKinds) ? kindOrKinds : [kindOrKinds]; + const kSet = new Set(kinds); + const fpIdx = buildFingerprintIndex(); + const out = []; + for (const m of messages) { + const c = m && m.value && m.value.content; + if (!c) continue; + if (!isTribeMsg(c)) { out.push(m); continue; } + const r = unwrapMsg(c, fpIdx); + if (!r || !r.body) continue; + const inner = r.body; + if (inner.k === 'tombstone' && inner.target) { + const flat = { type: 'tombstone', target: inner.target, deletedAt: inner.deletedAt, author: inner.author, _rootId: r.rootId }; + out.push({ ...m, value: { ...m.value, content: flat } }); + } else if (kSet.has(inner.k)) { + const flat = { ...inner, type: inner.k, _decrypted: true, _rootId: r.rootId }; + delete flat.k; + out.push({ ...m, value: { ...m.value, content: flat } }); + } + } + return out; + }, + async encryptTombstone(target, tribeId, author) { + const deletedAt = new Date().toISOString(); + if (!tribeId || !tribesModel) return { type: 'tombstone', target, deletedAt, author }; + const rootId = await tribesModel.getRootId(tribeId); + const key = getKey(rootId); + if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted tombstone`); + return wrapMsg({ k: 'tombstone', target, deletedAt, author }, key); + } + }); + + loadKeyring(); + + return { + SENSITIVE_FIELDS, ENVELOPE_PRESERVE, + ENVELOPE_TYPE, ENVELOPE_VERSION, KEY_DISTRIB_TYPE, KEY_DISTRIB_BATCH, + loadKeyring, saveKeyring, + generateTribeKey, getKey, getKeys, getGen, getAllRootIds, + setKey, setKeys, mergeKeys, addNewKey, dropKey, + fingerprint, buildFingerprintIndex, + isTribeMsg, wrapMsg, unwrapMsg, + encryptWithKey, decryptWithKey, + generateInviteSalt, hashInviteCode, deriveInviteKey, + encryptForInvite, decryptFromInvite, + encryptChainForInvite, decryptChainFromInvite, inviteMatchesCode, + buildKeyDistribPayload, isKeyDistribContent, tryUnboxKeyDistrib, + boxKeyForMember, unboxKeyFromMember, + canonicalAad, encryptChain, decryptChain, + encryptContent, decryptContent, + buildKeyChainSets, resolveKeyChain, resolveKeyChainSets, + encryptForTribe, decryptFromTribe, + createHelpers + }; +}; diff --git a/src/models/cv_model.js b/src/models/cv_model.js new file mode 100644 index 0000000..8e66e54 --- /dev/null +++ b/src/models/cv_model.js @@ -0,0 +1,177 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const extractBlobId = str => { + if (!str || typeof str !== 'string') return null; + const match = str.match(/\(([^)]+\.sha256)\)/); + return match ? match[1] : str.trim(); +}; + +const parseCSV = str => str + ? str.split(',').map(s => s.trim()).filter(Boolean) + : []; + +module.exports = ({ cooler }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + return { + type: 'curriculum', + + async createCV(data, photoBlobId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const content = { + type: 'curriculum', + author: userId, + name: data.name, + description: data.description, + photo: extractBlobId(photoBlobId) || null, + contact: userId, + personalSkills: parseCSV(data.personalSkills), + personalExperiences: data.personalExperiences || '', + oasisExperiences: data.oasisExperiences || '', + oasisSkills: parseCSV(data.oasisSkills), + educationExperiences: data.educationExperiences || '', + educationalSkills: parseCSV(data.educationalSkills), + languages: data.languages || '', + professionalExperiences: data.professionalExperiences || '', + professionalSkills: parseCSV(data.professionalSkills), + location: data.location || 'UNKNOWN', + status: data.status || 'LOOKING FOR WORK', + preferences: data.preferences || 'REMOTE WORKING', + visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC', + createdAt: new Date().toISOString() + }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)); + }); + }, + + async updateCV(id, data, photoBlobId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const old = await new Promise((res, rej) => + ssbClient.get(id, (err, msg) => + err || !msg?.content + ? rej(err || new Error('CV not found')) + : res(msg) + ) + ); + + if (old.author !== userId) { + throw new Error('Not the author'); + } + + const tombstone = { + type: 'tombstone', + target: id, + deletedAt: new Date().toISOString() + }; + + await new Promise((res, rej) => + ssbClient.publish(tombstone, err => err ? rej(err) : res()) + ); + + const content = { + type: 'curriculum', + author: userId, + name: data.name, + description: data.description, + photo: extractBlobId(photoBlobId) || null, + contact: userId, + personalSkills: parseCSV(data.personalSkills), + personalExperiences: data.personalExperiences || '', + oasisExperiences: data.oasisExperiences || '', + oasisSkills: parseCSV(data.oasisSkills), + educationExperiences: data.educationExperiences || '', + educationalSkills: parseCSV(data.educationalSkills), + languages: data.languages || '', + professionalExperiences: data.professionalExperiences || '', + professionalSkills: parseCSV(data.professionalSkills), + location: data.location || 'UNKNOWN', + status: data.status || 'LOOKING FOR WORK', + preferences: data.preferences || 'REMOTE WORKING', + visibility: data.visibility !== undefined + ? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC') + : (old.content.visibility || 'PUBLIC'), + createdAt: old.content.createdAt, + updatedAt: new Date().toISOString() + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)); + }); + }, + + async deleteCVById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const msg = await new Promise((res, rej) => + ssbClient.get(id, (err, msg) => + err || !msg?.content + ? rej(new Error('CV not found')) + : res(msg) + ) + ); + + if (msg.author !== userId) { + throw new Error('Not the author'); + } + + const tombstone = { + type: 'tombstone', + target: id, + deletedAt: new Date().toISOString() + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err, result) => err ? reject(err) : resolve(result)); + }); + }, + + async getCVByUserId(targetUserId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const authorId = targetUserId || userId; + + return new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => { + if (err) return reject(err); + + const tombstoned = buildValidatedTombstoneSet(msgs); + + const cvMsgs = msgs + .filter(m => + m.value?.content?.type === 'curriculum' && + m.value.author === authorId && + !tombstoned.has(m.key) + ) + .sort((a, b) => b.value.timestamp - a.value.timestamp); + + if (!cvMsgs.length) { + return resolve(null); + } + + const latest = cvMsgs[0]; + const c = latest.value.content; + const visibility = String(c.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC'; + if (visibility === 'HIDDEN' && authorId !== userId) return resolve(null); + resolve({ id: latest.key, ...c, visibility }); + }) + ); + }); + } + }; +}; + diff --git a/src/models/documents_model.js b/src/models/documents_model.js new file mode 100644 index 0000000..ada7c64 --- /dev/null +++ b/src/models/documents_model.js @@ -0,0 +1,370 @@ +const pull = require("../server/node_modules/pull-stream"); +const { getConfig } = require("../configs/config-manager.js"); +const categories = require("../backend/opinion_categories"); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe'); +const mediaFavorites = require("../backend/media-favorites"); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const parseBlobId = (blobMarkdown) => { + if (!blobMarkdown) return null; + const s = String(blobMarkdown); + const match = s.match(/\(([^)]+)\)/); + return match ? match[1] : s.trim(); +}; + +const parseCSV = (str) => + str === undefined || str === null ? undefined : String(str).split(",").map((s) => s.trim()).filter(Boolean); + +const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); + +module.exports = ({ cooler }) => { + let ssb; + let userId; + + const openSsb = async () => { + if (!ssb) { + ssb = await cooler.open(); + userId = ssb.id; + } + return ssb; + }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) + ); + }); + + const buildIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const opinionMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === "tombstone") continue; + if (c.type === "documentOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + if (c.type !== "document") continue; + + const ts = v.timestamp || m.timestamp || 0; + let sizeBytes = 0; + try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; } + nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author }); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, t] of Array.from(parent.entries())) { + const orig = nodes.get(t); + const node = nodes.get(k); + if (orig && node && orig.author === node.author) continue; + parent.delete(k); + if (child.get(t) === k) child.delete(t); + if (orig) nodes.delete(k); + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const opinionsByRoot = new Map(); + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const forward = new Map(); + for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId); + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + const lc = contentNode ? contentNode.c : {}; + const opinions = { ...(lc.opinions || {}) }; + const voters = safeArr(lc.opinions_inhabitants).slice(); + const voterSet = new Set(voters); + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); voters.push(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + return { contentTip, contentNode, opinions, voters }; + }; + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup }; + }; + + const pickDoc = (node, rootId, agg) => { + const c = node.c || {}; + return { + key: node.key, + rootId, + url: c.url, + createdAt: c.createdAt, + updatedAt: c.updatedAt || null, + tags: safeArr(c.tags), + author: node.author, + title: c.title || "", + description: c.description || "", + opinions: agg ? agg.opinions : (c.opinions || {}), + opinions_inhabitants: agg ? agg.voters : safeArr(c.opinions_inhabitants), + sizeBytes: node.sizeBytes || 0 + }; + }; + + const hasBlob = (ssbClient, blobId) => + new Promise((resolve) => { + if (!blobId) return resolve(false); + ssbClient.blobs.has(blobId, (err, has) => resolve(!err && !!has)); + }); + + const favoritesSetForDocuments = async () => { + try { + return await mediaFavorites.getFavoriteSet("documents"); + } catch { + return new Set(); + } + }; + + return { + type: "document", + + async resolveCurrentId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const tip = idx.tipByRoot.get(idx.rootOf(id)) || id; + if (idx.tomb.has(tip)) throw new Error("Document not found"); + return tip; + }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + let tip = id; + while (idx.forward.has(tip)) tip = idx.forward.get(tip); + if (idx.tomb.has(tip)) throw new Error("Document not found"); + + let root = tip; + while (idx.parent.has(root)) root = idx.parent.get(root); + return root; + }, + + async createDocument(blobMarkdown, tagsRaw, title, description) { + const ssbClient = await openSsb(); + const blobId = parseBlobId(blobMarkdown); + if (!blobId) throw new Error("Missing document blob"); + + const tags = parseCSV(tagsRaw) || []; + + const content = { + type: "document", + url: blobId, + createdAt: new Date().toISOString(), + author: userId, + tags, + title: title || "", + description: description || "", + opinions: {}, + opinions_inhabitants: [] + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async updateDocumentById(id, blobMarkdown, tagsRaw, title, description) { + const ssbClient = await openSsb(); + const tipId = await this.resolveCurrentId(id); + + const oldMsg = await new Promise((res, rej) => + ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error("Document not found")) : res(msg))) + ); + + if (oldMsg.content?.type !== "document") throw new Error("Document not found"); + if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author"); + const aggDoc = await this.getDocumentById(id); + if (aggDoc && Object.keys(aggDoc.opinions || {}).some(k => (aggDoc.opinions[k] || 0) > 0)) throw new Error("Cannot edit document after it has received opinions."); + + const parsedTags = parseCSV(tagsRaw); + const tags = parsedTags !== undefined ? parsedTags : safeArr(oldMsg.content.tags); + + const blobId = parseBlobId(blobMarkdown); + + const updatedAt = new Date().toISOString(); + + const updated = { + ...oldMsg.content, + replaces: tipId, + url: blobId || oldMsg.content.url, + tags, + title: title !== undefined ? (title || "") : oldMsg.content.title || "", + description: description !== undefined ? (description || "") : oldMsg.content.description || "", + updatedAt + }; + + const tombstone = { type: "tombstone", target: tipId, deletedAt: updatedAt, author: userId }; + await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); + + return new Promise((resolve, reject) => { + ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result))); + }); + }, + + async deleteDocumentById(id) { + const ssbClient = await openSsb(); + const tipId = await this.resolveCurrentId(id); + + const msg = await new Promise((res, rej) => + ssbClient.get(tipId, (err, m) => (err || !m ? rej(new Error("Document not found")) : res(m))) + ); + + if (msg.content?.type !== "document") throw new Error("Document not found"); + if (String(msg.content.author) !== String(userId)) throw new Error("Not the author"); + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }; + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res))); + }); + }, + + async listAll(arg1 = "all") { + const ssbClient = await openSsb(); + + const opts = typeof arg1 === "object" && arg1 !== null ? arg1 : { filter: arg1 }; + const filter = safeText(opts.filter || "all"); + const q = safeText(opts.q || "").toLowerCase(); + const sort = safeText(opts.sort || "recent"); + + const favorites = await favoritesSetForDocuments(); + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const items = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + items.push(pickDoc(node, rootId, idx.resolveGroup(rootId))); + } + + let out = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null); + const now = Date.now(); + + if (filter === "mine") out = out.filter((d) => String(d.author) === String(userId)); + else if (filter === "recent") out = out.filter((d) => new Date(d.createdAt).getTime() >= now - 86400000); + else if (filter === "favorites") out = out.filter((d) => favorites.has(d.rootId || d.key)); + + if (q) { + out = out.filter((d) => { + const t = String(d.title || "").toLowerCase(); + const desc = String(d.description || "").toLowerCase(); + const u = String(d.url || "").toLowerCase(); + const a = String(d.author || "").toLowerCase(); + const tags = safeArr(d.tags).join(" ").toLowerCase(); + return t.includes(q) || desc.includes(q) || u.includes(q) || a.includes(q) || tags.includes(q); + }); + } + + const effectiveSort = filter === "top" ? "top" : sort; + + if (effectiveSort === "top") { + out = out + .slice() + .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } else if (effectiveSort === "oldest") { + out = out.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + } else { + out = out.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + const checked = await Promise.all(out.map(async (d) => ((await hasBlob(ssbClient, d.url)) ? d : null))); + return checked + .filter(Boolean) + .map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) })); + }, + + async getDocumentById(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + const favorites = await favoritesSetForDocuments(); + + const rootId = idx.rootOf(id); + const agg = idx.resolveGroup(rootId); + if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Document not found"); + + const c = agg.contentNode.c || {}; + return { + key: agg.contentTip, + rootId, + url: c.url, + createdAt: c.createdAt, + updatedAt: c.updatedAt || null, + tags: c.tags || [], + author: agg.contentNode.author, + title: c.title || "", + description: c.description || "", + opinions: agg.opinions, + opinions_inhabitants: agg.voters, + isFavorite: favorites.has(rootId || agg.contentTip) + }; + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) return Promise.reject(new Error("Invalid voting category")); + + const ssbClient = await openSsb(); + + const doc = await this.getDocumentById(id); + if (!doc) throw new Error("Document not found"); + if (safeArr(doc.opinions_inhabitants).includes(userId)) throw new Error("Already voted"); + + const content = { type: "documentOpinion", target: doc.rootId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result))); + }); + } + }; +}; + diff --git a/src/models/events_model.js b/src/models/events_model.js new file mode 100644 index 0000000..fb6513d --- /dev/null +++ b/src/models/events_model.js @@ -0,0 +1,633 @@ +const pull = require('../server/node_modules/pull-stream'); +const moment = require('../server/node_modules/moment'); +const crypto = require('crypto'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { getConfig } = require('../configs/config-manager.js'); +const { dedupeBy, norm } = require('../backend/dedupe'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + const me = async () => (await openSsb()).id; + + const ownCrypto = eventCrypto || tribeCrypto; + const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null; + + const readAll = async (ssbClient) => + new Promise((resolve, reject) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))) + ); + + const ingestOwnTribeKeys = async () => { + if (!ownCrypto) return; + try { + const ssbClient = await openSsb(); + const ssbKeys = require("../server/node_modules/ssb-keys"); + const cfg = require("../server/ssb_config"); + const msgs = await readAll(ssbClient); + for (const m of msgs) { + const c = m.value && m.value.content; + if (!c || c.type !== "tribe-keys") continue; + const memberKeys = c.memberKeys; + if (!memberKeys || typeof memberKeys !== "object") continue; + const boxed = memberKeys[ssbClient.id]; + if (!boxed) continue; + try { + const unboxed = ssbKeys.unbox(boxed, cfg.keys); + const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null); + if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key); + } catch (_) {} + } + } catch (_) {} + }; + + const decryptEventContent = (rawContent, eventId) => { + if (!rawContent) return null; + if (!rawContent.encryptedPayload) return rawContent; + if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true }; + const keys = (ownCrypto.getKeys && ownCrypto.getKeys(eventId)) || []; + if (!keys.length) return { ...rawContent, _undecryptable: true }; + const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k])); + if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true }; + return { ...dec, _decrypted: true }; + }; + + const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length))); + + const normalizePrivacy = (v) => { + const s = String(v || 'public').toLowerCase(); + return s === 'private' ? 'private' : 'public'; + }; + + const normalizePrice = (price) => { + let p = typeof price === 'string' ? parseFloat(price.replace(',', '.')) : price; + if (isNaN(p) || p < 0) p = 0; + return Number(p).toFixed(6); + }; + + const normalizeDate = (date) => { + const m = moment(date); + if (!m.isValid()) throw new Error("Invalid date format"); + return m.toISOString(); + }; + + const deriveStatus = (c) => { + const dateM = moment(c.date); + let status = String(c.status || 'OPEN').toUpperCase(); + if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED'; + if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN'; + return status; + }; + + const buildEventIndex = (messages) => { + const eventAuthor = new Map(); + const eventReplaces = new Map(); + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || c.type !== 'event') continue; + eventAuthor.set(m.key, m.value.author); + if (c.replaces) eventReplaces.set(m.key, c.replaces); + } + for (const [key, target] of Array.from(eventReplaces.entries())) { + if (!eventAuthor.has(target)) { eventReplaces.delete(key); continue; } + if (eventAuthor.get(target) !== eventAuthor.get(key)) { eventReplaces.delete(key); eventAuthor.delete(key); } + } + const strictNext = new Map(); + for (const [key, target] of eventReplaces) { + if (eventAuthor.get(target) === eventAuthor.get(key)) strictNext.set(target, key); + } + const rootOf = (id) => { + let cur = id; + const seen = new Set(); + while (eventReplaces.has(cur) && eventAuthor.has(eventReplaces.get(cur))) { + if (seen.has(cur)) break; + seen.add(cur); + cur = eventReplaces.get(cur); + } + return cur; + }; + const contentTipOf = (root) => { + let cur = root; + const seen = new Set(); + while (strictNext.has(cur)) { + if (seen.has(cur)) break; + seen.add(cur); + cur = strictNext.get(cur); + } + return cur; + }; + return { eventAuthor, eventReplaces, strictNext, rootOf, contentTipOf }; + }; + + const collectCollab = (messages) => { + const opinions = new Map(); + const attend = new Map(); + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || typeof c.target !== 'string') continue; + const author = m.value.author; + const ts = (m.value && m.value.timestamp) || 0; + if (c.type === 'eventOpinion' && typeof c.category === 'string') { + if (!opinions.has(c.target)) opinions.set(c.target, new Map()); + const byAuthor = opinions.get(c.target); + if (!byAuthor.has(author)) byAuthor.set(author, c.category); + } else if (c.type === 'eventAttend') { + if (!attend.has(c.target)) attend.set(c.target, new Map()); + const byAuthor = attend.get(c.target); + const prev = byAuthor.get(author); + if (!prev || ts >= prev.ts) byAuthor.set(author, { on: c.on !== false, ts }); + } + } + return { opinions, attend }; + }; + + const aggregateCollab = (ownerContent, rootId, collab) => { + const attendees = new Set(uniq(ownerContent.attendees || [])); + if (ownerContent.organizer) attendees.add(ownerContent.organizer); + const attMap = collab.attend.get(rootId); + if (attMap) for (const [author, st] of attMap) { if (st.on) attendees.add(author); else if (author !== ownerContent.organizer) attendees.delete(author); } + + const opinionInh = new Set(Array.isArray(ownerContent.opinions_inhabitants) ? ownerContent.opinions_inhabitants : []); + const opinions = Object.assign({}, ownerContent.opinions || {}); + const opMap = collab.opinions.get(rootId); + if (opMap) for (const [author, category] of opMap) { + if (opinionInh.has(author)) continue; + opinionInh.add(author); + opinions[category] = (opinions[category] || 0) + 1; + } + return { attendees: uniq([...attendees]), opinions, opinions_inhabitants: [...opinionInh] }; + }; + + return { + type: 'event', + + async ingestKeys() { await ingestOwnTribeKeys(); }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await readAll(ssbClient); + const idx = buildEventIndex(messages); + return idx.rootOf(id); + }, + + async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) { + const ssbClient = await openSsb(); + const userId = await me(); + + const formattedDate = normalizeDate(date); + if (moment(formattedDate).isBefore(moment().startOf('minute'))) throw new Error("Cannot create an event in the past"); + + let attendeeList = attendees; + if (!Array.isArray(attendeeList)) attendeeList = String(attendeeList || '').split(',').map(s => s.trim()).filter(Boolean); + attendeeList = uniq([...attendeeList, userId]); + + const tags = Array.isArray(tagsRaw) + ? tagsRaw.filter(Boolean) + : String(tagsRaw || '').split(',').map(s => s.trim()).filter(Boolean); + + const visibility = normalizePrivacy(isPublic); + const plainContent = { + type: 'event', + title, + description, + date: formattedDate, + location, + price: normalizePrice(price), + url: url || '', + attendees: attendeeList, + tags, + createdAt: new Date().toISOString(), + organizer: userId, + status: 'OPEN', + isPublic: visibility, + mapUrl: String(mapUrl || "").trim(), + clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on', + opinions: {}, + opinions_inhabitants: [] + }; + + const shouldEncrypt = visibility === 'private' && ownCrypto && tribeCrypto; + let eventKey = null; + let content = plainContent; + if (shouldEncrypt) { + eventKey = ownCrypto.generateTribeKey(); + content = tribeCrypto.encryptContent(plainContent, [eventKey], true); + } + + const result = await new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)); + }); + + if (eventKey) { + ownCrypto.setKey(result.key, eventKey, 1); + try { + const ssbKeys = require("../server/node_modules/ssb-keys"); + const memberKeys = {}; + for (const m of attendeeList) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(eventKey, m, ssbKeys); } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys }, () => resolve()); + }); + } + } catch (_) {} + } + return result; + }, + + async generateInvite(eventId) { + if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable"); + const ssbClient = await openSsb(); + const userId = await me(); + const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("Error retrieving event")) : res(m))); + const rid = await this.resolveRootId(eventId); + const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content; + if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted"); + if (c.organizer !== userId) throw new Error("Only the organizer can generate invites"); + if (normalizePrivacy(c.isPublic) !== 'private') throw new Error("Only private events use invitation codes"); + const key = lookupKey(rid); + if (!key) throw new Error("Missing event key — cannot generate invite"); + const code = crypto.randomBytes(16).toString('hex'); + const inviteSalt = tribeCrypto.generateInviteSalt(); + const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt); + const inviteRef = { code, ek, salt: inviteSalt, gen: 1, rootId: rid }; + await new Promise((resolve, reject) => { + ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve()); + }); + return { code, eventId: rid }; + }, + + async getOpenInvite(eventId) { + const ssbClient = await openSsb(); + const rid = await this.resolveRootId(eventId).catch(() => eventId); + const messages = await readAll(ssbClient); + const markerTomb = new Set(); + const invTomb = new Set(); + for (const m of messages) { + const c = m.value && m.value.content; + if (!c) continue; + if (c.type === 'event-open-invite-tombstone' && typeof c.target === 'string') markerTomb.add(c.target); + if (c.type === 'event-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target); + } + let best = null; + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || c.type !== 'event-open-invite' || c.v !== 1) continue; + if (c.target !== rid || typeof c.code !== 'string') continue; + if (markerTomb.has(m.key)) continue; + if (c.inviteKey && invTomb.has(c.inviteKey)) continue; + const ts = (m.value && m.value.timestamp) || 0; + if (!best || ts > best.ts) best = { code: c.code, by: c.by || m.value.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts }; + } + return best ? { code: best.code, by: best.by, markerKey: best.markerKey, inviteKey: best.inviteKey } : null; + }, + + async generateOpenInvite(eventId) { + if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable"); + const ssbClient = await openSsb(); + const userId = await me(); + const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("Error retrieving event")) : res(m))); + const rid = await this.resolveRootId(eventId); + const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content; + if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted"); + if (c.organizer !== userId) throw new Error("Only the organizer can generate invites"); + if (normalizePrivacy(c.isPublic) !== 'private') throw new Error("Only private events use invitation codes"); + if (await this.getOpenInvite(eventId)) throw new Error("An open invitation already exists"); + const key = lookupKey(rid); + if (!key) throw new Error("Missing event key — cannot generate invite"); + const code = crypto.randomBytes(16).toString('hex'); + const inviteSalt = tribeCrypto.generateInviteSalt(); + const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt); + const invitePub = await new Promise((resolve, reject) => { + ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r)); + }); + await new Promise((resolve, reject) => { + ssbClient.publish({ type: 'event-open-invite', v: 1, target: rid, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err) => err ? reject(err) : resolve()); + }); + return { code, eventId: rid }; + }, + + async removeOpenInvite(eventId) { + const ssbClient = await openSsb(); + const userId = await me(); + const rec = await this.getOpenInvite(eventId); + if (!rec) return; + let organizer = null; + try { + const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("nf")) : res(m))); + const rid = await this.resolveRootId(eventId); + const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content; + organizer = c && c.organizer; + } catch (_) {} + if (rec.by !== userId && organizer !== userId) throw new Error("Not allowed to remove this invitation"); + await new Promise((resolve, reject) => ssbClient.publish({ type: 'event-open-invite-tombstone', target: rec.markerKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve())); + if (rec.inviteKey) await new Promise((resolve, reject) => ssbClient.publish({ type: 'event-invite-tombstone', target: rec.inviteKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve())); + }, + + async joinByInvite(code) { + if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable"); + const ssbClient = await openSsb(); + const userId = await me(); + const messages = await readAll(ssbClient); + const invTomb = new Set(); + for (const m of messages) { + const c = m.value && m.value.content; + if (c && c.type === 'event-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target); + } + let matched = null; + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || c.type !== 'event-invite') continue; + if (invTomb.has(m.key)) continue; + try { + const hash = tribeCrypto.hashInviteCode(code, c.salt); + if (hash === c.codeHash) { matched = c; break; } + } catch (_) {} + } + if (!matched) throw new Error("Invalid or expired invite code"); + const eventKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt); + if (!eventKey) throw new Error("Could not decrypt invite"); + ownCrypto.addNewKey(matched.target, eventKey); + await this.toggleAttendee(matched.target); + return { ok: true, eventId: matched.target }; + }, + + async toggleAttendee(eventId) { + const ssbClient = await openSsb(); + const userId = await me(); + const messages = await readAll(ssbClient); + const idx = buildEventIndex(messages); + const rid = idx.rootOf(eventId); + const contentTip = idx.contentTipOf(rid); + const ev = await new Promise((res, rej) => ssbClient.get(contentTip, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev))); + const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content; + if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted"); + + const status = deriveStatus(c); + if (status === 'CLOSED') throw new Error("Cannot attend a closed event"); + + const collab = collectCollab(messages); + const agg = aggregateCollab(c, rid, collab); + const isAttending = agg.attendees.includes(userId); + const isLeaving = isAttending; + const on = !isAttending; + + const isPrivate = normalizePrivacy(c.isPublic) === 'private'; + const isOrganizer = c.organizer === userId; + + const result = await new Promise((resolve, reject) => { + ssbClient.publish({ type: 'eventAttend', target: rid, on, createdAt: new Date().toISOString() }, (err2, res2) => err2 ? reject(err2) : resolve(res2)); + }); + + if (isPrivate && !isLeaving && ownCrypto && tribeCrypto) { + try { + const key = lookupKey(rid); + if (key) { + const ssbKeys = require("../server/node_modules/ssb-keys"); + const memberKeys = {}; + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(key, userId, ssbKeys); } catch (_) {} + if (memberKeys[userId]) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: 1, memberKeys }, () => resolve()); + }); + } + } + } catch (_) {} + } + + if (isPrivate && isLeaving && !isOrganizer && ownCrypto && tribeCrypto) { + try { + const remaining = agg.attendees.filter(a => a !== userId); + const newKey = ownCrypto.generateTribeKey(); + const newGen = ownCrypto.addNewKey(rid, newKey); + const ssbKeys = require("../server/node_modules/ssb-keys"); + const memberKeys = {}; + for (const m of remaining) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: newGen, memberKeys }, () => resolve()); + }); + } + } catch (_) {} + } + + return result; + }, + + async deleteEventById(eventId) { + const ssbClient = await openSsb(); + const userId = await me(); + const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev))); + const rid = await this.resolveRootId(eventId); + const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content; + if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted"); + if (c.organizer !== userId) throw new Error("Only the organizer can delete this event"); + const tombstone = { type: 'tombstone', target: eventId, deletedAt: new Date().toISOString(), author: userId }; + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res)); + }); + }, + + async getEventById(eventId) { + const ssbClient = await openSsb(); + const messages = await readAll(ssbClient); + const idx = buildEventIndex(messages); + const rid = idx.rootOf(eventId); + const contentTip = idx.contentTipOf(rid); + const msg = await new Promise((res, rej) => ssbClient.get(contentTip, (err, msg) => err || !msg || !msg.content ? rej(new Error("Error retrieving event")) : res(msg))); + const c = msg.content && msg.content.encryptedPayload ? decryptEventContent(msg.content, rid) : msg.content; + if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted with available keys"); + + const status = deriveStatus(c); + const agg = aggregateCollab(c, rid, collectCollab(messages)); + + return { + id: contentTip, + title: c.title || '', + description: c.description || '', + date: c.date || '', + location: c.location || '', + price: c.price || 0, + url: c.url || '', + attendees: agg.attendees, + tags: Array.isArray(c.tags) ? c.tags : [], + createdAt: c.createdAt || new Date().toISOString(), + updatedAt: c.updatedAt || new Date().toISOString(), + organizer: c.organizer || '', + status, + isPublic: normalizePrivacy(c.isPublic), + mapUrl: c.mapUrl || "", + clearnetPublic: !!c.clearnetPublic, + encrypted: normalizePrivacy(c.isPublic) === 'private', + opinions: agg.opinions, + opinions_inhabitants: agg.opinions_inhabitants + }; + }, + + async updateEventById(eventId, updatedData) { + const ssbClient = await openSsb(); + const userId = await me(); + const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev))); + const rid = await this.resolveRootId(eventId); + const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content; + if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted"); + if (c.organizer !== userId) throw new Error("Only the organizer can update this event"); + const status = deriveStatus(c); + if (status === 'CLOSED') throw new Error("Cannot edit a closed event"); + + const tags = updatedData.tags !== undefined + ? (Array.isArray(updatedData.tags) + ? updatedData.tags.filter(Boolean) + : String(updatedData.tags || '').split(',').map(t => t.trim()).filter(Boolean)) + : (Array.isArray(c.tags) ? c.tags : []); + + const date = updatedData.date !== undefined && updatedData.date !== '' + ? normalizeDate(updatedData.date) + : c.date; + + if (moment(date).isBefore(moment().startOf('minute'))) throw new Error("Cannot set an event in the past"); + + let updated = { + ...c, + title: updatedData.title ?? c.title, + description: updatedData.description ?? c.description, + date, + location: updatedData.location ?? c.location, + price: updatedData.price !== undefined ? normalizePrice(updatedData.price) : c.price, + url: updatedData.url ?? c.url, + tags, + isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic), + clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic, + attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []), + updatedAt: new Date().toISOString(), + replaces: eventId + }; + + const wasPrivate = normalizePrivacy(c.isPublic) === 'private'; + const isPrivate = updated.isPublic === 'private'; + let newKey = null; + if (isPrivate && ownCrypto && tribeCrypto) { + let key = lookupKey(rid); + if (!key) { + newKey = ownCrypto.generateTribeKey(); + ownCrypto.setKey(rid, newKey, 1); + key = newKey; + } + updated = tribeCrypto.encryptContent(updated, [key], true); + } + + const result = await new Promise((resolve, reject) => { + ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2)); + }); + + if (newKey && tribeCrypto) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys"); + const memberKeys = {}; + for (const m of (Array.isArray(c.attendees) ? c.attendees : [userId])) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: 1, memberKeys }, () => resolve()); + }); + } + } catch (_) {} + } + + return result; + }, + + async createOpinion(id, category) { + const categories = require('../backend/opinion_categories'); + if (!categories.includes(category)) throw new Error('Invalid opinion category'); + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const messages = await readAll(ssbClient); + const idx = buildEventIndex(messages); + const rid = idx.rootOf(id); + const contentTip = idx.contentTipOf(rid); + const ev = await new Promise((res, rej) => ssbClient.get(contentTip, (err, m) => (err || !m || !m.content) ? rej(new Error('Event not found')) : res(m))); + const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content; + if (c && c._undecryptable) throw new Error('Event is encrypted and cannot be decrypted'); + const agg = aggregateCollab(c, rid, collectCollab(messages)); + if (agg.opinions_inhabitants.includes(userId)) throw new Error('Already opined'); + return new Promise((res, rej) => ssbClient.publish({ type: 'eventOpinion', target: rid, category, createdAt: new Date().toISOString() }, (err, result) => err ? rej(err) : res(result))); + }, + + async listAll(author = null, filter = 'all') { + const ssbClient = await openSsb(); + const userId = await me(); + return new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, results) => { + if (err) return reject(new Error("Error listing events: " + err.message)); + const tombstoned = buildValidatedTombstoneSet(results); + const idx = buildEventIndex(results); + const collab = collectCollab(results); + + const roots = new Set(); + for (const r of results) { + const rawC = r.value && r.value.content; + if (!rawC || rawC.type !== 'event') continue; + if (!idx.eventAuthor.has(r.key)) continue; + roots.add(idx.rootOf(r.key)); + } + + const byRoot = new Map(); + for (const rid of roots) { + if (tombstoned.has(rid)) continue; + const contentTip = idx.contentTipOf(rid); + if (tombstoned.has(contentTip)) continue; + const r = results.find(x => x.key === contentTip); + if (!r) continue; + const rawC = r.value && r.value.content; + if (!rawC) continue; + const c = rawC.encryptedPayload ? decryptEventContent(rawC, rid) : rawC; + if (!c || c._undecryptable) continue; + if (author && c.organizer !== author) continue; + + const status = deriveStatus(c); + const agg = aggregateCollab(c, rid, collab); + + byRoot.set(rid, { + id: contentTip, + title: c.title || '', + description: c.description || '', + date: c.date || '', + location: c.location || '', + price: c.price || 0, + url: c.url || '', + attendees: agg.attendees, + tags: Array.isArray(c.tags) ? c.tags.filter(Boolean) : [], + createdAt: c.createdAt || new Date().toISOString(), + organizer: c.organizer || '', + status, + isPublic: normalizePrivacy(c.isPublic), + mapUrl: c.mapUrl || "", + encrypted: normalizePrivacy(c.isPublic) === 'private', + opinions: agg.opinions, + opinions_inhabitants: agg.opinions_inhabitants + }); + } + + let out = Array.from(byRoot.values()); + out = dedupeBy(out, e => e.title ? [norm(e.organizer), norm(e.title), norm(e.date)].join('|') : null); + + if (filter === 'mine') out = out.filter(e => e.organizer === userId); + if (filter === 'open') out = out.filter(e => String(e.status).toUpperCase() === 'OPEN'); + if (filter === 'closed') out = out.filter(e => String(e.status).toUpperCase() === 'CLOSED'); + + resolve(out); + }) + ); + }); + } + }; +}; + diff --git a/src/models/exportmode_model.js b/src/models/exportmode_model.js new file mode 100644 index 0000000..cd535d5 --- /dev/null +++ b/src/models/exportmode_model.js @@ -0,0 +1,52 @@ +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const archiver = require('../server/node_modules/archiver'); + +module.exports = { + exportSSB: async (outputPath) => { + try { + const homeDir = os.homedir(); + const ssbPath = path.join(homeDir, '.ssb'); + const output = fs.createWriteStream(outputPath); + const archive = archiver('zip', { + zlib: { level: 9 } + }); + archive.pipe(output); + + const addDirectoryToArchive = (dirPath, archive) => { + const files = fs.readdirSync(dirPath); + let hasFiles = false; + + files.forEach((file) => { + const filePath = path.join(dirPath, file); + const stat = fs.statSync(filePath); + + if (file === 'secret') { + return; + } + + if (stat.isDirectory()) { + addDirectoryToArchive(filePath, archive); + archive.directory(filePath, path.relative(ssbPath, filePath)); + hasFiles = true; + } else { + archive.file(filePath, { name: path.relative(ssbPath, filePath) }); + hasFiles = true; + } + }); + + if (!hasFiles) { + archive.directory(dirPath, path.relative(ssbPath, dirPath)); + } + }; + + addDirectoryToArchive(ssbPath, archive); + await archive.finalize(); + + return outputPath; + } catch (error) { + throw new Error("Error exporting data: " + error.message); + } + } +}; diff --git a/src/models/favorites_model.js b/src/models/favorites_model.js new file mode 100644 index 0000000..d0fd3f5 --- /dev/null +++ b/src/models/favorites_model.js @@ -0,0 +1,178 @@ +const mediaFavorites = require("../backend/media-favorites"); + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const getFn = (obj, names) => { + for (const n of names) { + if (obj && typeof obj[n] === "function") return obj[n].bind(obj); + } + return null; +}; + +const toTs = (d) => { + const t = Date.parse(String(d || "")); + return Number.isFinite(t) ? t : 0; +}; + +module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }) => { + const kindConfig = { + audios: { + base: "/audios/", + getById: getFn(audiosModel, ["getAudioById", "getById"]) + }, + bookmarks: { + base: "/bookmarks/", + getById: getFn(bookmarksModel, ["getBookmarkById", "getById"]) + }, + documents: { + base: "/documents/", + getById: getFn(documentsModel, ["getDocumentById", "getById"]) + }, + images: { + base: "/images/", + getById: getFn(imagesModel, ["getImageById", "getById"]) + }, + maps: { + base: "/maps/", + getById: getFn(mapsModel, ["getMapById", "getById"]) + }, + videos: { + base: "/videos/", + getById: getFn(videosModel, ["getVideoById", "getById"]) + }, + pads: { + base: "/pads/", + getById: getFn(padsModel, ["getPadById", "getById"]) + }, + chats: { + base: "/chats/", + getById: getFn(chatsModel, ["getChatById", "getById"]) + }, + calendars: { + base: "/calendars/", + getById: getFn(calendarsModel, ["getCalendarById", "getById"]) + }, + torrents: { + base: "/torrents/", + getById: getFn(torrentsModel, ["getTorrentById", "getById"]) + }, + market: { + base: "/market/", + getById: getFn(marketModel, ["getItemById", "getById"]) + }, + shopProducts: { + base: "/shops/product/", + getById: getFn(shopsModel, ["getProductById"]) + } + }; + + const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "torrents", "videos", "market", "shopProducts"]; + + const hydrateKind = async (kind, ids) => { + const cfg = kindConfig[kind]; + if (!cfg?.getById) return []; + + const out = await Promise.all( + safeArr(ids).map(async (favId) => { + const id = safeText(favId); + if (!id) return null; + try { + const obj = await cfg.getById(id); + const viewId = safeText(obj?.key || obj?.id || id); + + return { + kind, + favId: id, + viewHref: `${cfg.base}${encodeURIComponent(viewId)}`, + title: safeText(obj?.title) || safeText(obj?.name) || safeText(obj?.category) || safeText(obj?.url) || "", + description: safeText(obj?.description) || "", + tags: safeArr(obj?.tags), + author: safeText(obj?.author || obj?.organizer || obj?.seller || obj?.from || ""), + createdAt: obj?.createdAt || null, + updatedAt: obj?.updatedAt || null, + url: obj?.url || null, + category: obj?.category || null + }; + } catch { + return null; + } + }) + ); + + return out.filter(Boolean); + }; + + const loadAll = async () => { + const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k))); + const idsByKind = {}; + kindOrder.forEach((k, i) => { + idsByKind[k] = Array.from(sets[i] || []); + }); + + const hydrated = await Promise.all(kindOrder.map((k) => hydrateKind(k, idsByKind[k]))); + const byKind = {}; + kindOrder.forEach((k, i) => { + byKind[k] = hydrated[i] || []; + }); + + const flat = kindOrder.flatMap((k) => byKind[k]); + + const counts = { + audios: byKind.audios.length, + bookmarks: byKind.bookmarks.length, + calendars: byKind.calendars.length, + chats: byKind.chats.length, + documents: byKind.documents.length, + images: byKind.images.length, + maps: byKind.maps.length, + pads: byKind.pads.length, + torrents: byKind.torrents.length, + videos: byKind.videos.length, + market: byKind.market.length, + shopProducts: byKind.shopProducts.length, + all: flat.length + }; + + const recentFlat = flat + .slice() + .sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt))); + + return { byKind, flat, recentFlat, counts }; + }; + + return { + async listAll(opts = {}) { + const filter = safeText(opts.filter || "all").toLowerCase(); + const { byKind, recentFlat, counts } = await loadAll(); + + if (filter === "recent") { + return { items: recentFlat, counts }; + } + + if (kindOrder.includes(filter)) { + const items = byKind[filter] || []; + const sorted = items + .slice() + .sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt))); + return { items: sorted, counts }; + } + + const grouped = kindOrder.flatMap((k) => + (byKind[k] || []) + .slice() + .sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt))) + ); + + return { items: grouped, counts }; + }, + + async removeFavorite(kind, id) { + const k = safeText(kind); + const favId = safeText(id); + if (!k || !favId) return; + await mediaFavorites.removeFavorite(k, favId); + } + }; +}; + diff --git a/src/models/fediverse_model.js b/src/models/fediverse_model.js new file mode 100644 index 0000000..a8e1e0d --- /dev/null +++ b/src/models/fediverse_model.js @@ -0,0 +1,409 @@ +const fs = require('fs'); +const path = require('path'); +const { getConfig, saveConfig } = require('../configs/config-manager.js'); + +const ACCOUNTS_PATH = path.join(__dirname, '..', 'configs', 'fediverse-accounts.json'); +const FETCH_TIMEOUT_MS = 8000; +const TIMELINE_CACHE_MS = 60 * 1000; +const TIMELINE_LIMIT = 40; +const MEDIA_PROCESS_TIMEOUT_MS = 60000; +const MEDIA_POLL_INTERVAL_MS = 1500; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const SPOOF_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0'; +const spoofHeaders = (accept) => ({ + 'User-Agent': SPOOF_UA, + 'Accept': accept || '*/*', + 'Accept-Language': 'en-US,en;q=0.5', + 'DNT': '1' +}); + +const isPrivateHost = (hostname) => { + const h = String(hostname || '').toLowerCase(); + if (!h) return true; + if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true; + if (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true; + const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (m) { + const a = +m[1], b = +m[2]; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + } + return false; +}; + +module.exports = ({ isPublic } = {}) => { + const cache = new Map(); + let mediaHosts = new Set(); + + const readStore = () => { + try { + const obj = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, 'utf8')); + return obj && typeof obj === 'object' ? obj : {}; + } catch (_) { + return {}; + } + }; + + const writeStore = (obj) => { + const tmp = ACCOUNTS_PATH + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, ACCOUNTS_PATH); + try { fs.chmodSync(ACCOUNTS_PATH, 0o600); } catch (_) {} + }; + + const getMastodon = () => readStore().mastodon || null; + + const setModuleFlag = (value) => { + try { + const cfg = getConfig(); + if (cfg.modules && cfg.modules.fediverseMod !== value) { + cfg.modules.fediverseMod = value; + saveConfig(cfg); + } + } catch (_) {} + }; + + const normalizeInstance = (raw) => { + let s = String(raw || '').trim(); + if (!s) throw new Error('fediverseErrInstance'); + if (!/^https?:\/\//i.test(s)) s = 'https://' + s; + let u; + try { u = new URL(s); } catch (_) { throw new Error('fediverseErrInstance'); } + if (u.protocol !== 'https:') throw new Error('fediverseErrInstance'); + if (isPrivateHost(u.hostname)) throw new Error('fediverseErrInstance'); + return u.origin; + }; + + const apiFetch = async (instance, token, pathName, opts = {}) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const headers = Object.assign(spoofHeaders('application/json'), { Authorization: `Bearer ${token}` }, opts.headers || {}); + const res = await fetch(`${instance}${pathName}`, { ...opts, headers, signal: controller.signal }); + return res; + } finally { + clearTimeout(timer); + } + }; + + const proxify = (remoteUrl) => { + if (typeof remoteUrl !== 'string' || !remoteUrl) return ''; + try { + const u = new URL(remoteUrl); + if (u.protocol !== 'https:') return ''; + mediaHosts.add(u.host); + return `/fediverse/media?u=${encodeURIComponent(remoteUrl)}`; + } catch (_) { + return ''; + } + }; + + const mapAccount = (acc = {}) => ({ + displayName: acc.display_name || acc.username || '', + acct: acc.acct || acc.username || '', + avatar: proxify(acc.avatar_static || acc.avatar || ''), + url: typeof acc.url === 'string' ? acc.url : '' + }); + + const mapStatus = (st) => { + if (!st || typeof st !== 'object') return null; + const base = st.reblog && typeof st.reblog === 'object' ? st.reblog : st; + const media = Array.isArray(base.media_attachments) + ? base.media_attachments.map(m => ({ + type: m.type, + url: proxify(m.url || m.remote_url || ''), + description: typeof m.description === 'string' ? m.description : '' + })).filter(m => m.url) + : []; + return { + id: String(base.id || st.id || ''), + url: typeof base.url === 'string' ? base.url : '', + createdAt: base.created_at || st.created_at || '', + account: mapAccount(base.account || {}), + html: typeof base.content === 'string' ? base.content : '', + media, + boostedBy: st.reblog ? (st.account?.display_name || st.account?.acct || '') : '', + inReplyToId: base.in_reply_to_id || null, + favourited: base.favourited === true, + reblogged: base.reblogged === true, + counts: { + replies: Number(base.replies_count || 0), + reblogs: Number(base.reblogs_count || 0), + favourites: Number(base.favourites_count || 0) + } + }; + }; + + const invalidate = () => cache.clear(); + + const api = { + hasAccount() { + return !!getMastodon(); + }, + + invalidateCache() { + invalidate(); + }, + + getAccount() { + const m = getMastodon(); + if (!m) return null; + return { + network: 'mastodon', + instance: m.instance, + acct: m.acct, + displayName: m.displayName, + avatar: m.avatar ? proxify(m.avatar) : '' + }; + }, + + async getAccountStats() { + const m = getMastodon(); + if (!m) return null; + let res; + try { + res = await apiFetch(m.instance, m.token, '/api/v1/accounts/verify_credentials'); + } catch (_) { + return null; + } + if (!res.ok) return null; + const acc = await res.json().catch(() => null); + if (!acc) return null; + return { + followers: Number(acc.followers_count || 0), + following: Number(acc.following_count || 0), + posts: Number(acc.statuses_count || 0), + createdAt: acc.created_at || '', + bio: typeof acc.note === 'string' ? acc.note : '', + fields: Array.isArray(acc.fields) + ? acc.fields.map(f => ({ name: String(f && f.name || ''), value: String(f && f.value || ''), verified: !!(f && f.verified_at) })).filter(f => f.name || f.value) + : [] + }; + }, + + async connectMastodon({ instance, token }) { + if (isPublic) throw new Error('fediverseErrPublic'); + const origin = normalizeInstance(instance); + const tok = String(token || '').trim(); + if (!tok) throw new Error('fediverseErrToken'); + let res; + try { + res = await apiFetch(origin, tok, '/api/v1/accounts/verify_credentials'); + } catch (_) { + throw new Error('fediverseErrConnect'); + } + if (res.status === 401 || res.status === 403) throw new Error('fediverseErrAuth'); + if (!res.ok) throw new Error('fediverseErrConnect'); + const acc = await res.json().catch(() => ({})); + const store = readStore(); + store.mastodon = { + instance: origin, + token: tok, + id: String(acc.id || ''), + acct: acc.acct || acc.username || '', + displayName: acc.display_name || acc.username || '', + avatar: acc.avatar_static || acc.avatar || '' + }; + writeStore(store); + invalidate(); + mediaHosts = new Set(); + try { mediaHosts.add(new URL(origin).host); } catch (_) {} + setModuleFlag('on'); + return this.getAccount(); + }, + + disconnect() { + const store = readStore(); + delete store.mastodon; + writeStore(store); + invalidate(); + mediaHosts = new Set(); + return true; + }, + + async fetchStatuses(pathBase) { + const m = getMastodon(); + const res = await apiFetch(m.instance, m.token, `${pathBase}?limit=${TIMELINE_LIMIT}`); + if (res.status === 401 || res.status === 403) return { error: 'fediverseErrAuth' }; + if (!res.ok) return { error: 'fediverseErrFetch' }; + const arr = await res.json().catch(() => []); + const list = Array.isArray(arr) ? arr : []; + return { posts: list.map(mapStatus).filter(Boolean) }; + }, + + async getTimeline() { + const m = getMastodon(); + if (!m) return { connected: false, posts: [] }; + try { mediaHosts.add(new URL(m.instance).host); } catch (_) {} + const key = 'home'; + const now = Date.now(); + const hit = cache.get(key); + if (hit && now - hit.ts < TIMELINE_CACHE_MS) return hit.value; + let result; + try { + result = await this.fetchStatuses('/api/v1/timelines/home'); + } catch (_) { + return { connected: true, account: this.getAccount(), posts: [], error: 'fediverseErrFetch' }; + } + if (result.error) return { connected: true, account: this.getAccount(), posts: [], error: result.error }; + if (!result.posts.length && m.id) { + try { + const own = await this.fetchStatuses(`/api/v1/accounts/${encodeURIComponent(m.id)}/statuses`); + if (own && !own.error) result = own; + } catch (_) {} + } + const value = { connected: true, account: this.getAccount(), posts: result.posts }; + if (result.posts.length) cache.set(key, { ts: now, value }); + return value; + }, + + async getThread(id) { + const m = getMastodon(); + if (!m) return null; + const sid = encodeURIComponent(String(id || '')); + let statusRes, ctxRes; + try { + statusRes = await apiFetch(m.instance, m.token, `/api/v1/statuses/${sid}`); + ctxRes = await apiFetch(m.instance, m.token, `/api/v1/statuses/${sid}/context`); + } catch (_) { + return { error: 'fediverseErrFetch' }; + } + if (!statusRes.ok) return { error: 'fediverseErrFetch' }; + const status = mapStatus(await statusRes.json().catch(() => null)); + const context = ctxRes.ok ? await ctxRes.json().catch(() => ({})) : {}; + return { + status, + ancestors: (Array.isArray(context.ancestors) ? context.ancestors : []).map(mapStatus).filter(Boolean), + descendants: (Array.isArray(context.descendants) ? context.descendants : []).map(mapStatus).filter(Boolean) + }; + }, + + async uploadMedia(file) { + const m = getMastodon(); + if (!m) throw new Error('fediverseErrAuth'); + if (!file || !file.filepath) return null; + let buf; + try { buf = fs.readFileSync(file.filepath); } catch (_) { throw new Error('fediverseErrMedia'); } + const fd = new FormData(); + fd.append('file', new Blob([buf], { type: file.mimetype || 'application/octet-stream' }), file.originalFilename || 'upload'); + let res; + try { + res = await apiFetch(m.instance, m.token, '/api/v2/media', { method: 'POST', body: fd }); + } catch (_) { + throw new Error('fediverseErrMedia'); + } + if (!res.ok && res.status !== 202) throw new Error('fediverseErrMedia'); + const data = await res.json().catch(() => ({})); + if (!data || !data.id) return null; + const id = String(data.id); + if (res.status === 202) { + const deadline = Date.now() + MEDIA_PROCESS_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(MEDIA_POLL_INTERVAL_MS); + let poll; + try { poll = await apiFetch(m.instance, m.token, `/api/v1/media/${id}`); } catch (_) { break; } + if (poll.status === 200) break; + if (poll.status !== 206 && poll.status !== 202) break; + } + } + return { id, preview: proxify(data.preview_url || data.url || ''), description: '' }; + }, + + async postStatus({ text, inReplyToId, mediaIds, visibility } = {}) { + const m = getMastodon(); + if (!m) throw new Error('fediverseErrAuth'); + const status = String(text || '').trim(); + const ids = Array.isArray(mediaIds) ? mediaIds.filter(Boolean) : []; + if (!status && !ids.length) throw new Error('fediverseErrEmpty'); + const body = { status }; + if (inReplyToId) body.in_reply_to_id = String(inReplyToId); + if (ids.length) body.media_ids = ids; + if (visibility) body.visibility = String(visibility); + let res; + try { + res = await apiFetch(m.instance, m.token, '/api/v1/statuses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } catch (_) { + throw new Error('fediverseErrPost'); + } + if (res.status === 401 || res.status === 403) throw new Error('fediverseErrAuth'); + if (!res.ok) throw new Error('fediverseErrPost'); + invalidate(); + return true; + }, + + async action(id, verb) { + const m = getMastodon(); + if (!m) throw new Error('fediverseErrAuth'); + const allowed = { reblog: 1, unreblog: 1, favourite: 1, unfavourite: 1 }; + if (!allowed[verb]) throw new Error('fediverseErrPost'); + const sid = encodeURIComponent(String(id || '')); + let res; + try { + res = await apiFetch(m.instance, m.token, `/api/v1/statuses/${sid}/${verb}`, { method: 'POST' }); + } catch (_) { + throw new Error('fediverseErrPost'); + } + if (res.status === 401 || res.status === 403) throw new Error('fediverseErrAuth'); + if (!res.ok) throw new Error('fediverseErrPost'); + invalidate(); + return true; + }, + + reblog(id) { return this.action(id, 'reblog'); }, + unreblog(id) { return this.action(id, 'unreblog'); }, + favourite(id) { return this.action(id, 'favourite'); }, + unfavourite(id) { return this.action(id, 'unfavourite'); }, + + isHostAllowed(host) { + if (mediaHosts.has(host)) return true; + const m = getMastodon(); + if (m) { + try { if (new URL(m.instance).host === host) return true; } catch (_) {} + try { if (m.avatar && new URL(m.avatar).host === host) return true; } catch (_) {} + } + return false; + }, + + async proxyMedia(remoteUrl) { + let u; + try { u = new URL(String(remoteUrl)); } catch (_) { return null; } + if (u.protocol !== 'https:') return null; + if (isPrivateHost(u.hostname)) return null; + if (!this.isHostAllowed(u.host)) return null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + let current = u, res = null; + for (let hop = 0; hop < 5; hop++) { + res = await fetch(current.href, { signal: controller.signal, redirect: 'manual', headers: spoofHeaders('image/avif,image/webp,*/*') }); + if (res.status < 300 || res.status >= 400) break; + const loc = res.headers.get('location'); + if (!loc) return null; + let next; + try { next = new URL(loc, current); } catch (_) { return null; } + if (next.protocol !== 'https:') return null; + if (isPrivateHost(next.hostname)) return null; + current = next; + } + if (!res || !res.ok) return null; + const ct = res.headers.get('content-type') || 'application/octet-stream'; + if (!/^(image|video|audio)\//i.test(ct)) return null; + const ab = await res.arrayBuffer(); + return { contentType: ct, buffer: Buffer.from(ab) }; + } catch (_) { + return null; + } finally { + clearTimeout(timer); + } + } + }; + + return api; +}; diff --git a/src/models/feed_model.js b/src/models/feed_model.js new file mode 100644 index 0000000..80f894b --- /dev/null +++ b/src/models/feed_model.js @@ -0,0 +1,435 @@ +const pull = require("../server/node_modules/pull-stream"); +const { getConfig } = require("../configs/config-manager.js"); +const categories = require("../backend/opinion_categories"); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, mergeDuplicatesBy, norm } = require('../backend/dedupe'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1); +const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280); + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const cleanText = (t) => (typeof t === "string" ? t.trim() : ""); + + const isValidFeedText = (t) => { + const s = cleanText(t); + return s.length >= FEED_TEXT_MIN && s.length <= FEED_TEXT_MAX; + }; + + const getMsg = (ssbClient, id) => + new Promise((resolve, reject) => { + ssbClient.get(id, (err, val) => (err ? reject(err) : resolve({ key: id, value: val }))); + }); + + const getAllMessages = (ssbClient) => + new Promise((resolve, reject) => { + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))); + }); + + const extractTags = (text) => { + const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase()); + return Array.from(new Set(list)); + }; + + const buildIndex = async (ssbClient) => { + const messages = await getAllMessages(ssbClient); + + const forward = new Map(); + const replacedIds = new Set(); + const tombstoned = buildValidatedTombstoneSet(messages); + const feedsById = new Map(); + const authorById = new Map(); + const actions = []; + const opinionMsgs = []; + + for (const msg of messages) { + const c = msg?.value?.content; + const k = msg?.key; + if (!c || !k) continue; + if (c.type === 'tombstone') continue; + if (c.type === "feedOpinion") { + opinionMsgs.push({ target: c.target, author: msg?.value?.author, category: c.category }); + continue; + } + if (c.type === "feed") { + feedsById.set(k, msg); + authorById.set(k, msg?.value?.author); + continue; + } + if (c.type === "feed-action") { + actions.push(msg); + continue; + } + } + + for (const [k, msg] of feedsById) { + const t = msg?.value?.content?.replaces; + if (!t) continue; + if (authorById.get(t) === authorById.get(k)) { + forward.set(t, k); + replacedIds.add(t); + } + } + + const resolve = (id) => { + let cur = id; + const seen = new Set(); + while (forward.has(cur) && !seen.has(cur)) { + seen.add(cur); + cur = forward.get(cur); + } + return cur; + }; + + const actionsByRoot = new Map(); + for (const a of actions) { + const c = a?.value?.content || {}; + const target = c.root || c.target; + if (!target) continue; + const root = resolve(target); + if (!actionsByRoot.has(root)) actionsByRoot.set(root, []); + actionsByRoot.get(root).push(a); + } + + const opinionsByTip = new Map(); + for (const op of opinionMsgs) { + if (!op.target || !feedsById.has(op.target)) continue; + const tip = resolve(op.target); + if (!opinionsByTip.has(tip)) opinionsByTip.set(tip, []); + opinionsByTip.get(tip).push(op); + } + + return { resolve, tombstoned, feedsById, replacedIds, actionsByRoot, opinionsByTip }; + }; + + const resolveCurrentId = async (id) => { + const ssbClient = await openSsb(); + const idx = await buildIndex(ssbClient); + return idx.resolve(id); + }; + + const createFeed = async (text, mentions) => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + if (typeof text !== "string") throw new Error("Invalid text"); + const cleaned = cleanText(text); + + if (!isValidFeedText(cleaned)) { + if (cleaned.length < FEED_TEXT_MIN) throw new Error("Text too short"); + if (cleaned.length > FEED_TEXT_MAX) throw new Error("Text too long"); + throw new Error("Text required"); + } + + const content = { + type: "feed", + text: cleaned, + author: userId, + createdAt: new Date().toISOString(), + tags: extractTags(cleaned), + mentions: Array.isArray(mentions) && mentions.length > 0 ? mentions : undefined + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg))); + }); + }; + + const createRefeed = async (contentId) => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const idx = await buildIndex(ssbClient); + const tipId = idx.resolve(contentId); + + let msg; + try { + msg = idx.feedsById.get(tipId) || (await getMsg(ssbClient, tipId)); + } catch { + throw new Error("Invalid feed"); + } + + const c = msg?.value?.content; + if (!c || c.type !== "feed") throw new Error("Invalid feed"); + if (!isValidFeedText(c.text)) throw new Error("Invalid feed"); + + const existing = idx.actionsByRoot.get(tipId) || []; + for (const a of existing) { + const ac = a?.value?.content || {}; + if (ac.type === "feed-action" && ac.action === "refeed" && a.value?.author === userId) throw new Error("Already refeeded"); + } + + const action = { + type: "feed-action", + action: "refeed", + root: tipId, + createdAt: new Date().toISOString(), + author: userId + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(action, (err, out) => (err ? reject(err) : resolve(out))); + }); + }; + + const aggregateOpinions = (idx, tipId, ownerContent) => { + const opinions = {}; + const voters = new Set(); + + const cc = ownerContent || idx.feedsById.get(tipId)?.value?.content || {}; + const ownerVoters = Array.isArray(cc.opinions_inhabitants) ? cc.opinions_inhabitants : []; + const ownerOpinions = cc.opinions && typeof cc.opinions === "object" ? cc.opinions : {}; + for (const [k, v] of Object.entries(ownerOpinions)) opinions[k] = (Number(v) || 0); + for (const voter of ownerVoters) voters.add(voter); + + const actions = idx.actionsByRoot.get(tipId) || []; + for (const a of actions) { + const ac = a?.value?.content || {}; + if (ac.type !== "feed-action" || ac.action !== "vote") continue; + const author = a?.value?.author || ac.author; + if (!author || voters.has(author)) continue; + voters.add(author); + const cat = String(ac.category || ""); + opinions[cat] = (Number(opinions[cat]) || 0) + 1; + } + + for (const op of (idx.opinionsByTip.get(tipId) || [])) { + if (!op.author || voters.has(op.author)) continue; + voters.add(op.author); + const cat = String(op.category || ""); + opinions[cat] = (Number(opinions[cat]) || 0) + 1; + } + + return { opinions, voters }; + }; + + const addOpinion = async (contentId, category) => { + if (!categories.includes(category)) throw new Error("Invalid voting category"); + + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const idx = await buildIndex(ssbClient); + const tipId = idx.resolve(contentId); + + const msg = idx.feedsById.get(tipId); + const c = msg?.value?.content; + if (!c || c.type !== "feed") throw new Error("Invalid feed"); + if (!isValidFeedText(c.text)) throw new Error("Invalid feed"); + + const agg = aggregateOpinions(idx, tipId, c); + if (agg.voters.has(userId)) throw new Error("Already voted"); + + const content = { type: "feedOpinion", target: tipId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result))); + }); + }; + + const listFeeds = async (filterOrOpts = "ALL") => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const now = Date.now(); + + const opts = typeof filterOrOpts === "string" ? { filter: filterOrOpts } : (filterOrOpts || {}); + const filter = String(opts.filter || "ALL").toUpperCase(); + const q = typeof opts.q === "string" ? opts.q.trim().toLowerCase() : ""; + const tag = typeof opts.tag === "string" ? opts.tag.trim().toLowerCase() : ""; + + const idx = await buildIndex(ssbClient); + + const isValidFeedMsg = (m) => { + const c = m?.value?.content; + return !!c && c.type === "feed" && isValidFeedText(c.text); + }; + + let tips = Array.from(idx.feedsById.values()).filter( + (m) => + !idx.replacedIds.has(m.key) && + !idx.tombstoned.has(m.key) && + isValidFeedMsg(m) + ); + + const textEditedEver = (m) => { + const seen = new Set(); + let cur = m; + let lastText = cur?.value?.content?.text; + while (cur?.value?.content?.replaces) { + const prevId = cur.value.content.replaces; + if (!prevId || seen.has(prevId)) break; + seen.add(prevId); + const prev = idx.feedsById.get(prevId); + if (!prev) break; + if (prev?.value?.author !== cur?.value?.author) break; + const prevText = prev?.value?.content?.text; + if (typeof lastText === "string" && typeof prevText === "string" && lastText !== prevText) return true; + cur = prev; + lastText = prevText; + } + return false; + }; + + const materialize = (feedMsg) => { + const base = feedMsg || {}; + const content = { ...(base.value?.content || {}) }; + const root = base.key; + + let refeeds = Number(content.refeeds || 0) || 0; + const refeedsInhabitants = new Set(Array.isArray(content.refeeds_inhabitants) ? content.refeeds_inhabitants : []); + + let commentCount = 0; + + const actions = idx.actionsByRoot.get(root) || []; + for (const a of actions) { + const ac = a?.value?.content || {}; + const author = a?.value?.author || ac.author; + if (!author) continue; + + if (ac.action === "refeed") { + if (!refeedsInhabitants.has(author)) { + refeedsInhabitants.add(author); + refeeds += 1; + } + continue; + } + + if (ac.action === "comment") { + commentCount++; + continue; + } + } + + const agg = aggregateOpinions(idx, root, content); + + content.refeeds = refeeds; + content.refeeds_inhabitants = Array.from(refeedsInhabitants); + content.opinions = agg.opinions; + content.opinions_inhabitants = Array.from(agg.voters); + content.commentCount = commentCount; + + if (!Array.isArray(content.tags)) content.tags = extractTags(content.text); + + content._textEdited = textEditedEver(base); + + return { ...base, value: { ...base.value, content } }; + }; + + let feeds = dedupeBy(tips.map(materialize), m => { + const c = (m && m.value && m.value.content) || {}; + const author = (m && m.value && m.value.author) || c.author; + return c.text ? [norm(author), norm(c.text)].join('|') : null; + }); + + const tsOf = (m) => m?.value?.timestamp || Date.parse(m?.value?.content?.createdAt || "") || 0; + const isOwn = (m) => m?.value?.author === userId; + feeds = mergeDuplicatesBy(feeds, m => norm(m?.value?.content?.text) || null, (a, b) => { + let keep; + if (isOwn(a) !== isOwn(b)) keep = isOwn(a) ? a : b; + else keep = tsOf(a) <= tsOf(b) ? a : b; + const drop = keep === a ? b : a; + const kc = keep.value.content || {}; + const dc = drop.value.content || {}; + const voters = new Set([...(kc.opinions_inhabitants || []), ...(dc.opinions_inhabitants || [])]); + const ops = { ...(kc.opinions || {}) }; + for (const [cat, n] of Object.entries(dc.opinions || {})) ops[cat] = (Number(ops[cat]) || 0) + (Number(n) || 0); + const refeeders = new Set([...(kc.refeeds_inhabitants || []), ...(dc.refeeds_inhabitants || [])]); + keep.value = { + ...keep.value, + content: { + ...kc, + opinions: ops, + opinions_inhabitants: Array.from(voters), + refeeds_inhabitants: Array.from(refeeders), + refeeds: refeeders.size, + commentCount: (Number(kc.commentCount) || 0) + (Number(dc.commentCount) || 0) + } + }; + return keep; + }); + + if (q) { + const terms = q.split(/\s+/).map((s) => s.trim()).filter(Boolean); + feeds = feeds.filter((m) => { + const t = String(m.value?.content?.text || "").toLowerCase(); + return terms.every((term) => t.includes(term)); + }); + } + if (tag) feeds = feeds.filter((m) => Array.isArray(m.value?.content?.tags) && m.value.content.tags.includes(tag)); + + const getTs = (m) => m?.value?.timestamp || Date.parse(m?.value?.content?.createdAt || "") || 0; + const totalVotes = (m) => Object.values(m?.value?.content?.opinions || {}).reduce((s, x) => s + (Number(x) || 0), 0); + + if (filter === "MINE") { + feeds = feeds.filter((m) => (m.value?.author || m.value?.content?.author) === userId); + } else if (filter === "TODAY") { + feeds = feeds.filter((m) => now - getTs(m) < 86400000); + } + + if (filter === "TOP") { + feeds.sort( + (a, b) => + (b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) || + getTs(b) - getTs(a) + ); + } else { + feeds.sort((a, b) => getTs(b) - getTs(a)); + } + + return feeds; + }; + + const getFeedById = async (feedId) => { + const ssbClient = await openSsb(); + const idx = await buildIndex(ssbClient); + const currentId = idx.resolve(feedId); + if (idx.tombstoned.has(currentId)) return null; + const msg = idx.feedsById.get(currentId); + if (!msg) return null; + const actions = idx.actionsByRoot.get(currentId) || []; + const content = msg.value?.content || {}; + const refeedsInhabitants = []; + let refeeds = 0; + let commentCount = 0; + for (const a of actions) { + const ac = a?.value?.content || {}; + if (ac.type === "feed-action" && ac.action === "refeed") { + refeeds++; + if (ac.author || a?.value?.author) refeedsInhabitants.push(ac.author || a.value.author); + } + if (ac.type === "feed-action" && ac.action === "comment") { + commentCount++; + } + } + const agg = aggregateOpinions(idx, currentId, content); + const merged = { ...content, opinions: agg.opinions, opinions_inhabitants: Array.from(agg.voters), refeeds_inhabitants: refeedsInhabitants, refeeds, commentCount }; + return { key: currentId, value: { ...msg.value, content: merged } }; + }; + + const getComments = async (feedId) => { + const ssbClient = await openSsb(); + const idx = await buildIndex(ssbClient); + const currentId = idx.resolve(feedId); + const actions = idx.actionsByRoot.get(currentId) || []; + return actions + .filter(a => a?.value?.content?.type === "feed-action" && a?.value?.content?.action === "comment") + .sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0)); + }; + + const addComment = async (feedId, text) => { + const ssbClient = await openSsb(); + const idx = await buildIndex(ssbClient); + const currentId = idx.resolve(feedId); + await new Promise((resolve, reject) => { + ssbClient.publish({ type: "feed-action", action: "comment", root: currentId, text: cleanText(text) }, (err) => (err ? reject(err) : resolve())); + }); + }; + + return { createFeed, createRefeed, addOpinion, listFeeds, resolveCurrentId, getFeedById, getComments, addComment }; +}; + diff --git a/src/models/forum_model.js b/src/models/forum_model.js new file mode 100644 index 0000000..df27060 --- /dev/null +++ b/src/models/forum_model.js @@ -0,0 +1,532 @@ +const pull = require('../server/node_modules/pull-stream'); +const crypto = require('crypto'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler, tribeCrypto, forumCrypto }) => { + let ssb, userId; + const ownCrypto = forumCrypto || tribeCrypto; + const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null; + + const decryptForumContent = (rawContent, rootId) => { + if (!rawContent) return rawContent; + if (!rawContent.encryptedPayload) return rawContent; + if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true }; + let keys = (rootId && ownCrypto.getKeys && ownCrypto.getKeys(rootId)) || []; + if (!keys.length && typeof ownCrypto.getAllRootIds === 'function') { + const seen = new Set(); + for (const rid of ownCrypto.getAllRootIds()) { + const ks = ownCrypto.getKeys(rid) || []; + for (const k of ks) if (!seen.has(k)) { seen.add(k); keys.push(k); } + } + } + if (!keys.length) return { ...rawContent, _undecryptable: true }; + const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k])); + if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true }; + return { ...dec, _decrypted: true }; + }; + + const ingestOwnTribeKeys = async () => { + if (!ownCrypto) return; + try { + const ssbClient = await openSsb(); + const ssbKeys = require('../server/node_modules/ssb-keys'); + const cfg = require('../server/ssb_config'); + const msgs = await new Promise((res, rej) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m))) + ); + for (const m of msgs) { + const c = m.value && m.value.content; + if (!c || c.type !== 'tribe-keys') continue; + const memberKeys = c.memberKeys; + if (!memberKeys || typeof memberKeys !== 'object') continue; + const boxed = memberKeys[ssbClient.id]; + if (!boxed) continue; + try { + const unboxed = ssbKeys.unbox(boxed, cfg.keys); + const key = typeof unboxed === 'string' ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null); + if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key); + } catch (_) {} + } + } catch (_) {} + }; + + const openSsb = async () => { + if (!ssb) { + ssb = await cooler.open(); + userId = ssb.id; + } + return ssb; + }; + + const readForumLog = async () => { + const ssbClient = await openSsb(); + return new Promise((res, rej) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m || [])))); + }; + + const scanForumOpenInvite = async (forumId) => { + const messages = await readForumLog(); + const markerTomb = new Set(); + const invTomb = new Set(); + for (const m of messages) { + const c = m.value && m.value.content; + if (!c) continue; + if (c.type === 'forum-open-invite-tombstone' && typeof c.target === 'string') markerTomb.add(c.target); + if (c.type === 'forum-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target); + } + let best = null; + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || c.type !== 'forum-open-invite' || c.v !== 1) continue; + if (c.target !== forumId || typeof c.code !== 'string') continue; + if (markerTomb.has(m.key)) continue; + if (c.inviteKey && invTomb.has(c.inviteKey)) continue; + const ts = (m.value && m.value.timestamp) || 0; + if (!best || ts > best.ts) best = { code: c.code, by: c.by || m.value.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts }; + } + return best; + }; + + async function collectTombstones(ssbClient) { + return new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(buildValidatedTombstoneSet(msgs))) + ); + }); + } + + async function findActiveVote(ssbClient, targetId, voter) { + const tombstoned = await collectTombstones(ssbClient); + return new Promise((resolve, reject) => { + pull( + ssbClient.links({ source: voter, dest: targetId, rel: 'vote', values: true, keys: true }), + pull.filter(link => !tombstoned.has(link.key)), + pull.collect((err, links) => err ? reject(err) : resolve(links)) + ); + }); + } + + async function aggregateVotes(ssbClient, targetId) { + const tombstoned = await collectTombstones(ssbClient); + return new Promise((resolve, reject) => { + let positives = 0, negatives = 0; + pull( + ssbClient.links({ source: null, dest: targetId, rel: 'vote', values: true, keys: true }), + pull.filter(link => link.value.content?.vote && !tombstoned.has(link.key)), + pull.drain( + link => link.value.content.vote.value > 0 ? positives++ : negatives++, + err => err ? reject(err) : resolve({ positives, negatives }) + ) + ); + }); + } + + function nestReplies(flat) { + const lookup = new Map(); + const roots = []; + for (const msg of flat) { + msg.children = []; + lookup.set(msg.key, msg); + } + for (const msg of flat) { + if (msg.parent && lookup.has(msg.parent)) { + lookup.get(msg.parent).children.push(msg); + } else { + roots.push(msg); + } + } + return roots; + } + + async function getMessageById(id) { + const ssbClient = await openSsb(); + const msgs = await new Promise((res, rej) => + pull(ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, data) => err ? rej(err) : res(data))) + ); + const msg = msgs.find(m => m.key === id && m.value.content?.type === 'forum'); + if (!msg) throw new Error('Message not found'); + return { key: msg.key, ...msg.value.content, timestamp: msg.value.timestamp }; + } + + return { + ingestKeys: async () => { await ingestOwnTribeKeys(); }, + + createForum: async (category, title, text, isPrivate = false) => { + const ssbClient = await openSsb(); + const isPrivateFlag = isPrivate === true || isPrivate === 'true' || isPrivate === 'on'; + const plainContent = { + type: 'forum', + category, + title, + text, + createdAt: new Date().toISOString(), + author: userId, + votes: { positives: 0, negatives: 0 }, + votes_inhabitants: [], + isPrivate: isPrivateFlag + }; + let content = plainContent; + let forumKey = null; + if (isPrivateFlag && ownCrypto && tribeCrypto) { + forumKey = ownCrypto.generateTribeKey(); + content = tribeCrypto.encryptContent(plainContent, [forumKey], true); + } + const result = await new Promise((resolve, reject) => + ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)) + ); + if (forumKey) { + ownCrypto.setKey(result.key, forumKey, 1); + try { + const ssbKeys = require('../server/node_modules/ssb-keys'); + const memberKeys = { [userId]: tribeCrypto.boxKeyForMember(forumKey, userId, ssbKeys) }; + await new Promise((resolve) => { + ssbClient.publish({ type: 'tribe-keys', tribeId: result.key, generation: 1, memberKeys }, () => resolve()); + }); + } catch (_) {} + } + return { key: result.key, ...plainContent }; + }, + + addMessageToForum: async (forumId, message, parentId = null) => { + const ssbClient = await openSsb(); + const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m))); + const rootRaw = rawRoot && rawRoot.content; + const rootDec = rootRaw && rootRaw.encryptedPayload + ? decryptForumContent(rootRaw, forumId) + : rootRaw; + const isPrivate = rootDec && rootDec.isPrivate === true; + let content = { + ...message, + root: forumId, + type: 'forum', + author: userId, + timestamp: new Date().toISOString(), + votes: { positives: 0, negatives: 0 }, + votes_inhabitants: [] + }; + if (parentId) content.branch = parentId; + if (isPrivate && ownCrypto && tribeCrypto) { + const key = lookupKey(forumId); + if (!key) throw new Error('Missing forum key — cannot reply to encrypted forum'); + content = tribeCrypto.encryptContent(content, [key], true); + } + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)) + ); + }, + + generateInvite: async (forumId) => { + if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable'); + const ssbClient = await openSsb(); + const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m))); + if (!rawRoot || !rawRoot.content) throw new Error('Forum not found'); + const rawC = rawRoot.content; + const dec = rawC && rawC.encryptedPayload ? decryptForumContent(rawC, forumId) : rawC; + if (dec && dec._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted'); + if (dec.author !== userId) throw new Error('Only the author can generate invites'); + if (dec.isPrivate !== true) throw new Error('Only private forums use invitation codes'); + const key = lookupKey(forumId); + if (!key) throw new Error('Missing forum key'); + const code = crypto.randomBytes(16).toString('hex'); + const inviteSalt = tribeCrypto.generateInviteSalt(); + const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt); + await new Promise((resolve, reject) => { + ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve()); + }); + return { code, forumId }; + }, + + getOpenInvite: async (forumId) => { + const rec = await scanForumOpenInvite(forumId); + return rec ? { code: rec.code, by: rec.by, markerKey: rec.markerKey, inviteKey: rec.inviteKey } : null; + }, + + generateOpenInvite: async (forumId) => { + if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable'); + const ssbClient = await openSsb(); + const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m))); + if (!rawRoot || !rawRoot.content) throw new Error('Forum not found'); + const rawC = rawRoot.content; + const dec = rawC && rawC.encryptedPayload ? decryptForumContent(rawC, forumId) : rawC; + if (dec && dec._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted'); + if (dec.author !== userId) throw new Error('Only the author can generate invites'); + if (dec.isPrivate !== true) throw new Error('Only private forums use invitation codes'); + if (await scanForumOpenInvite(forumId)) throw new Error('An open invitation already exists'); + const key = lookupKey(forumId); + if (!key) throw new Error('Missing forum key'); + const code = crypto.randomBytes(16).toString('hex'); + const inviteSalt = tribeCrypto.generateInviteSalt(); + const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt); + const invitePub = await new Promise((resolve, reject) => { + ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r)); + }); + await new Promise((resolve, reject) => { + ssbClient.publish({ type: 'forum-open-invite', v: 1, target: forumId, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err) => err ? reject(err) : resolve()); + }); + return { code, forumId }; + }, + + removeOpenInvite: async (forumId) => { + const ssbClient = await openSsb(); + const rec = await scanForumOpenInvite(forumId); + if (!rec) return; + let author = null; + try { + const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m))); + const rawC = rawRoot && rawRoot.content; + const dec = rawC && rawC.encryptedPayload ? decryptForumContent(rawC, forumId) : rawC; + author = dec && dec.author; + } catch (_) {} + if (rec.by !== userId && author !== userId) throw new Error('Not allowed to remove this invitation'); + await new Promise((resolve, reject) => ssbClient.publish({ type: 'forum-open-invite-tombstone', target: rec.markerKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve())); + if (rec.inviteKey) await new Promise((resolve, reject) => ssbClient.publish({ type: 'forum-invite-tombstone', target: rec.inviteKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve())); + }, + + joinByInvite: async (code) => { + if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable'); + const ssbClient = await openSsb(); + const messages = await new Promise((res, rej) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m))) + ); + const invTomb = new Set(); + for (const m of messages) { + const c = m.value && m.value.content; + if (c && c.type === 'forum-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target); + } + let matched = null; + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || c.type !== 'forum-invite') continue; + if (invTomb.has(m.key)) continue; + try { + const hash = tribeCrypto.hashInviteCode(code, c.salt); + if (hash === c.codeHash) { matched = c; break; } + } catch (_) {} + } + if (!matched) throw new Error('Invalid or expired invite code'); + const forumKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt); + if (!forumKey) throw new Error('Could not decrypt invite'); + ownCrypto.addNewKey(matched.target, forumKey); + return { ok: true, forumId: matched.target }; + }, + + voteContent: async (targetId, value) => { + const ssbClient = await openSsb(); + const whoami = await new Promise((res, rej) => + ssbClient.whoami((err, info) => err ? rej(err) : res(info)) + ); + const voter = whoami.id; + const newVal = parseInt(value, 10); + const existing = await findActiveVote(ssbClient, targetId, voter); + if (existing.length > 0) { + const prev = existing[0].value.content.vote.value; + if (prev === newVal) return existing[0]; + await new Promise((resolve, reject) => + ssbClient.publish( + { type: 'tombstone', target: existing[0].key, timestamp: new Date().toISOString(), author: voter }, + err => err ? reject(err) : resolve() + ) + ); + } + return new Promise((resolve, reject) => + ssbClient.publish( + { + type: 'vote', + vote: { link: targetId, value: newVal }, + timestamp: new Date().toISOString(), + author: voter + }, + (err, result) => err ? reject(err) : resolve(result) + ) + ); + }, + + deleteForumById: async id => { + const ssbClient = await openSsb(); + return new Promise((resolve, reject) => + ssbClient.publish( + { type: 'tombstone', target: id, timestamp: new Date().toISOString(), author: userId }, + (err, res) => err ? reject(err) : resolve(res) + ) + ); + }, + + listAll: async filter => { + const ssbClient = await openSsb(); + const msgs = await new Promise((res, rej) => + pull(ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, data) => err ? rej(err) : res(data))) + ); + const deleted = buildValidatedTombstoneSet(msgs); + const decode = (m) => { + const c = m.value && m.value.content; + if (!c) return null; + if (c.encryptedPayload) { + const dec = decryptForumContent(c, m.value.content.root || m.key); + return (dec && !dec._undecryptable) ? dec : null; + } + return c; + }; + const forums = msgs + .map(m => ({ m, c: decode(m) })) + .filter(({ m, c }) => c && c.type === 'forum' && !c.root && !deleted.has(m.key)) + .map(({ m, c }) => ({ ...c, key: m.key })); + const forumsWithVotes = await Promise.all( + forums.map(async f => { + const { positives, negatives } = await aggregateVotes(ssbClient, f.key); + return { ...f, positiveVotes: positives, negativeVotes: negatives }; + }) + ); + const repliesByRoot = {}; + msgs.forEach(m => { + const cRaw = m.value && m.value.content; + if (!cRaw) return; + const root = cRaw.encryptedPayload ? null : cRaw.root; + if (!root) { + if (!cRaw.encryptedPayload) return; + const decReply = decryptForumContent(cRaw, null); + if (!decReply || decReply._undecryptable || decReply.type !== 'forum' || !decReply.root) return; + if (deleted.has(m.key)) return; + repliesByRoot[decReply.root] = repliesByRoot[decReply.root] || []; + repliesByRoot[decReply.root].push({ key: m.key, text: decReply.text, author: decReply.author, timestamp: m.value.timestamp }); + return; + } + if (cRaw.type === 'forum' && root && !deleted.has(m.key)) { + repliesByRoot[root] = repliesByRoot[root] || []; + repliesByRoot[root].push({ key: m.key, text: cRaw.text, author: cRaw.author, timestamp: m.value.timestamp }); + } + }); + const final = await Promise.all( + forumsWithVotes.map(async f => { + const replies = repliesByRoot[f.key] || []; + for (let r of replies) { + const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key); + r.positiveVotes = rp; + r.negativeVotes = rn; + r.score = rp - rn; + } + const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0); + const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0); + const positiveVotes = f.positiveVotes + replyPos; + const negativeVotes = f.negativeVotes + replyNeg; + const score = positiveVotes - negativeVotes; + const participants = new Set(replies.map(r => r.author).concat(f.author)); + const messagesCount = replies.length + 1; + const lastMessage = + replies.length + ? replies.reduce((a, b) => (new Date(a.timestamp) > new Date(b.timestamp) ? a : b)) + : null; + return { + ...f, + positiveVotes, + negativeVotes, + score, + participants: Array.from(participants), + messagesCount, + lastMessage, + messages: replies + }; + }) + ); + const filtered = + filter === 'mine' + ? final.filter(f => f.author === userId) + : filter === 'recent' + ? final.filter(f => new Date(f.createdAt).getTime() >= Date.now() - 86400000) + : final; + return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + }, + + getForumById: async id => { + const ssbClient = await openSsb(); + const msgs = await new Promise((res, rej) => + pull(ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, data) => err ? rej(err) : res(data))) + ); + const deleted = buildValidatedTombstoneSet(msgs); + const original = msgs.find(m => m.key === id && !deleted.has(m.key)); + if (!original || original.value.content?.type !== 'forum') throw new Error('Forum not found'); + const rawBase = original.value.content; + const base = rawBase.encryptedPayload ? decryptForumContent(rawBase, id) : rawBase; + if (base && base._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted with available keys'); + const { positives, negatives } = await aggregateVotes(ssbClient, id); + const replyAuthors = []; + for (const m of msgs) { + if (deleted.has(m.key)) continue; + const cRaw = m.value && m.value.content; + if (!cRaw) continue; + let rc = cRaw; + if (cRaw.encryptedPayload) { + const dec = decryptForumContent(cRaw, id); + if (!dec || dec._undecryptable) continue; + rc = dec; + } + if (rc.type === 'forum' && rc.root === id) replyAuthors.push(rc.author); + } + const participants = Array.from(new Set(replyAuthors.concat(base.author).filter(Boolean))); + return { + ...base, + key: id, + positiveVotes: positives, + negativeVotes: negatives, + score: positives - negatives, + participants + }; + }, + + getMessagesByForumId: async forumId => { + const ssbClient = await openSsb(); + const msgs = await new Promise((res, rej) => + pull(ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, data) => err ? rej(err) : res(data))) + ); + const deleted = buildValidatedTombstoneSet(msgs); + const decodeReply = (m) => { + const c = m.value && m.value.content; + if (!c || c.type !== 'forum') return null; + if (c.encryptedPayload) { + const dec = decryptForumContent(c, forumId); + if (!dec || dec._undecryptable || dec.root !== forumId) return null; + return { c: dec, m }; + } + if (c.root !== forumId) return null; + return { c, m }; + }; + const replies = msgs + .map(decodeReply) + .filter(r => r && !deleted.has(r.m.key)) + .map(({ c, m }) => ({ + key: m.key, + text: c.text, + author: c.author, + timestamp: m.value.timestamp, + parent: c.branch || null + })); + for (let r of replies) { + const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key); + r.positiveVotes = rp; + r.negativeVotes = rn; + r.score = rp - rn; + } + const { positives: p, negatives: n } = await aggregateVotes(ssbClient, forumId); + const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0); + const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0); + const positiveVotes = p + replyPos; + const negativeVotes = n + replyNeg; + const totalScore = positiveVotes - negativeVotes; + return { + messages: nestReplies(replies), + total: replies.length, + positiveVotes, + negativeVotes, + totalScore + }; + }, + + getMessageById + }; +}; + diff --git a/src/models/games_model.js b/src/models/games_model.js new file mode 100644 index 0000000..c148226 --- /dev/null +++ b/src/models/games_model.js @@ -0,0 +1,67 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const logLimit = getConfig().ssbLogStream?.limit || 5000; + +const VALID_GAMES = new Set([ + 'cocoland', 'ecoinflow', 'neoninfiltrator', 'spaceinvaders', 'arkanoid', 'pingpong', + 'asteroids', 'tiktaktoe', 'flipflop', + '8ball', 'artillery', 'labyrinth', 'cocoman', 'tetris' +]); + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + async function readAll(ssbClient) { + return new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, results) => (err ? reject(err) : resolve(results))) + ); + }); + } + + return { + async submitScore(game, score) { + if (!VALID_GAMES.has(game)) throw new Error('invalid game'); + const n = Number(score); + if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score'); + const ssbClient = await openSsb(); + return new Promise((resolve, reject) => { + ssbClient.publish({ type: 'gameScore', game, score: Math.round(n) }, (err, msg) => { + if (err) reject(err); else resolve(msg); + }); + }); + }, + + async getHallOfFame() { + const ssbClient = await openSsb(); + const messages = await readAll(ssbClient); + const best = {}; + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || c.type !== 'gameScore') continue; + if (!VALID_GAMES.has(c.game)) continue; + const author = m.value.author; + const score = Number(c.score); + if (!Number.isFinite(score) || score < 0) continue; + const key = `${c.game}:${author}`; + if (!best[key] || score > best[key].score) { + best[key] = { author, score, game: c.game, ts: m.value.timestamp || 0 }; + } + } + const hall = {}; + for (const game of VALID_GAMES) hall[game] = []; + for (const entry of Object.values(best)) { + if (hall[entry.game]) hall[entry.game].push(entry); + } + for (const game of VALID_GAMES) { + hall[game] = hall[game].sort((a, b) => b.score - a.score).slice(0, 10); + } + return hall; + } + }; +}; diff --git a/src/models/images_model.js b/src/models/images_model.js new file mode 100644 index 0000000..50c90e3 --- /dev/null +++ b/src/models/images_model.js @@ -0,0 +1,343 @@ +const pull = require("../server/node_modules/pull-stream"); +const { getConfig } = require("../configs/config-manager.js"); +const categories = require("../backend/opinion_categories"); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe'); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const safeArr = (v) => (Array.isArray(v) ? v : []); + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return undefined; + if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean); + return String(raw).split(",").map((t) => t.trim()).filter(Boolean); +}; + +const parseBlobId = (blobMarkdown) => { + const s = String(blobMarkdown || ""); + const match = s.match(/\(([^)]+)\)/); + return match ? match[1] : s || null; +}; + +const voteSum = (opinions = {}) => + Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); + +module.exports = ({ cooler }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) + ); + }); + + const getMsg = async (ssbClient, key) => + new Promise((resolve, reject) => { + ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg))); + }); + + const buildIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const opinionMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === "tombstone") continue; + if (c.type === "imageOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + if (c.type !== "image") continue; + + const ts = v.timestamp || m.timestamp || 0; + let sizeBytes = 0; + try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; } + nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author }); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, t] of Array.from(parent.entries())) { + const cn = nodes.get(k); + const pn = nodes.get(t); + if (!pn) { parent.delete(k); if (child.get(t) === k) child.delete(t); continue; } + if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); if (child.get(t) === k) child.delete(t); nodes.delete(k); } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const opinionsByRoot = new Map(); + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const forward = new Map(); + for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId); + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + const lc = contentNode ? contentNode.c : {}; + const opinions = { ...(lc.opinions || {}) }; + const voters = safeArr(lc.opinions_inhabitants).slice(); + const voterSet = new Set(voters); + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); voters.push(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + return { contentTip, contentNode, opinions, voters }; + }; + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup }; + }; + + const buildImage = (node, rootId, viewerId, agg) => { + const c = node.c || {}; + const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants); + return { + key: node.key, + rootId, + url: c.url, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + tags: safeArr(c.tags), + author: node.author, + title: c.title || "", + description: c.description || "", + mapUrl: c.mapUrl || "", + meme: !!c.meme, + opinions: agg ? agg.opinions : (c.opinions || {}), + opinions_inhabitants: voters, + hasVoted: viewerId ? voters.includes(viewerId) : false, + sizeBytes: node.sizeBytes || 0 + }; + }; + + return { + type: "image", + + async resolveCurrentId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const tip = idx.tipByRoot.get(idx.rootOf(id)) || id; + if (idx.tomb.has(tip)) throw new Error("Image not found"); + return tip; + }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + let tip = id; + while (idx.forward.has(tip)) tip = idx.forward.get(tip); + if (idx.tomb.has(tip)) throw new Error("Image not found"); + + let root = tip; + while (idx.parent.has(root)) root = idx.parent.get(root); + return root; + }, + + async createImage(blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) { + const ssbClient = await openSsb(); + const blobId = parseBlobId(blobMarkdown); + const tags = normalizeTags(tagsRaw) || []; + const now = new Date().toISOString(); + + const content = { + type: "image", + url: blobId, + createdAt: now, + updatedAt: now, + author: ssbClient.id, + tags, + title: title || "", + description: description || "", + mapUrl: mapUrl || "", + meme: !!memeBool, + opinions: {}, + opinions_inhabitants: [] + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const oldMsg = await getMsg(ssbClient, tipId); + + if (!oldMsg || oldMsg.content?.type !== "image") throw new Error("Image not found"); + if (oldMsg.content.author !== userId) throw new Error("Not the author"); + const aggImg = await this.getImageById(id, userId); + if (aggImg && Object.keys(aggImg.opinions || {}).some(k => (aggImg.opinions[k] || 0) > 0)) throw new Error("Cannot edit image after it has received opinions."); + + const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags); + const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null; + const now = new Date().toISOString(); + + const updated = { + ...oldMsg.content, + replaces: tipId, + url: blobId || oldMsg.content.url, + tags, + title: title !== undefined ? title || "" : oldMsg.content.title || "", + description: description !== undefined ? description || "" : oldMsg.content.description || "", + mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "", + meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme, + createdAt: oldMsg.content.createdAt, + updatedAt: now + }; + + const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId }; + await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); + + return new Promise((resolve, reject) => { + ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result))); + }); + }, + + async deleteImageById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const msg = await getMsg(ssbClient, tipId); + + if (!msg || msg.content?.type !== "image") throw new Error("Image not found"); + if (msg.content.author !== userId) throw new Error("Not the author"); + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }; + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res))); + }); + }, + + async listAll(filterOrOpts = "all", maybeOpts = {}) { + const ssbClient = await openSsb(); + + const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {}; + const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all"; + const q = String(opts.q || "").trim().toLowerCase(); + const sort = String(opts.sort || "recent").trim(); + const viewerId = opts.viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const items = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + items.push(buildImage(node, rootId, viewerId, idx.resolveGroup(rootId))); + } + + let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null); + const now = Date.now(); + + if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId)); + else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000); + else if (filter === "meme") list = list.filter((im) => im.meme === true); + else if (filter === "top") { + list = list + .slice() + .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } + + if (q) { + list = list.filter((im) => { + const t = String(im.title || "").toLowerCase(); + const d = String(im.description || "").toLowerCase(); + const tags = safeArr(im.tags).join(" ").toLowerCase(); + const a = String(im.author || "").toLowerCase(); + return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q); + }); + } + + if (sort === "top") { + list = list + .slice() + .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } else if (sort === "oldest") { + list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + } else { + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + return list; + }, + + async getImageById(id, viewerId = null) { + const ssbClient = await openSsb(); + const viewer = viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + const rootId = idx.rootOf(id); + const agg = idx.resolveGroup(rootId); + if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Image not found"); + + return buildImage(agg.contentNode, rootId, viewer, agg); + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error("Invalid voting category"); + + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const img = await this.getImageById(id, userId); + if (!img) throw new Error("Image not found"); + if (safeArr(img.opinions_inhabitants).includes(userId)) throw new Error("Already voted"); + + const content = { type: "imageOpinion", target: img.rootId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result))); + }); + } + }; +}; + diff --git a/src/models/inhabitants_model.js b/src/models/inhabitants_model.js new file mode 100644 index 0000000..2b3c0dd --- /dev/null +++ b/src/models/inhabitants_model.js @@ -0,0 +1,570 @@ +const pull = require('../server/node_modules/pull-stream'); +const ssbClientGUI = require("../client/gui"); +const coolerInstance = ssbClientGUI({ offline: require('../server/ssb_config').offline }); +const models = require("../models/main_models"); +const { about, friend } = models({ + cooler: coolerInstance, + isPublic: require('../server/ssb_config').public, +}); +const { getConfig } = require('../configs/config-manager.js'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +function toImageUrl(imgId, size=256){ + if (!imgId) return '/assets/images/default-avatar.png'; + if (typeof imgId === 'string' && imgId.startsWith('/image/')) return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/'); + return `/image/${size}/${encodeURIComponent(imgId)}`; +} + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + async function getLastKarmaScore(feedId) { + const ssbClient = await openSsb(); + return new Promise(resolve => { + const src = ssbClient.messagesByType + ? ssbClient.messagesByType({ type: "karmaScore", reverse: true }) + : ssbClient.createLogStream && ssbClient.createLogStream({ reverse: true }); + if (!src) return resolve(0); + pull( + src, + pull.filter(msg => { + const v = msg.value || msg; + const c = v.content || {}; + return v.author === feedId && c.type === "karmaScore" && typeof c.karmaScore !== "undefined"; + }), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(0); + const v = arr[0].value || arr[0]; + resolve(v.content.karmaScore || 0); + }) + ); + }); + } + + async function getLastActivityTimestamp(feedId) { + const ssbClient = await openSsb(); + const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0); + return new Promise((resolve) => { + pull( + ssbClient.createUserStream({ id: feedId, reverse: true }), + pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(null); + const m = arr[0]; + const ts = norm((m.value && m.value.timestamp) || m.timestamp); + resolve(ts || null); + }) + ); + }); + } + + function bucketLastActivity(ts) { + if (!ts) return { bucket: 'red', range: '≥6m' }; + const now = Date.now(); + const delta = Math.max(0, now - ts); + const days = delta / 86400000; + if (days < 14) return { bucket: 'green', range: '<2w' }; + if (days < 182.5) return { bucket: 'orange', range: '2w–6m' }; + return { bucket: 'red', range: '≥6m' }; + } + + const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout)); + const fetchUserImageUrl = async (feedId, size=256) => { + try{ + const img = await Promise.race([about.image(feedId), timeoutPromise(5000)]); + const id = typeof img === 'string' ? img : (img && (img.link || img.url)); + return toImageUrl(id, size); + }catch{ + return '/assets/images/default-avatar.png'; + } + }; + + async function listAllBase(ssbClient) { + const authorsMsgs = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit, reverse: true }), + pull.filter(msg => !!msg.value?.author && msg.value?.content?.type !== 'tombstone'), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + const uniqueFeedIds = Array.from(new Set(authorsMsgs.map(r => r.value.author).filter(Boolean))); + const users = await Promise.all( + uniqueFeedIds.map(async (feedId) => { + const rawName = await about.name(feedId); + const name = rawName || feedId.slice(0, 10); + const description = await about.description(feedId); + const photo = await fetchUserImageUrl(feedId, 256); + const lastActivityTs = await getLastActivityTimestamp(feedId); + const { bucket, range } = bucketLastActivity(lastActivityTs); + return { id: feedId, name, description, photo, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range }; + }) + ); + return Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values()); + } + + function normalizeRel(rel) { + const r = rel || {}; + const iFollow = !!(r.following || r.iFollow || r.youFollow || r.i_follow || r.isFollowing); + const followsMe = !!(r.followsMe || r.followingMe || r.follows_me || r.theyFollow || r.isFollowedBy); + const blocking = !!(r.blocking || r.iBlock || r.isBlocking); + const blockedBy = !!(r.blocked || r.blocksMe || r.isBlockedBy); + return { iFollow, followsMe, blocking, blockedBy }; + } + + return { + async listInhabitants(options = {}) { + const { filter = 'all', search = '', location = '', language = '', skills = '', includeInactive = false } = options; + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const filterInactive = (users) => { + if (includeInactive) return users; + return users.filter(u => u.lastActivityBucket !== 'red'); + }; + + if (filter === 'GALLERY') { + const users = await listAllBase(ssbClient); + return filterInactive(users); + } + + if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP ECO') { + let users = await listAllBase(ssbClient); + if (filter !== 'TOP ACTIVITY') { + users = filterInactive(users); + } + if (search) { + const q = search.toLowerCase(); + users = users.filter(u => + (u.name || '').toLowerCase().includes(q) || + (u.description || '').toLowerCase().includes(q) || + (u.id || '').toLowerCase().includes(q) + ); + } + const bytesByAuthor = await new Promise((res) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => { + if (err || !Array.isArray(msgs)) return res({}); + const acc = {}; + for (const m of msgs) { + const author = m && m.value && m.value.author; + if (!author) continue; + try { acc[author] = (acc[author] || 0) + Buffer.byteLength(JSON.stringify(m.value), 'utf8'); } catch (_) {} + } + res(acc); + }) + ); + }); + const withMetrics = await Promise.all(users.map(async u => { + const karmaScore = await getLastKarmaScore(u.id); + const bytes = (bytesByAuthor && bytesByAuthor[u.id]) || 0; + const carbonGrams = (bytes / (1024 * 1024)) * 0.095; + if (filter === 'TOP ECO') { + const ecoScore = karmaScore / Math.max(0.01, carbonGrams); + return { ...u, karmaScore, carbonGrams, ecoScore }; + } + return { ...u, karmaScore, carbonGrams }; + })); + if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0)); + if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0)); + if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0)); + return withMetrics; + } + + if (filter === 'contacts') { + const all = await this.listInhabitants({ filter: 'all' }); + const result = []; + for (const user of all) { + const rel = await friend.getRelationship(user.id).catch(() => ({})); + if (rel && (rel.following || rel.iFollow)) result.push(user); + } + return Array.from(new Map(result.map(u => [u.id, u])).values()); + } + + if (filter === 'blocked') { + const all = await this.listInhabitants({ filter: 'all', includeInactive: true }); + const result = []; + for (const user of all) { + const rel = await friend.getRelationship(user.id).catch(() => ({})); + const n = normalizeRel(rel); + if (n.blocking) result.push({ ...user, isBlocked: true }); + } + return Array.from(new Map(result.map(u => [u.id, u])).values()); + } + + if (filter === 'SUGGESTED') { + const base = await listAllBase(ssbClient); + const active = filterInactive(base); + const cvRecords = await new Promise((res) => { + pull( + ssbClient.createLogStream({ limit: logLimit, reverse: true }), + pull.filter(msg => msg && msg.value && msg.value.content && msg.value.content.type === 'curriculum'), + pull.collect((err, msgs) => err ? res([]) : res(msgs)) + ); + }); + const cvByAuthor = new Map(); + for (const r of cvRecords) { + const c = r.value && r.value.content; + if (c && c.author && !cvByAuthor.has(c.author)) cvByAuthor.set(c.author, c); + } + const extractSkills = (cv) => cv ? [ + ...(cv.personalSkills || []), + ...(cv.oasisSkills || []), + ...(cv.educationalSkills || []), + ...(cv.professionalSkills || []) + ].map(s => String(s || '').toLowerCase()).filter(Boolean) : []; + const mecv = await this.getCVByUserId().catch(() => null); + const mySkills = extractSkills(mecv); + const rels = await Promise.all( + active.map(async u => { + if (u.id === userId) return null; + const rel = await friend.getRelationship(u.id).catch(() => ({})); + const n = normalizeRel(rel); + if (n.iFollow || n.blocking || n.blockedBy) return null; + const karmaScore = await getLastKarmaScore(u.id); + const theirSkills = extractSkills(cvByAuthor.get(u.id)); + const commonSkills = mySkills.length && theirSkills.length + ? Array.from(new Set(mySkills.filter(s => theirSkills.includes(s)))) + : []; + const followsMeBonus = n.followsMe ? 20 : 0; + const karmaBonus = Math.min(20, Math.log10(1 + Math.max(0, karmaScore)) * 5); + const skillBonus = commonSkills.length * 4; + const activityBonus = u.lastActivityBucket === 'green' ? 5 : (u.lastActivityBucket === 'orange' ? 2 : 0); + const suggestionScore = followsMeBonus + karmaBonus + skillBonus + activityBonus; + return { user: u, rel: n, karmaScore, commonSkills, suggestionScore }; + }) + ); + const candidates = rels.filter(Boolean).filter(x => x.suggestionScore > 0); + const enriched = candidates.map(x => ({ + ...x.user, + karmaScore: x.karmaScore, + followsYou: x.rel.followsMe, + commonSkills: x.commonSkills, + mutualCount: x.rel.followsMe ? 1 : 0, + suggestionScore: x.suggestionScore + })); + const unique = Array.from(new Map(enriched.map(u => [u.id, u])).values()); + return unique.sort((a, b) => + (b.suggestionScore || 0) - (a.suggestionScore || 0) || + (b.karmaScore || 0) - (a.karmaScore || 0) || + (b.lastActivityTs || 0) - (a.lastActivityTs || 0) + ); + } + + if (filter === 'CVs' || filter === 'MATCHSKILLS') { + const records = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit, reverse: true}), + pull.filter(msg => + msg.value.content?.type === 'curriculum' && + msg.value.content?.type !== 'tombstone' + ), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + + let cvs = records.map(r => r.value.content); + cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values()); + + if (filter === 'CVs') { + let out = await Promise.all(cvs.map(async c => { + const photo = await fetchUserImageUrl(c.author, 256); + const lastActivityTs = await getLastActivityTimestamp(c.author); + const { bucket, range } = bucketLastActivity(lastActivityTs); + const base = this._normalizeCurriculum(c, photo); + return { ...base, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range }; + })); + out = filterInactive(out); + if (search) { + const q = search.toLowerCase(); + out = out.filter(u => + (u.name || '').toLowerCase().includes(q) || + (u.description || '').toLowerCase().includes(q) || + u.skills.some(s => (s || '').toLowerCase().includes(q)) + ); + } + if (location) out = out.filter(u => (u.location || '').toLowerCase() === location.toLowerCase()); + if (language) out = out.filter(u => u.languages.map(l => l.toLowerCase()).includes(language.toLowerCase())); + if (skills) { + const skillList = skills.split(',').map(s => s.trim().toLowerCase()).filter(Boolean); + out = out.filter(u => skillList.every(s => u.skills.map(k => (k || '').toLowerCase()).includes(s))); + } + return out; + } + + if (filter === 'MATCHSKILLS') { + let base = await Promise.all(cvs.map(async c => { + const photo = await fetchUserImageUrl(c.author, 256); + const lastActivityTs = await getLastActivityTimestamp(c.author); + const { bucket, range } = bucketLastActivity(lastActivityTs); + const norm = this._normalizeCurriculum(c, photo); + const karmaScore = await getLastKarmaScore(c.author).catch(() => 0); + return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range, karmaScore }; + })); + base = filterInactive(base); + const mecv = await this.getCVByUserId(); + const userSkills = Array.from(new Set( + (mecv + ? [ + ...(mecv.personalSkills || []), + ...(mecv.oasisSkills || []), + ...(mecv.educationalSkills || []), + ...(mecv.professionalSkills || []) + ] + : []).map(s => String(s || '').toLowerCase()).filter(Boolean) + )); + if (!userSkills.length) return []; + const userSet = new Set(userSkills); + const matches = base.map(c => { + if (c.id === userId) return null; + const theirSkillsRaw = (c.skills || []).map(s => String(s || '').toLowerCase()).filter(Boolean); + const theirSet = new Set(theirSkillsRaw); + const common = Array.from(theirSet).filter(s => userSet.has(s)); + if (!common.length) return null; + const unionSize = userSet.size + theirSet.size - common.length; + const matchScore = unionSize > 0 ? common.length / unionSize : 0; + const matchCoverage = userSet.size > 0 ? common.length / userSet.size : 0; + return { ...c, commonSkills: common, matchScore, matchCoverage }; + }).filter(Boolean); + return matches.sort((a, b) => + (b.matchScore - a.matchScore) || + (b.commonSkills.length - a.commonSkills.length) || + ((b.karmaScore || 0) - (a.karmaScore || 0)) || + ((b.lastActivityTs || 0) - (a.lastActivityTs || 0)) + ); + } + } + + return []; + }, + + _normalizeCurriculum(c, photoUrl) { + const photo = photoUrl || toImageUrl(c.photo, 256); + return { + id: c.author, + name: c.name, + description: c.description, + photo, + skills: [ + ...(c.personalSkills || []), + ...(c.oasisSkills || []), + ...(c.educationalSkills || []), + ...(c.professionalSkills || []) + ], + location: c.location, + languages: typeof c.languages === 'string' + ? c.languages.split(',').map(x => x.trim()) + : Array.isArray(c.languages) ? c.languages : [], + createdAt: c.createdAt + }; + }, + + async getLatestAboutById(id) { + const ssbClient = await openSsb(); + const records = await new Promise((res, rej) => { + pull( + ssbClient.createUserStream({ id }), + pull.filter(msg => + msg.value.content?.type === 'about' && + msg.value.content?.type !== 'tombstone' + ), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + if (!records.length) return null; + const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0]; + return latest.value.content; + }, + + async getFeedByUserId(id) { + const ssbClient = await openSsb(); + const targetId = id || ssbClient.id; + const records = await new Promise((res, rej) => { + pull( + ssbClient.createUserStream({ id: targetId }), + pull.filter(msg => + msg.value && + msg.value.content && + typeof msg.value.content.text === 'string' && + msg.value.content?.type !== 'tombstone' + ), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + return records + .filter(m => typeof m.value.content.text === 'string') + .sort((a, b) => b.value.timestamp - a.value.timestamp) + .slice(0, 10); + }, + + async getInhabitantStats(targetId, viewerId) { + const ssbClient = await openSsb(); + const target = targetId || ssbClient.id; + const viewer = viewerId || ssbClient.id; + const isOwner = viewer === target; + const arr = (v) => Array.isArray(v) ? v : []; + const up = (v) => String(v || '').toUpperCase(); + const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','shop','image','video','audio','document','bookmark','transfer','map']); + const accessible = (type, c) => { + if (c.encryptedPayload) return false; + switch (type) { + case 'task': return up(c.isPublic) !== 'PRIVATE' || isOwner || arr(c.assignees).includes(viewer); + case 'event': return String(c.isPublic || '').toLowerCase() !== 'private' || isOwner || arr(c.attendees).includes(viewer); + case 'forum': return c.isPrivate !== true || isOwner; + case 'job': return up(c.visibility) !== 'HIDDEN' || isOwner || arr(c.subscribers).includes(viewer); + case 'market': return up(c.visibility) !== 'HIDDEN' || isOwner; + case 'shop': return up(c.visibility) !== 'CLOSED' || isOwner; + case 'tribe': { const st = up(c.status); return !(st === 'PRIVATE' || st === 'INVITE-ONLY') || isOwner || arr(c.members).includes(viewer); } + default: return true; + } + }; + const counts = {}; + await new Promise((resolve) => { + pull( + ssbClient.createUserStream({ id: target }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (!c || typeof c !== 'object') return; + const type = c.type; + if (!type || !COUNTED.has(type)) return; + if (c.replaces) return; + if (!accessible(type, c)) return; + counts[type] = (counts[type] || 0) + 1; + }, () => resolve()) + ); + }); + return counts; + }, + + async getCVByUserId(id) { + const ssbClient = await openSsb(); + const targetId = id || ssbClient.id; + const records = await new Promise((res, rej) => { + pull( + ssbClient.createUserStream({ id: targetId }), + pull.filter(msg => + msg.value.content?.type === 'curriculum' && + msg.value.content?.type !== 'tombstone' + ), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + return records.length ? records[records.length - 1].value.content : null; + }, + + async getCandidatesForJob(job, viewerId = null) { + if (!job || typeof job !== 'object') return []; + const ssbClient = await openSsb(); + const tokenize = (s) => String(s || '') + .toLowerCase() + .split(/[^a-z0-9áéíóúñü+#./-]+/i) + .map(t => t.trim()) + .filter(t => t && t.length >= 2); + const stop = new Set(['the','a','an','and','or','of','to','in','for','on','with','is','are','be','as','at','by','from','that','this','it','we','you','our','your','un','una','el','la','los','las','de','del','en','con','para','por','y','o','un','una','que','se','su','sus','al','etc']); + const keywords = new Set(); + const addAll = (arr) => arr.forEach(t => { if (!stop.has(t)) keywords.add(t); }); + if (Array.isArray(job.tags)) job.tags.forEach(t => { const k = String(t || '').toLowerCase().trim(); if (k) keywords.add(k); }); + addAll(tokenize(job.title)); + addAll(tokenize(job.description)); + addAll(tokenize(job.requirements)); + if (keywords.size === 0) return []; + + const records = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit, reverse: true }), + pull.filter(msg => + msg.value?.content?.type === 'curriculum' && + msg.value?.content?.type !== 'tombstone' + ), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + let cvs = records.map(r => r.value.content); + cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values()); + cvs = cvs.filter(c => String(c.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN'); + + const jobAuthor = job.author || null; + const out = await Promise.all(cvs.map(async c => { + if (!c.author) return null; + if (jobAuthor && c.author === jobAuthor) return null; + const cvSkills = [ + ...(c.personalSkills || []), + ...(c.oasisSkills || []), + ...(c.educationalSkills || []), + ...(c.professionalSkills || []) + ].map(s => String(s || '').toLowerCase()).filter(Boolean); + const common = Array.from(new Set(cvSkills.filter(s => keywords.has(s)))); + if (common.length === 0) return null; + if (viewerId && c.author !== viewerId) { + try { + const rel = await friend.getRelationship(c.author); + if (rel && (rel.blocking || rel.blockedBy)) return null; + } catch (_) {} + } + const authorTs = await getLastActivityTimestamp(c.author); + let interactionTs = null; + try { + const cvId = c.id || c.key || null; + if (cvId) { + const ssbClient = await openSsb(); + interactionTs = await new Promise((resolve) => { + try { + pull( + ssbClient.backlinks.read({ query: [{ $filter: { dest: cvId } }], index: 'DTA', reverse: true, limit: 1 }), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(null); + const m = arr[0]; + const raw = (m.value && m.value.timestamp) || m.timestamp; + resolve(raw && raw < 1e12 ? raw * 1000 : raw || null); + }) + ); + } catch (_) { resolve(null); } + }); + } + } catch (_) {} + const lastActivityTs = Math.max(authorTs || 0, interactionTs || 0) || null; + const { bucket, range } = bucketLastActivity(lastActivityTs); + if (bucket === 'red') return null; + const photo = await fetchUserImageUrl(c.author, 256); + const matchScore = common.length / keywords.size; + return { + id: c.author, + name: c.name || 'Anonymous', + description: c.description || '', + photo, + location: c.location || '', + status: c.status || '', + preferences: c.preferences || '', + languages: typeof c.languages === 'string' + ? c.languages.split(',').map(x => x.trim()).filter(Boolean) + : Array.isArray(c.languages) ? c.languages : [], + commonSkills: common, + matchScore, + lastActivityTs, + lastActivityBucket: bucket, + lastActivityRange: range + }; + })); + return out.filter(Boolean).sort((a, b) => { + if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore; + return (b.lastActivityTs || 0) - (a.lastActivityTs || 0); + }).slice(0, 20); + }, + + async getPhotoUrlByUserId(id, size = 256) { + return await fetchUserImageUrl(id, size); + }, + + async getLastActivityTimestampByUserId(id) { + return await getLastActivityTimestamp(id); + }, + + bucketLastActivity(ts) { + return bucketLastActivity(ts); + } + }; +}; + diff --git a/src/models/jobs_model.js b/src/models/jobs_model.js new file mode 100644 index 0000000..3cf3ff6 --- /dev/null +++ b/src/models/jobs_model.js @@ -0,0 +1,558 @@ +const pull = require("../server/node_modules/pull-stream") +const moment = require("../server/node_modules/moment") +const { getConfig } = require("../configs/config-manager.js") +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeByPreferring } = require('../backend/dedupe') +const logLimit = getConfig().ssbLogStream?.limit || 1000 + +const norm = (s) => String(s || "").trim().toLowerCase() +const toNum = (v) => { + const n = parseFloat(String(v ?? "").replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} +const toInt = (v, fallback = 0) => { + const n = parseInt(String(v ?? ""), 10) + return Number.isFinite(n) ? n : fallback +} + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return [] + if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) + return String(raw).split(",").map(t => t.trim()).filter(Boolean) +} + +const matchSearch = (job, q) => { + const qq = norm(q) + if (!qq) return true + const hay = [ + job.title, + job.description, + job.requirements, + job.tasks, + job.languages, + Array.isArray(job.tags) ? job.tags.join(" ") : "" + ].map(x => norm(x)).join(" ") + return hay.includes(qq) +} + +module.exports = ({ cooler, tribeCrypto }) => { + let ssb + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } + + const readAll = async (ssbClient) => + new Promise((resolve, reject) => + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ) + ) + + const buildIndex = (messages, ssbClient) => { + const tomb = new Set() + const jobNodes = new Map() + const parent = new Map() + const child = new Map() + const naiveReplaces = new Map() + const jobSubLatest = new Map() + + for (const m of messages) { + const key = m.key + const v = m.value || {} + const c = v.content + if (!c) continue + + if (c.type === "tombstone" && c.target) { + tomb.add(c.target) + continue + } + + if (c.type === "job") { + jobNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + if (c.replaces) naiveReplaces.set(key, c.replaces) + continue + } + + if (c.type === "job_sub" && c.jobId) { + const author = v.author + if (!author) continue + const ts = v.timestamp || m.timestamp || 0 + const jobId = c.jobId + const k = `${jobId}::${author}` + const prev = jobSubLatest.get(k) + if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId }) + continue + } + } + + for (const [key, replacesId] of naiveReplaces.entries()) { + const node = jobNodes.get(key) + if (!node) continue + const orig = jobNodes.get(replacesId) + if (!orig) continue + if (String(orig.author) !== String(node.author)) { jobNodes.delete(key); continue } + parent.set(key, replacesId) + child.set(replacesId, key) + } + + const rootOf = (id) => { + let cur = id + while (parent.has(cur)) cur = parent.get(cur) + return cur + } + + const roots = new Set() + for (const id of jobNodes.keys()) roots.add(rootOf(id)) + + const tipOf = (id) => { + let cur = id + while (child.has(cur)) cur = child.get(cur) + return cur + } + + const tipByRoot = new Map() + for (const r of roots) tipByRoot.set(r, tipOf(r)) + + const subsByJob = new Map() + for (const { jobId, author, value } of jobSubLatest.values()) { + if (!subsByJob.has(jobId)) subsByJob.set(jobId, new Set()) + const set = subsByJob.get(jobId) + if (value) set.add(author) + else set.delete(author) + } + + if (ssbClient) { + for (const m of messages) { + if (typeof m.value?.content !== 'string') continue + try { + const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 }) + if (!dec?.value?.content) continue + const c = dec.value.content + if (c.type !== 'job_sub' || !c.jobId) continue + const author = dec.value.author + if (!author) continue + const ts = dec.value.timestamp || m.timestamp || 0 + const jobId = c.jobId + const k = `${jobId}::${author}` + const prev = jobSubLatest.get(k) + if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId }) + } catch {} + } + } + + return { tomb, jobNodes, parent, child, rootOf, tipOf, tipByRoot, subsByJob } + } + + const buildJobObject = (node, rootId, subscribers) => { + const visibleSubs = (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (node.author || node.c?.author) ? subscribers : []; + const c = node.c || {} + let blobId = c.image || null + if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1] + + const vacants = Math.max(1, toInt(c.vacants, 1)) + const salaryN = toNum(c.salary) + const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000" + + const hoursOfferedN = toNum(c.hoursOffered) + const hoursRequestedN = toNum(c.hoursRequested) + return { + id: node.key, + rootId, + job_type: c.job_type, + title: c.title, + description: c.description, + requirements: c.requirements, + languages: c.languages, + job_time: c.job_time, + tasks: c.tasks, + location: c.location, + vacants, + salary, + hoursOffered: Number.isFinite(hoursOfferedN) ? hoursOfferedN : 0, + hoursRequested: Number.isFinite(hoursRequestedN) ? hoursRequestedN : 0, + exchangeSkill: String(c.exchangeSkill || ""), + image: blobId, + author: node.author || c.author, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + status: c.status || "OPEN", + tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags), + subscribers: Array.isArray(visibleSubs) ? visibleSubs : [], + mapUrl: c.mapUrl || "", + visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC", + clearnetPublic: !!c.clearnetPublic + } + } + + return { + type: "job", + + async createJob(jobData) { + const ssbClient = await openSsb() + + const job_type = String(jobData.job_type || "").toLowerCase() + if (!["freelancer", "employee", "exchange"].includes(job_type)) throw new Error("Invalid job type") + + const title = String(jobData.title || "").trim() + const description = String(jobData.description || "").trim() + if (!title) throw new Error("Invalid title") + if (!description) throw new Error("Invalid description") + + const vacants = Math.max(1, toInt(jobData.vacants, 1)) + const salaryN = toNum(jobData.salary) + const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000" + + const job_time = String(jobData.job_time || "").toLowerCase() + if (!["partial", "complete"].includes(job_time)) throw new Error("Invalid job time") + + const location = String(jobData.location || "").toLowerCase() + if (!["remote", "presencial"].includes(location)) throw new Error("Invalid location") + + let blobId = jobData.image || null + if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1] + + const tags = normalizeTags(jobData.tags) + + const hoursOfferedN = toNum(jobData.hoursOffered) + const hoursRequestedN = toNum(jobData.hoursRequested) + const content = { + type: "job", + job_type, + title, + description, + requirements: String(jobData.requirements || ""), + languages: String(jobData.languages || ""), + job_time, + tasks: String(jobData.tasks || ""), + location, + vacants, + salary, + hoursOffered: Number.isFinite(hoursOfferedN) && hoursOfferedN > 0 ? hoursOfferedN : 0, + hoursRequested: Number.isFinite(hoursRequestedN) && hoursRequestedN > 0 ? hoursRequestedN : 0, + exchangeSkill: String(jobData.exchangeSkill || "").trim(), + image: blobId, + tags, + author: ssbClient.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: "OPEN", + mapUrl: String(jobData.mapUrl || "").trim(), + visibility: String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC", + clearnetPublic: jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on' + } + + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => { + if (e) return rej(e) + if (m && m.key && tribeCrypto) { + const key = tribeCrypto.generateTribeKey() + tribeCrypto.setKey(m.key, key, 1) + } + res(m) + })) + }, + + async resolveCurrentId(jobId) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const { tomb, child } = buildIndex(messages, ssbClient) + + let cur = jobId + while (child.has(cur)) cur = child.get(cur) + if (tomb.has(cur)) throw new Error("Job not found") + return cur + }, + + async resolveRootId(jobId) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const { tomb, parent, child } = buildIndex(messages, ssbClient) + + let tip = jobId + while (child.has(tip)) tip = child.get(tip) + if (tomb.has(tip)) throw new Error("Job not found") + + let root = tip + while (parent.has(root)) root = parent.get(root) + return root + }, + + async updateJob(id, jobData) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages, ssbClient) + + const tipId = await this.resolveCurrentId(id) + const node = idx.jobNodes.get(tipId) + if (!node || !node.c) throw new Error("Job not found") + + const existingContent = node.c + const author = existingContent.author + if (author !== ssbClient.id) throw new Error("Unauthorized") + + const patch = {} + + if (jobData.job_type !== undefined) { + const jt = String(jobData.job_type || "").toLowerCase() + if (!["freelancer", "employee", "exchange"].includes(jt)) throw new Error("Invalid job type") + patch.job_type = jt + } + + if (jobData.title !== undefined) { + const t = String(jobData.title || "").trim() + if (!t) throw new Error("Invalid title") + patch.title = t + } + + if (jobData.description !== undefined) { + const d = String(jobData.description || "").trim() + if (!d) throw new Error("Invalid description") + patch.description = d + } + + if (jobData.requirements !== undefined) patch.requirements = String(jobData.requirements || "") + if (jobData.languages !== undefined) patch.languages = String(jobData.languages || "") + if (jobData.tasks !== undefined) patch.tasks = String(jobData.tasks || "") + + if (jobData.job_time !== undefined) { + const jt = String(jobData.job_time || "").toLowerCase() + if (!["partial", "complete"].includes(jt)) throw new Error("Invalid job time") + patch.job_time = jt + } + + if (jobData.location !== undefined) { + const loc = String(jobData.location || "").toLowerCase() + if (!["remote", "presencial"].includes(loc)) throw new Error("Invalid location") + patch.location = loc + } + + if (jobData.vacants !== undefined) { + const v = Math.max(1, toInt(jobData.vacants, 1)) + patch.vacants = v + } + + if (jobData.salary !== undefined) { + const s = toNum(jobData.salary) + if (!Number.isFinite(s) || s < 0) throw new Error("Invalid salary") + patch.salary = s.toFixed(6) + } + + if (jobData.hoursOffered !== undefined) { + const h = toNum(jobData.hoursOffered) + if (!Number.isFinite(h) || h < 0) throw new Error("Invalid hoursOffered") + patch.hoursOffered = h + } + if (jobData.hoursRequested !== undefined) { + const h = toNum(jobData.hoursRequested) + if (!Number.isFinite(h) || h < 0) throw new Error("Invalid hoursRequested") + patch.hoursRequested = h + } + if (jobData.exchangeSkill !== undefined) patch.exchangeSkill = String(jobData.exchangeSkill || "").trim() + + if (jobData.tags !== undefined) patch.tags = normalizeTags(jobData.tags) + + if (jobData.image !== undefined) { + let blobId = jobData.image + if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1] + patch.image = blobId || null + } + + if (jobData.status !== undefined) { + const s = String(jobData.status || "").toUpperCase() + if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status") + patch.status = s + } + + if (jobData.visibility !== undefined) { + patch.visibility = String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" + } + if (jobData.clearnetPublic !== undefined) { + patch.clearnetPublic = jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on' + } + if (jobData.mapUrl !== undefined) patch.mapUrl = String(jobData.mapUrl || "").trim() + + const next = { + ...existingContent, + ...patch, + author, + createdAt: existingContent.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId, + type: "job" + } + + const tomb = { + type: "tombstone", + target: tipId, + deletedAt: new Date().toISOString(), + author: ssbClient.id + } + + await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res())) + return new Promise((res, rej) => ssbClient.publish(next, (e, m) => e ? rej(e) : res(m))) + }, + + async updateJobStatus(id, status) { + return this.updateJob(id, { status: String(status || "").toUpperCase() }) + }, + + async deleteJob(id) { + const ssbClient = await openSsb() + const tipId = await this.resolveCurrentId(id) + const job = await this.getJobById(tipId) + if (!job || job.author !== ssbClient.id) throw new Error("Unauthorized") + + const tomb = { + type: "tombstone", + target: tipId, + deletedAt: new Date().toISOString(), + author: ssbClient.id + } + + return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r))) + }, + + async subscribeToJob(id, userId) { + const ssbClient = await openSsb() + const me = ssbClient.id + const uid = userId || me + + const job = await this.getJobById(id) + if (!job) throw new Error("Job not found") + if (job.author === uid) throw new Error("Cannot subscribe to your own job") + if (String(job.status || "").toUpperCase() !== "OPEN") throw new Error("Job is closed") + if (Array.isArray(job.subscribers) && job.subscribers.includes(uid)) return { alreadySubscribed: true } + + const rootId = job.rootId || (await this.resolveRootId(id)) + + const msg = { + type: "job_sub", + jobId: rootId, + value: true, + createdAt: new Date().toISOString() + } + + const recps = [me, job.author] + return new Promise((res, rej) => ssbClient.private.publish(msg, recps, (e, m) => e ? rej(e) : res(m))) + }, + + async unsubscribeFromJob(id, userId) { + const ssbClient = await openSsb() + const me = ssbClient.id + const uid = userId || me + + const job = await this.getJobById(id) + if (!job) throw new Error("Job not found") + if (job.author === uid) throw new Error("Cannot unsubscribe from your own job") + if (Array.isArray(job.subscribers) && !job.subscribers.includes(uid)) return { notSubscribed: true } + + const rootId = job.rootId || (await this.resolveRootId(id)) + + const msg = { + type: "job_sub", + jobId: rootId, + value: false, + createdAt: new Date().toISOString() + } + + const recps = [me, job.author] + return new Promise((res, rej) => ssbClient.private.publish(msg, recps, (e, m) => e ? rej(e) : res(m))) + }, + + async listJobs(filter = "ALL", viewerId = null, query = {}) { + const ssbClient = await openSsb() + const me = ssbClient.id + const viewer = viewerId || me + + const messages = await readAll(ssbClient) + const idx = buildIndex(messages, ssbClient) + + const jobs = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.jobNodes.get(tipId) + if (!node) continue + const subsSet = idx.subsByJob.get(rootId) || new Set() + const subs = Array.from(subsSet) + const job = buildJobObject(node, rootId, subs) + if (job.visibility === "HIDDEN" && job.author !== viewer) continue + jobs.push(job) + } + + const F = String(filter || "ALL").toUpperCase() + let list = dedupeByPreferring(jobs, (j) => (j.author && j.createdAt) ? norm(j.author) + "|" + norm(j.createdAt) : null, (j) => (Array.isArray(j.subscribers) ? j.subscribers.length : 0)) + + if (F === "MINE") list = list.filter((j) => j.author === viewer) + else if (F === "REMOTE") list = list.filter((j) => String(j.location || "").toUpperCase() === "REMOTE") + else if (F === "PRESENCIAL") list = list.filter((j) => String(j.location || "").toUpperCase() === "PRESENCIAL") + else if (F === "FREELANCER") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "FREELANCER") + else if (F === "EMPLOYEE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EMPLOYEE") + else if (F === "EXCHANGE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EXCHANGE") + else if (F === "OPEN") list = list.filter((j) => String(j.status || "").toUpperCase() === "OPEN") + else if (F === "CLOSED") list = list.filter((j) => String(j.status || "").toUpperCase() === "CLOSED") + else if (F === "RECENT") list = list.filter((j) => moment(j.createdAt).isAfter(moment().subtract(24, "hours"))) + else if (F === "APPLIED") list = list.filter((j) => Array.isArray(j.subscribers) && j.subscribers.includes(viewer)) + + const search = String(query.search || query.q || "").trim() + const minSalary = query.minSalary ?? "" + const maxSalary = query.maxSalary ?? "" + const sort = String(query.sort || "").trim() + + if (search) list = list.filter((j) => matchSearch(j, search)) + + const minS = toNum(minSalary) + const maxS = toNum(maxSalary) + + if (Number.isFinite(minS)) list = list.filter((j) => toNum(j.salary) >= minS) + if (Number.isFinite(maxS)) list = list.filter((j) => toNum(j.salary) <= maxS) + + const byRecent = () => list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + const bySalary = () => list.sort((a, b) => toNum(b.salary) - toNum(a.salary)) + const bySubscribers = () => list.sort((a, b) => (b.subscribers || []).length - (a.subscribers || []).length) + + if (F === "TOP") bySalary() + else if (sort === "salary") bySalary() + else if (sort === "subscribers") bySubscribers() + else byRecent() + + return list + }, + + async getJobById(id, viewerId = null) { + const ssbClient = await openSsb() + const viewer = viewerId || ssbClient.id + + const messages = await readAll(ssbClient) + const idx = buildIndex(messages, ssbClient) + + let tipId = id + while (idx.child.has(tipId)) tipId = idx.child.get(tipId) + if (idx.tomb.has(tipId)) throw new Error("Job not found") + + let rootId = tipId + while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId) + + const gate = (job) => { + if (job.visibility === "HIDDEN" && job.author !== viewer) throw new Error("Job not found") + return job + } + + const node = idx.jobNodes.get(tipId) + if (!node) { + const msg = await new Promise((r, j) => ssbClient.get(tipId, (e, m) => e ? j(e) : r(m))) + if (!msg || !msg.content) throw new Error("Job not found") + const tmpNode = { key: tipId, ts: msg.timestamp || 0, c: msg.content, author: msg.author } + const subsSet = idx.subsByJob.get(rootId) || new Set() + const subs = Array.from(subsSet) + return gate(buildJobObject(tmpNode, rootId, subs)) + } + + const subsSet = idx.subsByJob.get(rootId) || new Set() + const subs = Array.from(subsSet) + return gate(buildJobObject(node, rootId, subs)) + }, + + async getJobTipId(id) { + return this.resolveCurrentId(id) + } + } +} + diff --git a/src/models/larp_model.js b/src/models/larp_model.js new file mode 100644 index 0000000..e495d69 --- /dev/null +++ b/src/models/larp_model.js @@ -0,0 +1,942 @@ +const pull = require('../server/node_modules/pull-stream'); +const fs = require('fs'); +const path = require('path'); + +const HOUSES_PATH = path.join(__dirname, '..', 'client', 'assets', 'larp', 'houses.json'); +let HOUSES = {}; +try { HOUSES = JSON.parse(fs.readFileSync(HOUSES_PATH, 'utf8')); } catch (_) { HOUSES = {}; } + +const HOUSE_KEYS = ['academia','solaris','arrakis','terraverde','unsystem','dogma','helix','quark','hermandad']; +const VALID_KEY = (k) => HOUSE_KEYS.includes(String(k || '').toLowerCase()); + +const TEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; + +const PROFILE_QUESTIONS = [ + { + k: "larpProfileQ1", + q: "When you face a complex problem, what do you do first?", + options: [ + { k: "larpProfileQ1O1", t: "Talk to others to find consensus", w: { solaris: 3, hermandad: 1 } }, + { k: "larpProfileQ1O2", t: "Build a prototype to test", w: { arrakis: 3, hermandad: 1 } }, + { k: "larpProfileQ1O3", t: "Research the literature", w: { dogma: 3, terraverde: 1 } }, + { k: "larpProfileQ1O4", t: "Disrupt the system that creates it", w: { unsystem: 3, quark: 1 } } + ] + }, + { + k: "larpProfileQ2", + q: "What gives meaning to your daily work?", + options: [ + { k: "larpProfileQ2O1", t: "Defending the people I love", w: { quark: 3, hermandad: 1 } }, + { k: "larpProfileQ2O2", t: "Creating something tangible", w: { arrakis: 2, hermandad: 2 } }, + { k: "larpProfileQ2O3", t: "Healing or nurturing life", w: { terraverde: 3, helix: 1 } }, + { k: "larpProfileQ2O4", t: "Crafting words and ideas", w: { dogma: 2, solaris: 2 } }, + { k: "larpProfileQ2O5", t: "Making others laugh", w: { helix: 3, unsystem: 1 } } + ] + }, + { + k: "larpProfileQ3", + q: "When conflict arises in your group, you…", + options: [ + { k: "larpProfileQ3O1", t: "Lead the dialogue", w: { solaris: 3 } }, + { k: "larpProfileQ3O2", t: "Take a side and stand firm", w: { quark: 2, dogma: 1 } }, + { k: "larpProfileQ3O3", t: "Crack a joke to defuse", w: { helix: 3, unsystem: 1 } }, + { k: "larpProfileQ3O4", t: "Question whether the conflict is real",w: { unsystem: 3, dogma: 1 } }, + { k: "larpProfileQ3O5", t: "Look for root ecological causes", w: { terraverde: 2, dogma: 1 } } + ] + }, + { + k: "larpProfileQ4", + q: "Your favorite long-term project would be…", + options: [ + { k: "larpProfileQ4O1", t: "Building a city", w: { hermandad: 3, arrakis: 1 } }, + { k: "larpProfileQ4O2", t: "Restoring a forest", w: { terraverde: 3, helix: 1 } }, + { k: "larpProfileQ4O3", t: "Writing a constitution", w: { solaris: 2, dogma: 2 } }, + { k: "larpProfileQ4O4", t: "Organising a festival", w: { helix: 3, hermandad: 1 } }, + { k: "larpProfileQ4O5", t: "Setting up a defense network", w: { quark: 3, hermandad: 1 } }, + { k: "larpProfileQ4O6", t: "Designing a new machine", w: { arrakis: 3, quark: 1 } }, + { k: "larpProfileQ4O7", t: "Curating an archive", w: { dogma: 3, solaris: 1 } }, + { k: "larpProfileQ4O8", t: "Disrupting an unjust order", w: { unsystem: 3, quark: 1 } } + ] + }, + { + k: "larpProfileQ5", + q: "What makes a good leader?", + options: [ + { k: "larpProfileQ5O1", t: "Someone who listens and mediates", w: { solaris: 3, hermandad: 1 } }, + { k: "larpProfileQ5O2", t: "Someone who can fight and protect", w: { quark: 3, unsystem: 1 } }, + { k: "larpProfileQ5O3", t: "Someone who knows history", w: { dogma: 3, terraverde: 1 } }, + { k: "larpProfileQ5O4", t: "Someone who makes you smile", w: { helix: 3, hermandad: 1 } } + ] + }, + { + k: "larpProfileQ6", + q: "Your relationship with rules:", + options: [ + { k: "larpProfileQ6O1", t: "Rules emerge from dialogue and law", w: { solaris: 3 } }, + { k: "larpProfileQ6O2", t: "Rules should be followed strictly", w: { dogma: 2, quark: 2 } }, + { k: "larpProfileQ6O3", t: "Rules should be broken often", w: { unsystem: 3, helix: 1 } }, + { k: "larpProfileQ6O4", t: "Rules should serve life", w: { terraverde: 3, helix: 1 } }, + { k: "larpProfileQ6O5", t: "Rules build solid infrastructure", w: { hermandad: 2, arrakis: 2 } } + ] + }, + { + k: "larpProfileQ7", + q: "How do you handle information?", + options: [ + { k: "larpProfileQ7O1", t: "I curate and preserve it", w: { dogma: 3, hermandad: 1 } }, + { k: "larpProfileQ7O2", t: "I share it through stories", w: { helix: 2, dogma: 2 } }, + { k: "larpProfileQ7O3", t: "I question its origins", w: { unsystem: 3, dogma: 1 } }, + { k: "larpProfileQ7O4", t: "I extract the useful bits", w: { arrakis: 2, quark: 2 } }, + { k: "larpProfileQ7O5", t: "I use it to heal", w: { terraverde: 3 } } + ] + }, + { + k: "larpProfileQ8", + q: "Your idea of success is…", + options: [ + { k: "larpProfileQ8O1", t: "A working machine", w: { arrakis: 3, hermandad: 1 } }, + { k: "larpProfileQ8O2", t: "A peaceful community", w: { solaris: 2, terraverde: 2 } }, + { k: "larpProfileQ8O3", t: "A lively festival", w: { helix: 3, hermandad: 1 } }, + { k: "larpProfileQ8O4", t: "A safe family", w: { quark: 3, terraverde: 1 } }, + { k: "larpProfileQ8O5", t: "An unbroken archive", w: { dogma: 3, hermandad: 1 } }, + { k: "larpProfileQ8O6", t: "A cracked dogma", w: { unsystem: 3, dogma: 1 } }, + { k: "larpProfileQ8O7", t: "A thriving harvest", w: { terraverde: 3, hermandad: 1 } }, + { k: "larpProfileQ8O8", t: "A finished building", w: { hermandad: 3, arrakis: 1 } } + ] + }, + { + k: "larpProfileQ9", + q: "When you wake up, you want to…", + options: [ + { k: "larpProfileQ9O1", t: "Train your body", w: { quark: 3, helix: 1 } }, + { k: "larpProfileQ9O2", t: "Read or write", w: { dogma: 3, solaris: 1 } }, + { k: "larpProfileQ9O3", t: "Garden or cook", w: { terraverde: 3, helix: 1 } }, + { k: "larpProfileQ9O4", t: "Tinker with something", w: { arrakis: 3, hermandad: 1 } }, + { k: "larpProfileQ9O5", t: "Question authority", w: { unsystem: 3, dogma: 1 } }, + { k: "larpProfileQ9O6", t: "Plan a project", w: { hermandad: 3, solaris: 1 } }, + { k: "larpProfileQ9O7", t: "Talk to friends", w: { solaris: 2, helix: 2 } }, + { k: "larpProfileQ9O8", t: "Make art", w: { helix: 3, unsystem: 1 } } + ] + }, + { + k: "larpProfileQ10", + q: "Your weakness might be:", + options: [ + { k: "larpProfileQ10O1", t: "Talking too much", w: { solaris: 3, dogma: 1 } }, + { k: "larpProfileQ10O2", t: "Being too pragmatic", w: { arrakis: 3, hermandad: 1 } }, + { k: "larpProfileQ10O3", t: "Being too idealistic", w: { terraverde: 3, helix: 1 } }, + { k: "larpProfileQ10O4", t: "Being too disruptive", w: { unsystem: 3, quark: 1 } }, + { k: "larpProfileQ10O5", t: "Being too rigid", w: { dogma: 3, quark: 1 } }, + { k: "larpProfileQ10O6", t: "Being too lighthearted", w: { helix: 3, unsystem: 1 } }, + { k: "larpProfileQ10O7", t: "Being too cautious", w: { quark: 3, hermandad: 1 } }, + { k: "larpProfileQ10O8", t: "Being too ambitious", w: { hermandad: 3, arrakis: 1 } } + ] + } +]; + +const TEST_QUESTIONS_COUNT = PROFILE_QUESTIONS.length; + +const SOLAR_AGE_OFFSET = 10000000 - 2026; + +function computeCycle(now = new Date()) { + const year = now.getFullYear(); + const monthIdx = now.getMonth(); + const start = new Date(year, 0, 1); + const dayOfYear = Math.floor((now - start) / 86400000) + 1; + const summerSolstice = new Date(year, 5, 21); + const winterSolstice = new Date(year, 11, 21); + const solsticeNum = now < summerSolstice ? 1 : (now < winterSolstice ? 2 : 1); + const houseKey = HOUSE_KEYS[monthIdx % HOUSE_KEYS.length]; + const house = HOUSES[houseKey] || { short: houseKey.slice(0, 3), name: houseKey }; + const solarAge = year + SOLAR_AGE_OFFSET; + const houseCycle = Math.floor(year - 2026 + 1); + return { + day: dayOfYear, + solstice: solsticeNum, + age: solarAge, + houseKey, + houseShort: house.short || houseKey.slice(0, 3), + cycle: houseCycle, + formatted: `${dayOfYear}.${solsticeNum}.${solarAge}.${house.short || houseKey.slice(0, 3)}.${houseCycle}` + }; +} + +function getGoverningHouseKey(now = new Date()) { + return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length]; +} + +module.exports = ({ cooler, tribesModel, tribeCrypto }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + const houseTribeTag = (houseKey) => { + const h = HOUSES[houseKey]; + return `larp-${h && h.name ? h.name : houseKey}`; + }; + + async function findMyHouseTribe(houseKey) { + if (!tribesModel || !VALID_KEY(houseKey)) return null; + const client = await openSsb(); + const me = client.id; + let list = []; + try { list = await tribesModel.listAll(); } catch (_) { return null; } + const tag = houseTribeTag(houseKey); + const candidates = list.filter(t => { + const tags = Array.isArray(t.tags) ? t.tags : []; + const members = Array.isArray(t.members) ? t.members : []; + return tags.includes(tag) && members.includes(me); + }); + if (!candidates.length) return null; + candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()); + return candidates[0]; + } + + async function findAnyHouseTribe(houseKey) { + if (!tribesModel || !VALID_KEY(houseKey)) return null; + let list = []; + try { list = await tribesModel.listAll(); } catch (_) { return null; } + const tag = houseTribeTag(houseKey); + const candidates = list.filter(t => { + const tags = Array.isArray(t.tags) ? t.tags : []; + return tags.includes(tag); + }); + if (!candidates.length) return null; + candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()); + return candidates[0]; + } + + async function findEarliestHouseAnchor(houseKey) { + if (!VALID_KEY(houseKey)) return null; + const client = await openSsb(); + return new Promise((resolve) => { + const anchors = []; + const anchorTombstones = []; + const tribeTombstones = new Map(); + const msgAuthorByKey = new Map(); + pull( + client.createLogStream(), + pull.drain((m) => { + if (!m || !m.value) return; + const author = m.value.author; + if (m.key && author) msgAuthorByKey.set(m.key, author); + const c = m.value.content; + if (!c || typeof c !== 'object') return; + if (c.type === 'larpHouseTribeAnchor') { + if (c.house !== houseKey) return; + if (typeof c.tribeRootId !== 'string') return; + const tribeTs = Number(Date.parse(c.tribeCreatedAt || '')) || m.value.timestamp || 0; + anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: author, tribeTs }); + } else if (c.type === 'larpHouseTribeAnchorTombstone') { + if (c.house !== houseKey) return; + if (typeof c.tribeRootId !== 'string') return; + anchorTombstones.push({ tribeRootId: c.tribeRootId, tombstoneAuthor: author }); + } else if (c.type === 'tombstone' && typeof c.target === 'string') { + tribeTombstones.set(c.target, author); + } + }, () => { + const killedAnchorPairs = new Set(); + for (const t of anchorTombstones) { + killedAnchorPairs.add(`${t.tribeRootId}|${t.tombstoneAuthor}`); + } + const deadTribes = new Set(); + for (const [target, tombAuthor] of tribeTombstones) { + const tribeAuthor = msgAuthorByKey.get(target); + if (tribeAuthor && tribeAuthor === tombAuthor) deadTribes.add(target); + } + const live = anchors.filter(a => + !killedAnchorPairs.has(`${a.tribeRootId}|${a.anchorAuthor}`) && + !deadTribes.has(a.tribeRootId) + ); + if (!live.length) return resolve(null); + live.sort((a, b) => a.tribeTs - b.tribeTs); + const first = live[0]; + resolve({ tribeRootId: first.tribeRootId, author: first.anchorAuthor, tribeTs: first.tribeTs }); + }) + ); + }); + } + + async function findHouseAnchorByTribe(houseKey, tribeRootId) { + if (!VALID_KEY(houseKey) || !tribeRootId) return null; + const client = await openSsb(); + return new Promise((resolve) => { + let hit = null; + pull( + client.createLogStream(), + pull.drain((m) => { + if (hit) return; + const c = m && m.value && m.value.content; + if (!c || c.type !== 'larpHouseTribeAnchor') return; + if (c.house !== houseKey) return; + if (c.tribeRootId !== tribeRootId) return; + hit = { author: m.value.author, ts: m.value.timestamp || 0 }; + }, () => resolve(hit)) + ); + }); + } + + async function publishHouseTribeAnchor(houseKey, tribeRootId, tribeCreatedAt) { + if (!VALID_KEY(houseKey) || !tribeRootId) return null; + const client = await openSsb(); + return new Promise((resolve) => { + client.publish({ + type: 'larpHouseTribeAnchor', + house: houseKey, + tribeRootId, + tribeCreatedAt: tribeCreatedAt || new Date().toISOString(), + anchoredAt: new Date().toISOString() + }, (err, msg) => resolve(err ? null : msg)); + }); + } + + async function listMyHouseTribes(houseKey) { + if (!tribesModel || !VALID_KEY(houseKey)) return []; + const client = await openSsb(); + const me = client.id; + let list = []; + try { list = await tribesModel.listAll(); } catch (_) { return []; } + const tag = houseTribeTag(houseKey); + const out = []; + for (const t of list) { + const tags = Array.isArray(t.tags) ? t.tags : []; + const members = Array.isArray(t.members) ? t.members : []; + if (!tags.includes(tag) || !members.includes(me)) continue; + const rootId = await tribesModel.getRootId(t.id).catch(() => t.id); + const createdAtTs = Number(Date.parse(t.createdAt || '')) || 0; + out.push({ tribe: t, rootId, createdAtTs }); + } + return out; + } + + async function tombstoneMyTribe(houseKey, rootId, tribeId) { + try { await tribesModel.publishTombstone(tribeId); } catch (_) {} + if (houseKey !== 'academia') { + await publishHouseAnchorTombstone(houseKey, rootId).catch(() => {}); + } + if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') { + try { tribeCrypto.dropKey(rootId); } catch (_) {} + } + } + + async function ensureHouseTribe(houseKey) { + if (!tribesModel || !VALID_KEY(houseKey)) return null; + const client = await openSsb(); + const me = client.id; + + if (houseKey === 'academia') { + const canonical = await findAnyHouseTribe(houseKey).catch(() => null); + const canonRoot = canonical ? await tribesModel.getRootId(canonical.id).catch(() => canonical.id) : null; + const myTribes = await listMyHouseTribes(houseKey); + myTribes.sort((a, b) => a.createdAtTs - b.createdAtTs); + const keepRoot = canonRoot || (myTribes[0] && myTribes[0].rootId); + for (const t of myTribes) { + if (t.rootId === keepRoot) continue; + if (t.tribe.author !== me) continue; + await tombstoneMyTribe(houseKey, t.rootId, t.tribe.id).catch(() => {}); + } + const mineNow = await findMyHouseTribe(houseKey); + if (mineNow) return mineNow; + if (canonical) { + await redeemPendingAutoInvites().catch(() => {}); + return await findMyHouseTribe(houseKey) || canonical; + } + const house = HOUSES[houseKey] || {}; + const tag = houseTribeTag(houseKey); + try { + await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], false, 'open', null, 'PUBLIC', ''); + } catch (_) {} + return await findMyHouseTribe(houseKey); + } + + const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null); + const myTribes = await listMyHouseTribes(houseKey); + myTribes.sort((a, b) => a.createdAtTs - b.createdAtTs); + + let myCanonical = null; + if (anchor) { + myCanonical = myTribes.find(x => x.rootId === anchor.tribeRootId) || null; + } + + if (!myCanonical && myTribes.length > 0) { + const myOldest = myTribes[0]; + const myOldestTs = myOldest.createdAtTs; + if (!anchor) { + await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {}); + myCanonical = myOldest; + } else if (myOldestTs > 0 && anchor.tribeTs > 0 && myOldestTs < anchor.tribeTs) { + const existingAnchor = await findHouseAnchorByTribe(houseKey, myOldest.rootId); + if (!existingAnchor) { + await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {}); + } + myCanonical = myOldest; + } + } + + for (const t of myTribes) { + if (myCanonical && t.rootId === myCanonical.rootId) continue; + if (t.tribe.author !== me) continue; + await tombstoneMyTribe(houseKey, t.rootId, t.tribe.id); + } + + if (myCanonical) { + const existingAnchorForMine = await findHouseAnchorByTribe(houseKey, myCanonical.rootId); + if (!existingAnchorForMine) { + await publishHouseTribeAnchor(houseKey, myCanonical.rootId, myCanonical.tribe.createdAt).catch(() => {}); + } + return myCanonical.tribe; + } + + if (anchor) return null; + + const existingAny = await findAnyHouseTribe(houseKey).catch(() => null); + if (existingAny) { + const rootId = await tribesModel.getRootId(existingAny.id).catch(() => existingAny.id); + const hasAnchor = await findHouseAnchorByTribe(houseKey, rootId); + if (!hasAnchor) await publishHouseTribeAnchor(houseKey, rootId, existingAny.createdAt).catch(() => {}); + if (Array.isArray(existingAny.members) && existingAny.members.includes(me)) return existingAny; + await redeemPendingAutoInvites().catch(() => {}); + return await findMyHouseTribe(houseKey); + } + + const house = HOUSES[houseKey] || {}; + const tag = houseTribeTag(houseKey); + try { + await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], true, 'open', null, 'PRIVATE', ''); + } catch (_) {} + const created = await findMyHouseTribe(houseKey); + if (created) { + const rootId = await tribesModel.getRootId(created.id).catch(() => created.id); + await publishHouseTribeAnchor(houseKey, rootId, created.createdAt).catch(() => {}); + } + return created; + } + + async function publishHouseAnchorTombstone(houseKey, tribeRootId) { + if (!VALID_KEY(houseKey) || !tribeRootId) return null; + const client = await openSsb(); + return new Promise((resolve) => { + client.publish({ + type: 'larpHouseTribeAnchorTombstone', + house: houseKey, + tribeRootId, + tombstonedAt: new Date().toISOString() + }, (err, msg) => resolve(err ? null : msg)); + }); + } + + async function leaveMyHouseTribe(houseKey) { + if (!tribesModel) return; + const client = await openSsb(); + const me = client.id; + const myTribes = await listMyHouseTribes(houseKey); + for (const t of myTribes) { + const isSoloAuthor = t.tribe.author === me && Array.isArray(t.tribe.members) && t.tribe.members.length === 1 && t.tribe.members[0] === me; + try { await tribesModel.leaveTribe(t.tribe.id, { force: true }); } catch (_) {} + if (isSoloAuthor) { + if (houseKey !== 'academia') { + await publishHouseAnchorTombstone(houseKey, t.rootId).catch(() => {}); + } + if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') { + try { tribeCrypto.dropKey(t.rootId); } catch (_) {} + } + } + } + } + + async function publishJoin(houseKey) { + if (!VALID_KEY(houseKey)) throw new Error('Invalid house key'); + const client = await openSsb(); + let previousHouse = null; + try { previousHouse = await getUserHouse(client.id); } catch (_) {} + await new Promise((resolve, reject) => { + client.publish({ + type: 'larpJoinHouse', + house: houseKey, + joinedAt: new Date().toISOString() + }, (err, msg) => err ? reject(err) : resolve(msg)); + }); + if (previousHouse && previousHouse !== houseKey) { + await leaveMyHouseTribe(previousHouse).catch(() => {}); + } + await ensureHouseTribe(houseKey).catch(() => {}); + await redeemPendingAutoInvites().catch(() => {}); + await issueAutoInvitesForMyHouse().catch(() => {}); + } + + async function getUserHouse(feedId) { + const client = await openSsb(); + const target = feedId || client.id; + return new Promise((resolve) => { + let latest = null; + let latestTs = 0; + pull( + client.createUserStream({ id: target, reverse: true }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (!c) return; + const ts = m.value.timestamp || 0; + if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) { + if (ts > latestTs) { latestTs = ts; latest = c.house; } + } else if (c.type === 'larpLeaveLarp') { + if (ts > latestTs) { latestTs = ts; latest = null; } + } + }, () => resolve(latest)) + ); + }); + } + + async function listAllMemberships() { + const client = await openSsb(); + return new Promise((resolve) => { + const byAuthor = new Map(); + pull( + client.createLogStream({ reverse: true }), + pull.drain((m) => { + const author = m && m.value && m.value.author; + if (!author) return; + const c = m.value.content; + if (!c) return; + const ts = m.value.timestamp || 0; + if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) { + const prev = byAuthor.get(author); + if (!prev || ts > prev.ts) byAuthor.set(author, { house: c.house, ts }); + } else if (c.type === 'larpLeaveLarp') { + const prev = byAuthor.get(author); + if (!prev || ts > prev.ts) byAuthor.set(author, { house: null, ts }); + } + }, () => { + const result = new Map(); + for (const [a, v] of byAuthor.entries()) { + if (v.house) result.set(a, v.house); + } + resolve(result); + }) + ); + }); + } + + async function publishLeaveLarp() { + const client = await openSsb(); + let previousHouse = null; + try { previousHouse = await getUserHouse(client.id); } catch (_) {} + await new Promise((resolve, reject) => { + client.publish({ + type: 'larpLeaveLarp', + leftAt: new Date().toISOString() + }, (err, msg) => err ? reject(err) : resolve(msg)); + }); + if (previousHouse) { + await leaveMyHouseTribe(previousHouse).catch(() => {}); + } + } + + async function listHousesWithCounts() { + const memberships = await listAllMemberships(); + const counts = Object.fromEntries(HOUSE_KEYS.map(k => [k, 0])); + for (const house of memberships.values()) { + if (counts[house] !== undefined) counts[house] += 1; + } + return HOUSE_KEYS.map(key => ({ + key, + ...HOUSES[key], + memberCount: counts[key] || 0 + })); + } + + async function getMembersOfHouse(houseKey) { + if (!VALID_KEY(houseKey)) return []; + const memberships = await listAllMemberships(); + const out = []; + for (const [author, house] of memberships.entries()) { + if (house === houseKey) out.push(author); + } + return out; + } + + async function publishHousePost({ house, text }) { + if (!VALID_KEY(house)) throw new Error('Invalid house key'); + const client = await openSsb(); + const clean = String(text || '').trim().slice(0, 4000); + if (!clean) throw new Error('Empty post'); + return new Promise((resolve, reject) => { + client.publish({ + type: 'larpHousePost', + house, + text: clean, + createdAt: new Date().toISOString() + }, (err, msg) => err ? reject(err) : resolve(msg)); + }); + } + + async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) { + if (!VALID_KEY(houseKey)) return []; + const viewerIsMember = viewerHouse === houseKey; + if (!viewerIsMember && !isGoverning) return []; + const client = await openSsb(); + const memberships = await listAllMemberships(); + return new Promise((resolve) => { + const posts = []; + pull( + client.createLogStream({ reverse: true }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (!c || c.type !== 'larpHousePost') return; + if (c.house !== houseKey) return; + const author = m.value.author; + const memberHouse = memberships.get(author) || 'academia'; + if (memberHouse !== houseKey) return; + posts.push({ + id: m.key, + author, + text: String(c.text || ''), + createdAt: c.createdAt || new Date(m.value.timestamp || 0).toISOString(), + ts: m.value.timestamp || 0 + }); + }, () => { + posts.sort((a, b) => b.ts - a.ts); + resolve(posts); + }) + ); + }); + } + + async function getLastTestAttempt(feedId) { + const client = await openSsb(); + const target = feedId || client.id; + return new Promise((resolve) => { + let latest = null; + pull( + client.createUserStream({ id: target, reverse: true }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (!c || c.type !== 'larpTestAttempt') return; + if (!VALID_KEY(c.house)) return; + const ts = m.value.timestamp || 0; + if (!latest || ts > latest.ts) latest = { house: c.house, ts, passed: c.passed === true, score: c.score || 0 }; + }, () => resolve(latest)) + ); + }); + } + + async function canTakeTest(feedId) { + const last = await getLastTestAttempt(feedId); + if (!last) return { allowed: true, nextAt: 0, last: null }; + const elapsed = Date.now() - last.ts; + if (elapsed >= TEST_COOLDOWN_MS) return { allowed: true, nextAt: 0, last }; + return { allowed: false, nextAt: last.ts + TEST_COOLDOWN_MS, last }; + } + + function getProfileTest() { + return PROFILE_QUESTIONS.map(q => ({ + key: q.k, + question: q.q, + options: q.options.map(o => ({ key: o.k, text: o.t })) + })); + } + + function scoreProfileAnswers(answers, memberCounts = {}) { + const scores = Object.fromEntries(HOUSE_KEYS.filter(k => k !== 'academia').map(k => [k, 0])); + PROFILE_QUESTIONS.forEach((q, i) => { + const choice = Number(answers && answers[i]); + if (!Number.isInteger(choice) || choice < 0 || choice >= q.options.length) return; + const weights = q.options[choice].w || {}; + for (const [house, weight] of Object.entries(weights)) { + if (scores[house] === undefined) continue; + scores[house] += Number(weight) || 0; + } + }); + const ranking = Object.entries(scores).sort((a, b) => { + if (b[1] !== a[1]) return b[1] - a[1]; + const ma = memberCounts[a[0]] || 0; + const mb = memberCounts[b[0]] || 0; + if (ma !== mb) return ma - mb; + return a[0].localeCompare(b[0]); + }); + const bestHouse = ranking[0] ? ranking[0][0] : null; + const bestScore = ranking[0] ? ranking[0][1] : 0; + return { scores, ranking, bestHouse, bestScore }; + } + + async function submitProfileTest({ answers }) { + const client = await openSsb(); + const can = await canTakeTest(client.id); + if (!can.allowed) return { ok: false, reason: 'cooldown', nextAt: can.nextAt }; + const housesWithCounts = await listHousesWithCounts(); + const memberCounts = Object.fromEntries(housesWithCounts.map(h => [h.key, h.memberCount || 0])); + const { scores, ranking, bestHouse, bestScore } = scoreProfileAnswers(answers, memberCounts); + const target = bestHouse || 'academia'; + await new Promise((resolve, reject) => { + client.publish({ + type: 'larpTestAttempt', + house: target, + passed: true, + attemptedAt: new Date().toISOString() + }, (err) => err ? reject(err) : resolve()); + }); + await publishJoin(target); + return { ok: true, passed: true, house: target, score: bestScore, scores, ranking }; + } + + async function issueAutoInvitesForMyHouse() { + if (!tribesModel) return; + const client = await openSsb(); + const me = client.id; + const myHouse = await getUserHouse(me).catch(() => null); + if (!VALID_KEY(myHouse) || myHouse === 'academia') return; + const anchor = await findEarliestHouseAnchor(myHouse).catch(() => null); + if (!anchor) return; + let canonicalTribe; + try { canonicalTribe = await tribesModel.getTribeById(anchor.tribeRootId); } catch (_) { return; } + if (!canonicalTribe) return; + const tribeMembers = Array.isArray(canonicalTribe.members) ? canonicalTribe.members : []; + if (!tribeMembers.includes(me)) return; + const houseMembers = await getMembersOfHouse(myHouse).catch(() => []); + const missing = houseMembers.filter(id => id && id !== me && !tribeMembers.includes(id)); + if (!missing.length) return; + const sent = await listMyAutoInviteRecipients(myHouse, anchor.tribeRootId).catch(() => new Set()); + for (const newMember of missing) { + if (sent.has(newMember)) continue; + try { + const code = await tribesModel.generateInvite(canonicalTribe.id); + await new Promise((resolve) => { + client.publish({ + type: 'larpAutoInvite', + house: myHouse, + tribeRootId: anchor.tribeRootId, + to: newMember, + code, + sentAt: new Date().toISOString(), + recps: [newMember, me] + }, () => resolve()); + }); + } catch (_) {} + } + } + + async function listMyAutoInviteRecipients(houseKey, tribeRootId) { + const client = await openSsb(); + const me = client.id; + const ssbKeys = require('../server/node_modules/ssb-keys'); + const config = require('../server/ssb_config'); + return new Promise((resolve) => { + const out = new Set(); + pull( + client.createUserStream({ id: me }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (typeof c !== 'string' || !c.endsWith('.box')) return; + let decoded; + try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; } + if (!decoded) return; + if (typeof decoded === 'string') { + try { decoded = JSON.parse(decoded); } catch (_) { return; } + } + if (!decoded || decoded.type !== 'larpAutoInvite') return; + if (decoded.house !== houseKey) return; + if (decoded.tribeRootId !== tribeRootId) return; + if (typeof decoded.to === 'string' && decoded.to !== me) out.add(decoded.to); + }, () => resolve(out)) + ); + }); + } + + async function alreadyInCanonical(houseKey) { + if (!VALID_KEY(houseKey)) return false; + const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null); + if (!anchor) return false; + try { + const canonical = await tribesModel.getTribeById(anchor.tribeRootId); + const client = await openSsb(); + return !!(canonical && Array.isArray(canonical.members) && canonical.members.includes(client.id)); + } catch (_) { return false; } + } + + async function redeemPendingAutoInvites() { + if (!tribesModel) return; + const client = await openSsb(); + const me = client.id; + const myHouse = await getUserHouse(me).catch(() => null); + if (!VALID_KEY(myHouse) || myHouse === 'academia') return; + if (await alreadyInCanonical(myHouse)) return; + const ssbKeys = require('../server/node_modules/ssb-keys'); + const config = require('../server/ssb_config'); + const codes = []; + await new Promise((resolve) => { + pull( + client.createLogStream({ reverse: true, limit: 2000 }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (typeof c !== 'string' || !c.endsWith('.box')) return; + let decoded; + try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; } + if (!decoded) return; + if (typeof decoded === 'string') { + try { decoded = JSON.parse(decoded); } catch (_) { return; } + } + if (!decoded || decoded.type !== 'larpAutoInvite') return; + if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return; + if (decoded.house !== myHouse) return; + if (m.value.author === me) return; + codes.push(decoded.code); + }, () => resolve()) + ); + }); + for (const code of codes) { + try { await tribesModel.joinByInvite(code); } catch (_) {} + } + } + + let liveSubscriberStarted = false; + let initRan = false; + let processingChain = Promise.resolve(); + + function enqueue(fn) { + processingChain = processingChain.then(fn).catch(() => {}); + return processingChain; + } + + async function runCatchup() { + try { + const client = await openSsb(); + const me = client.id; + const myHouse = await getUserHouse(me).catch(() => null); + if (VALID_KEY(myHouse)) { + await ensureHouseTribe(myHouse).catch(() => {}); + } + await redeemPendingAutoInvites().catch(() => {}); + await issueAutoInvitesForMyHouse().catch(() => {}); + } catch (_) {} + } + + async function handleLiveMessage(m) { + const c = m && m.value && m.value.content; + if (!c) return; + const client = await openSsb(); + const me = client.id; + const ssbKeys = require('../server/node_modules/ssb-keys'); + const config = require('../server/ssb_config'); + try { + if (typeof c === 'object' && c.type === 'larpJoinHouse' && VALID_KEY(c.house)) { + if (m.value.author === me) return; + const myHouse = await getUserHouse(me).catch(() => null); + if (myHouse !== c.house) return; + await issueAutoInvitesForMyHouse().catch(() => {}); + return; + } + if (typeof c === 'object' && c.type === 'larpHouseTribeAnchor' && VALID_KEY(c.house)) { + if (m.value.author === me) return; + const myHouse = await getUserHouse(me).catch(() => null); + if (myHouse !== c.house) return; + await ensureHouseTribe(c.house).catch(() => {}); + await redeemPendingAutoInvites().catch(() => {}); + await issueAutoInvitesForMyHouse().catch(() => {}); + return; + } + if (typeof c === 'object' && c.type === 'larpHouseTribeAnchorTombstone' && VALID_KEY(c.house)) { + if (m.value.author === me) return; + const myHouse = await getUserHouse(me).catch(() => null); + if (myHouse !== c.house) return; + await ensureHouseTribe(c.house).catch(() => {}); + await redeemPendingAutoInvites().catch(() => {}); + return; + } + if (typeof c === 'string' && c.endsWith('.box')) { + if (m.value.author === me) return; + let decoded; + try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; } + if (!decoded) return; + if (typeof decoded === 'string') { + try { decoded = JSON.parse(decoded); } catch (_) { return; } + } + if (!decoded || decoded.type !== 'larpAutoInvite') return; + if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return; + const myHouse = await getUserHouse(me).catch(() => null); + if (decoded.house !== myHouse) return; + if (await alreadyInCanonical(myHouse)) return; + try { await tribesModel.joinByInvite(decoded.code); } catch (_) {} + } + } catch (_) {} + } + + async function init() { + if (initRan) return; + initRan = true; + if (!liveSubscriberStarted) { + liveSubscriberStarted = true; + try { + const client = await openSsb(); + pull( + client.createLogStream({ live: true, old: false }), + pull.drain((m) => { enqueue(() => handleLiveMessage(m)); }, () => { liveSubscriberStarted = false; }) + ); + } catch (_) { liveSubscriberStarted = false; } + } + enqueue(runCatchup); + } + + async function createHouseInvite(houseKey) { + if (!VALID_KEY(houseKey)) throw new Error('Invalid house key'); + if (houseKey === 'academia') throw new Error('ACADEMIA does not issue invites'); + const client = await openSsb(); + const myHouse = await getUserHouse(client.id); + if (myHouse !== houseKey) throw new Error('Only members can issue invites'); + const tribe = await ensureHouseTribe(houseKey); + if (!tribe) throw new Error('Could not resolve house tribe'); + if (!tribesModel) throw new Error('tribesModel unavailable'); + const code = await tribesModel.generateInvite(tribe.id); + return { code, house: houseKey, tribeId: tribe.id }; + } + + async function redeemHouseInvite(rawCode) { + const code = String(rawCode || '').trim(); + if (!code) return { ok: false }; + if (!tribesModel) return { ok: false }; + const client = await openSsb(); + const myHouse = await getUserHouse(client.id); + if (myHouse && myHouse !== 'academia') return { ok: false }; + let rootId; + try { rootId = await tribesModel.joinByInvite(code); } catch (_) { return { ok: false }; } + if (!rootId) return { ok: false }; + let tribe = null; + try { tribe = await tribesModel.getTribeById(rootId); } catch (_) { tribe = null; } + const tags = (tribe && Array.isArray(tribe.tags)) ? tribe.tags : []; + const houseTag = tags.find(t => typeof t === 'string' && t.startsWith('larp-')); + if (!houseTag) return { ok: false }; + const suffix = houseTag.slice('larp-'.length); + const houseKey = HOUSE_KEYS.find(k => (HOUSES[k] && HOUSES[k].name === suffix) || k === suffix); + if (!houseKey || houseKey === 'academia') return { ok: false }; + await publishJoin(houseKey); + return { ok: true, house: houseKey, tribeId: rootId }; + } + + return { + HOUSES, + HOUSE_KEYS, + TEST_COOLDOWN_MS, + TEST_QUESTIONS_COUNT, + PROFILE_QUESTIONS, + computeCycle, + getGoverningHouseKey, + publishJoin, + publishLeaveLarp, + getUserHouse, + listHousesWithCounts, + getMembersOfHouse, + publishHousePost, + listHousePosts, + getLastTestAttempt, + canTakeTest, + getProfileTest, + scoreProfileAnswers, + submitProfileTest, + createHouseInvite, + redeemHouseInvite, + findMyHouseTribe, + ensureHouseTribe, + leaveMyHouseTribe, + issueAutoInvitesForMyHouse, + redeemPendingAutoInvites, + init, + getHouse: (key) => HOUSES[key] || null + }; +}; diff --git a/src/models/legacy_model.js b/src/models/legacy_model.js new file mode 100644 index 0000000..3499811 --- /dev/null +++ b/src/models/legacy_model.js @@ -0,0 +1,99 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const os = require('os'); + +const MAGIC = Buffer.from('OASIS1'); +const SALT_LEN = 16; +const IV_LEN = 12; +const TAG_LEN = 16; + +function normalizePassword(password) { + if (password && typeof password === 'object' && password.password) password = password.password; + return String(password || ''); +} + +function deriveKey(password, salt) { + return crypto.scryptSync(password, salt, 32); +} + +function encryptBuffer(plaintext, password) { + const salt = crypto.randomBytes(SALT_LEN); + const iv = crypto.randomBytes(IV_LEN); + const key = deriveKey(password, salt); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([MAGIC, salt, iv, tag, ciphertext]); +} + +function decryptBuffer(data, password) { + if (!Buffer.isBuffer(data) || data.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN) { + throw new Error('Unrecognized or corrupt backup file.'); + } + if (!data.slice(0, MAGIC.length).equals(MAGIC)) { + throw new Error('Unrecognized or corrupt backup file.'); + } + let o = MAGIC.length; + const salt = data.slice(o, o += SALT_LEN); + const iv = data.slice(o, o += IV_LEN); + const tag = data.slice(o, o += TAG_LEN); + const ciphertext = data.slice(o); + const key = deriveKey(password, salt); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +module.exports = { + exportData: async (password) => { + try { + const pw = normalizePassword(password); + const homeDir = os.homedir(); + const secretFilePath = path.join(homeDir, '.ssb', 'secret'); + if (!fs.existsSync(secretFilePath)) { + throw new Error(".ssb/secret file doesn't exist"); + } + const original = fs.readFileSync(secretFilePath); + const encrypted = encryptBuffer(original, pw); + const roundtrip = decryptBuffer(encrypted, pw); + if (!roundtrip.equals(original)) { + throw new Error('Backup verification failed; nothing was written.'); + } + const encryptedFilePath = path.join(homeDir, 'oasis.enc'); + fs.writeFileSync(encryptedFilePath, encrypted, { mode: 0o600 }); + return encryptedFilePath; + } catch (error) { + throw new Error("Error exporting data: " + error.message); + } + }, + importData: async ({ filePath, password }) => { + try { + const pw = normalizePassword(password); + if (!fs.existsSync(filePath)) { + throw new Error('Encrypted file not found.'); + } + const data = fs.readFileSync(filePath); + let decrypted; + try { + decrypted = decryptBuffer(data, pw); + } catch (_) { + throw new Error('Wrong password or corrupt backup file.'); + } + const homeDir = os.homedir(); + const ssbDir = path.join(homeDir, '.ssb'); + fs.mkdirSync(ssbDir, { recursive: true }); + const secretPath = path.join(ssbDir, 'secret'); + if (fs.existsSync(secretPath)) { + fs.copyFileSync(secretPath, path.join(ssbDir, 'secret.bak-' + new Date().toISOString().replace(/[:.]/g, '-'))); + } + const tmpPath = path.join(ssbDir, 'secret.tmp-' + process.pid); + fs.writeFileSync(tmpPath, decrypted, { mode: 0o600 }); + fs.renameSync(tmpPath, secretPath); + try { fs.unlinkSync(filePath); } catch (_) {} + return secretPath; + } catch (error) { + throw new Error("Error importing data: " + error.message); + } + } +}; diff --git a/src/models/logs_model.js b/src/models/logs_model.js new file mode 100644 index 0000000..88bade7 --- /dev/null +++ b/src/models/logs_model.js @@ -0,0 +1,362 @@ +const pull = require('../server/node_modules/pull-stream'); +const util = require('../server/node_modules/util'); +const axios = require('../server/node_modules/axios'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; + +const FILTER_WINDOWS = { + today: DAY_MS, + week: WEEK_MS, + month: MONTH_MS, + year: YEAR_MS, + always: null +}; + +const ACTION_TYPES = new Set([ + 'post', 'about', 'contact', 'feed', 'bookmark', 'image', 'audio', 'video', + 'document', 'torrent', 'event', 'task', 'taskAssignment', + 'votes', 'vote', 'report', 'tribe', 'chat', 'chatMessage', 'pad', 'padEntry', + 'forum', 'market', 'job', 'project', 'pixelia', 'map', 'mapMarker', + 'shop', 'shopProduct', 'curriculum', 'gameScore', + 'calendar', 'calendarDate', 'calendarNote', + 'transfer', 'bankClaim', 'ubiClaim', + 'parliamentCandidature', 'parliamentProposal', 'parliamentLaw', + 'parliamentTerm', 'parliamentRevocation', + 'courtsCase', 'courtsEvidence', 'courtsAnswer', 'courtsVerdict', + 'courtsNomination', 'courtsNominationVote', + 'courtsSettlementProposal', 'courtsSettlementAccepted', + 'tribeParliamentCandidature', 'tribeParliamentRule' +]); + +const ACTION_PHRASES = { + post: 'published a post', + about: 'updated profile information', + contact: 'followed or unfollowed someone', + feed: 'shared content in the feed', + bookmark: 'bookmarked a resource', + image: 'uploaded an image', + audio: 'uploaded an audio track', + video: 'uploaded a video', + document: 'uploaded a document', + torrent: 'shared a torrent', + event: 'created an event', + task: 'created a task', + taskAssignment: 'updated a task assignment', + votes: 'participated in a vote', + vote: 'cast a vote', + report: 'submitted a report', + tribe: 'interacted with a tribe', + chat: 'opened a chat room', + chatMessage: 'sent a chat message', + pad: 'worked on a collaborative pad', + padEntry: 'edited a pad entry', + market: 'posted in the market', + forum: 'posted in the forum', + job: 'posted a job opportunity', + project: 'advanced a project', + pixelia: 'placed a pixel in pixelia', + map: 'contributed to a map', + mapMarker: 'placed a marker on a map', + shop: 'updated a shop', + shopProduct: 'managed a shop product', + curriculum: 'edited the curriculum', + gameScore: 'logged a game score', + calendar: 'managed a calendar', + calendarDate: 'added a calendar date', + calendarNote: 'added a calendar note', + transfer: 'sent or confirmed a transfer', + bankClaim: 'completed a banking claim', + ubiClaim: 'claimed the UBI', + parliamentCandidature: 'published a parliamentary candidature', + parliamentProposal: 'published a parliamentary proposal', + parliamentLaw: 'participated in a parliamentary law', + parliamentTerm: 'participated in a parliamentary term', + parliamentRevocation: 'submitted a parliamentary revocation', + courtsCase: 'opened a courts case', + courtsEvidence: 'submitted courts evidence', + courtsAnswer: 'replied in a courts case', + courtsVerdict: 'reached a courts verdict', + courtsNomination: 'nominated a judge', + courtsNominationVote: 'voted on a judge nomination', + courtsSettlementProposal: 'proposed a courts settlement', + courtsSettlementAccepted: 'accepted a courts settlement', + tribeParliamentCandidature: 'stood for a tribe parliament', + tribeParliamentRule: 'contributed a tribe parliament rule' +}; + +const compact = (s, n = 200) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, n); + +module.exports = ({ cooler }) => { + let ssb; + let userId; + const openSsb = async () => { + if (!ssb) { + ssb = await cooler.open(); + userId = ssb.id; + } + return ssb; + }; + + async function listAllUserActions() { + const ssbClient = await openSsb(); + const msgs = await new Promise((resolve, reject) => + pull( + ssbClient.createLogStream({ reverse: true, limit: logLimit }), + pull.collect((err, arr) => err ? reject(err) : resolve(arr)) + ) + ); + const out = []; + for (const m of msgs) { + const v = m?.value || {}; + const c = v?.content; + if (!c || typeof c !== 'object' || !c.type) continue; + if (v.author !== userId) continue; + if (c.type === 'log') continue; + if (!ACTION_TYPES.has(c.type)) continue; + const ts = v.timestamp || 0; + const summary = c.title || c.text || c.question || c.subject || c.name || c.concept || c.description || ''; + out.push({ key: m.key, ts, type: c.type, summary: compact(summary) }); + } + return out; + } + + async function callAI(prompt) { + if (!prompt) return ''; + const tryOnce = async () => { + try { + const res = await axios.post('http://localhost:4001/ai', { input: prompt, raw: true }, { timeout: 90000 }); + return String(res?.data?.answer || '').trim(); + } catch { return ''; } + }; + let out = await tryOnce(); + if (!out) { + await new Promise(r => setTimeout(r, 2000)); + out = await tryOnce(); + } + return out; + } + + function buildActionPrompt(a) { + const d = new Date(a.ts).toISOString().slice(0, 16).replace('T', ' '); + const ctx = a.summary ? ` Subject: "${compact(a.summary, 120)}".` : ''; + return `One first-person diary sentence about a "${a.type}" action at ${d}.${ctx} Vary phrasing. No IDs, hashes, quotes, lists or markdown.`; + } + + function buildFallbackSentence(a) { + const phrase = ACTION_PHRASES[a.type] || `performed a ${a.type} action`; + const d = new Date(a.ts).toISOString().slice(0, 16).replace('T', ' '); + const ctx = a.summary ? ` — ${compact(a.summary, 120)}` : ''; + return `At ${d} I ${phrase}${ctx}.`; + } + + function isAImodOn() { + try { return getConfig().modules?.aiMod === 'on'; } catch { return false; } + } + + async function publishLog({ text, label, mode, ref }) { + const ssbClient = await openSsb(); + const content = { + type: 'log', + text: String(text || '').slice(0, 8000), + label: String(label || '').slice(0, 200), + mode: mode === 'ai' ? 'ai' : 'manual', + createdAt: new Date().toISOString(), + timestamp: Date.now(), + private: true + }; + if (ref) content.ref = String(ref); + const publishAsync = util.promisify(ssbClient.private.publish); + return publishAsync(content, [userId]); + } + + async function republishLog({ replaces, text, label, mode, createdAt }) { + const ssbClient = await openSsb(); + const content = { + type: 'log', + replaces, + text: String(text || '').slice(0, 8000), + label: String(label || '').slice(0, 200), + mode: mode === 'ai' ? 'ai' : 'manual', + createdAt: createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + timestamp: Date.now(), + private: true + }; + const publishAsync = util.promisify(ssbClient.private.publish); + return publishAsync(content, [userId]); + } + + async function publishTombstone(target) { + const ssbClient = await openSsb(); + const content = { + type: 'tombstone', + target, + deletedAt: new Date().toISOString(), + author: userId, + private: true + }; + const publishAsync = util.promisify(ssbClient.private.publish); + return publishAsync(content, [userId]); + } + + async function createManual(label, text) { + await openSsb(); + const t = String(text || '').trim(); + if (!t) return { status: 'empty' }; + await publishLog({ text: t, label: String(label || '').trim(), mode: 'manual' }); + return { status: 'ok' }; + } + + function sigOf(label, text) { + return `${String(label || '').trim()}||${String(text || '').trim().slice(0, 120)}`; + } + + async function getProcessedState() { + const items = await readAllLogMessages(); + const refs = new Set(); + const sigs = new Set(); + for (const it of items) { + if (it.ref) refs.add(it.ref); + sigs.add(sigOf(it.label, it.text)); + } + return { refs, sigs }; + } + + async function createAI() { + await openSsb(); + if (!isAImodOn()) return { status: 'ai_disabled' }; + const actions = await listAllUserActions(); + if (!actions.length) return { status: 'no_actions' }; + const state = await getProcessedState(); + const pending = actions.filter(a => a.key && !state.refs.has(a.key)); + if (!pending.length) return { status: 'no_new_actions' }; + const MAX_ACTIONS = 40; + const slice = pending.slice(0, MAX_ACTIONS); + let published = 0; + let aiFails = 0; + let aiDown = false; + for (const a of slice) { + let sentence = ''; + if (!aiDown) { + sentence = await callAI(buildActionPrompt(a)); + if (!sentence) { + aiFails++; + if (aiFails >= 3) aiDown = true; + } else { + aiFails = 0; + } + } + if (!sentence) sentence = buildFallbackSentence(a); + if (!sentence) continue; + const sig = sigOf(a.type, sentence); + if (state.sigs.has(sig)) { state.refs.add(a.key); continue; } + await publishLog({ text: sentence, label: a.type, mode: 'ai', ref: a.key }); + state.refs.add(a.key); + state.sigs.add(sig); + published++; + await new Promise(r => setTimeout(r, 300)); + } + if (!published) return { status: 'no_narrative' }; + return { status: 'ok', count: published }; + } + + async function readAllLogMessages() { + const ssbClient = await openSsb(); + const raw = await new Promise((resolve, reject) => + pull( + ssbClient.createLogStream({ reverse: false, limit: logLimit }), + pull.collect((err, arr) => err ? reject(err) : resolve(arr)) + ) + ); + const items = []; + const tombstoned = buildValidatedTombstoneSet(raw); + const replaced = new Map(); + for (const m of raw) { + if (!m || !m.value) continue; + const keyIn = m.key; + const valueIn = m.value; + const tsIn = m.timestamp || valueIn?.timestamp || Date.now(); + let dec; + try { + dec = ssbClient.private.unbox({ key: keyIn, value: valueIn, timestamp: tsIn }); + } catch { continue; } + const v = dec?.value; + const c = v?.content; + if (!c) continue; + if (v.author !== userId) continue; + if (c.type === 'tombstone') continue; + if (c.type !== 'log') continue; + if (c.replaces) replaced.set(c.replaces, dec.key || keyIn); + items.push({ + key: dec.key || keyIn, + author: v.author, + ts: v.timestamp || tsIn, + createdAt: c.createdAt || new Date(v.timestamp || tsIn).toISOString(), + text: String(c.text || ''), + label: String(c.label || ''), + mode: c.mode === 'ai' ? 'ai' : 'manual', + replaces: c.replaces || null, + ref: c.ref || null + }); + } + const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key)); + survivors.sort((a, b) => b.ts - a.ts); + return survivors; + } + + async function listLogs(filter = 'today') { + const items = await readAllLogMessages(); + const win = FILTER_WINDOWS[filter]; + if (win === null || win === undefined) return items; + const cutoff = Date.now() - win; + return items.filter(i => i.ts >= cutoff); + } + + async function getLogById(id) { + const items = await readAllLogMessages(); + return items.find(i => i.key === id) || null; + } + + async function updateLog(id, { text, label, mode }) { + const current = await getLogById(id); + if (!current) return { status: 'not_found' }; + await republishLog({ + replaces: current.key, + text: text !== undefined ? text : current.text, + label: label !== undefined ? label : current.label, + mode: mode || current.mode, + createdAt: current.createdAt + }); + return { status: 'ok' }; + } + + async function deleteLog(id) { + const current = await getLogById(id); + if (!current) return { status: 'not_found' }; + await publishTombstone(current.key); + return { status: 'ok' }; + } + + async function countLogs() { + const items = await readAllLogMessages(); + return items.length; + } + + return { + createManual, + createAI, + updateLog, + deleteLog, + getLogById, + listLogs, + countLogs, + isAImodOn + }; +}; diff --git a/src/models/main_models.js b/src/models/main_models.js new file mode 100644 index 0000000..d53de86 --- /dev/null +++ b/src/models/main_models.js @@ -0,0 +1,2347 @@ +"use strict"; + +const { buildValidatedTombstoneSet } = require("./tombstone_validator"); +const debug = require("../server/node_modules/debug")("oasis"); +const { isRoot, isReply: isComment } = require("../server/node_modules/ssb-thread-schema"); +const lodash = require("../server/node_modules/lodash"); +const prettyMs = require("../server/node_modules/pretty-ms"); +const pullAbortable = require("../server/node_modules/pull-abortable"); +const pullParallelMap = require("../server/node_modules/pull-paramap"); +const pull = require("../server/node_modules/pull-stream"); +const pullSort = require("../server/node_modules/pull-sort"); + +const path = require('path'); +const fs = require('fs/promises'); +const os = require('os'); + +const ssbRef = require("../server/node_modules/ssb-ref"); +const nameCache = require('../backend/nameCache'); + +const { getConfig } = require('../configs/config-manager.js'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const isEncrypted = (message) => typeof message.value.content === "string"; +const isNotEncrypted = (message) => isEncrypted(message) === false; + +const isDecrypted = (message) => + lodash.get(message, "value.meta.private", false); + +const isPrivate = (message) => isEncrypted(message) || isDecrypted(message); + +const isNotPrivate = (message) => isPrivate(message) === false; + +const hasRoot = (message) => + ssbRef.isMsg(lodash.get(message, "value.content.root", null)); + +const hasFork = (message) => + ssbRef.isMsg(lodash.get(message, "value.content.fork", null)); + +const hasNoRoot = (message) => hasRoot(message) === false; +const hasNoFork = (message) => hasFork(message) === false; + +const isPost = (message) => + lodash.get(message, "value.content.type") === "post" && + typeof lodash.get(message, "value.content.text") === "string"; + +const isBlogPost = (message) => + lodash.get(message, "value.content.type") === "blog" && + typeof lodash.get(message, "value.content.title") === "string" && + ssbRef.isBlob(lodash.get(message, "value.content.blog", null)); + +const isTextLike = (message) => isPost(message) || isBlogPost(message); + +const isSubtopic = require("../server/node_modules/ssb-thread-schema/post/nested-reply/validator"); + +const nullImage = `&${"0".repeat(43)}=.sha256`; + +const defaultOptions = { + private: true, + reverse: true, + meta: true, +}; + +const publicOnlyFilter = pull.filter(isNotPrivate); + +const configure = (...customOptions) => + Object.assign({}, defaultOptions, ...customOptions); + +// PEERS +const ebtDir = path.join(os.homedir(), '.ssb', 'ebt'); +const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json'); + +async function loadPeersFromEbt() { + let result = []; + try { + await fs.access(ebtDir); + const files = await fs.readdir(ebtDir); + for (const file of files) { + if (!file.endsWith('.ed25519')) continue; + const base = file.replace(/^@/, '').replace('.ed25519', ''); + let core = base.replace(/_/g, '/').replace(/-/g, '+'); + if (!core.endsWith('=')) core += '='; + const filePath = path.join(ebtDir, file); + try { + const data = await fs.readFile(filePath, 'utf8'); + const users = JSON.parse(data); + const userList = Object.keys(users).map(u => ({ + id: u, + link: `/author/${encodeURIComponent(u)}` + })); + result.push({ + pub: `@${core}.ed25519`, + users: userList + }); + } catch {} + } + } catch {} + return result; +} + +async function loadConnectedUsersFromEbt(pubId) { + const filePath = path.join(ebtDir, `@${pubId.replace(/\//g, '_')}.ed25519`); + try { + const data = await fs.readFile(filePath, 'utf8'); + const users = JSON.parse(data); + return Object.keys(users).map(userId => ({ + id: userId, + link: `/author/${encodeURIComponent(userId)}` + })); + } catch { + return []; + } +} + +const canonicalizePubId = (s) => { + const core0 = String(s).replace(/^@/, '').replace(/\.ed25519$/, ''); + let core = core0.replace(/_/g, '/').replace(/-/g, '+'); + if (!core.endsWith('=')) core += '='; + return `@${core}.ed25519`; +}; + +const parseRemote = (remote) => { + let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote); + if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) }; + m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote); + if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) }; + m = /~shs:([^=]+)=/.exec(remote); + if (m) return { host: null, pubId: canonicalizePubId(m[1]) }; + return { host: null, pubId: null }; +}; + +async function ensureJSONFile(p, initial = []) { + await fs.mkdir(path.dirname(p), { recursive: true }); + try { await fs.access(p) } catch { await fs.writeFile(p, JSON.stringify(initial, null, 2), 'utf8') } +} + +async function readJSON(p) { + await ensureJSONFile(p, []); + try { return JSON.parse((await fs.readFile(p, 'utf8')) || '[]') } catch { return [] } +} + +function canonicalKey(key) { + let core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/'); + if (!core.endsWith('=')) core += '='; + return `@${core}.ed25519`; +} + +async function loadUnfollowedSet() { + const list = await readJSON(unfollowedPath); + return new Set(list.map(x => canonicalKey(x && x.key))); +} + +function toLegacyInvite(s) { + const t = String(s || '').trim(); + if (/^[^:]+:\d+:@[^~]+~[^~]+$/.test(t)) return t; + let m = t.match(/^net:([^:]+):(\d+)~shs:([^~]+)~invite:([^~]+)$/); + if (!m) m = t.match(/^([^:]+):(\d+)~shs:([^~]+)~invite:([^~]+)$/); + if (!m) return t; + let key = m[3].replace(/^@/, ''); + if (!/\.ed25519$/.test(key)) key += '.ed25519'; + return `${m[1]}:${m[2]}:@${key}~${m[4]}`; +} + +// CORE MODEL +module.exports = ({ cooler, isPublic }) => { + const models = {}; + const getAbout = async ({ key, feedId }) => { + const ssb = await cooler.open(); + const source = ssb.backlinks.read({ + reverse: true, + query: [ + { + $filter: { + dest: feedId, + value: { + author: feedId, + content: { type: "about", about: feedId }, + }, + }, + }, + ], + }); + return new Promise((resolve, reject) => + pull( + source, + pull.find( + (message) => message.value.content[key] !== undefined, + (err, message) => { + if (err) { + reject(err); + } else { + if (message === null) { + resolve(null); + } else { + resolve(message.value.content[key]); + } + } + } + ) + ) + ); + }; + const feeds_to_name = {}; + let all_the_names = {}; + let dirty = false; + let running = false; + const transposeLookupTable = () => { + if (!dirty) return; + if (running) return; + running = true; + + all_the_names = {}; + + const allFeeds = Object.keys(feeds_to_name); + console.log(`- Synced-peers: [ ${allFeeds.length} ]`); + console.time("- Sync-time"); + + const lookups = []; + for (const feed of allFeeds) { + const e = feeds_to_name[feed]; + let pair = { feed, name: e.name }; + lookups.push(enhanceFeedInfo(pair)); + } + Promise.all(lookups) + .then(() => { + dirty = false; + running = false; + console.timeEnd("- Sync-time"); + }) + .catch((err) => { + running = false; + console.warn("- Lookup Sync failed: ", err); + }); + }; + const enhanceFeedInfo = ({ feed, name }) => { + return new Promise((resolve, reject) => { + getAbout({ feedId: feed, key: "image" }) + .then((img) => { + if ( + img !== null && + typeof img !== "string" && + typeof img === "object" && + typeof img.link === "string" + ) { + img = img.link; + } else if (img === null) { + img = nullImage; + } + + models.friend + .getRelationship(feed) + .then((rel) => { + let feeds_named = all_the_names[name] || []; + feeds_named.push({ feed, name, rel, img }); + all_the_names[name.toLowerCase()] = feeds_named; + resolve(); + }) + .catch(reject); + }) + .catch(reject); + }); + }; + + async function enrichEntries(entries) { + const ebtList = await loadPeersFromEbt(); + const ebtMap = new Map(ebtList.map(e => [e.pub, e.users])); + const ssb = await cooler.open(); + return Promise.all( + entries.map(async ([remote, data]) => { + const { host, pubId } = parseRemote(remote); + const effectiveKey = pubId || (data && data.key ? canonicalizePubId(data.key) : null); + const name = host || (effectiveKey ? await models.about.name(effectiveKey).catch(() => (effectiveKey || '').slice(0, 10)) : remote); + const users = effectiveKey && ebtMap.has(effectiveKey) ? ebtMap.get(effectiveKey) : []; + const usersWithNames = await Promise.all( + users.map(async (user) => { + const userName = await models.about.name(user.id).catch(() => user.id); + return { ...user, name: userName }; + }) + ); + return [ + remote, + { + ...data, + key: effectiveKey || remote, + name, + users: usersWithNames + } + ]; + }) + ); +}; + +// ABOUT MODEL +models.about = { + publicWebHosting: async (feedId) => { + const result = await getAbout({ + key: "publicWebHosting", + feedId, + }); + return result === true; + }, + deviceSource: async (feedId) => { + const result = await getAbout({ key: "deviceSource", feedId }); + return typeof result === 'string' && result.trim() ? result : null; + }, + visibilityPrefs: async (feedId) => { + const result = await getAbout({ key: "visibilityPrefs", feedId }); + if (!result || typeof result !== 'object') return null; + return { + activity: result.activity === true, + device: result.device === true, + karma: result.karma !== false, + ubi: result.ubi === true, + wallet: result.wallet === true, + ecoTax: result.ecoTax !== false, + larpSign: result.larpSign === true, + gpg: result.gpg !== false, + clearnet: result.clearnet === true, + fediverse: result.fediverse === true, + fediverseHandle: typeof result.fediverseHandle === 'string' ? result.fediverseHandle : '', + clearnetShops: result.clearnetShops === true, + clearnetJobs: result.clearnetJobs === true, + clearnetEvents: result.clearnetEvents === true, + clearnetProjects: result.clearnetProjects === true, + clearnetPosts: result.clearnetPosts === true, + clearnetAudios: result.clearnetAudios === true, + clearnetVideos: result.clearnetVideos === true, + clearnetImages: result.clearnetImages === true, + clearnetDocuments: result.clearnetDocuments === true, + clearnetTorrents: result.clearnetTorrents === true, + clearnetBookmarks: result.clearnetBookmarks === true, + profileShops: result.profileShops === true, + profileJobs: result.profileJobs === true, + profileEvents: result.profileEvents === true, + profileProjects: result.profileProjects === true, + profilePosts: result.profilePosts === true, + profileAudios: result.profileAudios === true, + profileVideos: result.profileVideos === true, + profileImages: result.profileImages === true, + profileDocuments: result.profileDocuments === true, + profileTorrents: result.profileTorrents === true, + profileBookmarks: result.profileBookmarks === true + }; + }, + name: async (feedId) => { + if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { + return "Redacted"; + } + const resolved = await getAbout({ key: "name", feedId }); + if (resolved) nameCache.set(feedId, resolved, Date.now()); + return resolved || feedId.slice(1, 1 + 8); + }, + nameSync: (feedId) => { + if (!feedId) return null; + const cached = nameCache.get(feedId); + if (cached) return cached; + const local = feeds_to_name[feedId]; + return local && local.name ? local.name : null; + }, + named: (name) => { + let found = []; + let matched = Object.keys(all_the_names).filter((n) => { + return n.startsWith(name.toLowerCase()); + }); + for (const m of matched) { + found = found.concat(all_the_names[m]); + } + return found; + }, + image: async (feedId) => { + if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { + return nullImage; + } + const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout)); + + try { + const raw = await Promise.race([ + getAbout({ + key: "image", + feedId, + }), + timeoutPromise(5000), + ]); + if (raw == null || raw.link == null) { + return nullImage; + } + if (typeof raw.link === "string") { + return raw.link; + } + return raw; + } catch (error) { + return '/assets/images/default-avatar.png'; + } + }, + description: async (feedId) => { + if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { + return "Redacted"; + } + const raw = + (await getAbout({ + key: "description", + feedId, + })) || ""; + return raw; + }, + gpgFingerprint: async (feedId) => { + if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { + return ""; + } + const raw = await getAbout({ key: "gpgFingerprint", feedId }); + return typeof raw === "string" ? raw : ""; + }, + gpgBlobId: async (feedId) => { + if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { + return ""; + } + const raw = await getAbout({ key: "gpgBlobId", feedId }); + return typeof raw === "string" ? raw : ""; + }, + _startNameWarmup() { + const abortable = pullAbortable(); + let intervals = []; + cooler.open().then((ssb) => { + const _warmupStart = Date.now(); + pull( + ssb.query.read({ + live: true, + query: [ + { + $filter: { + value: { + content: { + type: "about", + name: { $is: "string" }, + }, + }, + }, + }, + ], + }), + abortable, + pull.filter((msg) => { + if (msg.sync && msg.sync === true) { + const _elapsed = Date.now() - _warmupStart; + const _fmt = _elapsed >= 1000 ? `${(_elapsed/1000).toFixed(2)}s` : `${_elapsed}ms`; + try { require('../server/ssb_metadata').setWarmupTime(_fmt); } catch (_) { console.log(`- Warmup-time: ${_fmt}`); } + transposeLookupTable(); + intervals.push(setInterval(transposeLookupTable, 1000 * 60)); + return false; + } + return msg.value.author == msg.value.content.about; + }), + pull.drain((msg) => { + const name = msg.value.content.name; + const ts = msg.value.timestamp; + const feed = msg.value.author; + + const newEntry = { name, ts }; + const currentEntry = feeds_to_name[feed]; + if (typeof currentEntry == "undefined") { + dirty = true; + feeds_to_name[feed] = newEntry; + nameCache.set(feed, name, ts); + } else if (currentEntry.ts < ts) { + dirty = true; + feeds_to_name[feed] = newEntry; + nameCache.set(feed, name, ts); + } + }, (err) => { + console.error(err); + }) + ); + }); + return { + close: () => { + abortable.abort(); + intervals.forEach((i) => clearInterval(i)); + }, + }; + }, +}; + +// BLOBS MODEL +function blobIdToHexPath(blobId) { + const homeDir = os.homedir(); + const m = /^&([A-Za-z0-9+/=]+)\.sha256$/.exec(blobId); + if (!m) throw new Error('Invalid blobId: ' + blobId); + const b64 = m[1]; + const buf = Buffer.from(b64, 'base64'); + const hex = buf.toString('hex'); + const prefix = hex.slice(0, 2); + return path.join(homeDir, '.ssb', 'blobs', 'sha256', prefix, hex); +} + +async function checkLocalBlob(blobId) { + const filePath = blobIdToHexPath(blobId); + try { + const buf = await fs.readFile(filePath); + if (buf && buf.length) return buf; + } catch (_) { /* not found */ } + return null; +} + +models.blob = { + getResolved: async ({ blobId, timeout = 30000 }) => { + let buf = await checkLocalBlob(blobId); + if (buf) return buf; + const ssb = await cooler.open(); + await new Promise((resolve, reject) => { + ssb.blobs.want(blobId, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + return new Promise((resolve, reject) => { + let timer = setTimeout(() => resolve(null), timeout); + pull( + ssb.blobs.get(blobId), + pull.collect(async (err, bufs) => { + clearTimeout(timer); + if (err || !bufs || !bufs.length) return resolve(null); + const buffer = Buffer.concat(bufs); + try { + const filePath = blobIdToHexPath(blobId); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, buffer); + } catch (e) { /* ignore */ } + resolve(buffer); + }) + ); + }); + }, + want: async ({ blobId }) => { + const ssb = await cooler.open(); + return new Promise((resolve, reject) => { + ssb.blobs.want(blobId, (err) => { + if (err) reject(new Error(`Failed to request blob: ${blobId}`)); + else resolve(); + }); + }); + } +}; + +// FRIENDS MODEL +models.friend = { + setRelationship: async ({ feedId, following, blocking }) => { + if (following && blocking) { + throw new Error("Cannot follow and block at the same time"); + } + const current = await models.friend.getRelationship(feedId); + const alreadySet = + current.following === following && current.blocking === blocking; + + if (alreadySet) { + return; + } + const ssb = await cooler.open(); + const content = { + type: "contact", + contact: feedId, + following, + blocking, + }; + transposeLookupTable(); + return new Promise((resolve, reject) => { + ssb.publish(content, (err, msg) => { + if (err) reject(err); + else resolve(msg); + }); + }); + }, + follow: (feedId) => + models.friend.setRelationship({ + feedId, + following: true, + blocking: false, + }), + unfollow: (feedId) => + models.friend.setRelationship({ + feedId, + following: false, + blocking: false, + }), + block: (feedId) => + models.friend.setRelationship({ + feedId, + blocking: true, + following: false, + }), + unblock: (feedId) => + models.friend.setRelationship({ + feedId, + blocking: false, + following: false, + }), + getRelationship: async (feedId) => { + const ssb = await cooler.open(); + const { id } = ssb; + if (feedId === id) { + return { + me: true, + following: false, + blocking: false, + followsMe: false, + }; + } + const isFollowing = await new Promise((resolve, reject) => { + ssb.friends.isFollowing({ source: id, dest: feedId }, (err, val) => { + if (err) reject(err); + else resolve(val); + }); + }); + const isBlocking = await new Promise((resolve, reject) => { + ssb.friends.isBlocking({ source: id, dest: feedId }, (err, val) => { + if (err) reject(err); + else resolve(val); + }); + }); + const followsMe = await new Promise((resolve, reject) => { + ssb.friends.isFollowing({ source: feedId, dest: id }, (err, val) => { + if (err) reject(err); + else resolve(val); + }); + }); + return { + me: false, + following: isFollowing, + blocking: isBlocking, + followsMe, + }; + }, + }; + +// META MODEL +models.meta = { + myFeedId: async () => { + const ssb = await cooler.open(); + const { id } = ssb; + return id; + }, + get: async (msgId) => { + const ssb = await cooler.open(); + return new Promise((resolve, reject) => { + ssb.get( + { + id: msgId, + meta: true, + private: true, + }, + (err, msg) => { + if (err) reject(err); + else resolve(msg); + } + ); + }); + }, + status: async () => { + const ssb = await cooler.open(); + return ssb.status(); + }, + peers: async () => { + const ssb = await cooler.open(); + return new Promise((resolve, reject) => { + pull( + ssb.conn.peers(), + pull.take(1), + pull.collect((err, [entries]) => { + if (err) return reject(err); + resolve(entries || []); + }) + ); + }); + }, + connectedPeers: async () => { + const ssb = await cooler.open(); + const connEntries = await models.meta.peers(); + const seen = new Set(); + const result = []; + + const lookupAddr = (key) => { + for (const [addr, data] of connEntries) { + if (data && data.key === key) return addr; + } + return null; + }; + + const lookupConnData = (key) => { + for (const [, data] of connEntries) { + if (data && data.key === key) return data; + } + return null; + }; + + try { + const livePeers = ssb && ssb.peers && typeof ssb.peers === "object" ? ssb.peers : {}; + for (const rawKey of Object.keys(livePeers)) { + if (!rawKey || rawKey === ssb.id) continue; + const rpcs = livePeers[rawKey]; + if (!Array.isArray(rpcs) || rpcs.length === 0) continue; + const key = canonicalizePubId(rawKey); + if (seen.has(key)) continue; + seen.add(key); + const existing = lookupConnData(key) || {}; + const addr = (rpcs[0] && rpcs[0].stream && rpcs[0].stream.address) || lookupAddr(key) || `live:${key}`; + result.push([addr, { ...existing, key, state: "connected", source: "rpc" }]); + } + } catch {} + + for (const [addr, data] of connEntries) { + if (!data || data.state !== "connected" || !data.key || seen.has(data.key)) continue; + seen.add(data.key); + result.push([addr, data]); + } + + try { + const gp = ssb.gossip && typeof ssb.gossip.peers === "function" ? ssb.gossip.peers() : []; + const RECENT_MS = 30 * 60 * 1000; + const now = Date.now(); + for (const p of (gp || [])) { + if (!p || !p.key) continue; + const key = canonicalizePubId(p.key); + if (seen.has(key)) continue; + const isConnected = p.state === "connected"; + const recentlyConnected = + !isConnected && + (p.failure === 0 || p.failure === undefined || p.failure === null) && + typeof p.stateChange === "number" && + (now - p.stateChange) < RECENT_MS; + if (!isConnected && !recentlyConnected) continue; + let addr = p.address; + if (!addr && p.host && p.port) { + const core = String(p.key).replace(/^@/, "").replace(/\.ed25519$/, ""); + addr = `net:${p.host}:${p.port}~shs:${core}`; + } + if (!addr) continue; + seen.add(key); + result.push([addr, { ...p, key, state: "connected", source: isConnected ? "gossip" : "recent" }]); + } + } catch {} + + try { + const myId = ssb.id; + const status = ssb.ebt && typeof ssb.ebt.peerStatus === "function" ? ssb.ebt.peerStatus(myId) : null; + const ebtPeers = (status && status.peers) ? Object.keys(status.peers) : []; + for (const rawKey of ebtPeers) { + if (!rawKey) continue; + const key = canonicalizePubId(rawKey); + if (seen.has(key)) continue; + let addr = lookupAddr(key); + if (!addr) { + const core = String(key).replace(/^@/, "").replace(/\.ed25519$/, ""); + addr = `ebt:${core}`; + } + seen.add(key); + result.push([addr, { key, state: "connected", source: "ebt" }]); + } + } catch {} + + return result; + }, + onlinePeers: async () => { + const entries = await models.meta.connectedPeers(); + return enrichEntries(entries); + }, + discovered: async () => { + const ssb = await cooler.open(); + const snapshot = await ssb.conn.dbPeers(); + const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json'); + let gossipMap = new Map(); + try { + const gossipData = JSON.parse(await fs.readFile(gossipPath, 'utf8')); + if (Array.isArray(gossipData)) { + for (const g of gossipData) { + if (g.key) gossipMap.set(canonicalizePubId(g.key), g); + } + } + } catch {} + let stagedEntries = []; + try { + if (ssb.conn && typeof ssb.conn.stagedPeers === 'function') { + stagedEntries = await new Promise((resolve) => { + try { + pull( + ssb.conn.stagedPeers(), + pull.take(1), + pull.collect((err, results) => { + if (err || !results || !results[0]) return resolve([]); + resolve(Array.isArray(results[0]) ? results[0] : []); + }) + ); + } catch (_) { resolve([]); } + }); + } + } catch {} + const dbKeys = new Set( + snapshot + .map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null) + .filter(Boolean) + ); + const mergedSnapshot = snapshot.slice(); + for (const entry of stagedEntries) { + const data = Array.isArray(entry) ? entry[1] : entry; + const addr = Array.isArray(entry) ? entry[0] : null; + if (!data || !data.key) continue; + const ck = canonicalizePubId(data.key); + if (dbKeys.has(ck)) continue; + let host = data.host, port = data.port; + if ((!host || !port) && addr) { + const m = String(addr).match(/^net:([^:]+):(\d+)/); + if (m) { host = host || m[1]; port = port || Number(m[2]); } + } + mergedSnapshot.push([addr, { key: data.key, host, port, source: data.type || 'staged', verified: data.verified }]); + dbKeys.add(ck); + } + const allDbPeers = await enrichEntries(mergedSnapshot); + for (const [, peerData] of allDbPeers) { + if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) { + const gossipEntry = gossipMap.get(peerData.key); + if (gossipEntry.announcers) peerData.announcers = gossipEntry.announcers; + } + } + const connectedEntries = await models.meta.connectedPeers(); + const onlineKeys = new Set( + connectedEntries + .map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null) + .filter(Boolean) + ); + const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(canonicalizePubId(d.key))); + const discoveredIds = new Set(allDbPeers.map(([, d]) => canonicalizePubId(d.key))); + const ebtList = await loadPeersFromEbt(); + const ebtMap = new Map(ebtList.map(e => [e.pub, e.users])); + const unknownPeers = []; + for (const { pub } of ebtList) { + if (!discoveredIds.has(pub) && !onlineKeys.has(pub)) { + const name = await models.about.name(pub).catch(() => pub); + unknownPeers.push([pub, { key: pub, name, users: ebtMap.get(pub) || [] }]); + } + } + return { discoveredPeers, unknownPeers }; + }, + connStop: async () => { + const ssb = await cooler.open(); + try { + const result = await ssb.conn.stop(); + return result; + } catch (e) { + const expectedName = "TypeError"; + const expectedMessage = "Cannot read property 'close' of null"; + if (e.name === expectedName && e.message === expectedMessage) { + debug("ssbConn is already stopped -- caught error"); + } else { + throw new Error(e); + } + } + }, + connStart: async () => { + const ssb = await cooler.open(); + const result = await ssb.conn.start(); + + return result; + }, + connRestart: async () => { + await models.meta.connStop(); + await models.meta.connStart(); + }, + sync: async () => { + const ssb = await cooler.open(); + + const progress = await ssb.progress(); + let previousTarget = progress.indexes.target; + + let keepGoing = true; + const timeoutInterval = setTimeout(() => { + keepGoing = false; + }, 5 * 60 * 1000); + + await ssb.conn.start(); + + const diff = async () => + new Promise((resolve) => { + setTimeout(async () => { + const currentProgress = await ssb.progress(); + const currentTarget = currentProgress.indexes.target; + const difference = currentTarget - previousTarget; + previousTarget = currentTarget; + debug(`Difference: ${difference} bytes`); + resolve(difference); + }, 5000); + }); + + debug("Starting sync, waiting for new messages..."); + while (keepGoing && (await diff()) === 0) { + debug("Received no new messages."); + } + debug("Finished waiting for first new message."); + while (keepGoing && (await diff()) > 0) { + debug(`Still receiving new messages...`); + } + debug("Finished waiting for last new message."); + clearInterval(timeoutInterval); + await ssb.conn.stop(); + }, + acceptInvite: async (invite) => { + const ssb = await cooler.open(); + const code = toLegacyInvite(String(invite || '')); + return await new Promise((resolve, reject) => { + ssb.invite.accept(code, (err, res) => err ? reject(err) : resolve(res)); + }); + }, + rebuild: async () => { + const ssb = await cooler.open(); + return ssb.rebuild(); + }, + }; + + const isLooseRoot = (message) => { + const conditions = [ + isPost(message), + hasNoRoot(message), + hasNoFork(message), + ]; + + return conditions.every((x) => x); + }; + + const isLooseSubtopic = (message) => { + const conditions = [isPost(message), hasRoot(message), hasFork(message)]; + + return conditions.every((x) => x); + }; + + const isLooseComment = (message) => { + const conditions = [isPost(message), hasRoot(message), hasNoFork(message)]; + + return conditions.every((x) => x === true); + }; + + const maxMessages = 30; // change it to control post overloading + + const getMessages = async ({ + myFeedId, + customOptions, + ssb, + query, + filter = null, + }) => { + const source = ssb.createLogStream({ reverse: true, limit: logLimit }); + + return new Promise((resolve, reject) => { + pull( + source, + pull.filter((msg) => { + return msg.value.content.type === "post"; + }), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(collectedMessages); + } + }) + ); + }); + }; + + const socialFilter = async ({ + following = null, + blocking = false, + me = null, + } = {}) => { + const ssb = await cooler.open(); + const { id } = ssb; + + const relationshipObject = await new Promise((resolve, reject) => { + ssb.friends.graph((err, graph) => { + if (err) { + console.error(err); + reject(err); + } + resolve(graph[id] || {}); + }); + }); + + const followingList = Object.entries(relationshipObject) + .filter(([, val]) => val >= 0) + .map(([key]) => key); + + const blockingList = Object.entries(relationshipObject) + .filter(([, val]) => val === -1) + .map(([key]) => key); + + return pull.filter((message) => { + if (message.value.author === id) { + return me !== false; + } else { + return ( + (following === null || + followingList.includes(message.value.author) === following) && + (blocking === null || + blockingList.includes(message.value.author) === blocking) + ); + } + }); + }; + const getUserInfo = async (feedId) => { + const pendingName = models.about.name(feedId); + const pendingAvatarMsg = models.about.image(feedId); + const pending = [pendingName, pendingAvatarMsg]; + const [name, avatarMsg] = await Promise.all(pending); + const avatarId = + avatarMsg != null && typeof avatarMsg.link === "string" + ? avatarMsg.link || nullImage + : avatarMsg || nullImage; + + const avatarUrl = `/image/64/${encodeURIComponent(avatarId)}`; + return { name, feedId, avatarId, avatarUrl }; + }; + + function getRecipientFeedId(recipient) { + if (typeof recipient === "string") { + return recipient; + } else { + return recipient.link; + } + } + + const transform = (ssb, messages, myFeedId) => + Promise.all( + messages.map(async (msg) => { + try { + debug("transforming %s", msg.key); + + if (msg == null) { + return null; + } + + const filterQuery = { + $filter: { + dest: msg.key, + }, + }; + + const referenceStream = ssb.backlinks.read({ + query: [filterQuery], + index: "DTA", + private: true, + meta: true, + }); + if (lodash.get(msg, "value.content.type") === "blog") { + const blogTitle = msg.value.content.title; + const blogSummary = lodash.get(msg, "value.content.summary", null); + const blobId = msg.value.content.blog; + const blogContent = await models.blob.getResolved({ blobId }); + let textElements = [`# ${blogTitle}`, blogContent]; + if (blogSummary) { + textElements.splice(1, 0, `**${blogSummary}**`); + } + lodash.set(msg, "value.content.text", textElements.join("\n\n")); + } + const rawVotes = await new Promise((resolve, reject) => { + pull( + referenceStream, + pull.filter( + (ref) => + isNotEncrypted(ref) && + ref.value.content.type === "vote" && + ref.value.content.vote && + typeof ref.value.content.vote.value === "number" && + ref.value.content.vote.value >= 0 && + ref.value.content.vote.link === msg.key + ), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(collectedMessages); + } + }) + ); + }); + const reducedVotes = rawVotes.reduce((acc, vote) => { + acc[vote.value.author] = vote.value.content.vote.value; + return acc; + }, {}); + + const voters = Object.entries(reducedVotes) + .filter(([, value]) => value === 1) + .map(([key]) => key); + + const pendingVoterNames = voters.map(async (author) => ({ + name: await models.about.name(author), + key: author, + })); + const voterNames = await Promise.all(pendingVoterNames); + const { name, avatarId, avatarUrl } = await getUserInfo( + msg.value.author + ); + + if (isPublic) { + const publicOptIn = await models.about.publicWebHosting( + msg.value.author + ); + if (publicOptIn === false) { + lodash.set( + msg, + "value.content.text", + "This is a public message that has been redacted because Oasis is running in public mode. This redaction is only meant to make Oasis consistent with other public SSB viewers. Please do not mistake this for privacy. All public messages are public. Any peer on the Oasis network can see this message." + ); + + if (msg.value.content.contentWarning != null) { + msg.value.content.contentWarning = "Redacted"; + } + } + } + const ts = new Date(msg.value.timestamp); + let isoTs; + + try { + isoTs = ts.toISOString(); + } catch (e) { + const receivedTs = new Date(msg.timestamp); + isoTs = receivedTs.toISOString(); + } + + lodash.set(msg, "value.meta.timestamp.received.iso8601", isoTs); + + const ago = Date.now() - Number(ts); + const prettyAgo = prettyMs(ago, { compact: true }); + lodash.set(msg, "value.meta.timestamp.received.since", prettyAgo); + lodash.set(msg, "value.meta.author.name", name); + lodash.set(msg, "value.meta.author.avatar", { + id: avatarId, + url: avatarUrl, + }); + if (isTextLike(msg) && hasNoRoot(msg) && hasNoFork(msg)) { + lodash.set(msg, "value.meta.postType", "post"); + } else if (isTextLike(msg) && hasRoot(msg) && hasNoFork(msg)) { + lodash.set(msg, "value.meta.postType", "comment"); + } else if (isTextLike(msg) && hasRoot(msg) && hasFork(msg)) { + lodash.set(msg, "value.meta.postType", "subtopic"); + } else { + lodash.set(msg, "value.meta.postType", "mystery"); + } + + lodash.set(msg, "value.meta.votes", voterNames); + lodash.set(msg, "value.meta.voted", voters.includes(myFeedId)); + + if (isPrivate(msg)) { + msg.value.meta.recpsInfo = await Promise.all( + msg.value.content.recps.map((recipient) => { + return getUserInfo(getRecipientFeedId(recipient)); + }) + ); + } + + const { blocking } = await models.friend.getRelationship( + msg.value.author + ); + lodash.set(msg, "value.meta.blocking", blocking); + + return msg; + + } catch (err) { + return null; + } + }) + ); + + const getLimitPost = async (feedId, reverse) => { + const ssb = await cooler.open(); + const source = ssb.createUserStream({ id: feedId, reverse: reverse }); + const messages = await new Promise((resolve, reject) => { + pull( + source, + pull.filter((msg) => isDecrypted(msg) === false && isPost(msg)), + pull.take(1), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, feedId)); + } + }) + ); + }); + return messages.length ? messages[0] : undefined; + }; + +// POST MODEL +const post = { + firstBy: async (feedId) => { + return getLimitPost(feedId, false); + }, + latestBy: async (feedId) => { + return getLimitPost(feedId, true); + }, + fromPublicFeed: async (feedId, gt = -1, lt = -1, customOptions = {}) => { + const ssb = await cooler.open(); + + const myFeedId = ssb.id; + + let defaultOptions = { id: feedId }; + if (lt >= 0) defaultOptions.lt = lt; + if (gt >= 0) defaultOptions.gt = gt; + defaultOptions.reverse = !(gt >= 0 && lt < 0); + const options = configure(defaultOptions, customOptions); + const { blocking } = await models.friend.getRelationship(feedId); + if (blocking) { + return []; + } + + const source = ssb.createUserStream(options); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + pull.filter((msg) => isDecrypted(msg) === false && isTextLike(msg)), + pull.take(maxMessages), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, myFeedId)); + } + }) + ); + }); + + if (!defaultOptions.reverse) return messages.reverse(); + else return messages; + }, + + mentionsMe: async (customOptions = {}) => { + const ssb = await cooler.open(); + const myFeedId = ssb.id; + const { name: myUsername } = await getUserInfo(myFeedId); + const query = [ + { + $filter: { + "value.content.type": "post", + }, + }, + ]; + const messages = await getMessages({ + myFeedId, + customOptions, + ssb, + query, + filter: (msg) => { + const content = msg.value.content; + if (content.mentions) { + if (Array.isArray(content.mentions)) { + return content.mentions.some(m => m.link === myFeedId || m.name === myUsername || m.name === '@' + myUsername); + } + if (typeof content.mentions === 'object' && !Array.isArray(content.mentions)) { + const values = Object.values(content.mentions); + return values.some(v => v.link === myFeedId || v.name === myUsername || v.name === '@' + myUsername); + } + } + const mentionsText = lodash.get(content, "text", ""); + if (mentionsText.includes(myFeedId) || mentionsText.includes(myFeedId.slice(1))) return true; + const mdMentionRegex = /\[@[^\]]*\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g; + let match; + while ((match = mdMentionRegex.exec(mentionsText))) { + if ('@' + match[1] === myFeedId || match[1] === myFeedId.slice(1)) return true; + } + return false; + }, + }); + return { messages, myFeedId }; + }, + + fromHashtag: async (hashtag, customOptions = {}) => { + const ssb = await cooler.open(); + const myFeedId = ssb.id; + const query = [ + { + $filter: { + dest: `#${hashtag}`, + }, + }, + ]; + const messages = await getMessages({ + myFeedId, + customOptions, + ssb, + query, + }); + + return messages; + }, + topicComments: async (rootId, customOptions = {}) => { + const ssb = await cooler.open(); + const myFeedId = ssb.id; + const query = [ + { + $filter: { + value: { + content: { + type: "post", + root: rootId, + }, + }, + }, + }, + ]; + const messages = await getMessages({ + myFeedId, + customOptions, + ssb, + query, + }); + const fullMessages = await Promise.all( + messages.map(async (msg) => { + if (typeof msg === "string") { + return new Promise((resolve, reject) => { + ssb.get({ id: msg, meta: true, private: true }, (err, fullMsg) => { + if (err) reject(err); + else resolve(fullMsg); + }); + }); + } + return msg; + }) + ); + return fullMessages; + }, + likes: async ({ feed }, customOptions = {}) => { + const ssb = await cooler.open(); + const query = [ + { + $filter: { + value: { + author: feed, + timestamp: { $lte: Date.now() }, + content: { + type: 'vote', + }, + }, + }, + }, + ]; + const options = { ...defaultOptions, query, reverse: true, ...customOptions }; + const source = await ssb.query.read(options); + const messages = await new Promise((resolve, reject) => { + pull( + source, + pull.filter((msg) => { + return ( + isNotEncrypted(msg) && + msg.value.author === feed && + typeof msg.value.content.vote === 'object' && + typeof msg.value.content.vote.link === 'string' + ); + }), + pull.take(maxMessages), + pull.unique((message) => message.value.content.vote.link), + pullParallelMap(async (val, cb) => { + const msg = await post.get(val.value.content.vote.link); + cb(null, msg); + }), + pull.filter((message) => + message.value.meta.votes.map((voter) => voter.key).includes(feed) + ), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(collectedMessages); + } + }) + ); + }); + return messages; + }, + search: async ({ query }) => { + const ssb = await cooler.open(); + + const myFeedId = ssb.id; + + const options = configure({ + query, + }); + + const source = await ssb.search.query(options); + const basicSocialFilter = await socialFilter(); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + basicSocialFilter, + pull.filter(isNotPrivate), + pull.take(maxMessages), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, myFeedId)); + } + }) + ); + }); + + return messages; + }, + latest: async () => { + const ssb = await cooler.open(); + + const myFeedId = ssb.id; + + const source = ssb.query.read( + configure({ + query: [ + { + $filter: { + value: { + timestamp: { $lte: Date.now() }, + content: { + type: { $in: ["post", "blog"] }, + }, + }, + }, + }, + ], + }) + ); + const followingFilter = await socialFilter({ following: true }); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + followingFilter, + publicOnlyFilter, + pull.take(maxMessages), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, myFeedId)); + } + }) + ); + }); + + return messages; + }, + latestExtended: async () => { + const ssb = await cooler.open(); + + const myFeedId = ssb.id; + + const source = ssb.query.read( + configure({ + query: [ + { + $filter: { + value: { + timestamp: { $lte: Date.now() }, + content: { + type: { $in: ["post", "blog"] }, + }, + }, + }, + }, + ], + }) + ); + + const extendedFilter = await socialFilter({ + following: false, + me: false, + }); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + publicOnlyFilter, + extendedFilter, + pull.take(maxMessages), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, myFeedId)); + } + }) + ); + }); + + return messages; + }, + latestTopics: async () => { + const ssb = await cooler.open(); + + const myFeedId = ssb.id; + + const source = ssb.query.read( + configure({ + query: [ + { + $filter: { + value: { + timestamp: { $lte: Date.now() }, + content: { + type: { $in: ["post", "blog"] }, + }, + }, + }, + }, + ], + }) + ); + + const extendedFilter = await socialFilter({ + following: true, + }); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + publicOnlyFilter, + pull.filter(hasNoRoot), + extendedFilter, + pull.take(maxMessages), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, myFeedId)); + } + }) + ); + }); + + return messages; + }, + latestSummaries: async () => { + const ssb = await cooler.open(); + + const myFeedId = ssb.id; + + const options = configure({ + type: "post", + private: false, + }); + + const source = ssb.messagesByType(options); + + const extendedFilter = await socialFilter({ + following: true, + }); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + pull.filter((message) => isNotPrivate(message) && hasNoRoot(message)), + extendedFilter, + pull.take(maxMessages), + pullParallelMap(async (message, cb) => { + const thread = await post.fromThread(message.key); + lodash.set( + message, + "value.meta.thread", + await transform(ssb, thread, myFeedId) + ); + cb(null, message); + }), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, myFeedId)); + } + }) + ); + }); + + return messages; + }, + latestThreads: async () => { + const ssb = await cooler.open(); + + const myFeedId = ssb.id; + + const source = ssb.query.read( + configure({ + query: [ + { + $filter: { + value: { + timestamp: { $lte: Date.now() }, + content: { + type: { $in: ["post", "blog"] }, + }, + }, + }, + }, + ], + }) + ); + const basicSocialFilter = await socialFilter(); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + basicSocialFilter, + pull.filter((message) => isNotPrivate(message) && hasNoRoot(message)), + pull.take(maxMessages), + pullParallelMap(async (message, cb) => { + const thread = await post.fromThread(message.key); + lodash.set( + message, + "value.meta.thread", + await transform(ssb, thread, myFeedId) + ); + cb(null, message); + }), + pull.filter((message) => message.value.meta.thread.length > 1), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, myFeedId)); + } + }) + ); + }); + + return messages; + }, + + popular: async ({ period }) => { + const ssb = await cooler.open(); + + const periodDict = { + day: 1, + week: 7, + month: 30.42, + year: 365, + }; + + if (period in periodDict === false) { + throw new Error("invalid period"); + } + + const myFeedId = ssb.id; + + const now = new Date(); + const earliest = Number(now) - 1000 * 60 * 60 * 24 * periodDict[period]; + const source = ssb.query.read( + configure({ + query: [ + { + $filter: { + value: { + timestamp: { $gte: earliest }, + content: { + type: "vote", + }, + }, + }, + }, + ], + }) + ); + const basicSocialFilter = await socialFilter(); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + publicOnlyFilter, + pull.filter((msg) => { + return ( + isNotEncrypted(msg) && + typeof msg.value.content.vote === "object" && + typeof msg.value.content.vote.link === "string" && + typeof msg.value.content.vote.value === "number" + ); + }), + pull.reduce( + (acc, cur) => { + const author = cur.value.author; + const target = cur.value.content.vote.link; + const value = cur.value.content.vote.value; + + if (acc[author] == null) { + acc[author] = {}; + } + acc[author][target] = Math.max(-1, Math.min(1, value)); + + return acc; + }, + {}, + (err, obj) => { + if (err) { + return reject(err); + } + const adjustedObj = Object.entries(obj).reduce( + (acc, [author, values]) => { + if (author === myFeedId) { + return acc; + } + const entries = Object.entries(values); + const total = 1 + Math.log(entries.length); + + entries.forEach(([link, value]) => { + if (acc[link] == null) { + acc[link] = 0; + } + acc[link] += value / total; + }); + return acc; + }, + [] + ); + + const arr = Object.entries(adjustedObj); + const length = arr.length; + + pull( + pull.values(arr), + pullSort(([, aVal], [, bVal]) => bVal - aVal), + pull.take(Math.min(length, maxMessages)), + pull.map(([key]) => key), + pullParallelMap(async (key, cb) => { + try { + const msg = await post.get(key); + cb(null, msg); + } catch (e) { + cb(null, null); + } + }), + pull.filter( + (message) => + message && + isNotPrivate(message) && + (message.value.content.type === "post" || + message.value.content.type === "blog") + ), + basicSocialFilter, + pull.collect((collectErr, collectedMessages) => { + if (collectErr) { + reject(collectErr); + } else { + resolve(collectedMessages); + } + }) + ); + } + ) + ); + }); + + return messages; + }, + fromThread: async (msgId, customOptions) => { + const ssb = await cooler.open(); + const myFeedId = ssb.id; + const options = configure({ id: msgId }, customOptions); + + const rawMsg = await new Promise((resolve, reject) => { + ssb.get(options, (err, msg) => { + if (err) reject(err); + else resolve(msg); + }); + }); + const parents = []; + const getRootAncestor = (msg) => + new Promise((resolve, reject) => { + if (msg.key == null) { + resolve(parents); + } else if (isEncrypted(msg)) { + if (parents.length > 0) { + resolve(parents); + } else { + resolve(msg); + } + } else if (msg.value.content.type !== "post") { + resolve(msg); + } else if (isLooseSubtopic(msg) && ssbRef.isMsg(msg.value.content.fork)) { + ssb.get( + { id: msg.value.content.fork, meta: true, private: true }, + (err, fork) => { + if (err) reject(err); + else getRootAncestor(fork).then(resolve).catch(reject); + } + ); + } else if (isLooseComment(msg) && ssbRef.isMsg(msg.value.content.root)) { + ssb.get( + { id: msg.value.content.root, meta: true, private: true }, + (err, root) => { + if (err) reject(err); + else getRootAncestor(root).then(resolve).catch(reject); + } + ); + } else if (isLooseRoot(msg)) { + resolve(msg); + } else { + resolve(msg); + } + }); + + const getDirectDescendants = (key) => + new Promise((resolve, reject) => { + const filterQuery = { + $filter: { + dest: key, + }, + }; + + const referenceStream = ssb.backlinks.read({ + query: [filterQuery], + index: "DTA", + }); + + pull( + referenceStream, + pull.filter((msg) => { + if (!isTextLike(msg)) return false; + const root = lodash.get(msg, "value.content.root"); + const fork = lodash.get(msg, "value.content.fork"); + if (root !== key && fork !== key) return false; + if (fork === key) return false; + return true; + }), + pull.collect((err, messages) => { + if (err) reject(err); + else resolve(messages || undefined); + }) + ); + }); + + const flattenDeep = (arr1) => + arr1.reduce( + (acc, val) => + Array.isArray(val) + ? acc.concat(flattenDeep(val)) + : acc.concat(val), + [] + ); + + const getDeepDescendants = (key) => + new Promise((resolve, reject) => { + const oneDeeper = async (descendantKey, depth) => { + const descendants = await getDirectDescendants(descendantKey); + if (descendants.length === 0) return descendants; + return Promise.all( + descendants.map(async (descendant) => { + const deeperDescendants = await oneDeeper(descendant.key, depth + 1); + lodash.set(descendant, "value.meta.thread.depth", depth); + lodash.set(descendant, "value.meta.thread.subtopic", true); + return [descendant, deeperDescendants]; + }) + ); + }; + oneDeeper(key, 0) + .then((nested) => { + const nestedDescendants = [...nested]; + const deepDescendants = flattenDeep(nestedDescendants); + resolve(deepDescendants); + }) + .catch(reject); + }); + const rootAncestor = await getRootAncestor(rawMsg); + const deepDescendants = await getDeepDescendants(rootAncestor.key); + const allMessages = [rootAncestor, ...deepDescendants].map((message) => { + const isThreadTarget = message.key === msgId; + lodash.set(message, "value.meta.thread.target", isThreadTarget); + return message; + }); + return await transform(ssb, allMessages, myFeedId); + }, + get: async (msgId, customOptions) => { + const ssb = await cooler.open(); + const myFeedId = ssb.id; + const options = configure({ id: msgId }, customOptions); + const rawMsg = await new Promise((resolve, reject) => { + ssb.get(options, (err, msg) => { + if (err) reject(err); + else resolve(msg); + }); + }); + const transformed = await transform(ssb, [rawMsg], myFeedId); + return transformed[0]; + }, + publish: async (options) => { + const ssb = await cooler.open(); + const body = { type: "post", ...options }; + return new Promise((resolve, reject) => { + ssb.publish(body, (err, msg) => { + if (err) reject(err); + else resolve(msg); + }); + }); + }, + publishFediverseHandle: async (handle) => { + const ssb = await cooler.open(); + const current = (await models.about.visibilityPrefs(ssb.id).catch(() => null)) || {}; + const h = String(handle || ''); + const prefs = { ...current, fediverseHandle: h }; + if (h === '') prefs.fediverse = false; + return new Promise((resolve, reject) => { + ssb.publish({ type: "about", about: ssb.id, visibilityPrefs: prefs }, (err, msg) => err ? reject(err) : resolve(msg)); + }); + }, + publishProfileEdit: async ({ name, description, image, visibilityPrefs, gpgFingerprint, gpgBlob, gpgBlobId }) => { + const ssb = await cooler.open(); + const normalizePrefs = (raw) => { + const r = raw || {}; + return { + activity: r.activity === true, + device: r.device === true, + karma: r.karma !== false, + ubi: r.ubi === true, + wallet: r.wallet === true, + ecoTax: r.ecoTax !== false, + larpSign: r.larpSign === true, + gpg: r.gpg !== false, + clearnet: r.clearnet === true, + fediverse: r.fediverse === true, + fediverseHandle: typeof r.fediverseHandle === 'string' ? r.fediverseHandle : '', + clearnetShops: r.clearnetShops === true, + clearnetJobs: r.clearnetJobs === true, + clearnetEvents: r.clearnetEvents === true, + clearnetProjects: r.clearnetProjects === true, + clearnetPosts: r.clearnetPosts === true, + clearnetAudios: r.clearnetAudios === true, + clearnetVideos: r.clearnetVideos === true, + clearnetImages: r.clearnetImages === true, + clearnetDocuments: r.clearnetDocuments === true, + clearnetTorrents: r.clearnetTorrents === true, + clearnetBookmarks: r.clearnetBookmarks === true, + profileShops: r.profileShops === true, + profileJobs: r.profileJobs === true, + profileEvents: r.profileEvents === true, + profileProjects: r.profileProjects === true, + profilePosts: r.profilePosts === true, + profileAudios: r.profileAudios === true, + profileVideos: r.profileVideos === true, + profileImages: r.profileImages === true, + profileDocuments: r.profileDocuments === true, + profileTorrents: r.profileTorrents === true, + profileBookmarks: r.profileBookmarks === true + }; + }; + const prefs = visibilityPrefs ? normalizePrefs(visibilityPrefs) : undefined; + const baseFields = { type: "about", about: ssb.id, name, description }; + const curTheme = getConfig()?.themes?.current; + baseFields.deviceSource = curTheme === 'OasisKIT' ? 'KIT' : (curTheme === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP'; + if (prefs) baseFields.visibilityPrefs = prefs; + if (gpgFingerprint !== undefined) baseFields.gpgFingerprint = String(gpgFingerprint || ""); + let resolvedBlobId = gpgBlobId; + if (gpgBlob && gpgBlob.length > 0) { + resolvedBlobId = await new Promise((resolve, reject) => { + pull( + pull.values([gpgBlob]), + ssb.blobs.add((err, id) => err ? reject(err) : resolve(id)) + ); + }); + } + if (resolvedBlobId !== undefined) baseFields.gpgBlobId = String(resolvedBlobId || ""); + if (image && image.length > 0) { + const megabyte = Math.pow(2, 20); + const maxSize = 50 * megabyte; + if (image.length > maxSize) { + throw new Error("File is too big, maximum size is 50 megabytes"); + } + return new Promise((resolve, reject) => { + pull( + pull.values([image]), + ssb.blobs.add((err, blobId) => { + if (err) { + reject(err); + } else { + const content = { ...baseFields, image: blobId }; + ssb.publish(content, (err, msg) => { + if (err) reject(err); + else resolve(msg); + }); + } + }) + ); + }); + } else { + return new Promise((resolve, reject) => { + ssb.publish(baseFields, (err, msg) => { + if (err) reject(err); + else resolve(msg); + }); + }); + } + }, + publishCustom: async (options) => { + const ssb = await cooler.open(); + return new Promise((resolve, reject) => { + ssb.publish(options, (err, msg) => { + if (err) reject(err); + else resolve(msg); + }); + }); + }, + subtopic: async ({ parent, message }) => { + message = { ...message }; + message.root = parent.key; + message.fork = lodash.get(parent, "value.content.root"); + message.branch = await post.branch({ root: parent.key }); + message.type = "post"; + if (!Array.isArray(message.mentions)) message.mentions = []; + if (isSubtopic(message) !== true) { + const messageString = JSON.stringify(message, null, 2); + throw new Error(`message should be valid subtopic: ${messageString}`); + } + return post.publish(message); + }, + root: async (options) => { + const message = { type: "post", ...options }; + if (isRoot(message) !== true) { + const messageString = JSON.stringify(message, null, 2); + } + return post.publish(message); + }, + comment: async ({ parent, message }) => { + if (!parent || !parent.value) { + throw new Error("Invalid parent message: Missing 'value'"); + } + + const parentKey = parent.key; + const parentFork = lodash.get(parent, "value.content.fork"); + const parentRoot = lodash.get(parent, "value.content.root", parentKey); + + if (isDecrypted(parent)) { + message.recps = lodash + .get(parent, "value.content.recps", []) + .map((recipient) => { + if ( + typeof recipient === "object" && + typeof recipient.link === "string" && + recipient.link.length + ) { + return recipient.link; + } else { + return recipient; + } + }); + + if (message.recps.length === 0) { + throw new Error("Refusing to publish message with no recipients"); + } + } + + const parentHasFork = parentFork != null; + message.root = parentHasFork ? parentKey : parentRoot; + message.branch = await post.branch({ root: parent.key }); + message.type = "post"; + + if (isComment(message) !== true) { + const messageString = JSON.stringify(message, null, 2); + throw new Error(`Message should be a valid comment: ${messageString}`); + } + return post.publish(message); + }, + branch: async ({ root }) => { + const ssb = await cooler.open(); + return new Promise((resolve, reject) => { + ssb.tangle.branch(root, (err, keys) => { + if (err) { + return reject(err); + } + resolve(keys); + }); + }); + }, + channels: async () => { + const ssb = await cooler.open(); + + const source = ssb.createUserStream({ id: ssb.id }); + + const messages = await new Promise((resolve, reject) => { + pull( + source, + pull.filter((message) => { + return lodash.get(message, "value.content.type") === "channel" + ? true + : false; + }), + pull.collect((err, collectedMessages) => { + if (err) { + reject(err); + } else { + resolve(transform(ssb, collectedMessages, ssb.id)); + } + }) + ); + }); + + const channels = messages.map((msg) => { + return { + channel: msg.value.content.channel, + subscribed: msg.value.content.subscribed, + }; + }); + + let subbedChannels = []; + + channels.forEach((ch) => { + if (ch.subscribed && !subbedChannels.includes(ch.channel)) { + subbedChannels.push(ch.channel); + } + if (ch.subscribed === false && subbedChannels.includes(ch.channel)) { + subbedChannels = lodash.pull(subbedChannels, ch.channel); + } + }); + + return subbedChannels; + }, + inbox: async () => { + const ssb = await cooler.open(); + const myFeedId = ssb.id; + const rawMessages = await new Promise((resolve, reject) => { + pull( + ssb.createLogStream({ reverse: true, limit: logLimit }), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) + ); + }); + const decryptedMessages = rawMessages.map(msg => { + try { + return ssb.private.unbox(msg); + } catch { + return null; + } + }).filter(Boolean); + const tombstoneTargets = buildValidatedTombstoneSet(decryptedMessages); + return decryptedMessages.filter(msg => { + if (tombstoneTargets.has(msg.key)) return false; + const content = msg.value?.content; + const author = msg.value?.author; + return content?.type === 'post' && content?.private === true && (author === myFeedId || content.to?.includes(myFeedId)); + }); + } + + }; + models.post = post; + +// SPREAD MODEL +models.vote = { + publish: async ({ messageKey, value, recps }) => { + const ssb = await cooler.open(); + const branch = await new Promise((resolve, reject) => { + ssb.tangle.branch(messageKey, (err, result) => { + if (err) { + console.error("Error fetching branch:", err); + reject(err); + } else { + resolve(result); + } + }); + }); + const content = { + type: "vote", + vote: { + link: messageKey, + value: Number(value), + }, + branch, + recps, + }; + return new Promise((resolve, reject) => { + ssb.publish(content, (err, msg) => { + if (err) { + console.error("Publish error:", err); + reject(err); + } else { + resolve(msg); + } + }); + }); + }, +}; + +models.lifetime = (() => { + const FRESH_GREEN_DAYS = 14; + const FRESH_ORANGE_DAYS = 182.5; + const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0); + const bucketOf = (ts) => { + if (!ts) return { bucket: 'red', range: '≥6m' }; + const days = Math.max(0, Date.now() - ts) / 86400000; + if (days < FRESH_GREEN_DAYS) return { bucket: 'green', range: '<2w' }; + if (days < FRESH_ORANGE_DAYS) return { bucket: 'orange', range: '2w–6m' }; + return { bucket: 'red', range: '≥6m' }; + }; + const lastAuthorTs = async (feedId) => { + if (!feedId) return null; + const ssbClient = await cooler.open(); + return new Promise((resolve) => { + try { + pull( + ssbClient.createUserStream({ id: feedId, reverse: true }), + pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(null); + const m = arr[0]; + resolve(norm((m.value && m.value.timestamp) || m.timestamp) || null); + }) + ); + } catch (_) { resolve(null); } + }); + }; + const lastBacklinkTs = async (msgKey) => { + if (!msgKey) return null; + const ssbClient = await cooler.open(); + return new Promise((resolve) => { + try { + pull( + ssbClient.backlinks.read({ query: [{ $filter: { dest: msgKey } }], index: 'DTA', reverse: true, limit: 1 }), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(null); + const m = arr[0]; + resolve(norm((m.value && m.value.timestamp) || m.timestamp) || null); + }) + ); + } catch (_) { resolve(null); } + }); + }; + return { + bucket: bucketOf, + lastAuthorTs, + lastBacklinkTs, + async forContent({ key, author, createdAt } = {}) { + const [authorTs, interactionTs] = await Promise.all([ + author ? lastAuthorTs(author) : null, + key ? lastBacklinkTs(key) : null + ]); + const createdTs = createdAt ? new Date(createdAt).getTime() : null; + const candidates = [authorTs, interactionTs, createdTs].filter(x => typeof x === 'number' && x > 0); + const lastTs = candidates.length ? Math.max(...candidates) : null; + const { bucket, range } = bucketOf(lastTs); + return { bucket, range, lastTs, authorTs, interactionTs, createdTs }; + }, + async enrichAndFilter(items, opts = {}) { + const { includeDead = false, getKey = (x) => x.id || x.key, getAuthor = (x) => x.author, getCreatedAt = (x) => x.createdAt } = opts; + const authorCache = new Map(); + const out = []; + for (const item of items) { + const author = getAuthor(item); + let authorTs; + if (author && authorCache.has(author)) { + authorTs = authorCache.get(author); + } else { + authorTs = author ? await lastAuthorTs(author) : null; + if (author) authorCache.set(author, authorTs); + } + const key = getKey(item); + const interactionTs = key ? await lastBacklinkTs(key) : null; + const createdAt = getCreatedAt(item); + const createdTs = createdAt ? new Date(createdAt).getTime() : null; + const candidates = [authorTs, interactionTs, createdTs].filter(x => typeof x === 'number' && x > 0); + const lastTs = candidates.length ? Math.max(...candidates) : null; + const { bucket, range } = bucketOf(lastTs); + if (!includeDead && bucket === 'red') continue; + out.push({ ...item, lifetime: { bucket, range, lastTs, authorTs, interactionTs, createdTs } }); + } + return out; + } + }; +})(); + +const ownSpreadsByTarget = new Map(); +const ownTombstoned = new Set(); +models.spreads = { + noteOwnSpread: (target, key) => { + if (!target || !key) return; + const set = ownSpreadsByTarget.get(target) || new Set(); + set.add(key); + ownSpreadsByTarget.set(target, set); + }, + noteOwnTombstone: (key) => { + if (key) ownTombstoned.add(key); + }, + getCachedActiveOwnSpreadKey: (target) => { + if (!target) return null; + const set = ownSpreadsByTarget.get(target); + if (!set || set.size === 0) return null; + for (const k of Array.from(set).reverse()) { + if (!ownTombstoned.has(k)) return k; + } + return null; + }, + forMessages: async (keys) => { + const out = new Map(); + const list = Array.isArray(keys) ? keys.filter(k => typeof k === 'string' && k.startsWith('%')) : []; + const results = await Promise.all(list.map(k => models.spreads.forMessage(k).catch(() => null))); + list.forEach((k, i) => { if (results[i]) out.set(k, results[i]); }); + return out; + }, + forMessage: async (msgKey) => { + if (!msgKey || typeof msgKey !== 'string') return { count: 0, voters: [], alreadySpread: false }; + const ssb = await cooler.open(); + const myId = ssb.id; + const refs = await new Promise((resolve) => { + pull( + ssb.backlinks.read({ + query: [{ $filter: { dest: msgKey } }], + index: 'DTA', + meta: true + }), + pull.filter(ref => { + if (!ref || !ref.value || !ref.value.content) return false; + const c = ref.value.content; + if (c.type === 'spread' && c.link === msgKey) return true; + if (c.type === 'vote' && c.vote && c.vote.link === msgKey && Number(c.vote.value) === 1) { + const br = Array.isArray(c.branch) ? c.branch : (typeof c.branch === 'string' ? [c.branch] : []); + return br.includes(msgKey); + } + return false; + }), + pull.collect((err, arr) => resolve(!err && arr ? arr : [])) + ); + }); + const tombstoned = new Set(ownTombstoned); + await Promise.all(refs.map(r => new Promise((resolve) => { + pull( + ssb.backlinks.read({ query: [{ $filter: { dest: r.key } }], meta: true }), + pull.filter(t => t && t.value && t.value.content && t.value.content.type === 'tombstone' && t.value.content.target === r.key), + pull.collect((err, ts) => { if (!err && ts && ts.length) tombstoned.add(r.key); resolve(); }) + ); + }))); + const byAuthor = new Map(); + for (const r of refs) { + if (tombstoned.has(r.key)) continue; + byAuthor.set(r.value.author, true); + } + const ownExtra = ownSpreadsByTarget.get(msgKey); + if (ownExtra && ownExtra.size > 0) { + const hasNonTombstoned = Array.from(ownExtra).some(k => !tombstoned.has(k)); + if (hasNonTombstoned) byAuthor.set(myId, true); + } + const authors = Array.from(byAuthor.keys()); + const voters = await Promise.all(authors.map(async (k) => ({ + key: k, + name: await models.about.name(k).catch(() => k.slice(1, 9)) + }))); + return { count: voters.length, voters, alreadySpread: authors.includes(myId) }; + } +}; + +return models; +}; diff --git a/src/models/maps_model.js b/src/models/maps_model.js new file mode 100644 index 0000000..b1bd77c --- /dev/null +++ b/src/models/maps_model.js @@ -0,0 +1,920 @@ +const pull = require("../server/node_modules/pull-stream"); +const crypto = require("crypto"); +const { getConfig } = require("../configs/config-manager.js"); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { collabContent, openInviteOf } = require('../backend/collab_content'); +const mapCollab = collabContent({ membersField: 'members', undecField: 'encrypted', contentFields: ['title', 'description', 'image'], listFields: ['tags', 'invites', 'markers'] }); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; +const INVITE_CODE_BYTES = 16; + +const safeArr = (v) => (Array.isArray(v) ? v : []); + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return undefined; + if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean); + return String(raw).split(",").map((t) => t.trim()).filter(Boolean); +}; + +const ALLOWED_MAP_TYPES = new Set(["OPEN", "CLOSED", "SINGLE"]); + +module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const ownCrypto = mapCrypto || tribeCrypto; + const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null; + const lookupKeys = (rid) => { + const a = (ownCrypto && ownCrypto.getKeys(rid)) || []; + if (a.length) return a; + return (tribeCrypto && tribeCrypto.getKeys(rid)) || []; + }; + const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0); + + const rotateMapKey = async (rootId, remainingMembers) => { + if (!ownCrypto || !tribeCrypto || !rootId) return; + const existing = lookupKey(rootId); + if (!existing) return; + const newKey = ownCrypto.generateTribeKey(); + const newGen = ownCrypto.addNewKey(rootId, newKey); + if (!Array.isArray(remainingMembers) || !remainingMembers.length) return; + const ssbClient = await openSsb(); + const ssbKeys = require("../server/node_modules/ssb-keys"); + const memberKeys = {}; + for (const m of remainingMembers) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve()); + }); + } + }; + + const ingestOwnTribeKeys = async () => { + if (!ownCrypto) return; + try { + const ssbClient = await openSsb(); + const ssbKeys = require("../server/node_modules/ssb-keys"); + const config = require("../server/ssb_config"); + const msgs = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m)))); + for (const m of msgs) { + const c = m.value && m.value.content; + if (!c || c.type !== "tribe-keys") continue; + const memberKeys = c.memberKeys; + if (!memberKeys || typeof memberKeys !== "object") continue; + const boxed = memberKeys[ssbClient.id]; + if (!boxed) continue; + try { + const unboxed = ssbKeys.unbox(boxed, config.keys); + const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null); + if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key); + } catch (_) {} + } + } catch (_) {} + }; + + const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null; + const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c; + const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c; + const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}; + const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, ['map', 'mapMarker']) : msgs; + const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author }; + + const encryptStandalone = (content, rootId) => { + if (!tribeCrypto || !rootId) return content; + const key = lookupKey(rootId); + if (!key) return content; + return tribeCrypto.encryptContent(content, [key], true); + }; + + const tryDecryptPublicInviteKey = (invites) => { + if (!tribeCrypto || !Array.isArray(invites)) return null; + for (const inv of invites) { + if (!inv || typeof inv !== "object" || inv.public !== true) continue; + if (typeof inv.code !== "string") continue; + if (typeof inv.ek === "string") { + try { + const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt); + if (k) return k; + } catch (_) {} + } + if (typeof inv.ekChain === "string") { + try { + const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt); + if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key; + } catch (_) {} + } + } + return null; + }; + + const decryptMapRoot = (content, rootId) => { + if (!content || !content.encryptedPayload) return content; + if (!tribeCrypto) return content; + const keys = lookupKeys(rootId); + let candidateChains = (keys || []).map(k => [k]); + const pubKey = tryDecryptPublicInviteKey(content.invites); + if (pubKey) candidateChains.push([pubKey]); + if (!candidateChains.length) return { ...content, _undecryptable: true }; + return tribeCrypto.decryptContent(content, candidateChains); + }; + + const decryptIndexNodes = async (idx) => { + if (!tribeCrypto) return; + for (const [k, n] of (idx.nodes ? idx.nodes.entries() : [])) { + if (!n.c || !n.c.encryptedPayload) continue; + const root = idx.rootOf(k); + let dec = null; + if (n.c.tribeId && tribesModel) { + try { + const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel); + if (r && !r._undecryptable) dec = r; + } catch (_) {} + } + if (!dec) { + const r = decryptMapRoot(n.c, root); + if (r && !r._undecryptable) dec = r; + } + if (dec) { + idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } }); + } else { + idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } }); + } + } + }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) + ); + }); + + const getMsg = async (ssbClient, key) => + new Promise((resolve) => { + ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg))); + }); + + const buildIndex = (messages) => { + const tomb = new Set(); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const markers = new Map(); + const rawMarkers = new Map(); + const authorByKey = new Map(); + const tombRequests = []; + const memberMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === "tombstone" && c.target) { + tombRequests.push({ target: c.target, author: v.author }); + continue; + } + + if (c.type === "mapMember" && c.target) { + memberMsgs.push({ target: c.target, member: c.member, on: c.on !== false, author: v.author, ts: v.timestamp || m.timestamp || 0, code: typeof c.code === "string" ? c.code : "" }); + continue; + } + + if (c.type === "mapMarker") { + const mapId = c.mapId; + if (mapId) { + authorByKey.set(k, v.author); + if (!rawMarkers.has(mapId)) rawMarkers.set(mapId, []); + rawMarkers.get(mapId).push({ + key: k, + ts: v.timestamp || m.timestamp || 0, + c, + envAuthor: v.author + }); + } + continue; + } + + if (c.type !== "map") continue; + + const ts = v.timestamp || m.timestamp || 0; + nodes.set(k, { key: k, ts, c, author: v.author }); + authorByKey.set(k, v.author); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, node] of nodes.entries()) { + const t = node.c.replaces; + if (!t) continue; + const orig = nodes.get(t); + if (orig && orig.author === node.author) strictChild.set(t, k); + } + + const rootOf = (id) => { + let cur = id; + while (parent.has(cur)) cur = parent.get(cur); + return cur; + }; + + const tipOf = (id) => { + let cur = id; + while (child.has(cur)) cur = child.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + const rn = nodes.get(root); + if (!rn) return root; + let cur = root, best = root, g = 0; + const seen = new Set(); + while (child.has(cur) && !seen.has(cur) && g++ < 100000) { + seen.add(cur); + const next = child.get(cur); + const n = nodes.get(next); + if (!n) break; + if (n.author === rn.author && !tomb.has(next)) best = next; + cur = next; + } + return best; + }; + + for (const t of tombRequests) { + const targetAuthor = authorByKey.get(t.target); + if (targetAuthor && t.author === targetAuthor) tomb.add(t.target); + } + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, tipOf(r)); + + const contentTipByRoot = new Map(); + for (const r of roots) contentTipByRoot.set(r, contentTipOf(r)); + + const forward = new Map(); + for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId); + + const memberByRoot = new Map(); + const consumedByRoot = new Map(); + for (const mm of memberMsgs) { + if (!nodes.has(mm.target)) continue; + const r = rootOf(mm.target); + const ownerAuthor = nodes.get(r) && nodes.get(r).author; + if (!ownerAuthor) continue; + const self = mm.member === mm.author; + const byOwner = mm.author === ownerAuthor; + if (!self && !byOwner) continue; + if (!mm.member) continue; + if (mm.on && mm.code) { + if (!consumedByRoot.has(r)) consumedByRoot.set(r, new Set()); + consumedByRoot.get(r).add(mm.code); + } + if (!memberByRoot.has(r)) memberByRoot.set(r, new Map()); + const m2 = memberByRoot.get(r); + const prev = m2.get(mm.member); + if (!prev || mm.ts >= prev.ts) m2.set(mm.member, { on: mm.on, ts: mm.ts }); + } + + const isCodeConsumed = (root, code) => { + if (!code) return false; + const set = consumedByRoot.get(root); + return !!(set && set.has(code)); + }; + + const resolveMembers = (root) => { + const ownerNode = nodes.get(root); + const oc = ownerNode ? ownerNode.c : {}; + const set = new Set(Array.isArray(oc.members) ? oc.members.filter(x => typeof x === "string" && x) : []); + for (const [mem, st] of (memberByRoot.get(root) || new Map())) { + if (st.on) set.add(mem); else set.delete(mem); + } + return [...set]; + }; + + const ensureMemberKeys = async (ssbClient, messages, items) => { + if (!tribeCrypto) return; + const distributed = new Map(); + for (const m of messages) { + const c = m.value && m.value.content; + if (!c || c.type !== "tribe-keys" || !c.tribeId) continue; + const mk = c.memberKeys; + if (!mk || typeof mk !== "object") continue; + if (!distributed.has(c.tribeId)) distributed.set(c.tribeId, new Set()); + for (const id of Object.keys(mk)) distributed.get(c.tribeId).add(id); + } + const ssbKeys = require("../server/node_modules/ssb-keys"); + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || item.encrypted) continue; + const rootId = item.rootId; + if (!rootId) continue; + const key = lookupKey(rootId); + if (!key) continue; + const have = distributed.get(rootId) || new Set(); + const missing = (Array.isArray(item.members) ? item.members : []).filter(m => m && m !== ssbClient.id && !have.has(m)); + if (!missing.length) continue; + const memberKeys = {}; + for (const m of missing) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {} + } + if (!Object.keys(memberKeys).length) continue; + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve()) + }); + } + } + + return { tomb, nodes, parent, child, strictChild, rootOf, tipOf, contentTipOf, tipByRoot, contentTipByRoot, forward, markers, rawMarkers, resolveMembers, isCodeConsumed }; + }; + + const expandMarkers = async (idx) => { + for (const [mapId, raws] of idx.rawMarkers.entries()) { + const list = []; + for (const r of raws) { + let c = r.c; + if (c.encryptedPayload && tribeCrypto && tribesModel) { + const dec = await tribeCrypto.decryptFromTribe(c, tribesModel); + if (dec && !dec._undecryptable) c = dec; + } + list.push({ + key: r.key, + lat: parseFloat(c.lat) || 0, + lng: parseFloat(c.lng) || 0, + label: c.label || "", + image: c.image || "", + author: r.envAuthor, + encrypted: !!(r.c.encryptedPayload && (!c || c._undecryptable)), + createdAt: c.createdAt || new Date(r.ts).toISOString() + }); + } + idx.markers.set(mapId, list); + } + }; + + + const buildMap = (node, rootId, viewerId, markerList = [], members = null) => { + const c = node.c || {}; + const undec = c.encryptedPayload && c._decrypted === false; + return { + key: node.key, + rootId, + title: undec ? "" : (c.title || ""), + lat: parseFloat(c.lat) || 0, + lng: parseFloat(c.lng) || 0, + description: undec ? "" : (c.description || ""), + markerLabel: undec ? "" : (c.markerLabel || ""), + image: undec ? "" : (c.image || ""), + mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE", + tags: safeArr(c.tags), + author: node.author, + members: Array.isArray(members) ? members : (Array.isArray(c.members) ? c.members : []), + invites: Array.isArray(c.invites) ? c.invites : [], + tribeId: c.tribeId || null, + encrypted: !!undec, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + markers: markerList.filter((mk) => !mk.tombstoned) + }; + }; + + const collectMaps = (idx, viewerId) => { + const items = []; + for (const [rootId, tipId] of idx.contentTipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + const markerList = safeArr(idx.markers.get(tipId)).concat(safeArr(idx.markers.get(rootId))); + items.push(buildMap(node, rootId, viewerId, markerList, idx.resolveMembers(rootId))); + } + return items; + }; + + return { + type: "map", + + async ingestKeys() { await ingestOwnTribeKeys() }, + + async pruneOrphanKeys() { + if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0; + try { + const ssbClient = await openSsb(); + const messages = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m)))); + const live = new Set(); + const tomb = buildValidatedTombstoneSet(messages); + for (const m of messages) { + const c = m.value && m.value.content; + if (!c) continue; + if (c.type === "map") live.add(m.key); + } + const all = ownCrypto.getAllRootIds(); + let removed = 0; + for (const rid of all) { + if (!live.has(rid) || tomb.has(rid)) { + try { ownCrypto.dropKey(rid); removed += 1; } catch (_) {} + } + } + return removed; + } catch (_) { return 0; } + }, + + async leaveMap(mapId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const map = await this.getMapById(mapId, userId); + if (!map) throw new Error("Map not found"); + if (map.author === userId) throw new Error("Author cannot leave their own map"); + if (!Array.isArray(map.members) || !map.members.includes(userId)) return; + const members = map.members.filter(m => m !== userId); + const rootId = await this.resolveRootId(map.rootId || map.key); + const content = { type: "mapMember", target: rootId, member: userId, on: false, createdAt: new Date().toISOString() }; + const result = await new Promise((resolve, reject) => ssbClient.publish(content, (e, r) => e ? reject(e) : resolve(r))); + try { await rotateMapKey(rootId, members); } catch (_) {} + return result; + }, + + async resolveCurrentId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + + let root = id; + while (idx.parent.has(root)) root = idx.parent.get(root); + const tip = idx.contentTipOf(root); + if (idx.tomb.has(tip)) throw new Error("Map not found"); + return tip; + }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + + let root = id; + while (idx.parent.has(root)) root = idx.parent.get(root); + const tip = idx.contentTipOf(root); + if (idx.tomb.has(tip)) throw new Error("Map not found"); + return root; + }, + + async createMap(lat, lng, description, mapType, tagsRaw, title, tribeId, markerLabel, image) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tags = normalizeTags(tagsRaw) || []; + const now = new Date().toISOString(); + const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE"; + + let plainContent = { + type: "map", + title: title || "", + lat: parseFloat(lat) || 0, + lng: parseFloat(lng) || 0, + description: description || "", + markerLabel: markerLabel || "", + mapType: mType, + author: userId, + members: [userId], + invites: [], + tags, + ...(tribeId ? { tribeId } : {}), + ...(image ? { image } : {}), + createdAt: now, + updatedAt: now + }; + + const isPublicOpen = mType === "OPEN" && !tribeId; + const shouldEncryptStandalone = !tribeId && !isPublicOpen && tribeCrypto; + let mapKey = null; + let content = plainContent; + if (tribeId) { + content = await encryptIfTribe(plainContent); + } else if (shouldEncryptStandalone) { + mapKey = ownCrypto.generateTribeKey(); + content = tribeCrypto.encryptContent(plainContent, [mapKey], true); + } + + const result = await new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + + if (mapKey) { + ownCrypto.setKey(result.key, mapKey, 1); + try { + const ssbKeys = require("../server/node_modules/ssb-keys"); + const boxedKey = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve()); + }); + } catch (_) {} + if (mType === "OPEN") { + try { + const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex"); + const inviteSalt = tribeCrypto.generateInviteSalt(); + const ek = tribeCrypto.encryptForInvite(mapKey, pubCode, inviteSalt); + let updated = { + type: "map", + replaces: result.key, + title: plainContent.title, + lat: plainContent.lat, + lng: plainContent.lng, + description: plainContent.description, + markerLabel: plainContent.markerLabel, + mapType: mType, + author: userId, + members: [userId], + invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }], + tags, + ...(image ? { image } : {}), + createdAt: now, + updatedAt: new Date().toISOString() + }; + updated = encryptStandalone(updated, result.key); + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r))); + await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: result.key, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve())); + } catch (_) {} + } + } + + return result; + }, + + async updateMapById(id, lat, lng, description, mapType, tagsRaw, title, image) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const rootId = await this.resolveRootId(id); + const oldMsg = await getMsg(ssbClient, tipId); + + if (!oldMsg) throw new Error("Map not found"); + const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content); + const tribeIdHint = isWrapped ? null : (oldMsg.content && oldMsg.content.tribeId); + const oldDecrypted = (isWrapped || tribeIdHint) + ? await decryptIfTribe(oldMsg.content) + : decryptMapRoot(oldMsg.content, rootId); + const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId; + if (!oldDecrypted || (oldDecrypted.type && oldDecrypted.type !== "map" && !isWrapped && oldMsg.content?.type !== "map")) throw new Error("Map not found"); + assertReadable(oldDecrypted, "Map"); + if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author"); + + const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDecrypted.tags); + const now = new Date().toISOString(); + const mType = mapType && ALLOWED_MAP_TYPES.has(mapType) ? mapType : oldDecrypted.mapType; + + let updated = { + type: "map", + replaces: tipId, + title: title !== undefined ? title || "" : oldDecrypted.title || "", + lat: lat !== undefined ? parseFloat(lat) || 0 : oldDecrypted.lat, + lng: lng !== undefined ? parseFloat(lng) || 0 : oldDecrypted.lng, + description: description !== undefined ? description || "" : oldDecrypted.description || "", + markerLabel: oldDecrypted.markerLabel || "", + mapType: mType, + tags, + author: oldDecrypted.author || userId, + members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId], + invites: Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [], + ...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}), + ...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})), + createdAt: oldDecrypted.createdAt, + updatedAt: now + }; + + if (effectiveTribeId) { + updated = await encryptIfTribe(updated); + } else if (mType !== "SINGLE") { + updated = encryptStandalone(updated, rootId); + } + + const result = await new Promise((resolve, reject) => { + ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res))); + }); + const tombstone = await tombFor(tipId, effectiveTribeId, userId); + await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); + return result; + }, + + async deleteMapById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const msg = await getMsg(ssbClient, tipId); + + if (!msg) throw new Error("Map not found"); + const decrypted = await decryptIfTribe(msg.content); + if (!decrypted) throw new Error("Map not found"); + if ((decrypted.author || msg.content.author) !== userId) throw new Error("Not the author"); + + const tombstone = await tombFor(tipId, decrypted.tribeId, userId); + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res))); + }); + }, + + async addMarker(mapId, lat, lng, label, image) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + + let tipId = mapId; + while (idx.forward.has(tipId)) tipId = idx.forward.get(tipId); + if (idx.tomb.has(tipId)) throw new Error("Map not found"); + + const node = idx.nodes.get(tipId); + if (!node) throw new Error("Map not found"); + + const mapDecrypted = await decryptIfTribe(node.c); + assertReadable(mapDecrypted, "Map"); + const mapType = mapDecrypted.mapType || node.c.mapType || "SINGLE"; + const mapAuthor = mapDecrypted.author || node.c.author; + if (mapType === "SINGLE") throw new Error("Map does not allow markers"); + if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers"); + + const now = new Date().toISOString(); + const rootId = idx.rootOf(tipId); + let content = { + type: "mapMarker", + mapId: rootId, + lat: parseFloat(lat) || 0, + lng: parseFloat(lng) || 0, + label: label || "", + author: userId, + createdAt: now, + ...(node.c.tribeId ? { tribeId: node.c.tribeId } : {}) + }; + if (image) content.image = image; + + if (node.c.tribeId) { + content = await encryptIfTribe(content); + } else if (tribeCrypto) { + const mapKey = lookupKey(rootId); + if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true); + } + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async listAll(filterOrOpts = "all", maybeOpts = {}) { + const ssbClient = await openSsb(); + + const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {}; + const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all"; + const q = String(opts.q || "").trim().toLowerCase(); + const viewerId = opts.viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + await decryptIndexNodes(idx); + await expandMarkers(idx); + + let list = mapCollab.visibleThenCollapsed(collectMaps(idx, viewerId), viewerId); + const now = Date.now(); + + if (filter === "mine") list = list.filter((m) => String(m.author) === String(viewerId)); + else if (filter === "recent") list = list.filter((m) => new Date(m.createdAt).getTime() >= now - 86400000); + + if (q) { + list = list.filter((m) => { + const d = String(m.description || "").toLowerCase(); + const tags = safeArr(m.tags).join(" ").toLowerCase(); + const a = String(m.author || "").toLowerCase(); + return d.includes(q) || tags.includes(q) || a.includes(q); + }); + } + + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + try { await ensureMemberKeys(ssbClient, messages, list); } catch (_) {} + return list; + }, + + async getMapById(id, viewerId = null) { + const ssbClient = await openSsb(); + const viewer = viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + await decryptIndexNodes(idx); + await expandMarkers(idx); + + let root = id; + while (idx.parent.has(root)) root = idx.parent.get(root); + const tip = idx.contentTipOf(root); + if (idx.tomb.has(tip)) throw new Error("Map not found"); + + const node = idx.nodes.get(tip); + if (!node) { + const msg = await getMsg(ssbClient, tip); + if (!msg || msg.content?.type !== "map") throw new Error("Map not found"); + let c = msg.content; + if (c.encryptedPayload && tribeCrypto && tribesModel) { + const dec = await tribeCrypto.decryptFromTribe(c, tribesModel); + c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false }; + } + const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root))); + return buildMap({ key: tip, ts: msg.timestamp || 0, c, author: msg.author }, root, viewer, markerList, idx.resolveMembers(root)); + } + + const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root))); + const map = buildMap(node, root, viewer, markerList, idx.resolveMembers(root)); + return mapCollab.fold(map, collectMaps(idx, viewer)); + }, + + async generateInvite(mapId, opts = {}) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const map = await this.getMapById(mapId, userId); + if (!map) throw new Error("Map not found"); + if (map.author !== userId) throw new Error("Only the author can generate invites"); + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex"); + let invite = code; + if (tribeCrypto && !map.tribeId) { + const inviteSalt = tribeCrypto.generateInviteSalt(); + const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code, inviteSalt); + if (ekChain) invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(map.rootId || map.key) || 1, ...(opts.public ? { public: true } : {}) }; + } + if (opts.public && typeof invite !== "object") invite = { code, public: true }; + const tipId = await this.resolveCurrentId(mapId); + const rootId = await this.resolveRootId(mapId); + const oldMsg = await getMsg(ssbClient, tipId); + const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content); + const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId)) + ? await decryptIfTribe(oldMsg.content) + : decryptMapRoot(oldMsg.content, rootId); + const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId; + const invites = [...(Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []), invite]; + let updated = { + type: "map", + replaces: tipId, + title: oldDecrypted.title || "", + lat: oldDecrypted.lat, + lng: oldDecrypted.lng, + description: oldDecrypted.description || "", + markerLabel: oldDecrypted.markerLabel || "", + mapType: oldDecrypted.mapType, + tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [], + author: oldDecrypted.author, + members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId], + invites, + ...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}), + ...(oldDecrypted.image ? { image: oldDecrypted.image } : {}), + createdAt: oldDecrypted.createdAt, + updatedAt: new Date().toISOString() + }; + if (effectiveTribeId) updated = await encryptIfTribe(updated); + else updated = encryptStandalone(updated, rootId); + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r))); + const tomb1 = await tombFor(tipId, effectiveTribeId, userId); + await new Promise((resolve, reject) => ssbClient.publish(tomb1, e => e ? reject(e) : resolve())); + return code; + }, + + async getOpenInvite(mapId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + return openInviteOf(await this.getMapById(mapId, userId).catch(() => null)); + }, + + async generateOpenInvite(mapId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const map = await this.getMapById(mapId, userId); + if (!map) throw new Error("Map not found"); + if (map.author !== userId) throw new Error("Only the author can generate invites"); + const existing = (Array.isArray(map.invites) ? map.invites : []).find(inv => typeof inv === "object" && inv.public === true); + if (existing) throw new Error("An open invitation already exists"); + return this.generateInvite(mapId, { public: true }); + }, + + async removeOpenInvite(mapId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const map = await this.getMapById(mapId, userId); + if (!map) throw new Error("Map not found"); + if (map.author !== userId) throw new Error("Only the author can remove invites"); + const tipId = await this.resolveCurrentId(mapId); + const rootId = await this.resolveRootId(mapId); + const oldMsg = await getMsg(ssbClient, tipId); + const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content); + const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId)) + ? await decryptIfTribe(oldMsg.content) + : decryptMapRoot(oldMsg.content, rootId); + const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId; + const invites = (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true)); + let updated = { + type: "map", + replaces: tipId, + title: oldDecrypted.title || "", + lat: oldDecrypted.lat, + lng: oldDecrypted.lng, + description: oldDecrypted.description || "", + markerLabel: oldDecrypted.markerLabel || "", + mapType: oldDecrypted.mapType, + tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [], + author: oldDecrypted.author, + members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId], + invites, + ...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}), + ...(oldDecrypted.image ? { image: oldDecrypted.image } : {}), + createdAt: oldDecrypted.createdAt, + updatedAt: new Date().toISOString() + }; + if (effectiveTribeId) updated = await encryptIfTribe(updated); + else updated = encryptStandalone(updated, rootId); + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r))); + const tomb1 = await tombFor(tipId, effectiveTribeId, userId); + await new Promise((resolve, reject) => ssbClient.publish(tomb1, e => e ? reject(e) : resolve())); + }, + + async joinByInvite(code) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const joinIdx = buildIndex(unwrapForIndex(await getAllMessages(ssbClient))); + await decryptIndexNodes(joinIdx); + await expandMarkers(joinIdx); + const maps = collectMaps(joinIdx, userId); + let matched = null; + let matchedInvite = null; + for (const m of maps) { + const invs = Array.isArray(m.invites) ? m.invites : []; + for (const inv of invs) { + if (typeof inv === "string" && inv === code) { matched = m; matchedInvite = inv; break; } + if (typeof inv === "object" && inv.code === code) { matched = m; matchedInvite = inv; break; } + } + if (matched) break; + } + if (!matched) throw new Error("Invalid or expired invite code"); + if (Array.isArray(matched.members) && matched.members.includes(userId)) throw new Error("Already a member"); + const isPublic = (typeof matchedInvite === "object" && matchedInvite.public === true); + const rootId = await this.resolveRootId(matched.rootId || matched.key); + if (!isPublic) { + const idx = buildIndex(unwrapForIndex(await getAllMessages(ssbClient))); + if (idx.isCodeConsumed(rootId, code)) throw new Error("Invite already used"); + } + let mapKey = null; + if (tribeCrypto && typeof matchedInvite === "object") { + if (matchedInvite.ekChain) { + const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt); + if (Array.isArray(chain) && chain.length) { + for (const entry of chain) { + if (Array.isArray(entry.keys) && entry.keys.length) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length); + } else if (entry.key) { + tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1); + } + } + mapKey = chain[0].key; + } + } else if (matchedInvite.ek) { + mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt); + ownCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1); + } + } + await new Promise((resolve, reject) => ssbClient.publish({ type: "mapMember", target: rootId, member: userId, on: true, createdAt: new Date().toISOString(), ...(isPublic ? {} : { code }) }, (e, r) => e ? reject(e) : resolve(r))); + if (tribeCrypto && mapKey) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys"); + const memberKeys = {}; + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); } catch (_) {} + if (matched.author && matched.author !== userId) { + try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(mapKey, matched.author, ssbKeys); } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve()); + }); + } + } catch (_) {} + } + return rootId; + }, + + async joinMap(mapId) { + const userId = (await openSsb()).id; + const map = await this.getMapById(mapId, userId); + if (!map) throw new Error("Map not found"); + if (Array.isArray(map.members) && map.members.includes(userId)) return map.rootId || map.key; + if (tribeCrypto && Array.isArray(map.invites)) { + const pub = map.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain)); + if (pub) return await this.joinByInvite(pub.code); + } + throw new Error("This map requires an invite code to join"); + } + }; +}; diff --git a/src/models/market_model.js b/src/models/market_model.js new file mode 100644 index 0000000..f4143a7 --- /dev/null +++ b/src/models/market_model.js @@ -0,0 +1,583 @@ +const pull = require("../server/node_modules/pull-stream") +const moment = require("../server/node_modules/moment") +const { getConfig } = require("../configs/config-manager.js") +const { buildValidatedTombstoneSet } = require('./tombstone_validator') +const { dedupeByPreferring, norm } = require('../backend/dedupe') +const logLimit = getConfig().ssbLogStream?.limit || 1000 + +const N = (s) => String(s || "").toUpperCase().replace(/\s+/g, "_") +const D = (s) => ({ FOR_SALE: "FOR SALE", OPEN: "OPEN", RESERVED: "RESERVED", CLOSED: "CLOSED", SOLD: "SOLD", DISCARDED: "DISCARDED" })[s] || (s ? s.replace(/_/g, " ") : s) +const ORDER = ["FOR_SALE", "OPEN", "RESERVED", "CLOSED", "SOLD", "DISCARDED"] +const OI = (s) => ORDER.indexOf(N(s)) + +const parseBidEntry = (raw) => { + const s = String(raw || "").trim() + if (!s) return null + + if (s.includes("|")) { + const parts = s.split("|") + if (parts.length < 3) return null + const bidder = parts[0] || "" + const amount = parseFloat(String(parts[1] || "").replace(",", ".")) + const time = parts.slice(2).join("|") + if (!bidder || !Number.isFinite(amount) || !time) return null + return { bidder, amount, time } + } + + const first = s.indexOf(":") + const second = s.indexOf(":", first + 1) + if (first === -1 || second === -1) return null + + const bidder = s.slice(0, first) + const amountStr = s.slice(first + 1, second) + const time = s.slice(second + 1) + const amount = parseFloat(String(amountStr || "").replace(",", ".")) + if (!bidder || !Number.isFinite(amount) || !time) return null + return { bidder, amount, time } +} + +const highestBidAmount = (poll) => { + const arr = Array.isArray(poll) ? poll : [] + let best = 0 + for (const x of arr) { + const b = parseBidEntry(x) + if (b && Number.isFinite(b.amount) && b.amount > best) best = b.amount + } + return best +} + +const hasBidder = (poll, userId) => { + const arr = Array.isArray(poll) ? poll : [] + for (const x of arr) { + const b = parseBidEntry(x) + if (b && b.bidder === userId) return true + } + return false +} + +module.exports = ({ cooler, tribeCrypto }) => { + let ssb + const openSsb = async () => { + if (!ssb) ssb = await cooler.open() + return ssb + } + + const readAll = async (ssbClient) => { + return new Promise((resolve, reject) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))) + ) + } + + const buildMarketIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages) + const nodes = new Map() + const bids = [] + const purchases = [] + for (const m of messages) { + const c = m.value && m.value.content + if (!c) continue + if (c.type === "tombstone") continue + if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue } + if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue } + if (c.type !== "market") continue + nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c, author: m.value.author }) + } + const naivePrev = new Map(), strictNext = new Map() + for (const [key, n] of nodes) { + const t = n.c.replaces + if (!t) continue + naivePrev.set(key, t) + } + for (const [child, parent] of Array.from(naivePrev.entries())) { + const cn = nodes.get(child) + const pn = nodes.get(parent) + if (!pn) { naivePrev.delete(child); continue } + if (!cn || cn.author !== pn.author) { naivePrev.delete(child); nodes.delete(child); continue } + strictNext.set(parent, child) + } + return { tomb, nodes, bids, purchases, naivePrev, strictNext } + } + + const resolveGroups = (idx) => { + const { tomb, nodes, bids, purchases, naivePrev, strictNext } = idx + const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && nodes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x } + const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x } + + const groups = new Map() + for (const key of nodes.keys()) { const r = rootOf(key); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(key) } + const bidsByRoot = new Map() + for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) } + const soldByRoot = new Map() + for (const pu of purchases) { if (!nodes.has(pu.target)) continue; const r = rootOf(pu.target); soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1) } + + const out = new Map() + for (const [root, keys] of groups) { + const rootNode = nodes.get(root) + const sellerId = rootNode ? rootNode.author : null + const sellerKeys = keys.filter(k => { const n = nodes.get(k); return n && n.author === sellerId }) + let tip = followStrict(root) + const tipNode0 = nodes.get(tip) + if (!tipNode0 || tipNode0.author !== sellerId) tip = root + if (tomb.has(tip)) continue + let best = nodes.get(tip) + if (!best) continue + let bestS = N(best.c.status || "FOR_SALE") + for (const k of (sellerKeys.length ? sellerKeys : keys)) { + const n = nodes.get(k); if (!n) continue + const s = N(n.c.status || "") + if (OI(s) > OI(bestS)) { best = n; bestS = s } + } + const pollSet = new Set(); const poll = [] + const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) } + for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) } + for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) } + out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0 }) + } + return out + } + + return { + type: "market", + + async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0, mapUrl = "", shopOpts = {}, visibility = "PUBLIC") { + const ssbClient = await openSsb() + + const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true) : null + if (!formattedDeadline || !formattedDeadline.isValid()) throw new Error("Invalid deadline") + if (formattedDeadline.isBefore(moment(), "minute")) throw new Error("Cannot create an item in the past") + + let blobId = null + if (image) { + blobId = String(image).trim() || null + } + + const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(",").map((t) => t.trim()).filter(Boolean) + + const p = typeof price === "string" ? parseFloat(String(price).replace(",", ".")) : parseFloat(price) + if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price") + + const s = parseInt(String(stock || "0"), 10) + if (!Number.isFinite(s) || s <= 0) throw new Error("Invalid stock") + + const itemContent = { + type: "market", + item_type, + title, + description, + image: blobId, + price: p.toFixed(6), + tags, + item_status, + status: "FOR SALE", + deadline: formattedDeadline.toISOString(), + includesShipping: !!includesShipping, + stock: s, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + seller: ssbClient.id, + auctions_poll: [], + mapUrl: String(mapUrl || "").trim(), + shopProductId: shopOpts.shopProductId || "", + shopId: shopOpts.shopId || "", + shopTitle: shopOpts.shopTitle || "", + visibility: String(visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" + } + + return new Promise((resolve, reject) => { + ssbClient.publish(itemContent, (err, res) => { + if (err) return reject(err) + if (res && res.key && tribeCrypto) { + const key = tribeCrypto.generateTribeKey() + tribeCrypto.setKey(res.key, key, 1) + } + resolve(res) + }) + }) + }, + + async resolveCurrentId(itemId) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const { tomb, strictNext } = buildMarketIndex(messages) + let cur = itemId, g = 0 + while (strictNext.has(cur) && g++ < 100000) cur = strictNext.get(cur) + if (tomb.has(cur)) throw new Error("Item not found") + return cur + }, + + async updateItemById(itemId, updatedData) { + const tipId = await this.resolveCurrentId(itemId) + const ssbClient = await openSsb() + const userId = ssbClient.id + + const normalizeTags = (v) => { + if (v === undefined) return undefined + if (Array.isArray(v)) return v.filter(Boolean) + if (typeof v === "string") return v.split(",").map((t) => t.trim()).filter(Boolean) + return [] + } + + const normalized = { ...(updatedData || {}) } + const tagsCandidate = normalizeTags(updatedData && updatedData.tags) + if (tagsCandidate !== undefined) normalized.tags = tagsCandidate + + if (normalized.price !== undefined && normalized.price !== null && normalized.price !== "") { + const p = typeof normalized.price === "string" ? parseFloat(String(normalized.price).replace(",", ".")) : parseFloat(normalized.price) + if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price") + normalized.price = p.toFixed(6) + } + + if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") { + const dl = moment(normalized.deadline, moment.ISO_8601, true) + if (!dl.isValid()) throw new Error("Invalid deadline") + normalized.deadline = dl.toISOString() + } + + if (normalized.stock !== undefined) { + const s = parseInt(String(normalized.stock), 10) + if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock") + normalized.stock = s + } + + if (normalized.includesShipping !== undefined) { + normalized.includesShipping = !!normalized.includesShipping + } + + if (normalized.visibility !== undefined) { + normalized.visibility = String(normalized.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" + } + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item || !item.content) return reject(new Error("Item not found")) + if (item.content.seller !== userId) return reject(new Error("Not the seller")) + + const curStatusNorm = N(item.content.status || "FOR SALE") + const curStatus = D(curStatusNorm) + if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Cannot update this item")) + + const updated = { + ...item.content, + ...normalized, + tags: updatedData && updatedData.tags !== undefined ? normalized.tags : item.content.tags, + updatedAt: new Date().toISOString(), + replaces: tipId + } + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + + ssbClient.publish(tombstone, (err1) => { + if (err1) return reject(err1) + ssbClient.publish(updated, (err2, res) => (err2 ? reject(err2) : resolve(res))) + }) + }) + }) + }, + + async deleteItemById(itemId) { + const tipId = await this.resolveCurrentId(itemId) + const ssbClient = await openSsb() + const userId = ssbClient.id + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item || !item.content) return reject(new Error("Item not found")) + if (item.content.seller !== userId) return reject(new Error("Not the seller")) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (err2) => (err2 ? reject(err2) : resolve({ message: "Item deleted successfully" }))) + }) + }) + }, + + async listAllItems(filter = "all") { + const ssbClient = await openSsb() + const userId = ssbClient.id + const messages = await readAll(ssbClient) + + const items = [] + const now = moment() + + for (const { tip, rootId, best, statusN, poll, soldCount } of resolveGroups(buildMarketIndex(messages)).values()) { + const leaf = tip + const c = best.c + let status = D(statusN) + const stock = Math.max(0, (Number(c.stock) || 0) - (soldCount || 0)) + if (status === "FOR SALE" && soldCount > 0 && stock === 0) status = "SOLD" + + if (c.deadline) { + const dl = moment(c.deadline) + if (dl.isValid() && dl.isBefore(now)) { + if (status !== "SOLD" && status !== "DISCARDED") { + if (String(c.item_type || "").toLowerCase() === "auction") { + status = highestBidAmount(poll) > 0 ? "SOLD" : "DISCARDED" + } else { + status = "DISCARDED" + } + } + } + } + + if (status === "FOR SALE" && stock === 0) continue + + const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" + if (visibility === "HIDDEN" && best.author !== userId) continue + + items.push({ + id: leaf, + rootId, + title: c.title, + description: c.description, + image: c.image, + price: c.price, + tags: c.tags || [], + item_type: c.item_type, + item_status: c.item_status || "NEW", + status, + visibility, + createdAt: c.createdAt || new Date(best.ts).toISOString(), + updatedAt: c.updatedAt, + seller: best.author, + includesShipping: !!c.includesShipping, + stock, + deadline: c.deadline || null, + auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [], + mapUrl: c.mapUrl || "", + shopProductId: c.shopProductId || "", + shopId: c.shopId || "", + shopTitle: c.shopTitle || "" + }) + } + + let list = dedupeByPreferring(items, (i) => (i.seller && i.createdAt) ? norm(i.seller) + "|" + norm(i.createdAt) : null, (i) => (Array.isArray(i.auctions_poll) ? i.auctions_poll.length : 0)) + switch (filter) { + case "mine": + list = list.filter((i) => i.seller === userId) + break + case "exchange": + list = list.filter((i) => i.item_type === "exchange" && i.status === "FOR SALE") + break + case "auctions": + list = list.filter((i) => i.item_type === "auction" && i.status === "FOR SALE") + break + case "mybids": + list = list.filter((i) => i.item_type === "auction").filter((i) => hasBidder(i.auctions_poll, userId)) + break + case "new": + list = list.filter((i) => i.item_status === "NEW" && i.status === "FOR SALE") + break + case "used": + list = list.filter((i) => i.item_status === "USED" && i.status === "FOR SALE") + break + case "broken": + list = list.filter((i) => i.item_status === "BROKEN" && i.status === "FOR SALE") + break + case "for sale": + list = list.filter((i) => i.status === "FOR SALE") + break + case "sold": + list = list.filter((i) => i.status === "SOLD") + break + case "discarded": + list = list.filter((i) => i.status === "DISCARDED") + break + case "recent": { + const oneDayAgo = moment().subtract(1, "days") + list = list.filter((i) => i.status === "FOR SALE" && moment(i.createdAt).isAfter(oneDayAgo)) + break + } + } + + return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + }, + + async getItemById(itemId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const messages = await readAll(ssbClient) + + const idx = buildMarketIndex(messages) + const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x } + const grp = resolveGroups(idx).get(rootOf(itemId)) + if (!grp) return null + const { tip, rootId, best, statusN, poll, soldCount } = grp + + const c = best.c + let status = D(statusN) + const stock = Math.max(0, (Number(c.stock) || 0) - (soldCount || 0)) + if (status === "FOR SALE" && soldCount > 0 && stock === 0) status = "SOLD" + + const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" + if (visibility === "HIDDEN" && best.author !== userId) return null + + const now = moment() + if (c.deadline) { + const dl = moment(c.deadline) + if (dl.isValid() && dl.isBefore(now)) { + if (status !== "SOLD" && status !== "DISCARDED") { + if (String(c.item_type || "").toLowerCase() === "auction") { + status = highestBidAmount(poll) > 0 ? "SOLD" : "DISCARDED" + } else { + status = "DISCARDED" + } + } + } + } + + return { + id: tip, + rootId, + title: c.title, + description: c.description, + image: c.image, + price: c.price, + tags: c.tags || [], + item_type: c.item_type, + item_status: c.item_status, + status, + visibility, + createdAt: c.createdAt || new Date(best.ts).toISOString(), + updatedAt: c.updatedAt, + seller: best.author, + includesShipping: !!c.includesShipping, + stock, + deadline: c.deadline, + auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [], + mapUrl: c.mapUrl || "", + shopProductId: c.shopProductId || "", + shopId: c.shopId || "", + shopTitle: c.shopTitle || "" + } + }, + + async checkAuctionItemsStatus(items) { + const ssbClient = await openSsb() + const myId = ssbClient.id + const now = moment() + const list = Array.isArray(items) ? items : [] + + for (const item of list) { + if (!item || !item.deadline) continue + if (item.seller !== myId) continue + const dl = moment(item.deadline) + if (!dl.isValid()) continue + if (!dl.isBefore(now)) continue + + const curStatus = D(N(item.status)) + if (curStatus === "SOLD" || curStatus === "DISCARDED") continue + + let status = curStatus + const kind = String(item.item_type || "").toLowerCase() + + if (kind === "auction") { + status = highestBidAmount(item.auctions_poll) > 0 ? "SOLD" : "DISCARDED" + } else { + status = "DISCARDED" + } + + try { + await this.updateItemById(item.id, { status }) + } catch (_) {} + } + }, + + async getItemByShopProductId(shopProductId) { + if (!shopProductId) return null + const items = await this.listAllItems("all") + return items.find((i) => i.shopProductId === shopProductId) || null + }, + + async setItemAsSold(itemId) { + const tipId = await this.resolveCurrentId(itemId) + const ssbClient = await openSsb() + const userId = ssbClient.id + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item || !item.content) return reject(new Error("Item not found")) + if (item.content.seller !== userId) return reject(new Error("Not the seller")) + + const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g, "_") + if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Already sold/discarded")) + + const soldMsg = { + ...item.content, + stock: 0, + status: "SOLD", + updatedAt: new Date().toISOString(), + replaces: tipId + } + + const tomb1 = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + + ssbClient.publish(tomb1, (err1) => { + if (err1) return reject(err1) + ssbClient.publish(soldMsg, (err2, soldRes) => { + if (err2) return reject(err2) + + const touchMsg = { + ...soldMsg, + updatedAt: new Date().toISOString(), + replaces: soldRes.key + } + + const tomb2 = { type: "tombstone", target: soldRes.key, deletedAt: new Date().toISOString(), author: userId } + + ssbClient.publish(tomb2, (err3) => { + if (err3) return reject(err3) + ssbClient.publish(touchMsg, (err4, finalRes) => (err4 ? reject(err4) : resolve(finalRes))) + }) + }) + }) + }) + }) + }, + + async addBidToAuction(itemId, userId, bidAmount) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildMarketIndex(messages) + const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x } + const grp = resolveGroups(idx).get(rootOf(itemId)) + if (!grp) throw new Error("Item not found") + const c = grp.best.c + + if (String(c.item_type || "").toLowerCase() !== "auction") throw new Error("Not an auction") + if (grp.best.author === userId) throw new Error("Cannot bid on your own item") + if (D(N(c.status || "FOR_SALE")) !== "FOR SALE") throw new Error("Auction is not active") + + const dl = c.deadline ? moment(c.deadline) : null + if (!dl || !dl.isValid()) throw new Error("Invalid deadline") + if (dl.isBefore(moment())) throw new Error("Auction closed") + + if ((Number(c.stock) || 0) <= 0) throw new Error("Out of stock") + + const basePrice = parseFloat(String(c.price || "0").replace(",", ".")) + const bid = parseFloat(String(bidAmount || "").replace(",", ".")) + if (!Number.isFinite(bid) || bid <= 0) throw new Error("Invalid bid") + + const highest = highestBidAmount(grp.poll) + const min = Number.isFinite(highest) && highest > 0 ? highest : Number.isFinite(basePrice) ? basePrice : 0 + if (bid <= min) throw new Error("Bid not highest") + + const content = { type: "marketBid", target: grp.rootId, amount: bid.toFixed(6), time: new Date().toISOString(), createdAt: new Date().toISOString() } + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))) + ) + }, + + async decrementStock(itemId) { + const ssbClient = await openSsb() + const item = await this.getItemById(itemId) + if (!item) throw new Error("Item not found") + const curStatus = String(item.status).toUpperCase().replace(/\s+/g, "_") + if (["SOLD", "DISCARDED"].includes(curStatus)) return { ok: true, noop: true } + if ((Number(item.stock) || 0) <= 0) return { ok: true, noop: true } + + const content = { type: "marketPurchase", target: item.rootId, createdAt: new Date().toISOString() } + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))) + ) + } + } +} + diff --git a/src/models/melody_model.js b/src/models/melody_model.js new file mode 100644 index 0000000..072b763 --- /dev/null +++ b/src/models/melody_model.js @@ -0,0 +1,181 @@ +"use strict"; + +const pull = require('../server/node_modules/pull-stream'); + +const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; +const OCTAVES = [3, 4, 5]; + +const TYPE_TO_DEGREE = { + post: 0, + vote: 2, + about: 4, + contact: 5, + forum: 7, + tribe: 9, + transfer: 11, + shop: 1, + shopProduct: 1, + job: 3, + project: 6, + event: 8, + task: 10, + bookmark: 4, + feed: 5, + pad: 6, + chat: 7, + audio: 8, + video: 9, + image: 10, + document: 11, + torrent: 0, + map: 2, + pixelia: 3, + gameScore: 5, + votes: 7, + calendar: 8, + curriculum: 9, + report: 10, + parliament: 11, + courts: 0, + market: 1, + aiExchange: 6, + tombstone: 4 +}; + +const NOTE_FREQS = (() => { + const a4 = 440; + const map = {}; + for (const oct of [2, 3, 4, 5, 6]) { + for (let i = 0; i < NOTE_NAMES.length; i++) { + const name = NOTE_NAMES[i] + oct; + const semis = (oct - 4) * 12 + (i - 9); + map[name] = a4 * Math.pow(2, semis / 12); + } + } + return map; +})(); + +function blockToNote(msg) { + const c = msg && msg.value && msg.value.content; + if (!c || typeof c !== 'object') return null; + const type = String(c.type || '').trim() || 'unknown'; + const degree = TYPE_TO_DEGREE[type] != null ? TYPE_TO_DEGREE[type] : (Math.abs(hashStr(type)) % 12); + const size = Buffer.byteLength(JSON.stringify(msg.value), 'utf8'); + const octIdx = size < 256 ? 0 : (size < 1024 ? 1 : 2); + const octave = OCTAVES[octIdx]; + const name = NOTE_NAMES[degree] + octave; + const freq = NOTE_FREQS[name] || 440; + const durMs = Math.min(600, 200 + Math.round(size / 64)); + return { + id: msg.key, + ts: msg.value.timestamp, + type, + size, + name, + freq, + durMs + }; +} + +function hashStr(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + return h; +} + +module.exports = ({ cooler }) => { + let ssb = null; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + async function getUserMelodyInternal(userId, limit = 200) { + const client = await openSsb(); + const me = userId || client.id; + const msgs = await new Promise((resolve, reject) => { + pull( + client.createUserStream({ id: me, reverse: true, limit }), + pull.collect((err, rows) => err ? reject(err) : resolve(rows)) + ); + }); + const seq = msgs + .filter(m => m && m.value && m.value.content && typeof m.value.content === 'object') + .map(blockToNote) + .filter(Boolean) + .reverse(); + const stats = {}; + for (const n of seq) { + stats[n.type] = (stats[n.type] || 0) + 1; + } + return { feedId: me, total: seq.length, sequence: seq, stats }; + } + + return { + NOTE_NAMES, + OCTAVES, + TYPE_TO_DEGREE, + getUserMelody: getUserMelodyInternal, + embedTextInWav, + extractTextFromWav + }; +}; + +const STEG_MAGIC = Buffer.from([0xBC, 0x53]); +const STEG_HEADER_BITS = 32; + +function embedTextInWav(wavBuffer, text) { + if (!Buffer.isBuffer(wavBuffer) || wavBuffer.length < 44) return wavBuffer; + const payload = Buffer.from(String(text || ''), 'utf8'); + if (payload.length === 0) return wavBuffer; + if (payload.length > 4096) return wavBuffer; + const lenBuf = Buffer.alloc(2); + lenBuf.writeUInt16BE(payload.length, 0); + const fullData = Buffer.concat([STEG_MAGIC, lenBuf, payload]); + const totalBits = fullData.length * 8; + const dataLen = wavBuffer.readUInt32LE(40); + const numSamples = Math.floor(dataLen / 2); + if (totalBits > numSamples) return wavBuffer; + const out = Buffer.from(wavBuffer); + for (let i = 0; i < totalBits; i++) { + const byteIdx = i >> 3; + const bitIdx = i & 7; + const bit = (fullData[byteIdx] >> (7 - bitIdx)) & 1; + const sampleOffset = 44 + (i * 2); + let sample = out.readInt16LE(sampleOffset); + sample = (sample & ~1) | bit; + out.writeInt16LE(sample, sampleOffset); + } + return out; +} + +function readBitsAsBuffer(wavBuffer, startBit, numBits) { + const bytes = Math.ceil(numBits / 8); + const out = Buffer.alloc(bytes, 0); + for (let i = 0; i < numBits; i++) { + const sampleOffset = 44 + ((startBit + i) * 2); + if (sampleOffset + 2 > wavBuffer.length) return null; + const sample = wavBuffer.readInt16LE(sampleOffset); + const bit = sample & 1; + const byteIdx = i >> 3; + const bitIdx = i & 7; + out[byteIdx] |= bit << (7 - bitIdx); + } + return out; +} + +function extractTextFromWav(wavBuffer) { + if (!Buffer.isBuffer(wavBuffer) || wavBuffer.length < 44 + STEG_HEADER_BITS * 2) return null; + if (wavBuffer.slice(0, 4).toString('ascii') !== 'RIFF') return null; + if (wavBuffer.slice(8, 12).toString('ascii') !== 'WAVE') return null; + const magic = readBitsAsBuffer(wavBuffer, 0, 16); + if (!magic || magic[0] !== STEG_MAGIC[0] || magic[1] !== STEG_MAGIC[1]) return null; + const lenBuf = readBitsAsBuffer(wavBuffer, 16, 16); + if (!lenBuf) return null; + const len = lenBuf.readUInt16BE(0); + if (len === 0 || len > 4096) return null; + const payload = readBitsAsBuffer(wavBuffer, 32, len * 8); + if (!payload) return null; + try { + const text = payload.toString('utf8'); + if (!text) return null; + return text; + } catch (_) { return null; } +} diff --git a/src/models/opinions_model.js b/src/models/opinions_model.js new file mode 100644 index 0000000..4ffb636 --- /dev/null +++ b/src/models/opinions_model.js @@ -0,0 +1,268 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const categories = require('../backend/opinion_categories'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { buildVoteTally } = require('../backend/vote_tally'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const hasBlob = async (ssbClient, url) => { + return new Promise(resolve => { + ssbClient.blobs.has(url, (err, has) => { + resolve(!err && has); + }); + }); + }; + + const validTypes = [ + 'bookmark', 'votes', 'transfer', + 'feed', 'image', 'audio', 'video', 'document', 'torrent' + ]; + + const getPreview = c => { + if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`; + return c.text || c.description || c.title || ''; + }; + + const OPINION_MSG_TYPE = { + bookmark: 'bookmarkOpinion', + votes: 'votesOpinion', + transfer: 'transferOpinion', + feed: 'feedOpinion', + image: 'imageOpinion', + audio: 'audioOpinion', + video: 'videoOpinion', + document: 'documentOpinion', + torrent: 'torrentOpinion' + }; + + const createVote = async (contentId, category) => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + if (!categories.includes(category)) throw new Error("Invalid voting category."); + const msg = await new Promise((resolve, reject) => + ssbClient.get(contentId, (err, value) => err ? reject(err) : resolve(value)) + ); + if (!msg || !msg.content) throw new Error("Opinion not found."); + const type = msg.content.type; + const otype = OPINION_MSG_TYPE[type]; + if (!otype) throw new Error("Voting not allowed on this content type."); + if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted."); + const content = { type: otype, target: contentId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => + ssbClient.publish(content, (err, result) => err ? reject(err) : resolve(result)) + ); + }; + + const listOpinions = async (filter = 'ALL', category = '') => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const messages = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + const tombstoned = buildValidatedTombstoneSet(messages); + const replaces = new Map(); + const byId = new Map(); + const opinionMsgs = []; + + for (const msg of messages) { + const key = msg.key; + const c = msg.value?.content; + if (!c) continue; + if (c.type === 'tombstone') { + if (tombstoned.has(c.target)) byId.delete(c.target); + continue; + } + if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) { + opinionMsgs.push({ target: c.target, author: msg.value.author, category: c.category }); + continue; + } + if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) { + if (c.replaces) replaces.set(c.replaces, key); + byId.set(key, { + key, + value: { + ...msg.value, + content: c, + preview: getPreview(c) + } + }); + } + if (c.type === 'feed' && !tombstoned.has(key) && !byId.has(key)) { + if (c.replaces) replaces.set(c.replaces, key); + byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } }); + } + } + + for (const [oldId, newId] of Array.from(replaces.entries())) { + const oldM = byId.get(oldId); + const newM = byId.get(newId); + if (!oldM) { replaces.delete(oldId); continue; } + if (!newM || String(newM.value.author) !== String(oldM.value.author)) { + replaces.delete(oldId); + byId.delete(newId); + } + } + + for (const replacedId of replaces.keys()) { + byId.delete(replacedId); + } + + const tipToChain = new Map(); + for (const [oldId, newId] of replaces.entries()) { + let tip = newId; let g = 0; + while (replaces.has(tip) && g++ < 100000) tip = replaces.get(tip); + if (!tipToChain.has(tip)) tipToChain.set(tip, new Set([tip])); + tipToChain.get(tip).add(oldId); tipToChain.get(tip).add(newId); + } + const idToTip = new Map(); + for (const [tip, chain] of tipToChain.entries()) for (const id of chain) idToTip.set(id, tip); + const opinionByTip = new Map(); + for (const op of opinionMsgs) { + const tip = idToTip.get(op.target) || op.target; + if (!opinionByTip.has(tip)) opinionByTip.set(tip, { opinions: {}, voters: new Set() }); + const agg = opinionByTip.get(tip); + if (agg.voters.has(op.author)) continue; + agg.voters.add(op.author); + if (op.category) agg.opinions[op.category] = (agg.opinions[op.category] || 0) + 1; + } + for (const [key, m] of byId.entries()) { + const agg = opinionByTip.get(key); + if (!agg) continue; + const c = m.value.content; + const mergedVoters = new Set([...(Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])]); + const mergedOpinions = { ...(c.opinions || {}) }; + for (const [cat, n] of Object.entries(agg.opinions)) mergedOpinions[cat] = (mergedOpinions[cat] || 0) + n; + for (const v of agg.voters) mergedVoters.add(v); + m.value.content = { ...c, opinions: mergedOpinions, opinions_inhabitants: [...mergedVoters] }; + } + + let filtered = Array.from(byId.values()).filter(m => validTypes.includes(m.value?.content?.type)); + const blobTypes = ['document', 'image', 'audio', 'video']; + const blobCheckCache = new Map(); + + filtered = await Promise.all( + filtered.map(async m => { + const c = m.value.content; + if (blobTypes.includes(c.type) && c.url) { + if (!blobCheckCache.has(c.url)) { + const valid = await hasBlob(ssbClient, c.url); + blobCheckCache.set(c.url, valid); + } + if (!blobCheckCache.get(c.url)) return null; + } + return m; + }) + ); + filtered = filtered.filter(Boolean); + + const signatureOf = (m) => { + const c = m.value?.content || {}; + switch (c.type) { + case 'document': + case 'image': + case 'audio': + case 'video': + return `${c.type}::${(c.url || '').trim()}`; + case 'bookmark': { + const u = (c.url || c.bookmark || '').trim().toLowerCase(); + return `bookmark::${u}`; + } + case 'feed': { + const t = (c.text || '').replace(/\s+/g, ' ').trim(); + return `feed::${t}`; + } + case 'votes': { + const q = (c.question || '').replace(/\s+/g, ' ').trim(); + return `votes::${q}`; + } + case 'transfer': { + const concept = (c.concept || '').trim(); + const amount = c.amount || ''; + const from = c.from || ''; + const to = c.to || ''; + const deadline = c.deadline || ''; + return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`; + } + default: + return `key::${m.key}`; + } + }; + + const voteTally = buildVoteTally(messages); + const votesPrev = new Map(); + for (const m of messages) { + const c = m.value?.content; + if (c && c.type === 'votes' && typeof c.replaces === 'string') votesPrev.set(m.key, c.replaces); + } + const resolveTallyKey = (k) => { + let cur = k; + let g = 0; + while (g++ < 1000 && !voteTally.has(cur) && votesPrev.has(cur)) cur = votesPrev.get(cur); + return cur; + }; + filtered = filtered.map(m => { + if (m.value?.content?.type !== 'votes') return m; + const t = voteTally.get(resolveTallyKey(m.key)); + return t ? { ...m, value: { ...m.value, content: { ...m.value.content, ...t } } } : m; + }); + + const bySig = new Map(); + for (const m of filtered) { + const sig = `${m.value?.author || ''}::${signatureOf(m)}`; + const prev = bySig.get(sig); + if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) { + bySig.set(sig, m); + } + } + filtered = Array.from(bySig.values()); + + if (filter === 'MINE') { + filtered = filtered.filter(m => m.value.author === userId); + } else if (filter === 'RECENT') { + const now = Date.now(); + filtered = filtered.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000); + } else if (filter === 'TOP') { + filtered = filtered.sort((a, b) => { + const sum = v => Object.values(v.content.opinions || {}).reduce((acc, x) => acc + x, 0); + return sum(b.value) - sum(a.value); + }); + } else if (categories.includes(filter)) { + filtered = filtered + .filter(m => m.value.content.opinions?.[filter]) + .sort((a, b) => + (b.value.content.opinions[filter] || 0) - (a.value.content.opinions[filter] || 0) + ); + } + + return filtered; + }; + + const getMessageById = async id => { + const ssbClient = await openSsb(); + return new Promise((resolve, reject) => + ssbClient.get(id, (err, msg) => + err ? reject(new Error("Error fetching opinion: " + err)) : + !msg?.content ? reject(new Error("Opinion not found")) : + resolve(msg) + ) + ); + }; + + return { + createVote, + listOpinions, + getMessageById, + categories + }; +}; + diff --git a/src/models/pads_model.js b/src/models/pads_model.js new file mode 100644 index 0000000..6390e46 --- /dev/null +++ b/src/models/pads_model.js @@ -0,0 +1,877 @@ +const pull = require("../server/node_modules/pull-stream") +const crypto = require("crypto") +const fs = require("fs") +const { buildValidatedTombstoneSet } = require('./tombstone_validator') +const { collabContent, openInviteOf } = require('../backend/collab_content') +const padCollab = collabContent({ membersField: 'members', undecField: 'undecryptable', contentFields: ['title', 'deadline', 'status'], listFields: ['tags', 'invites'] }) +const path = require("path") +const { getConfig } = require("../configs/config-manager.js") +const logLimit = getConfig().ssbLogStream?.limit || 1000 + +const safeText = (v) => String(v || "").trim() +const normalizeTags = (raw) => { + if (!raw) return [] + if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) + return String(raw).split(",").map(t => t.trim()).filter(Boolean) +} +const INVITE_SALT = "SolarNET.HuB-pads" +const INVITE_BYTES = 16 +const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"] + +module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }) => { + let ssb + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } + + const ownCrypto = padCrypto || tribeCrypto + const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null + const lookupKeys = (rid) => { + const a = (ownCrypto && ownCrypto.getKeys(rid)) || [] + if (a.length) return a + return (tribeCrypto && tribeCrypto.getKeys(rid)) || [] + } + const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0) + + let keyringPath = null + let migratedToTribeCrypto = false + const getLegacyKeyringPath = () => { + if (!keyringPath) { + const ssbConfig = require("../server/node_modules/ssb-config/inject")() + keyringPath = path.join(ssbConfig.path, "pad-keys.json") + } + return keyringPath + } + const migrateLegacyKeyring = () => { + if (migratedToTribeCrypto || !ownCrypto) { migratedToTribeCrypto = true; return } + migratedToTribeCrypto = true + try { + const p = getLegacyKeyringPath() + if (!fs.existsSync(p)) return + const legacy = JSON.parse(fs.readFileSync(p, "utf8")) || {} + for (const [rootId, keyHex] of Object.entries(legacy)) { + if (rootId && keyHex && !ownCrypto.getKey(rootId)) { + ownCrypto.setKey(rootId, keyHex, 1) + } + } + } catch (_) {} + } + const getPadKey = (rootId) => { + migrateLegacyKeyring() + if (ownCrypto) return lookupKey(rootId) + try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null } + } + const setPadKey = (rootId, keyHex) => { + migrateLegacyKeyring() + if (ownCrypto) { ownCrypto.setKey(rootId, keyHex, 1); return } + let kr = {} + try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {} + kr[rootId] = keyHex + fs.writeFileSync(getLegacyKeyringPath(), JSON.stringify(kr, null, 2), "utf8") + } + + const encryptField = (text, keyHex) => { + const key = Buffer.from(keyHex, "hex") + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv) + const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]) + const authTag = cipher.getAuthTag() + return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex") + } + + const decryptField = (encrypted, keyHex) => { + try { + const key = Buffer.from(keyHex, "hex") + const iv = Buffer.from(encrypted.slice(0, 24), "hex") + const authTag = Buffer.from(encrypted.slice(24, 56), "hex") + const ciphertext = Buffer.from(encrypted.slice(56), "hex") + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv) + decipher.setAuthTag(authTag) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8") + } catch (_) { return "" } + } + + const tryDecryptField = (encrypted, keyHex) => { + const key = Buffer.from(keyHex, "hex") + const iv = Buffer.from(encrypted.slice(0, 24), "hex") + const authTag = Buffer.from(encrypted.slice(24, 56), "hex") + const ciphertext = Buffer.from(encrypted.slice(56), "hex") + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv) + decipher.setAuthTag(authTag) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8") + } + + const getTribeKeysFor = async (tribeId) => { + if (!tribeCrypto || !tribesModel || !tribeId) return [] + try { + const rootId = await tribesModel.getRootId(tribeId) + const keys = tribeCrypto.getKeys(rootId) || [] + return keys + } catch (_) { return [] } + } + + const decryptWithKeys = (c, keys) => { + if (!c.title || !keys.length) return null + for (const k of keys) { + try { + const title = tryDecryptField(c.title, k) + let deadline = "" + let tagsRaw = "" + try { deadline = c.deadline ? tryDecryptField(c.deadline, k) : "" } catch (_) {} + try { tagsRaw = c.tags ? tryDecryptField(c.tags, k) : "" } catch (_) {} + const ensureMemberKeys = async (ssbClient, messages, items) => { + if (!tribeCrypto) return + const distributed = new Map() + for (const m of messages) { + const c = m.value && m.value.content + if (!c || c.type !== "tribe-keys" || !c.tribeId) continue + const mk = c.memberKeys + if (!mk || typeof mk !== "object") continue + if (!distributed.has(c.tribeId)) distributed.set(c.tribeId, new Set()) + for (const id of Object.keys(mk)) distributed.get(c.tribeId).add(id) + } + const ssbKeys = require("../server/node_modules/ssb-keys") + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || item.undecryptable) continue + const rootId = item.rootId + if (!rootId) continue + const key = lookupKey(rootId) + if (!key) continue + const have = distributed.get(rootId) || new Set() + const missing = (Array.isArray(item.members) ? item.members : []).filter(m => m && m !== ssbClient.id && !have.has(m)) + if (!missing.length) continue + const memberKeys = {} + for (const m of missing) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {} + } + if (!Object.keys(memberKeys).length) continue + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve()) + }) + } + } + + return { title: safeText(title), deadline, tags: normalizeTags(tagsRaw) } + } catch (_) {} + } + return null + } + + const encryptForInvite = (padKeyHex, code, saltHex) => { + const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT) + const derived = crypto.scryptSync(code, salt, 32) + return encryptField(padKeyHex, derived.toString("hex")) + } + + const decryptFromInvite = (encryptedKey, code, saltHex) => { + const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT) + const derived = crypto.scryptSync(code, salt, 32) + return decryptField(encryptedKey, derived.toString("hex")) + } + + const tryDecryptPublicInviteKey = (invites) => { + if (!Array.isArray(invites)) return null + for (const inv of invites) { + if (!inv || typeof inv !== "object") continue + if (inv.public !== true) continue + if (typeof inv.code !== "string" || typeof inv.ek !== "string") continue + try { + const key = decryptFromInvite(inv.ek, inv.code, inv.salt) + if (key) return key + } catch (_) {} + } + return null + } + + const generateInviteSalt = () => crypto.randomBytes(16).toString("hex") + + const rotatePadKey = async (rootId, remainingMembers) => { + if (!ownCrypto || !tribeCrypto || !rootId) return + const existing = getPadKey(rootId) + if (!existing) return + const newKey = crypto.randomBytes(32).toString("hex") + const newGen = ownCrypto.addNewKey(rootId, newKey) + if (!Array.isArray(remainingMembers) || !remainingMembers.length) return + const ssbClient = await openSsb() + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + for (const m of remainingMembers) { + try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve()) + }) + } + } + + const ingestOwnTribeKeys = async () => { + if (!ownCrypto) return + try { + const ssbClient = await openSsb() + const ssbKeys = require("../server/node_modules/ssb-keys") + const config = require("../server/ssb_config") + const msgs = await readAll(ssbClient) + for (const m of msgs) { + const c = m.value && m.value.content + if (!c || c.type !== "tribe-keys") continue + const memberKeys = c.memberKeys + if (!memberKeys || typeof memberKeys !== "object") continue + const boxed = memberKeys[ssbClient.id] + if (!boxed) continue + try { + const unboxed = ssbKeys.unbox(boxed, config.keys) + const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null) + if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key) + } catch (_) {} + } + } catch (_) {} + } + + const readAll = async (ssbClient) => + new Promise((resolve, reject) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))) + ) + + const buildIndex = (messages) => { + const tomb = new Set() + const nodes = new Map() + const parent = new Map() + const child = new Map() + const strictParent = new Map() + const strictChild = new Map() + const authorByKey = new Map() + const tombRequests = [] + const memberMsgs = [] + + for (const m of messages) { + const k = m.key + const v = m.value || {} + const c = v.content + if (!c) continue + if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue } + if (c.type === "padMember" && c.target) { memberMsgs.push({ target: c.target, member: c.member, on: c.on !== false, author: v.author, ts: v.timestamp || m.timestamp || 0, code: typeof c.code === "string" ? c.code : "" }); continue } + if (c.type === "pad") { + nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + authorByKey.set(k, v.author) + if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) } + } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) { strictParent.set(k, t); strictChild.set(t, k) } } + } + + for (const t of tombRequests) { + const targetAuthor = authorByKey.get(t.target) + if (targetAuthor && t.author === targetAuthor) tomb.add(t.target) + } + + const rootOf = (id) => { let cur = id, g = 0; while (parent.has(cur) && g++ < 100000) cur = parent.get(cur); return cur } + const tipOf = (id) => { let cur = id, g = 0; while (child.has(cur) && g++ < 100000) cur = child.get(cur); return cur } + const strictRootOf = (id) => { let cur = id, g = 0; while (strictParent.has(cur) && g++ < 100000) cur = strictParent.get(cur); return cur } + const contentTipOf = (root) => { + const rn = nodes.get(root) + if (!rn) return root + let cur = root, best = root, g = 0 + const seen = new Set() + while (child.has(cur) && !seen.has(cur) && g++ < 100000) { + seen.add(cur) + const next = child.get(cur) + const n = nodes.get(next) + if (!n) break + if (n.author === rn.author && !tomb.has(next)) best = next + cur = next + } + return best + } + + const roots = new Set() + for (const id of nodes.keys()) roots.add(strictRootOf(id)) + + const memberByRoot = new Map() + const consumedByRoot = new Map() + for (const mm of memberMsgs) { + if (!nodes.has(mm.target)) continue + const r = strictRootOf(mm.target) + const ownerAuthor = nodes.get(r) && nodes.get(r).author + if (!ownerAuthor) continue + const self = mm.member === mm.author + const byOwner = mm.author === ownerAuthor + if (!self && !byOwner) continue + if (!mm.member) continue + if (!memberByRoot.has(r)) memberByRoot.set(r, new Map()) + const m2 = memberByRoot.get(r) + const p = m2.get(mm.member) + if (!p || mm.ts >= p.ts) m2.set(mm.member, { on: mm.on, ts: mm.ts }) + if (mm.on && mm.code) { + if (!consumedByRoot.has(r)) consumedByRoot.set(r, new Set()) + consumedByRoot.get(r).add(mm.code) + } + } + + const isCodeConsumed = (root, code) => { + if (!code) return false + const set = consumedByRoot.get(root) + return !!(set && set.has(code)) + } + + const resolveMembers = (root) => { + const ownerNode = nodes.get(root) + const oc = ownerNode ? ownerNode.c : {} + const set = new Set(Array.isArray(oc.members) ? oc.members.filter(x => typeof x === "string" && x) : []) + for (const [mem, st] of (memberByRoot.get(root) || new Map())) { if (st.on) set.add(mem); else set.delete(mem) } + return [...set] + } + + const tipByRoot = new Map() + for (const r of roots) tipByRoot.set(r, contentTipOf(r)) + + return { tomb, nodes, parent, child, rootOf, tipOf, strictRootOf, contentTipOf, tipByRoot, resolveMembers, isCodeConsumed } + } + + const decryptPadFields = (c, rootId, tribeKeys) => { + if (c.encrypted !== true) { + return { title: safeText(c.title), deadline: c.deadline ? String(c.deadline) : "", tags: normalizeTags(c.tags), _undec: false } + } + if (c.tribeId && Array.isArray(tribeKeys) && tribeKeys.length) { + const viaTribe = decryptWithKeys(c, tribeKeys) + if (viaTribe) return { ...viaTribe, _undec: false } + } + let keyHex = getPadKey(rootId) + if (!keyHex) keyHex = tryDecryptPublicInviteKey(c.invites) + if (!keyHex) return { title: "", deadline: "", tags: [], _undec: true } + const title = c.title ? decryptField(c.title, keyHex) : "" + const deadline = c.deadline ? decryptField(c.deadline, keyHex) : "" + const tagsRaw = c.tags ? decryptField(c.tags, keyHex) : "" + const tags = normalizeTags(tagsRaw) + return { title, deadline, tags, _undec: false } + } + + const buildPad = (node, rootId, tribeKeys, members) => { + const c = node.c || {} + if (c.type !== "pad") return null + const { title, deadline, tags, _undec } = decryptPadFields(c, rootId, tribeKeys) + return { + key: node.key, + rootId, + title, + status: c.status || "OPEN", + deadline, + tags, + author: c.author || node.author, + members: Array.isArray(members) ? members : (Array.isArray(c.members) ? c.members : []), + invites: Array.isArray(c.invites) ? c.invites : [], + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + tribeId: c.tribeId || null, + encrypted: c.encrypted === true, + undecryptable: !!_undec + } + } + + const isClosed = (pad) => { + if (pad.status === "CLOSED") return true + if (!pad.deadline) return false + return new Date(pad.deadline).getTime() <= Date.now() + } + + const collectPads = async (idx) => { + const tribeKeyCache = new Map() + const items = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "pad") continue + let tKeys = [] + if (node.c.tribeId) { + if (!tribeKeyCache.has(node.c.tribeId)) tribeKeyCache.set(node.c.tribeId, await getTribeKeysFor(node.c.tribeId)) + tKeys = tribeKeyCache.get(node.c.tribeId) + } + const pad = buildPad(node, rootId, tKeys, idx.resolveMembers(rootId)) + if (!pad) continue + pad.isClosed = isClosed(pad) + items.push(pad) + } + return items + } + + return { + type: "pad", + + async decryptContent(content, rootId) { + const tKeys = content && content.tribeId ? await getTribeKeysFor(content.tribeId) : [] + return decryptPadFields(content, rootId, tKeys) + }, + + decryptContentPublicSync(content) { + if (!content) return null + if (content.encrypted !== true) { + return { title: safeText(content.title), deadline: content.deadline ? String(content.deadline) : "", tags: normalizeTags(content.tags) } + } + if (content.tribeId) return null + const keyHex = tryDecryptPublicInviteKey(content.invites) + if (!keyHex) return null + try { + const title = content.title ? decryptField(content.title, keyHex) : "" + const deadline = content.deadline ? decryptField(content.deadline, keyHex) : "" + const tagsRaw = content.tags ? decryptField(content.tags, keyHex) : "" + return { title, deadline, tags: normalizeTags(tagsRaw) } + } catch (_) { return null } + }, + + async resolveRootId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const root = idx.strictRootOf(id) + const tip = idx.contentTipOf(root) + if (idx.tomb.has(tip)) throw new Error("Not found") + return root + }, + + async resolveCurrentId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const root = idx.strictRootOf(id) + const tip = idx.contentTipOf(root) + if (idx.tomb.has(tip)) throw new Error("Not found") + return tip + }, + + async createPad(title, status, deadline, tagsRaw, tribeId) { + const ssbClient = await openSsb() + const now = new Date().toISOString() + const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN" + const userId = ssbClient.id + const tagsArr = normalizeTags(tagsRaw) + + const isPublicOpen = validStatus === "OPEN" && !tribeId + if (isPublicOpen) { + const content = { + type: "pad", + title: safeText(title), + status: validStatus, + deadline: deadline ? String(deadline) : "", + tags: tagsArr, + author: userId, + members: [userId], + invites: [], + createdAt: now, + updatedAt: now, + encrypted: false + } + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + } + + let keyHex = null + let usesTribeKey = false + if (tribeId) { + const tKeys = await getTribeKeysFor(tribeId) + if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true } + } + if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex") + const enc = (text) => encryptField(text, keyHex) + + const content = { + type: "pad", + title: enc(safeText(title)), + status: validStatus, + deadline: deadline ? enc(String(deadline)) : "", + tags: enc(tagsArr.join(",")), + author: userId, + members: [userId], + invites: [], + createdAt: now, + updatedAt: now, + encrypted: true, + ...(tribeId ? { tribeId } : {}) + } + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => { + if (err) return reject(err) + if (!usesTribeKey) { + setPadKey(msg.key, keyHex) + if (tribeCrypto) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys) + ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg)) + return + } catch (_) {} + } + } + resolve(msg) + }) + }) + }, + + async updatePadById(id, data) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + const rootId = await this.resolveRootId(id) + + return new Promise(async (resolve, reject) => { + ssbClient.get(tipId, async (err, item) => { + if (err || !item?.content) return reject(new Error("Pad not found")) + if (item.content.author !== userId) return reject(new Error("Not the author")) + const c = item.content + const isEncrypted = c.encrypted === true + let keyHex = null + let usesTribeKey = false + if (isEncrypted) { + if (c.tribeId) { + const tKeys = await getTribeKeysFor(c.tribeId) + if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true } + } + if (!keyHex) keyHex = getPadKey(rootId) + if (!keyHex) return reject(new Error(`Missing pad key for ${rootId} — cannot update pad`)) + } + const enc = (text) => isEncrypted ? encryptField(text, keyHex) : text + const tagsField = (raw) => isEncrypted ? encryptField(normalizeTags(raw).join(","), keyHex) : normalizeTags(raw) + const updated = { + ...c, + title: data.title !== undefined ? enc(safeText(data.title)) : c.title, + status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status, + deadline: data.deadline !== undefined ? (isEncrypted ? enc(String(data.deadline)) : String(data.deadline)) : c.deadline, + tags: data.tags !== undefined ? tagsField(data.tags) : c.tags, + invites: data.invites !== undefined ? data.invites : c.invites, + updatedAt: new Date().toISOString(), + replaces: tipId + } + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (e1) => { + if (e1) return reject(e1) + ssbClient.publish(updated, (e2, res) => { + if (e2) return reject(e2) + if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex) + resolve(res) + }) + }) + }) + }) + }, + + async closePadById(id) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + const rootId = await this.resolveRootId(id) + return new Promise(async (resolve, reject) => { + ssbClient.get(tipId, async (err, item) => { + if (err || !item?.content) return reject(new Error("Pad not found")) + if (item.content.author !== userId) return reject(new Error("Not the author")) + const c = item.content + let keyHex = null + let usesTribeKey = false + if (c.tribeId) { + const tKeys = await getTribeKeysFor(c.tribeId) + if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true } + } + if (!keyHex) keyHex = getPadKey(rootId) + const updated = { + ...c, + status: "CLOSED", + updatedAt: new Date().toISOString(), + replaces: tipId + } + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (e1) => { + if (e1) return reject(e1) + ssbClient.publish(updated, (e2, res) => { + if (e2) return reject(e2) + if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex) + resolve(res) + }) + }) + }) + }) + }, + + async leavePad(padId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const pad = await this.getPadById(padId) + if (!pad) throw new Error("Pad not found") + if (pad.author === userId) throw new Error("Author cannot leave their own pad") + if (!Array.isArray(pad.members) || !pad.members.includes(userId)) return + const members = pad.members.filter(m => m !== userId) + const rootId = await this.resolveRootId(padId) + await new Promise((resolve, reject) => { + const content = { type: "padMember", target: rootId, member: userId, on: false, createdAt: new Date().toISOString() } + ssbClient.publish(content, (e) => e ? reject(e) : resolve()) + }) + try { await rotatePadKey(rootId, members) } catch (_) {} + }, + + async ingestKeys() { await ingestOwnTribeKeys() }, + + async pruneOrphanKeys() { + if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0 + try { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const live = new Set() + const tomb = buildValidatedTombstoneSet(messages) + for (const m of messages) { + const c = m.value && m.value.content + if (!c) continue + if (c.type === "pad") live.add(m.key) + } + const all = ownCrypto.getAllRootIds() + let removed = 0 + for (const rid of all) { + if (!live.has(rid) || tomb.has(rid)) { + try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {} + } + } + return removed + } catch (_) { return 0 } + }, + + async addMemberToPad(padId, feedId, consumedCode) { + const tipId = await this.resolveCurrentId(padId) + const ssbClient = await openSsb() + const rootId = await this.resolveRootId(padId) + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item?.content) return reject(new Error("Pad not found")) + const c = item.content + if (c.encrypted === true && !c.tribeId && !getPadKey(rootId)) { + const key = tryDecryptPublicInviteKey(c.invites) + if (key) setPadKey(rootId, key) + } + const content = { type: "padMember", target: rootId, member: feedId, on: true, createdAt: new Date().toISOString(), ...(typeof consumedCode === "string" && consumedCode ? { code: consumedCode } : {}) } + ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res)) + }) + }) + }, + + async deletePadById(id) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item?.content) return reject(new Error("Pad not found")) + if (item.content.author !== userId) return reject(new Error("Not the author")) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve()) + }) + }) + }, + + async getPadById(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const root = idx.strictRootOf(id) + const tip = idx.contentTipOf(root) + if (idx.tomb.has(tip)) return null + const node = idx.nodes.get(tip) + if (!node || node.c.type !== "pad") return null + const tKeys = node.c.tribeId ? await getTribeKeysFor(node.c.tribeId) : [] + const pad = buildPad(node, root, tKeys, idx.resolveMembers(root)) + if (!pad) return null + pad.isClosed = isClosed(pad) + return padCollab.fold(pad, await collectPads(idx)) + }, + + async listAll({ filter = "all", viewerId } = {}) { + const ssbClient = await openSsb() + const uid = viewerId || ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const now = Date.now() + let list = padCollab.visibleThenCollapsed(await collectPads(idx), uid) + if (filter === "mine") list = list.filter(p => p.author === uid) + else if (filter === "recent") list = list.filter(p => new Date(p.createdAt).getTime() >= now - 86400000) + else if (filter === "open") list = list.filter(p => !p.isClosed) + else if (filter === "closed") list = list.filter(p => p.isClosed) + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + try { await ensureMemberKeys(ssbClient, messages, list) } catch (_) {} + return list + }, + + async generateInvite(padId, opts = {}) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const pad = await this.getPadById(padId) + if (!pad) throw new Error("Pad not found") + if (pad.author !== userId) throw new Error("Only the author can generate invites") + const rootId = await this.resolveRootId(padId) + const keyHex = getPadKey(rootId) + const code = crypto.randomBytes(INVITE_BYTES).toString("hex") + let invite = code + const pubFlag = opts.public ? { public: true } : {} + if (keyHex) { + const inviteSalt = generateInviteSalt() + const ek = encryptForInvite(keyHex, code, inviteSalt) + invite = { code, ek, salt: inviteSalt, ...pubFlag } + } + if (opts.public && typeof invite !== "object") invite = { code, public: true } + const invites = [...pad.invites, invite] + await this.updatePadById(padId, { invites }) + return code + }, + + async getOpenInvite(padId) { + return openInviteOf(await this.getPadById(padId).catch(() => null)) + }, + + async generateOpenInvite(padId) { + const pad = await this.getPadById(padId) + if (!pad) throw new Error("Pad not found") + const existing = (Array.isArray(pad.invites) ? pad.invites : []).find(inv => typeof inv === "object" && inv.public === true) + if (existing) throw new Error("An open invitation already exists") + return this.generateInvite(padId, { public: true }) + }, + + async removeOpenInvite(padId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const pad = await this.getPadById(padId) + if (!pad) throw new Error("Pad not found") + if (pad.author !== userId) throw new Error("Only the author can remove invites") + const invites = (Array.isArray(pad.invites) ? pad.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true)) + await this.updatePadById(padId, { invites }) + }, + + async joinByInvite(code) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const pads = await this.listAll() + let matchedPad = null + let matchedInvite = null + for (const p of pads) { + for (const inv of p.invites) { + if (typeof inv === "string" && inv === code) { matchedPad = p; matchedInvite = inv; break } + if (typeof inv === "object" && inv.code === code) { matchedPad = p; matchedInvite = inv; break } + } + if (matchedPad) break + } + if (!matchedPad) throw new Error("Invalid or expired invite code") + if (matchedPad.members.includes(userId)) throw new Error("Already a member") + const isPublic = typeof matchedInvite === "object" && matchedInvite.public === true + let resolvedRootId = await this.resolveRootId(matchedPad.rootId) + if (!isPublic) { + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + if (idx.isCodeConsumed(resolvedRootId, code)) throw new Error("Invite already used") + } + let padKey = null + if (typeof matchedInvite === "object" && matchedInvite.ek) { + padKey = decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt) + setPadKey(resolvedRootId, padKey) + } + await this.addMemberToPad(matchedPad.rootId, userId, isPublic ? "" : code) + if (tribeCrypto && padKey && resolvedRootId) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {} + if (matchedPad.author && matchedPad.author !== userId) { + try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve()) + }) + } + } catch (_) {} + } + return matchedPad.rootId + }, + + async addEntry(padId, text) { + const ssbClient = await openSsb() + const rootId = await this.resolveRootId(padId) + const pad = await this.getPadById(rootId) + const padIsEncrypted = !!(pad && pad.encrypted) + const now = new Date().toISOString() + const safeBody = safeText(text) + + if (!padIsEncrypted) { + const content = { + type: "padEntry", + padId: rootId, + text: safeBody, + author: ssbClient.id, + createdAt: now, + encrypted: false, + ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {}) + } + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + } + + let keyHex = null + if (pad && pad.tribeId) { + const tKeys = await getTribeKeysFor(pad.tribeId) + if (tKeys.length) keyHex = tKeys[0] + } + if (!keyHex) keyHex = getPadKey(rootId) + if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot publish pad entry`) + const content = { + type: "padEntry", + padId: rootId, + text: encryptField(safeBody, keyHex), + author: ssbClient.id, + createdAt: now, + encrypted: true, + ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {}) + } + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + }, + + async getEntries(padRootId) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const wantRoot = idx.rootOf(padRootId) + const pad = await this.getPadById(padRootId) + const padKey = getPadKey(padRootId) + let tribeKeys = [] + if (pad && pad.tribeId) { + tribeKeys = await getTribeKeysFor(pad.tribeId) + } + const entries = [] + for (const m of messages) { + const v = m.value || {} + const c = v.content + if (!c || c.type !== "padEntry") continue + if (c.padId !== padRootId && idx.rootOf(c.padId) !== wantRoot) continue + let text = c.text || "" + if (c.encrypted && c.text) { + let decoded = "" + for (const k of tribeKeys) { + try { decoded = tryDecryptField(c.text, k); break } catch (_) {} + } + if (!decoded && padKey) decoded = decryptField(c.text, padKey) + text = decoded + } + entries.push({ + key: m.key, + author: c.author || v.author, + text, + createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString() + }) + } + entries.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + return entries + }, + + getMemberColor(members, feedId) { + const idx = members.indexOf(feedId) + return idx >= 0 ? MEMBER_COLORS[idx % MEMBER_COLORS.length] : "#888" + } + } +} diff --git a/src/models/panicmode_model.js b/src/models/panicmode_model.js new file mode 100644 index 0000000..95933fc --- /dev/null +++ b/src/models/panicmode_model.js @@ -0,0 +1,15 @@ +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +module.exports = { + removeSSB: async () => { + try { + const homeDir = os.homedir(); + const ssbPath = path.join(homeDir, '.ssb'); + await fs.promises.rm(ssbPath, { recursive: true, force: true }); + } catch (error) { + throw new Error("Error deleting data: " + error.message); + } + } +}; diff --git a/src/models/parliament_model.js b/src/models/parliament_model.js new file mode 100644 index 0000000..83dc837 --- /dev/null +++ b/src/models/parliament_model.js @@ -0,0 +1,1531 @@ +const pull = require('../server/node_modules/pull-stream'); +const moment = require('../server/node_modules/moment'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; +const TERM_DAYS = 60; +const PROPOSAL_DAYS = 7; +const REVOCATION_DAYS = 15; +const PROPOSAL_QUORUM = 2; +const QUORUM_RATIO = 0.25; +const VOTE_METHODS = new Set(['DEMOCRACY', 'ANARCHY', 'MAJORITY', 'MINORITY']); +const METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY']; +const FEED_ID_RE = /^@.+\.ed25519$/; + +module.exports = ({ cooler, services = {} }) => { + let ssb; + let userId; + + const CACHE_MS = 250; + let logCache = { at: 0, arr: null }; + + let electionInFlight = null; + let sweepInFlight = null; + + const openSsb = async () => { + if (!ssb) { + ssb = await cooler.open(); + userId = ssb.id; + } + return ssb; + }; + + const nowISO = () => new Date().toISOString(); + const parseISO = (s) => moment(s, moment.ISO_8601, true); + const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []); + const stripId = (obj) => { + if (!obj || typeof obj !== 'object') return obj; + const { id, ...rest } = obj; + return rest; + }; + const normMs = (t) => (t && t < 1e12 ? t * 1000 : t || 0); + + const isExpiredTerm = (t) => { + const end = t && t.endAt ? parseISO(t.endAt) : null; + if (!end || !end.isValid()) return false; + return moment().isSameOrAfter(end); + }; + + async function publishMsg(content) { + const ssbClient = await openSsb(); + const res = await new Promise((resolve, reject) => + ssbClient.publish(content, (e, r) => (e ? reject(e) : resolve(r))) + ); + logCache = { at: 0, arr: null }; + return res; + } + + async function readLog() { + const now = Date.now(); + if (logCache.arr && now - logCache.at < CACHE_MS) return logCache.arr; + const ssbClient = await openSsb(); + const arr = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, out) => (err ? rej(err) : res(out || []))) + ); + }); + logCache = { at: now, arr }; + return arr; + } + + function listByTypeFromMsgs(msgs, type) { + const tomb = buildValidatedTombstoneSet(msgs); + const rep = new Map(); + const children = new Map(); + const map = new Map(); + + for (const m of msgs || []) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; +if (c.type === type) { + if (c.replaces) { + const oldId = c.replaces; + const ts = normMs(v.timestamp || m.timestamp || Date.now()); + const prev = rep.get(oldId); + if (!prev || ts > prev.ts) rep.set(oldId, { id: k, ts }); + if (!children.has(oldId)) children.set(oldId, new Set()); + children.get(oldId).add(k); + } + map.set(k, { ...c, id: k }); + } + } + + for (const oldId of rep.keys()) map.delete(oldId); + for (const [oldId, kids] of children.entries()) { + const winner = rep.get(oldId)?.id || null; + for (const kid of kids) { + if (kid !== winner) map.delete(kid); + } + } + for (const tId of tomb) map.delete(tId); + return [...map.values()]; + } + + async function listByType(type) { + const msgs = await readLog(); + return listByTypeFromMsgs(msgs, type); + } + + async function listTribesAny() { + if (services.tribes?.listAll) return await services.tribes.listAll(); + return await listByType('tribe'); + } + + async function getLatestAboutFromLog(feedId) { + const msgs = await readLog(); + let latest = null; + for (let i = msgs.length - 1; i >= 0; i--) { + const v = msgs[i].value || {}; + const c = v.content || {}; + if (!c || c.type !== 'about') continue; + const bySelf = v.author === feedId && typeof c.name === 'string'; + const aboutTarget = c.about === feedId && (typeof c.name === 'string' || typeof c.description === 'string' || typeof c.image === 'string'); + if (bySelf || aboutTarget) { + const ts = normMs(v.timestamp || msgs[i].timestamp || Date.now()); + if (!latest || ts > latest.ts) latest = { ts, content: c }; + } + } + return latest ? latest.content : null; + } + + async function getTribeMetaById(tribeId) { + let tribe = null; + if (services.tribes?.getTribeById) { + try { tribe = await services.tribes.getTribeById(tribeId); } catch {} + } + if (!tribe) return { isTribe: true, name: tribeId, avatarUrl: '/assets/images/default-tribe.png', bio: '' }; + const imgId = tribe.image || null; + const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-tribe.png'; + return { isTribe: true, name: tribe.title || tribe.name || tribeId, avatarUrl, bio: tribe.description || '' }; + } + + async function getInhabitantMetaById(feedId) { + let aboutRec = null; + if (services.inhabitants?.getLatestAboutById) { + try { aboutRec = await services.inhabitants.getLatestAboutById(feedId); } catch {} + } + if (!aboutRec) { + try { aboutRec = await getLatestAboutFromLog(feedId); } catch {} + } + const name = (aboutRec && typeof aboutRec.name === 'string' && aboutRec.name.trim()) ? aboutRec.name.trim() : feedId; + const imgField = aboutRec && aboutRec.image; + const imgId = typeof imgField === 'string' ? imgField : (imgField && (imgField.link || imgField.url)) ? (imgField.link || imgField.url) : null; + const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-avatar.png'; + const bio = (aboutRec && typeof aboutRec.description === 'string') ? aboutRec.description : ''; + return { isTribe: false, name, avatarUrl, bio }; + } + + async function actorMeta({ targetType, targetId }) { + const t = String(targetType || '').toLowerCase(); + if (t === 'tribe') return await getTribeMetaById(targetId); + return await getInhabitantMetaById(targetId); + } + + async function getInhabitantTitleSSB(feedId) { + const msgs = await readLog(); + for (let i = msgs.length - 1; i >= 0; i--) { + const v = msgs[i].value || {}; + const c = v.content || {}; + if (!c || c.type !== 'about') continue; + if (c.about === feedId && typeof c.name === 'string' && c.name.trim()) return c.name.trim(); + if (v.author === feedId && typeof c.name === 'string' && c.name.trim()) return c.name.trim(); + } + return null; + } + + async function findFeedIdByName(name) { + const q = String(name || '').trim().toLowerCase(); + if (!q) return null; + const msgs = await readLog(); + let best = null; + for (let i = msgs.length - 1; i >= 0; i--) { + const v = msgs[i].value || {}; + const c = v.content || {}; + if (!c || c.type !== 'about' || typeof c.name !== 'string') continue; + if (c.name.trim().toLowerCase() !== q) continue; + const fid = typeof c.about === 'string' && FEED_ID_RE.test(c.about) ? c.about : v.author; + const ts = normMs(v.timestamp || msgs[i].timestamp || Date.now()); + if (!best || ts > best.ts) best = { id: fid, ts }; + } + return best ? best.id : null; + } + + async function resolveTarget(candidateInput) { + const s = String(candidateInput || '').trim(); + if (!s) return null; + const tribes = await listTribesAny(); + const t = tribes.find(tr => + tr.id === s || + (tr.title && tr.title.toLowerCase() === s.toLowerCase()) || + (tr.name && tr.name.toLowerCase() === s.toLowerCase()) + ); + if (t) { + return { type: 'tribe', id: t.id, title: t.title || t.name || t.id, members: ensureArray(t.members) }; + } + if (FEED_ID_RE.test(s)) { + const title = await getInhabitantTitleSSB(s); + return { type: 'inhabitant', id: s, title: title || s, members: [] }; + } + const fid = await findFeedIdByName(s); + if (fid) { + const title = await getInhabitantTitleSSB(fid); + return { type: 'inhabitant', id: fid, title: title || s, members: [] }; + } + return null; + } + + function majorityThreshold(total) { return Math.ceil(Number(total || 0) * 0.8); } + function minorityThreshold(total) { return Math.ceil(Number(total || 0) * 0.2); } + function democracyThreshold(total) { return Math.floor(Number(total || 0) / 2) + 1; } + let _inhCache = { at: 0, n: 0 }; + async function inhabitantsCount() { + const now = Date.now(); + if (_inhCache.at && now - _inhCache.at < 30000) return _inhCache.n; + let n = 0; + try { + if (services.inhabitants?.listInhabitants) { + const list = await services.inhabitants.listInhabitants({ filter: 'all', includeInactive: true }); + n = Array.isArray(list) ? list.length : 0; + } + } catch {} + _inhCache = { at: now, n }; + return n; + } + async function proposalQuorum() { + const n = await inhabitantsCount(); + return Math.max(PROPOSAL_QUORUM, Math.ceil(n * QUORUM_RATIO)); + } + async function passesThreshold(method, total, yes) { + const m = String(method || '').toUpperCase(); + const quorum = await proposalQuorum(); + if (Number(total || 0) < quorum) return false; + if (m === 'DEMOCRACY' || m === 'ANARCHY') return yes >= democracyThreshold(total); + if (m === 'MAJORITY') return yes >= majorityThreshold(total); + if (m === 'MINORITY') return yes >= minorityThreshold(total); + return false; + } + + async function listCandidaturesOpenRaw() { + const all = await listByType('parliamentCandidature'); + const filtered = all.filter(c => (c.status || 'OPEN') === 'OPEN'); + return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + async function getFirstUserTimestamp(feedId) { + const ssbClient = await openSsb(); + return new Promise((resolve) => { + pull( + ssbClient.createUserStream({ id: feedId, reverse: false }), + pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'), + pull.take(1), + pull.collect((err, arr) => { + if (err || !arr || !arr.length) return resolve(Date.now()); + const m = arr[0]; + const ts = normMs((m.value && m.value.timestamp) || m.timestamp); + resolve(ts || Date.now()); + }) + ); + }); + } + + async function getInhabitantKarma(feedId) { + if (services.banking?.getUserEngagementScore) { + try { return Number(await services.banking.getUserEngagementScore(feedId)) || 0; } catch { return 0; } + } + return 0; + } + + async function getTribeSince(tribeId) { + if (services.tribes?.getTribeById) { + try { + const t = await services.tribes.getTribeById(tribeId); + if (t?.createdAt) return new Date(t.createdAt).getTime(); + } catch {} + } + return Date.now(); + } + + async function getCandidatureVotesMap() { + const msgs = await readLog(); + const byTarget = new Map(); + for (const m of msgs || []) { + const c = m.value && m.value.content; + if (!c || c.type !== 'parliamentCandidatureVote' || !c.target) continue; + if (!byTarget.has(c.target)) byTarget.set(c.target, new Set()); + byTarget.get(c.target).add(m.value.author); + } + return byTarget; + } + + async function listCandidaturesOpen() { + const rows = await listCandidaturesOpenRaw(); + const votesMap = await getCandidatureVotesMap(); + const enriched = await Promise.all(rows.map(async c => { + const signedVoters = [...(votesMap.get(c.id) || [])]; + const cleanVoters = signedVoters.filter(v => !(c.targetType === 'inhabitant' && String(v) === String(c.targetId))); + const base = { ...c, voters: cleanVoters, votes: cleanVoters.length }; + if (c.targetType === 'inhabitant') { + const karma = await getInhabitantKarma(c.targetId); + const since = await getFirstUserTimestamp(c.targetId); + return { ...base, karma, profileSince: since }; + } else { + const since = await getTribeSince(c.targetId); + return { ...base, karma: 0, profileSince: since }; + } + })); + return enriched; + } + + async function listTermsBase(filter = 'all') { + const all = await listByType('parliamentTerm'); + const collapsed = collapseOverlappingTerms(all); + const latestStart = collapsed.reduce((mx, t) => Math.max(mx, new Date(t.startAt).getTime() || 0), 0); + let arr = collapsed.map(t => { + const superseded = (new Date(t.startAt).getTime() || 0) < latestStart; + return { ...t, status: (isExpiredTerm(t) || superseded) ? 'EXPIRED' : 'ACTIVE' }; + }); + if (filter === 'active') arr = arr.filter(t => t.status === 'ACTIVE'); + if (filter === 'expired') arr = arr.filter(t => t.status === 'EXPIRED'); + return arr.sort((a, b) => new Date(b.startAt) - new Date(a.startAt)); + } + + async function getLatestTermAny() { + const terms = await listByType('parliamentTerm'); + const collapsed = collapseOverlappingTerms(terms); + return collapsed[0] || null; + } + + async function getCurrentTermBase() { + const t = await getLatestTermAny(); + if (!t) return null; + return isExpiredTerm(t) ? null : t; + } + + function currentCycleStart(term) { + return term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString(); + } + + async function archiveAllCandidatures() { + const all = await listCandidaturesOpenRaw(); + for (const c of all) { + const updated = { ...stripId(c), replaces: c.id, status: 'DISCARDED', updatedAt: nowISO() }; + await publishMsg(updated); + } + } + + async function chooseWinnerFromCandidaturesAsync(cands) { + if (!cands.length) return null; + const norm = cands.map(c => ({ + ...c, + votes: Number(c.votes || 0), + karma: Number(c.karma || 0), + since: Number(c.profileSince || 0), + createdAtMs: new Date(c.createdAt).getTime() || 0 + })); + const totalVotes = norm.reduce((s, c) => s + c.votes, 0); + if (totalVotes > 0) { + const maxVotes = Math.max(...norm.map(c => c.votes)); + let tied = norm.filter(c => c.votes === maxVotes); + if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes }; + const maxKarma = Math.max(...tied.map(c => c.karma)); + tied = tied.filter(c => c.karma === maxKarma); + if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes }; + tied.sort((a, b) => (a.since || 0) - (b.since || 0)); + const oldestSince = tied[0].since || 0; + tied = tied.filter(c => c.since === oldestSince); + if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes }; + tied.sort((a, b) => a.createdAtMs - b.createdAtMs); + const earliest = tied[0].createdAtMs; + tied = tied.filter(c => c.createdAtMs === earliest); + if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes }; + tied.sort((a, b) => String(a.targetId).localeCompare(String(b.targetId))); + return { chosen: tied[0], totalVotes, winnerVotes: maxVotes }; + } + const latest = norm.sort((a, b) => b.createdAtMs - a.createdAtMs)[0]; + return { chosen: latest, totalVotes: 0, winnerVotes: 0 }; + } + + async function summarizePoliciesForTerm(termOrId) { + let termId = null; + let termStart = null; + let termStartMs = null; + let termEndMs = Infinity; + if (termOrId && typeof termOrId === 'object') { + termId = termOrId.id || termOrId.startAt; + termStart = termOrId.startAt || null; + termStartMs = termStart ? new Date(termStart).getTime() : null; + termEndMs = termOrId.endAt ? new Date(termOrId.endAt).getTime() : (termStartMs != null ? termStartMs + TERM_DAYS * 86400000 : Infinity); + } else { + termId = termOrId; + } + const matchesTerm = (x) => { + if (termStartMs != null && x.createdAt) { + const t = new Date(x.createdAt).getTime(); + if (Number.isFinite(t) && t >= termStartMs && t < termEndMs) return true; + } + return x.termId === termId || (termStart && x.termId === termStart); + }; + const proposals = await listByType('parliamentProposal'); + const mine = termId ? proposals.filter(matchesTerm) : []; + const proposed = mine.length; + let approved = 0; + let declined = 0; + let discarded = 0; + for (const p of mine) { + const baseStatus = String(p.status || 'OPEN').toUpperCase(); + let finalStatus = baseStatus; + let isDiscarded = false; + if (p.voteId && services.votes?.getVoteById) { + try { + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + const dl = v.deadline || v.endAt || v.expiresAt || null; + const closed = v.status === 'CLOSED' || (dl && moment(dl).isBefore(moment())); + const reached = await passesThreshold(p.method, total, yes); + if (!closed) { + if (dl && moment(dl).isBefore(moment()) && !reached) isDiscarded = true; + else finalStatus = 'OPEN'; + } else { + finalStatus = reached ? 'APPROVED' : 'REJECTED'; + } + } catch {} + } else { + if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) isDiscarded = true; + } + if (isDiscarded) { + discarded++; + continue; + } + if (finalStatus === 'ENACTED' || finalStatus === 'APPROVED') { + approved++; + continue; + } + if (finalStatus === 'REJECTED') { + declined++; + continue; + } + } + const revs = await listByType('parliamentRevocation'); + const revocated = termId + ? revs.filter(r => r.status === 'ENACTED' && matchesTerm(r)).length + : 0; + return { proposed, approved, declined, discarded, revocated }; + } + + async function computeGovernmentCard(term) { + if (!term) return null; + const method = term.method || 'DEMOCRACY'; + const isTribe = term.powerType === 'tribe'; + let members = 1; + if (isTribe && term.powerId) { + let tribe = null; + if (services.tribes) { + try { tribe = await services.tribes.getTribeById(term.powerId); } catch {} + } + members = tribe && Array.isArray(tribe.members) ? tribe.members.length : 0; + } + const pol = await summarizePoliciesForTerm({ ...term }); + const eff = pol.proposed > 0 ? Math.round((pol.approved / pol.proposed) * 100) : 0; + const cands = await listByType('parliamentCandidature'); + const presented = cands.length; + return { + method, + powerType: term.powerType, + powerId: term.powerId, + powerTitle: term.powerTitle, + votesReceived: term.winnerVotes || 0, + totalVotes: term.totalVotes || 0, + members, + since: term.startAt, + end: term.endAt, + presented, + proposed: pol.proposed, + approved: pol.approved, + declined: pol.declined, + discarded: pol.discarded, + revocated: pol.revocated, + efficiency: eff + }; + } + + async function countMyProposalsThisTerm(term) { + const termId = term.id || term.startAt; + const proposals = await listByType('parliamentProposal'); + const laws = await listByType('parliamentLaw'); + const nProp = proposals.filter(p => p.termId === termId && p.proposer === userId).length; + const nLaw = laws.filter(l => l.termId === termId && l.proposer === userId).length; + return nProp + nLaw; + } + + async function closeExpiredKarmatocracy(term) { + const termId = term.id || term.startAt; + const all = await listByType('parliamentProposal'); + const pending = all.filter(p => + p.termId === termId && + String(p.method).toUpperCase() === 'KARMATOCRACY' && + (p.status || 'OPEN') !== 'ENACTED' && + p.deadline && moment().isAfter(parseISO(p.deadline)) + ); + if (!pending.length) return; + const withKarma = await Promise.all(pending.map(async p => ({ + ...p, + karma: await getInhabitantKarma(p.proposer), + createdAtMs: new Date(p.createdAt).getTime() || 0 + }))); + withKarma.sort((a, b) => { + if (b.karma !== a.karma) return b.karma - a.karma; + if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs; + return String(a.proposer).localeCompare(String(b.proposer)); + }); + const winner = withKarma[0]; + const losers = withKarma.slice(1); + const approve = { ...stripId(winner), replaces: winner.id, status: 'APPROVED', updatedAt: nowISO() }; + await publishMsg(approve); + for (const lo of losers) { + const rej = { ...stripId(lo), replaces: lo.id, status: 'REJECTED', updatedAt: nowISO() }; + await publishMsg(rej); + } + } + + async function closeExpiredDictatorship(term) { + const termId = term.id || term.startAt; + const all = await listByType('parliamentProposal'); + const pending = all.filter(p => + p.termId === termId && + String(p.method).toUpperCase() === 'DICTATORSHIP' && + (p.status || 'OPEN') !== 'ENACTED' && + p.deadline && moment().isAfter(parseISO(p.deadline)) + ); + if (!pending.length) return; + for (const p of pending) { + const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() }; + await publishMsg(updated); + } + } + + async function closeExpiredRevocationKarmatocracy(term) { + const termId = term.id || term.startAt; + const all = await listByType('parliamentRevocation'); + const pending = all.filter(p => + p.termId === termId && + String(p.method).toUpperCase() === 'KARMATOCRACY' && + (p.status || 'OPEN') !== 'ENACTED' && + p.deadline && moment().isAfter(parseISO(p.deadline)) + ); + if (!pending.length) return; + for (const p of pending) { + const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() }; + await publishMsg(updated); + } + } + + async function closeExpiredRevocationDictatorship(term) { + const termId = term.id || term.startAt; + const all = await listByType('parliamentRevocation'); + const pending = all.filter(p => + p.termId === termId && + String(p.method).toUpperCase() === 'DICTATORSHIP' && + (p.status || 'OPEN') !== 'ENACTED' && + p.deadline && moment().isAfter(parseISO(p.deadline)) + ); + if (!pending.length) return; + for (const p of pending) { + const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() }; + await publishMsg(updated); + } + } + + async function createRevocation({ lawId, title, reasons }) { + const term = await getCurrentTermBase(); + if (!term) throw new Error('No active government'); + const allowed = await canPropose(); + if (!allowed) throw new Error('You are not in the goverment, yet.'); + const lawIdStr = String(lawId || '').trim(); + if (!lawIdStr) throw new Error('Law required'); + const laws = await listByType('parliamentLaw'); + const law = laws.find(l => l.id === lawIdStr); + if (!law) throw new Error('Law not found'); + const method = String(term.method || 'DEMOCRACY').toUpperCase(); + const deadline = moment().add(REVOCATION_DAYS, 'days').toISOString(); + if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') { + const rev = { + type: 'parliamentRevocation', + lawId: lawIdStr, + title: title || law.question || '', + reasons: reasons || '', + method, + termId: term.id || term.startAt, + proposer: userId, + status: 'OPEN', + deadline, + createdAt: nowISO() + }; + return await publishMsg(rev); + } + const voteMsg = await services.votes.createVote( + `Revoke: ${title || law.question || ''}`, + deadline, + ['YES', 'NO', 'ABSTENTION'], + [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'revocation'] + ); + const rev = { + type: 'parliamentRevocation', + lawId: lawIdStr, + title: title || law.question || '', + reasons: reasons || '', + method, + voteId: voteMsg.key || voteMsg.id, + termId: term.id || term.startAt, + proposer: userId, + status: 'OPEN', + createdAt: nowISO() + }; + return await publishMsg(rev); + } + + async function closeRevocation(revId) { + const ssbClient = await openSsb(); + const msg = await new Promise((resolve, reject) => + ssbClient.get(revId, (e, m) => (e || !m) ? reject(new Error('Revocation not found')) : resolve(m)) + ); + if (msg.content?.type !== 'parliamentRevocation') throw new Error('Revocation not found'); + const p = msg.content; + const currentStatus = String(p.status || 'OPEN').toUpperCase(); + if (currentStatus === 'ENACTED' || currentStatus === 'REJECTED' || currentStatus === 'DISCARDED') return p; + const method = String(p.method || '').toUpperCase(); + if (method === 'DICTATORSHIP') { + if (currentStatus === 'APPROVED') return p; + const updated = { ...p, replaces: revId, status: 'APPROVED', updatedAt: nowISO() }; + await publishMsg(updated); + return updated; + } + if (method === 'KARMATOCRACY') return p; + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + const ok = await passesThreshold(method, total, yes); + const desiredStatus = ok ? 'APPROVED' : 'REJECTED'; + if (currentStatus === desiredStatus) return p; + const updated = { ...p, replaces: revId, status: desiredStatus, updatedAt: nowISO() }; + await publishMsg(updated); + return updated; + } + + async function proposeCandidature({ candidateId, method }) { + const m = String(method || '').toUpperCase(); + if (!METHODS.includes(m)) throw new Error('Invalid method'); + const target = await resolveTarget(candidateId); + if (!target) throw new Error('Candidate not found'); + const term = await getCurrentTermBase(); + const since = currentCycleStart(term); + const myAll = await listByType('parliamentCandidature'); + const mineThisCycle = myAll.filter(c => c.proposer === userId && new Date(c.createdAt) >= new Date(since)); + if (mineThisCycle.length >= 3) throw new Error('Candidate limit reached'); + const open = await listCandidaturesOpenRaw(); + const duplicate = open.find(c => c.targetType === target.type && c.targetId === target.id && new Date(c.createdAt) >= new Date(since)); + if (duplicate) throw new Error('Candidate already proposed this cycle'); + const content = { + type: 'parliamentCandidature', + targetType: target.type, + targetId: target.id, + targetTitle: target.title, + method: m, + votes: 0, + voters: [], + proposer: userId, + status: 'OPEN', + createdAt: nowISO() + }; + return await publishMsg(content); + } + + async function voteCandidature(candidatureMsgId) { + const ssbClient = await openSsb(); + const votesMap = await getCandidatureVotesMap(); + const open = await listCandidaturesOpenRaw(); + const already = open.some(c => (votesMap.get(c.id) || new Set()).has(userId)); + if (already) throw new Error('Already voted this cycle'); + const msg = await new Promise((resolve, reject) => + ssbClient.get(candidatureMsgId, (e, m) => (e || !m) ? reject(new Error('Candidate not found')) : resolve(m)) + ); + if (msg.content?.type !== 'parliamentCandidature') throw new Error('Candidate not found'); + const c = msg.content; + if ((c.status || 'OPEN') !== 'OPEN') throw new Error('Closed'); + if (c.targetType === 'inhabitant' && String(c.targetId) === String(userId)) throw new Error('You cannot vote for yourself'); + if ((votesMap.get(candidatureMsgId) || new Set()).has(userId)) throw new Error('Already voted this cycle'); + const content = { type: 'parliamentCandidatureVote', target: candidatureMsgId, createdAt: nowISO() }; + return await publishMsg(content); + } + + async function createProposal({ title, description }) { + let term = await getCurrentTermBase(); + if (!term) { + await resolveElection(); + term = await getCurrentTermBase(); + } + if (!term) throw new Error('No active government'); + const allowed = await canPropose(); + if (!allowed) throw new Error('You are not in the goverment, yet.'); + if (!title || !title.trim()) throw new Error('Title required'); + if (String(description || '').length > 1000) throw new Error('Description too long'); + const used = await countMyProposalsThisTerm(term); + if (used >= 3) throw new Error('Proposal limit reached'); + const method = String(term.method || 'DEMOCRACY').toUpperCase(); + const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString(); + if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') { + const proposal = { type: 'parliamentProposal', title, description: description || '', method, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', deadline, createdAt: nowISO() }; + return await publishMsg(proposal); + } + const voteMsg = await services.votes.createVote(title, deadline, ['YES', 'NO', 'ABSTENTION'], [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'proposal']); + const proposal = { type: 'parliamentProposal', title, description: description || '', method, voteId: voteMsg.key || voteMsg.id, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', createdAt: nowISO() }; + return await publishMsg(proposal); + } + + async function closeProposal(proposalId) { + const ssbClient = await openSsb(); + const msg = await new Promise((resolve, reject) => + ssbClient.get(proposalId, (e, m) => (e || !m) ? reject(new Error('Proposal not found')) : resolve(m)) + ); + if (msg.content?.type !== 'parliamentProposal') throw new Error('Proposal not found'); + const p = msg.content; + const currentStatus = String(p.status || 'OPEN').toUpperCase(); + if (currentStatus === 'ENACTED' || currentStatus === 'REJECTED' || currentStatus === 'DISCARDED') return p; + const method = String(p.method || '').toUpperCase(); + if (method === 'DICTATORSHIP') { + if (currentStatus === 'APPROVED') return p; + const updated = { ...p, replaces: proposalId, status: 'APPROVED', updatedAt: nowISO() }; + await publishMsg(updated); + return updated; + } + if (method === 'KARMATOCRACY') return p; + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + const ok = await passesThreshold(method, total, yes); + const desiredStatus = ok ? 'APPROVED' : 'REJECTED'; + if (currentStatus === desiredStatus) return p; + const updated = { ...p, replaces: proposalId, status: desiredStatus, updatedAt: nowISO() }; + await publishMsg(updated); + return updated; + } + + async function sweepProposals() { + if (sweepInFlight) return sweepInFlight; + sweepInFlight = (async () => { + const term = await getCurrentTermBase(); + if (!term) return; + await closeExpiredKarmatocracy(term); + await closeExpiredDictatorship(term); + + const allProps = await listByType('parliamentProposal'); + const voteProps = allProps.filter(p => { + const m = String(p.method || '').toUpperCase(); + return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId; + }); + + for (const p of voteProps) { + try { + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + const deadline = v.deadline || v.endAt || v.expiresAt || null; + const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + if (closed) { try { await closeProposal(p.id); } catch {} ; continue; } + } catch {} + } + + await closeExpiredRevocationKarmatocracy(term); + await closeExpiredRevocationDictatorship(term); + + const revs = await listByType('parliamentRevocation'); + const voteRevs = revs.filter(p => { + const m = String(p.method || '').toUpperCase(); + return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId; + }); + + for (const p of voteRevs) { + try { + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + const deadline = v.deadline || v.endAt || v.expiresAt || null; + const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + if (closed) { try { await closeRevocation(p.id); } catch {} ; continue; } + } catch {} + } + })().finally(() => { sweepInFlight = null; }); + + return sweepInFlight; + } + + async function getActorMeta({ targetType, targetId }) { + return await actorMeta({ targetType, targetId }); + } + + async function listCandidatures(filter = 'OPEN') { + if (filter === 'OPEN') return await listCandidaturesOpen(); + const all = await listByType('parliamentCandidature'); + return all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + async function listTerms(filter = 'all') { + return await listTermsBase(filter); + } + + async function getCurrentTerm() { + return await getCurrentTermBase(); + } + + async function listLeaders() { + const terms = await listTermsBase('all'); + const map = new Map(); + for (const t of terms) { + if (!(t.powerType === 'tribe' || t.powerType === 'inhabitant')) continue; + const k = `${t.powerType}:${t.powerId}`; + if (!map.has(k)) map.set(k, { powerType: t.powerType, powerId: t.powerId, powerTitle: t.powerTitle, inPower: 0, presented: 0, proposed: 0, approved: 0, declined: 0, discarded: 0, revocated: 0 }); + const rec = map.get(k); + rec.inPower += 1; + const sum = await summarizePoliciesForTerm(t); + rec.proposed += sum.proposed; + rec.approved += sum.approved; + rec.declined += sum.declined; + rec.discarded += sum.discarded; + rec.revocated += sum.revocated; + } + const cands = await listByType('parliamentCandidature'); + for (const c of cands) { + const k = `${c.targetType}:${c.targetId}`; + if (!map.has(k)) continue; + const rec = map.get(k); + rec.presented = (rec.presented || 0) + 1; + } + const rows = [...map.values()].map(r => ({ ...r, presented: r.presented || 0, efficiency: (r.proposed > 0 ? r.approved / r.proposed : 0) })); + rows.sort((a, b) => { + if (b.approved !== a.approved) return b.approved - a.approved; + if ((b.efficiency || 0) !== (a.efficiency || 0)) return (b.efficiency || 0) - (a.efficiency || 0); + if (b.inPower !== a.inPower) return b.inPower - a.inPower; + if (b.proposed !== a.proposed) return b.proposed - a.proposed; + return String(a.powerId).localeCompare(String(b.powerId)); + }); + return rows; + } + + async function listProposalsCurrent() { + const all = await listByType('parliamentProposal'); + const rows = all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + const out = []; + for (const p of rows) { + let deadline = p.deadline || null; + let yes = 0; + let total = 0; + const baseStatus = String(p.status || 'OPEN').toUpperCase(); + let derivedStatus = baseStatus; + if (p.voteId && services.votes?.getVoteById) { + try { + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + total = Number(v.totalVotes ?? v.total ?? sum); + yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + deadline = deadline || v.deadline || v.endAt || v.expiresAt || null; + const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + const reached = await passesThreshold(p.method, total, yes); + if (closed) derivedStatus = reached ? 'APPROVED' : 'REJECTED'; + else derivedStatus = 'OPEN'; + } catch { + derivedStatus = baseStatus; + } + } else { + if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) derivedStatus = 'DISCARDED'; + } + if (derivedStatus === 'ENACTED' || derivedStatus === 'REJECTED' || derivedStatus === 'DISCARDED') continue; + const needed = await proposalQuorum(); + const onTrack = await passesThreshold(p.method, total, yes); + out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus }); + } + return out; + } + + async function deriveProposalStatus(method, voteId) { + const m = String(method || '').toUpperCase(); + if (!voteId || !(m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY')) return null; + try { + const v = await services.votes.getVoteById(voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + const deadline = v.deadline || v.endAt || v.expiresAt || null; + const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + if (!closed) return 'OPEN'; + return (await passesThreshold(m, total, yes)) ? 'APPROVED' : 'REJECTED'; + } catch { return null; } + } + + async function listFutureLawsCurrent() { + const term = await getCurrentTermBase(); + if (!term) return []; + const termId = term.id || term.startAt; + const all = await listByType('parliamentProposal'); + const rows = all + .filter(p => p.termId === termId) + .filter(p => p.status === 'APPROVED') + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + const out = []; + for (const p of rows) { + let yes = 0; + let total = 0; + let deadline = p.deadline || null; + let voteClosed = true; + if (p.voteId && services.votes?.getVoteById) { + try { + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + total = Number(v.totalVotes ?? v.total ?? sum); + yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + deadline = deadline || v.deadline || v.endAt || v.expiresAt || null; + voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + if (!voteClosed) continue; + if (VOTE_METHODS.has(String(p.method || '').toUpperCase()) && !(await passesThreshold(p.method, total, yes))) continue; + } catch {} + } + const needed = await proposalQuorum(); + out.push({ ...p, deadline, yes, total, needed }); + } + return out; + } + + async function listRevocationsCurrent() { + const all = await listByType('parliamentRevocation'); + const rows = all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + const out = []; + for (const p of rows) { + let deadline = p.deadline || null; + let yes = 0; + let total = 0; + const baseStatus = String(p.status || 'OPEN').toUpperCase(); + let derivedStatus = baseStatus; + if (p.voteId && services.votes?.getVoteById) { + try { + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + total = Number(v.totalVotes ?? v.total ?? sum); + yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + deadline = deadline || v.deadline || v.endAt || v.expiresAt || null; + const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + const reached = await passesThreshold(p.method, total, yes); + if (closed) derivedStatus = reached ? 'APPROVED' : 'REJECTED'; + else derivedStatus = 'OPEN'; + } catch { + derivedStatus = baseStatus; + } + } else { + if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) derivedStatus = 'DISCARDED'; + } + if (derivedStatus === 'ENACTED' || derivedStatus === 'REJECTED' || derivedStatus === 'DISCARDED') continue; + const needed = await proposalQuorum(); + const onTrack = await passesThreshold(p.method, total, yes); + out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus }); + } + return out; + } + + async function listFutureRevocationsCurrent() { + const term = await getCurrentTermBase(); + if (!term) return []; + const termId = term.id || term.startAt; + const all = await listByType('parliamentRevocation'); + const rows = all + .filter(p => p.termId === termId) + .filter(p => p.status === 'APPROVED') + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + const out = []; + for (const p of rows) { + let yes = 0; + let total = 0; + let deadline = p.deadline || null; + let voteClosed = true; + if (p.voteId && services.votes?.getVoteById) { + try { + const v = await services.votes.getVoteById(p.voteId); + const votesMap = v.votes || {}; + const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0); + total = Number(v.totalVotes ?? v.total ?? sum); + yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0); + deadline = deadline || v.deadline || v.endAt || v.expiresAt || null; + voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment())); + if (!voteClosed) continue; + if (VOTE_METHODS.has(String(p.method || '').toUpperCase()) && !(await passesThreshold(p.method, total, yes))) continue; + } catch {} + } + const needed = await proposalQuorum(); + out.push({ ...p, deadline, yes, total, needed }); + } + return out; + } + + async function countRevocationsEnacted() { + const all = await listByType('parliamentRevocation'); + return all.filter(r => r.status === 'ENACTED').length; + } + + async function voteSnapshot(voteId) { + if (!voteId || !services.votes?.getVoteById) return null; + try { + const v = await services.votes.getVoteById(voteId); + const vm = v?.votes || {}; + const sum = Object.values(vm).reduce((s, n) => s + Number(n || 0), 0); + const total = Number(v.totalVotes ?? v.total ?? sum); + return { + YES: Number(vm.YES ?? vm.Yes ?? vm.yes ?? 0), + NO: Number(vm.NO ?? vm.No ?? vm.no ?? 0), + ABSTENTION: Number(vm.ABSTENTION ?? vm.Abstention ?? vm.abstention ?? 0), + total + }; + } catch { + return null; + } + } + + async function enactApprovedChanges(expiringTerm) { + if (!expiringTerm) return; + const termId = expiringTerm.id || expiringTerm.startAt; + + const proposals = await listByType('parliamentProposal'); + const revocations = await listByType('parliamentRevocation'); + + const approvedProps = proposals.filter(p => p.termId === termId && p.status === 'APPROVED'); + for (const p of approvedProps) { + const snap = await voteSnapshot(p.voteId); + const votesFinal = + (p.votes && Object.keys(p.votes).length ? p.votes : null) || + snap || + { YES: 1, NO: 0, ABSTENTION: 0, total: 1 }; + + const totalFinal = Number(votesFinal.total ?? Object.entries(votesFinal).reduce((s, [k, n]) => s + (k === 'total' ? 0 : Number(n || 0)), 0)); + const yesFinal = Number(votesFinal.YES ?? votesFinal.Yes ?? votesFinal.yes ?? 0); + if (VOTE_METHODS.has(String(p.method || '').toUpperCase()) && !(await passesThreshold(p.method, totalFinal, yesFinal))) { + const rej = { ...stripId(p), replaces: p.id, status: 'REJECTED', updatedAt: nowISO() }; + await publishMsg(rej); + continue; + } + + const law = { + type: 'parliamentLaw', + question: p.title, + description: p.description || '', + method: p.method, + proposer: p.proposer, + termId: p.termId, + voteId: p.voteId || null, + votes: votesFinal, + proposedAt: p.createdAt, + proposalId: p.id, + enactedAt: nowISO() + }; + + await publishMsg(law); + const updated = { ...stripId(p), replaces: p.id, status: 'ENACTED', updatedAt: nowISO() }; + await publishMsg(updated); + } + + const approvedRevs = revocations.filter(r => r.termId === termId && r.status === 'APPROVED'); + for (const r of approvedRevs) { + const tomb = { type: 'tombstone', target: r.lawId, deletedAt: nowISO(), author: userId }; + await publishMsg(tomb); + + const snap = await voteSnapshot(r.voteId); + const updated = { + ...stripId(r), + replaces: r.id, + status: 'ENACTED', + votes: (r.votes && Object.keys(r.votes).length ? r.votes : null) || snap || undefined, + updatedAt: nowISO() + }; + await publishMsg(updated); + } + } + + async function resolveElectionImpl() { + const now = moment(); + const latestAny = await getLatestTermAny(); + if (latestAny && !isExpiredTerm(latestAny)) return latestAny; + + if (latestAny && isExpiredTerm(latestAny)) { + try { await enactApprovedChanges(latestAny); } catch (e) { console.error('enactApprovedChanges failed:', e); } + } + + const open = await listCandidaturesOpen(); + let chosen = null; + let totalVotes = 0; + let winnerVotes = 0; + + if (open.length) { + const pick = await chooseWinnerFromCandidaturesAsync(open); + chosen = pick && pick.chosen; + totalVotes = (pick && pick.totalVotes) || 0; + winnerVotes = (pick && pick.winnerVotes) || 0; + const quorum = await proposalQuorum(); + if (winnerVotes < quorum) { + chosen = null; + totalVotes = 0; + winnerVotes = 0; + } + } + + const startAt = now.toISOString(); + const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString(); + + if (!chosen) { + const termAnarchy = { + type: 'parliamentTerm', + method: 'ANARCHY', + powerType: 'none', + powerId: null, + powerTitle: 'ANARCHY', + winnerTribeId: null, + winnerInhabitantId: null, + winnerVotes: 0, + totalVotes: 0, + startAt, + endAt, + createdBy: userId, + createdAt: nowISO() + }; + + const resAnarchy = await publishMsg(termAnarchy); + try { + await sleep(250); + const canonical = await getCurrentTermBase(); + const myId = resAnarchy.key || resAnarchy.id; + if (canonical && canonical.id && myId && canonical.id !== myId) { + const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId }; + await publishMsg(tomb); + await archiveAllCandidatures(); + return canonical; + } + } catch {} + await archiveAllCandidatures(); + return resAnarchy; + } + + const term = { + type: 'parliamentTerm', + method: chosen.method, + powerType: chosen.targetType, + powerId: chosen.targetId, + powerTitle: chosen.targetTitle, + winnerTribeId: chosen.targetType === 'tribe' ? chosen.targetId : null, + winnerInhabitantId: chosen.targetType === 'inhabitant' ? chosen.targetId : null, + winnerVotes, + totalVotes, + startAt, + endAt, + createdBy: userId, + createdAt: nowISO() + }; + + const res = await publishMsg(term); + try { + await sleep(250); + const canonical = await getCurrentTermBase(); + const myId = res.key || res.id; + if (canonical && canonical.id && myId && canonical.id !== myId) { + const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId }; + await publishMsg(tomb); + await archiveAllCandidatures(); + return canonical; + } + } catch {} + await archiveAllCandidatures(); + return res; + } + + async function resolveElection() { + if (electionInFlight) return electionInFlight; + electionInFlight = resolveElectionImpl().finally(() => { electionInFlight = null; }); + return electionInFlight; + } + + async function getGovernmentCard() { + const term = await getCurrentTermBase(); + if (!term) return null; + return await computeGovernmentCard({ ...term, id: term.id || term.startAt }); + } + + async function listLaws() { + const items = await listByType('parliamentLaw'); + return items.sort((a, b) => new Date(b.enactedAt) - new Date(a.enactedAt)); + } + + async function listHistorical() { + const list = await listTermsBase('expired'); + const out = []; + for (const t of list) { + const card = await computeGovernmentCard({ ...t, id: t.id || t.startAt }); + if (card) out.push(card); + } + return out; + } + + async function canPropose() { + const term = await getCurrentTermBase(); + if (!term) return true; + if (String(term.method || '').toUpperCase() === 'ANARCHY') return true; + if (term.powerType === 'inhabitant') return term.powerId === userId; + if (term.powerType === 'tribe') { + let tribe = null; + if (services.tribes) { + try { tribe = await services.tribes.getTribeById(term.powerId); } catch {} + } + const members = ensureArray(tribe?.members); + return members.includes(userId); + } + return false; + } + + const tribeReadLog = async () => { + const client = await openSsb(); + return new Promise((resolve, reject) => { + pull( + client.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs || [])) + ); + }); + }; + + const tribeListByType = async (type, tribeId) => { + let chainIds; + try { + chainIds = services.tribes && services.tribes.getChainIds + ? await services.tribes.getChainIds(tribeId) + : [tribeId]; + } catch (_) { chainIds = [tribeId]; } + const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]); + const msgs = await tribeReadLog(); + const tomb = buildValidatedTombstoneSet(msgs); + const replaced = new Set(); + const items = new Map(); + for (const m of msgs) { + const c = m.value?.content; if (!c) continue; + if (c.type === 'tombstone') continue; + if (c.type !== type) continue; + if (!tribeIdSet.has(c.tribeId)) continue; + if (c.replaces) replaced.add(c.replaces); + items.set(m.key, { ...c, id: m.key, _ts: m.value?.timestamp || 0 }); + } + return [...items.values()].filter(it => !tomb.has(it.id) && !replaced.has(it.id)); + }; + + const tribeGetCurrentTerm = async (tribeId) => { + const terms = await tribeListByType('tribeParliamentTerm', tribeId); + if (terms.length === 0) return null; + const now = moment(); + const active = terms.find(t => moment(t.startAt).isSameOrBefore(now) && moment(t.endAt).isAfter(now)); + if (active) return active; + terms.sort((a, b) => String(b.startAt).localeCompare(String(a.startAt))); + return terms[0] || null; + }; + + const tribeElectionInFlight = new Map(); + + async function tribePublishInitialTerm(tribeId) { + if (!tribeId) throw new Error('Missing tribeId'); + await openSsb(); + const existing = await tribeListByType('tribeParliamentTerm', tribeId); + if (existing.length > 0) return existing[0]; + const startAt = moment().toISOString(); + const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString(); + const term = { + type: 'tribeParliamentTerm', + tribeId, + method: 'ANARCHY', + leaderId: null, + winnerVotes: 0, + totalVotes: 0, + startAt, + endAt, + createdBy: userId, + createdAt: nowISO() + }; + return await publishMsg(term); + } + + async function tribeElectionQuorum(tribeId) { + let n = 0; + try { + if (services.tribes && services.tribes.getTribeById) { + const t = await services.tribes.getTribeById(tribeId); + n = Array.isArray(t && t.members) ? t.members.length : 0; + } + } catch {} + return Math.max(2, Math.ceil(n * 0.25)); + } + + async function tribeResolveElectionImpl(tribeId) { + const latest = await tribeGetCurrentTerm(tribeId); + if (latest && !isExpiredTerm(latest)) return latest; + const opens = (await tribeListCandidatures(tribeId)) + .filter(c => (c.status || 'OPEN') === 'OPEN') + .map(c => { + const voters = ensureArray(c.voters).filter(v => String(v) !== String(c.candidateId)); + return { ...c, voters, votes: voters.length }; + }); + let chosen = null, totalVotes = 0, winnerVotes = 0; + if (opens.length) { + opens.sort((a, b) => Number(b.votes || 0) - Number(a.votes || 0) || new Date(a.createdAt) - new Date(b.createdAt)); + chosen = opens[0]; + totalVotes = opens.reduce((s, c) => s + Number(c.votes || 0), 0); + winnerVotes = Number(chosen.votes || 0); + const quorum = await tribeElectionQuorum(tribeId); + if (winnerVotes < quorum) chosen = null; + } + const startAt = moment().toISOString(); + const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString(); + const term = { + type: 'tribeParliamentTerm', + tribeId, + method: chosen ? String(chosen.method || 'DEMOCRACY').toUpperCase() : 'ANARCHY', + leaderId: chosen ? chosen.candidateId : null, + winnerVotes, + totalVotes, + startAt, + endAt, + createdBy: userId, + createdAt: nowISO() + }; + const res = await publishMsg(term); + for (const c of opens) { + try { await publishMsg({ type: 'tombstone', target: c.id, deletedAt: nowISO(), author: userId }); } catch {} + } + return res; + } + + async function tribeResolveElection(tribeId) { + if (tribeElectionInFlight.has(tribeId)) return tribeElectionInFlight.get(tribeId); + const p = tribeResolveElectionImpl(tribeId).catch(e => { console.error('tribeResolveElection failed:', e); return null; }).finally(() => tribeElectionInFlight.delete(tribeId)); + tribeElectionInFlight.set(tribeId, p); + return p; + } + + async function tribeEnsureTerm(tribeId) { + const cur = await tribeGetCurrentTerm(tribeId); + if (cur && !isExpiredTerm(cur)) return cur; + if (!cur) return await tribePublishInitialTerm(tribeId); + return await tribeResolveElection(tribeId); + } + + const tribeListCandidatures = (tribeId) => tribeListByType('tribeParliamentCandidature', tribeId); + const tribeListRules = (tribeId) => tribeListByType('tribeParliamentRule', tribeId); + + const tribePublishCandidature = async ({ tribeId, candidateId, method }) => { + const m = String(method || '').toUpperCase(); + if (!METHODS.includes(m)) throw new Error('Invalid method'); + if (!tribeId) throw new Error('Missing tribeId'); + if (!candidateId) throw new Error('Missing candidateId'); + const term = await tribeGetCurrentTerm(tribeId); + const since = term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString(); + const existing = await tribeListCandidatures(tribeId); + const dupe = existing.find(c => c.candidateId === candidateId && new Date(c.createdAt) >= new Date(since) && (c.status || 'OPEN') === 'OPEN'); + if (dupe) throw new Error('Candidate already proposed this cycle'); + const client = await openSsb(); + const content = { + type: 'tribeParliamentCandidature', + tribeId, candidateId, method: m, + votes: 0, voters: [], proposer: client.id, + status: 'OPEN', createdAt: nowISO() + }; + return new Promise((resolve, reject) => client.publish(content, (e, r) => e ? reject(e) : resolve(r))); + }; + + const tribeVoteCandidature = async ({ tribeId, candidatureId }) => { + const client = await openSsb(); + const all = await tribeListCandidatures(tribeId); + const alreadyThisCycle = all.some(c => Array.isArray(c.voters) && c.voters.includes(client.id)); + if (alreadyThisCycle) throw new Error('Already voted this cycle'); + const cand = all.find(c => c.id === candidatureId); + if (!cand) throw new Error('Candidate not found'); + if (String(cand.candidateId) === String(client.id)) throw new Error('You cannot vote for yourself'); + const cleanVoters = ensureArray(cand.voters).filter(v => String(v) !== String(cand.candidateId)); + const updated = { + type: 'tribeParliamentCandidature', + tribeId, replaces: candidatureId, + candidateId: cand.candidateId, method: cand.method, + votes: cleanVoters.length + 1, + voters: [...cleanVoters, client.id], + proposer: cand.proposer, status: cand.status || 'OPEN', + createdAt: cand.createdAt, updatedAt: nowISO() + }; + return new Promise((resolve, reject) => client.publish(updated, (e, r) => e ? reject(e) : resolve(r))); + }; + + const tribePublishRule = async ({ tribeId, title, body }) => { + if (!title || !title.trim()) throw new Error('Title required'); + const client = await openSsb(); + const content = { + type: 'tribeParliamentRule', tribeId, + title: String(title).trim(), body: String(body || '').trim(), + author: client.id, createdAt: nowISO() + }; + return new Promise((resolve, reject) => client.publish(content, (e, r) => e ? reject(e) : resolve(r))); + }; + + const tribeDeleteRule = async (ruleId) => { + const client = await openSsb(); + return new Promise((resolve, reject) => client.publish({ type: 'tombstone', target: ruleId, deletedAt: nowISO(), author: client.id }, (e, r) => e ? reject(e) : resolve(r))); + }; + + const tribeHasCandidatureInGlobalCycle = async (tribeId, globalTermStart) => { + let chainIds; + try { + chainIds = services.tribes && services.tribes.getChainIds + ? await services.tribes.getChainIds(tribeId) + : [tribeId]; + } catch (_) { chainIds = [tribeId]; } + const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]); + const msgs = await tribeReadLog(); + const cutoff = globalTermStart ? new Date(globalTermStart) : new Date(Date.now() - TERM_DAYS * 86400000); + return msgs.some(m => { + const c = m.value?.content; if (!c) return false; + return c.type === 'parliamentCandidature' && c.targetType === 'tribe' && tribeIdSet.has(c.targetId) && (c.status || 'OPEN') === 'OPEN' && new Date(c.createdAt) >= cutoff; + }); + }; + + return { + proposeCandidature, + voteCandidature, + resolveElection, + getGovernmentCard, + listLaws, + listHistorical, + canPropose, + listProposalsCurrent, + deriveProposalStatus, + listFutureLawsCurrent, + createProposal, + closeProposal, + listCandidatures, + listTerms, + getCurrentTerm, + listLeaders, + sweepProposals, + getActorMeta, + createRevocation, + listRevocationsCurrent, + listFutureRevocationsCurrent, + closeRevocation, + countRevocationsEnacted, + tribe: { + METHODS, + TERM_DAYS, + getCurrentTerm: tribeGetCurrentTerm, + listCandidatures: tribeListCandidatures, + listRules: tribeListRules, + publishTribeCandidature: tribePublishCandidature, + voteTribeCandidature: tribeVoteCandidature, + publishTribeRule: tribePublishRule, + deleteTribeRule: tribeDeleteRule, + hasCandidatureInGlobalCycle: tribeHasCandidatureInGlobalCycle, + publishInitialTerm: tribePublishInitialTerm, + resolveElection: tribeResolveElection, + ensureTerm: tribeEnsureTerm + } + }; +}; + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +function collapseOverlappingTerms(terms = []) { + if (!terms.length) return []; + const sorted = [...terms].sort((a, b) => new Date(a.startAt) - new Date(b.startAt)); + const groups = []; + for (const t of sorted) { + const tStart = new Date(t.startAt).getTime(); + const tEnd = new Date(t.endAt).getTime(); + let placed = false; + for (const g of groups) { + if (tStart < g.maxEnd && tEnd > g.minStart) { + g.items.push(t); + if (tStart < g.minStart) g.minStart = tStart; + if (tEnd > g.maxEnd) g.maxEnd = tEnd; + placed = true; + break; + } + } + if (!placed) groups.push({ items: [t], minStart: tStart, maxEnd: tEnd }); + } + const winners = groups.map(g => { + g.items.sort((a, b) => { + const aAn = String(a.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0; + const bAn = String(b.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0; + if (aAn !== bAn) return aAn - bAn; + const aC = new Date(a.createdAt || a.startAt).getTime(); + const bC = new Date(b.createdAt || b.startAt).getTime(); + if (aC !== bC) return aC - bC; + const aS = new Date(a.startAt).getTime(); + const bS = new Date(b.startAt).getTime(); + if (aS !== bS) return aS - bS; + return String(a.id || '').localeCompare(String(b.id || '')); + }); + return g.items[0]; + }); + return winners.sort((a, b) => new Date(b.startAt) - new Date(a.startAt)); +} + diff --git a/src/models/pixelia_model.js b/src/models/pixelia_model.js new file mode 100644 index 0000000..1b14854 --- /dev/null +++ b/src/models/pixelia_model.js @@ -0,0 +1,144 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const getPixelByCoordinate = async (coordinateKey) => { + const ssbClient = await openSsb(); + const messages = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + + const tombstoned = buildValidatedTombstoneSet(messages); + + const replaces = new Map(); + const byId = new Map(); + + for (const m of messages) { + const c = m.value?.content; + const k = m.key; + if (!c || c.type !== 'pixelia' || c.coordinateKey !== coordinateKey) continue; + if (tombstoned.has(k)) continue; + if (c.replaces) replaces.set(c.replaces, k); + byId.set(k, m); + } + + for (const [replacedId, replacingId] of replaces.entries()) { + const orig = byId.get(replacedId); + if (!orig) continue; + const rep = byId.get(replacingId); + if (!rep || rep.value.author !== orig.value.author) { + byId.delete(replacingId); + continue; + } + byId.delete(replacedId); + } + + return [...byId.values()][0] || null; + }; + + const paintPixel = async (x, y, color) => { + if (x < 1 || x > 50 || y < 1 || y > 200) { + throw new Error('Coordinates out of bounds. Please use x (1-50) and y (1-200)'); + } + + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const coordinateKey = `${x}:${y}`; + const existingPixel = await getPixelByCoordinate(coordinateKey); + + if (existingPixel) { + const tombstone = { + type: 'tombstone', + target: existingPixel.key, + deletedAt: new Date().toISOString() + }; + await new Promise((resolve, reject) => + ssbClient.publish(tombstone, err => err ? reject(err) : resolve()) + ); + } + + const contributors = existingPixel?.value?.content?.contributors_inhabitants || []; + const contributors_inhabitants = contributors.includes(userId) + ? contributors + : [...contributors, userId]; + + const content = { + type: 'pixelia', + x: x - 1, + y: y - 1, + color, + author: userId, + contributors_inhabitants, + timestamp: Date.now(), + coordinateKey, + replaces: existingPixel?.key || null + }; + + await new Promise((resolve, reject) => { + ssbClient.publish(content, (err) => err ? reject(err) : resolve()); + }); + }; + + const listPixels = async () => { + const ssbClient = await openSsb(); + const messages = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + + const tombstoned = buildValidatedTombstoneSet(messages); + const replaces = new Map(); + const byKey = new Map(); + + for (const m of messages) { + const c = m.value?.content; + const k = m.key; + if (!c) continue; + if (c.type === 'tombstone') continue; + if (c.type === 'pixelia') { + if (tombstoned.has(k)) continue; + if (c.replaces) replaces.set(c.replaces, k); + byKey.set(k, m); + } + } + + for (const [replacedId, replacingId] of replaces.entries()) { + const orig = byKey.get(replacedId); + if (!orig) continue; + const rep = byKey.get(replacingId); + if (!rep || rep.value.author !== orig.value.author) { + byKey.delete(replacingId); + continue; + } + byKey.delete(replacedId); + } + + return Array.from(byKey.values()).map(m => ({ + x: m.value.content.x + 1, + y: m.value.content.y + 1, + color: m.value.content.color, + author: m.value.author, + contributors_inhabitants: m.value.content.contributors_inhabitants || [], + timestamp: m.value.timestamp + })); + }; + + return { + paintPixel, + listPixels + }; +}; diff --git a/src/models/pm_model.js b/src/models/pm_model.js new file mode 100644 index 0000000..03be1d3 --- /dev/null +++ b/src/models/pm_model.js @@ -0,0 +1,147 @@ +const pull = require('../server/node_modules/pull-stream'); +const util = require('../server/node_modules/util'); + +module.exports = ({ cooler }) => { + let ssb; + let userId; + const openSsb = async () => { + if (!ssb) { + ssb = await cooler.open(); + userId = ssb.id; + } + return ssb; + }; + + function uniqueRecps(list) { + const out = []; + const seen = new Set(); + for (const x of (list || [])) { + if (typeof x !== 'string') continue; + const id = x.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; + } + + return { + type: 'post', + + async sendMessage(recipients = [], subject = '', text = '', crypter = false) { + const ssbClient = await openSsb(); + const recps = uniqueRecps([userId, ...recipients]); + const content = { + type: 'post', + from: userId, + to: recps, + subject, + text, + sentAt: new Date().toISOString(), + private: true, + ...(crypter ? { crypter: true } : {}) + }; + const publishAsync = util.promisify(ssbClient.private.publish); + return publishAsync(content, recps); + }, + + async deleteMessageById(messageId) { + const ssbClient = await openSsb(); + const rawMsg = await new Promise((resolve, reject) => + ssbClient.get(messageId, (err, m) => + err ? reject(new Error("Error retrieving message.")) : resolve(m) + ) + ); + let decrypted; + try { + decrypted = ssbClient.private.unbox({ + key: messageId, + value: rawMsg, + timestamp: rawMsg?.timestamp || Date.now() + }); + } catch { + throw new Error("Malformed message."); + } + const content = decrypted?.value?.content; + const author = decrypted?.value?.author; + const originalRecps = Array.isArray(content?.to) ? content.to : []; + if (!content || !author) throw new Error("Malformed message."); + const isAuthor = author === userId; + const isRecipient = originalRecps.includes(userId); + if (!isAuthor && !isRecipient) throw new Error("Not authorized."); + if (content.type === 'tombstone') throw new Error("Message already deleted."); + const tombstone = { + type: 'tombstone', + target: messageId, + deletedAt: new Date().toISOString(), + private: true + }; + const tombstoneRecps = isAuthor + ? uniqueRecps([userId, author, ...originalRecps]) + : uniqueRecps([userId]); + const publishAsync = util.promisify(ssbClient.private.publish); + return publishAsync(tombstone, tombstoneRecps); + }, + + async listAllPrivate() { + const ssbClient = await openSsb(); + const raw = await new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ reverse: false }), + pull.collect((err, arr) => err ? reject(err) : resolve(arr)) + ); + }); + const posts = []; + const tombed = new Set(); + const tombClaims = new Map(); + const authorByKey = new Map(); + const recpsByKey = new Map(); + for (const m of raw) { + if (!m || !m.value) continue; + const keyIn = m.key || m.value?.key || m.value?.hash || ''; + const valueIn = m.value || m; + const tsIn = m.timestamp || m.value?.timestamp || Date.now(); + let dec; + try { + dec = ssbClient.private.unbox({ key: keyIn, value: valueIn, timestamp: tsIn }); + } catch { + continue; + } + const v = dec?.value || {}; + const c = v.content || {}; + const k = dec?.key || keyIn; + if (!c || c.private !== true || !k) continue; + if (c.type === 'tombstone' && c.target) { + const set = tombClaims.get(c.target) || new Set(); + set.add(v.author); + tombClaims.set(c.target, set); + continue; + } + authorByKey.set(k, v.author); + if (c.type === 'post') { + const to = Array.isArray(c.to) ? c.to : []; + recpsByKey.set(k, to); + const author = v.author; + if (author === userId || to.includes(userId)) { + posts.push({ + key: k, + value: { author, content: c }, + timestamp: v.timestamp || tsIn + }); + } + } + } + for (const [target, tombAuthors] of tombClaims.entries()) { + const origAuthor = authorByKey.get(target); + const origRecps = recpsByKey.get(target) || []; + for (const tombAuthor of tombAuthors) { + if (tombAuthor === origAuthor || tombAuthor === userId || origRecps.includes(tombAuthor)) { + tombed.add(target); + break; + } + } + } + return posts.filter(m => m && m.key && !tombed.has(m.key)); + } + }; +}; diff --git a/src/models/projects_model.js b/src/models/projects_model.js new file mode 100644 index 0000000..05ecf90 --- /dev/null +++ b/src/models/projects_model.js @@ -0,0 +1,601 @@ +const pull = require("../server/node_modules/pull-stream") +const moment = require("../server/node_modules/moment") +const { getConfig } = require("../configs/config-manager.js") +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe') +const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000 + +module.exports = ({ cooler }) => { + let ssb + const openSsb = async () => { + if (!ssb) ssb = await cooler.open() + return ssb + } + + const TYPE = "project" + + const clampPercent = (n) => { + const x = parseInt(n, 10) + if (!Number.isFinite(x)) return 0 + return Math.max(0, Math.min(100, x)) + } + + async function getAllMsgs(ssbClient) { + return new Promise((r, j) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((e, m) => (e ? j(e) : r(m))) + ) + }) + } + + function extractBlobId(possibleMarkdownImage) { + return possibleMarkdownImage || null + } + + function normalizeMilestonesFrom(data) { + if (Array.isArray(data.milestones)) { + return data.milestones + .map((m) => { + return { + title: String((m && m.title) || "").trim(), + description: (m && m.description) || "", + targetPercent: clampPercent(m && m.targetPercent), + dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null, + done: !!(m && m.done) + } + }) + .filter((m) => m.title) + } + + const title = String((data["milestones[0][title]"] || data.milestoneTitle || "")).trim() + const description = data["milestones[0][description]"] || data.milestoneDescription || "" + const tpRaw = (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent) != null + ? (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent) + : 0 + const targetPercent = clampPercent(tpRaw) + const dueRaw = data["milestones[0][dueDate]"] || data.milestoneDueDate || null + const dueDate = dueRaw ? new Date(dueRaw).toISOString() : null + const out = [] + if (title) out.push({ title, description, targetPercent, dueDate, done: false }) + return out + } + + function safeMilestoneIndex(project, idx) { + const total = Array.isArray(project.milestones) ? project.milestones.length : 0 + if (idx === null || idx === undefined || idx === "" || isNaN(idx)) return null + const n = parseInt(idx, 10) + if (!Number.isFinite(n)) return null + if (n < 0 || n >= total) return null + return n + } + + function autoCompleteMilestoneIfReady(projectLike, milestoneIdx) { + if (milestoneIdx === null || milestoneIdx === undefined) { + return { milestones: projectLike.milestones || [], progress: projectLike.progress || 0, changed: false } + } + const milestones = Array.isArray(projectLike.milestones) ? projectLike.milestones.slice() : [] + if (!milestones[milestoneIdx]) { + return { milestones, progress: projectLike.progress || 0, changed: false } + } + const bounties = Array.isArray(projectLike.bounties) ? projectLike.bounties : [] + const related = bounties.filter((b) => b && b.milestoneIndex === milestoneIdx) + if (related.length === 0) { + return { milestones, progress: projectLike.progress || 0, changed: false } + } + const allDone = related.every((b) => !!(b && b.done)) + let progress = projectLike.progress || 0 + let changed = false + if (allDone && !milestones[milestoneIdx].done) { + milestones[milestoneIdx].done = true + const target = clampPercent(milestones[milestoneIdx].targetPercent || 0) + const pInt = parseInt(progress, 10) + progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target) + changed = true + } + return { milestones, progress, changed } + } + + const COLLAB_MSG = new Set(["projectOpinion", "projectFollow", "projectPledge", "projectClaim", "projectPledgeConfirm"]) + + function buildProjectIndex(messages) { + const tomb = buildValidatedTombstoneSet(messages) + const nodes = new Map() + const collab = { opinion: [], follow: [], pledge: [], claim: [], pledgeConfirm: [] } + for (const m of messages) { + const c = m && m.value && m.value.content + if (!c) continue + if (c.type === "tombstone") continue + if (c.type === "projectOpinion") { collab.opinion.push({ target: c.target, author: m.value.author, category: c.category, ts: (m.value && m.value.timestamp) || 0 }); continue } + if (c.type === "projectFollow") { collab.follow.push({ target: c.target, author: m.value.author, on: c.on !== false, ts: (m.value && m.value.timestamp) || 0 }); continue } + if (c.type === "projectPledge") { collab.pledge.push({ target: c.target, author: m.value.author, amount: Math.max(0, parseFloat(c.amount || 0) || 0), transferId: c.transferId || null, milestoneIndex: c.milestoneIndex != null ? c.milestoneIndex : null, bountyIndex: c.bountyIndex != null ? c.bountyIndex : null, ts: (m.value && m.value.timestamp) || 0 }); continue } + if (c.type === "projectPledgeConfirm") { collab.pledgeConfirm.push({ target: c.target, author: m.value.author, transferId: c.transferId || null, ts: (m.value && m.value.timestamp) || 0 }); continue } + if (c.type === "projectClaim") { collab.claim.push({ target: c.target, author: m.value.author, index: parseInt(c.index, 10), ts: (m.value && m.value.timestamp) || 0 }); continue } + if (c.type !== TYPE) continue + nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || 0, c, author: m.value.author }) + } + const naiveNext = new Map(), naivePrev = new Map(), strictNext = new Map() + for (const [key, n] of nodes) { + const t = n.c.replaces + if (!t) continue + naiveNext.set(t, key); naivePrev.set(key, t) + const orig = nodes.get(t) + if (orig && orig.author === n.author) strictNext.set(t, key) + } + return { tomb, nodes, collab, naiveNext, naivePrev, strictNext } + } + + function resolveProjectGroup(idx, root) { + const { nodes, collab, naiveNext, strictNext } = idx + const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x } + const rootNode = nodes.get(root) + const rootAuthor = rootNode ? rootNode.author : null + let tip = followStrict(root) + const tn = nodes.get(tip) + if (!tn || tn.author !== rootAuthor) tip = root + const best = nodes.get(tip) + if (!best) return null + + const groupKeys = [] + { let x = root, g = 0; groupKeys.push(x); while (naiveNext.has(x) && g++ < 100000) { x = naiveNext.get(x); groupKeys.push(x) } } + + const project = { ...best.c, id: tip } + + const followers = new Set() + const opinions = {} + const opinionSet = new Set() + let backers = [] + const claimByIndex = new Map() + for (const k of groupKeys) { + const n = nodes.get(k); if (!n) continue + if (n.author !== rootAuthor) continue + const c = n.c + for (const f of (Array.isArray(c.followers) ? c.followers : [])) followers.add(f) + if (Array.isArray(c.backers) && c.backers.length > backers.length) backers = c.backers.slice() + for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) { + if (!opinionSet.has(v)) opinionSet.add(v) + } + if (c.opinions && typeof c.opinions === "object") { for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0) } + if (Array.isArray(c.bounties)) c.bounties.forEach((b, i) => { if (b && b.claimedBy && !claimByIndex.has(i)) claimByIndex.set(i, b.claimedBy) }) + } + + for (const f of collab.follow.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { if (f.on) followers.add(f.author); else followers.delete(f.author) } + for (const op of collab.opinion.filter(x => x.target === root)) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } } + const confirmedTransfers = new Set(collab.pledgeConfirm.filter(x => x.target === root && x.author === rootAuthor && x.transferId).map(x => String(x.transferId))) + const pledgeMsgs = collab.pledge.filter(x => x.target === root).sort((a, b) => a.ts - b.ts) + for (const p of pledgeMsgs) backers.push({ userId: p.author, amount: p.amount, at: new Date(p.ts || 0).toISOString(), transferId: p.transferId || null, milestoneIndex: p.milestoneIndex, bountyIndex: p.bountyIndex, confirmed: !!(p.transferId && confirmedTransfers.has(String(p.transferId))) }) + for (const cl of collab.claim.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { if (Number.isInteger(cl.index) && !claimByIndex.has(cl.index)) claimByIndex.set(cl.index, cl.author) } + + project.followers = [...followers] + project.opinions = opinions + project.opinions_inhabitants = [...opinionSet] + project.backers = backers + const pledged = backers.reduce((s, b) => s + (parseFloat(b && b.amount || 0) || 0), 0) + project.pledged = pledged + if (Array.isArray(project.bounties)) project.bounties = project.bounties.map((b, i) => ({ ...b, claimedBy: claimByIndex.has(i) ? claimByIndex.get(i) : (b && b.claimedBy) || null })) + const goalNum = parseFloat(project.goal || 0) || 0 + if (goalNum > 0) project.progress = Math.max(clampPercent(project.progress), Math.min(100, Math.round((pledged / goalNum) * 100))) + + return { tip, root, best, project, tombstoned: idx.tomb.has(tip) } + } + + const rootOfIdx = (idx, key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x } + + async function resolveTipId(id) { + const ssbClient = await openSsb() + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Project not found") + return g.tip + } + + async function getById(id) { + const ssbClient = await openSsb() + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Project not found") + return g.project + } + + const CONTENT_COLLAB_FIELDS = ["followers", "backers", "opinions", "opinions_inhabitants", "pledged", "activity"] + function stripCollab(content) { + const out = { ...content } + delete out.id + for (const f of CONTENT_COLLAB_FIELDS) delete out[f] + out.followers = [] + out.backers = [] + out.opinions = {} + out.opinions_inhabitants = [] + out.pledged = 0 + if (Array.isArray(out.bounties)) out.bounties = out.bounties.map((b) => ({ ...b, claimedBy: null })) + return out + } + + async function publishReplace(ssbClient, currentId, content) { + const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id } + const updated = { ...stripCollab(content), type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() } + await new Promise((res, rej) => ssbClient.publish(tomb, (e) => (e ? rej(e) : res()))) + return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => (e ? rej(e) : res(m)))) + } + + function isParticipant(project, uid) { + if (!project || !uid) return false + const backers = Array.isArray(project.backers) ? project.backers : [] + if (backers.some((b) => b && b.userId === uid)) return true + const bounties = Array.isArray(project.bounties) ? project.bounties : [] + if (bounties.some((b) => b && b.claimedBy === uid)) return true + return false + } + + return { + type: TYPE, + + async createProject(data) { + const ssbClient = await openSsb() + const blobId = extractBlobId(data.image) + const milestones = normalizeMilestonesFrom(data) + + let goal = parseFloat(data.goal || 0) || 0 + if (goal < 0) goal = 0 + + const deadlineISO = data.deadline ? new Date(data.deadline).toISOString() : null + + const content = { + type: TYPE, + title: data.title, + description: data.description, + image: blobId || null, + goal, + pledged: parseFloat(data.pledged || 0) || 0, + deadline: deadlineISO, + progress: clampPercent(data.progress || 0), + status: String(data.status || "ACTIVE").toUpperCase(), + milestones, + bounties: Array.isArray(data.bounties) + ? data.bounties + .map((b) => { + return { + title: String((b && b.title) || "").trim(), + amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0), + description: (b && b.description) || "", + claimedBy: (b && b.claimedBy) || null, + done: !!(b && b.done), + milestoneIndex: b && b.milestoneIndex != null ? parseInt(b.milestoneIndex, 10) : null + } + }) + .filter((b) => b.title) + : [], + followers: [], + backers: [], + author: ssbClient.id, + createdAt: new Date().toISOString(), + updatedAt: null, + mapUrl: String(data.mapUrl || "").trim(), + clearnetPublic: data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on', + opinions: {}, + opinions_inhabitants: [] + } + + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + }, + + async updateProject(id, patch) { + const ssbClient = await openSsb() + const current = await getById(id) + if (current.author !== ssbClient.id) throw new Error("Unauthorized") + + let blobId = patch.image === undefined ? current.image : patch.image + blobId = extractBlobId(blobId) + + let milestones = patch.milestones === undefined ? current.milestones : patch.milestones + if (milestones != null) { + milestones = Array.isArray(milestones) + ? milestones + .map((m) => { + return { + title: String((m && m.title) || "").trim(), + description: (m && m.description) || "", + targetPercent: clampPercent(m && m.targetPercent), + dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null, + done: !!(m && m.done) + } + }) + .filter((m) => m.title) + : current.milestones + } + + let bounties = patch.bounties === undefined ? current.bounties : patch.bounties + if (bounties != null) { + bounties = Array.isArray(bounties) + ? bounties + .map((b) => { + return { + title: String((b && b.title) || "").trim(), + amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0), + description: (b && b.description) || "", + claimedBy: (b && b.claimedBy) || null, + done: !!(b && b.done), + milestoneIndex: b && b.milestoneIndex != null ? safeMilestoneIndex({ milestones: milestones || current.milestones }, b.milestoneIndex) : null + } + }) + .filter((b) => b.title) + : current.bounties + } + + let deadline = patch.deadline === undefined ? current.deadline : patch.deadline + if (deadline != null && deadline !== "") deadline = new Date(deadline).toISOString() + else if (deadline === "") deadline = null + + const updated = { + ...current, + ...patch, + image: blobId || null, + milestones, + bounties, + deadline, + progress: patch.progress === undefined ? current.progress : clampPercent(patch.progress), + status: patch.status === undefined ? current.status : String(patch.status || "").toUpperCase(), + clearnetPublic: patch.clearnetPublic === undefined ? !!current.clearnetPublic : (patch.clearnetPublic === true || patch.clearnetPublic === 'true' || patch.clearnetPublic === 'on') + } + + return publishReplace(ssbClient, current.id, updated) + }, + + async createOpinion(id, category) { + const categories = require('../backend/opinion_categories') + if (!categories.includes(category)) throw new Error('Invalid opinion category') + const ssbClient = await openSsb() + const userId = ssbClient.id + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error('Project not found') + if ((g.project.opinions_inhabitants || []).includes(userId)) throw new Error('Already opined') + const content = { type: 'projectOpinion', target: g.root, category, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + }, + + async deleteProject(id) { + const ssbClient = await openSsb() + const tip = await resolveTipId(id) + const project = await getById(tip) + if (project.author !== ssbClient.id) throw new Error("Unauthorized") + const tomb = { type: "tombstone", target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id } + return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => (e ? rej(e) : res(r)))) + }, + + async updateProjectStatus(id, status) { + const s = String(status || "").toUpperCase() + return this.updateProject(id, { status: s }) + }, + + async updateProjectProgress(id, progress) { + const p = clampPercent(progress) + return this.updateProject(id, { progress: p, ...(p >= 100 ? { status: "COMPLETED" } : {}) }) + }, + + async followProject(id, uid) { + const ssbClient = await openSsb() + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Project not found") + const content = { type: "projectFollow", target: g.root, on: true, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + }, + + async unfollowProject(id, uid) { + const ssbClient = await openSsb() + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Project not found") + const content = { type: "projectFollow", target: g.root, on: false, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + }, + + async pledgeToProject(id, uid, amount, extra = {}) { + const ssbClient = await openSsb() + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Project not found") + const amt = Math.max(0, parseFloat(amount || 0) || 0) + if (amt <= 0) throw new Error("Invalid amount") + const content = { type: "projectPledge", target: g.root, amount: amt, createdAt: new Date().toISOString() } + if (extra && extra.transferId) content.transferId = extra.transferId + if (extra && extra.milestoneIndex != null) content.milestoneIndex = extra.milestoneIndex + if (extra && extra.bountyIndex != null) content.bountyIndex = extra.bountyIndex + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + }, + + async confirmPledge(id, transferId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Project not found") + if (String(g.project.author) !== String(userId)) throw new Error("Only the project author can confirm pledges") + const content = { type: "projectPledgeConfirm", target: g.root, transferId: String(transferId || ""), createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + }, + + async addBounty(id, bounty) { + const tip = await resolveTipId(id) + const ssbClient = await openSsb() + const project = await getById(tip) + if (project.author !== ssbClient.id) throw new Error("Unauthorized") + const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [] + const clean = { + title: String((bounty && bounty.title) || "").trim(), + amount: Math.max(0, parseFloat((bounty && bounty.amount) || 0) || 0), + description: (bounty && bounty.description) || "", + claimedBy: null, + done: false, + milestoneIndex: safeMilestoneIndex(project, bounty && bounty.milestoneIndex) + } + if (!clean.title) throw new Error("Bounty title required") + bounties.push(clean) + return publishReplace(ssbClient, project.id, { ...project, bounties }) + }, + + async updateBounty(id, index, patch) { + const tip = await resolveTipId(id) + const ssbClient = await openSsb() + const project = await getById(tip) + if (project.author !== ssbClient.id) throw new Error("Unauthorized") + const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [] + if (!bounties[index]) throw new Error("Bounty not found") + + if (patch.title !== undefined) bounties[index].title = String(patch.title || "").trim() + if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0) + if (patch.description !== undefined) bounties[index].description = patch.description || "" + if (patch.milestoneIndex !== undefined) bounties[index].milestoneIndex = safeMilestoneIndex(project, patch.milestoneIndex) + if (patch.done !== undefined) bounties[index].done = !!patch.done + + return publishReplace(ssbClient, project.id, { ...project, bounties }) + }, + + async addMilestone(id, milestone) { + const tip = await resolveTipId(id) + const ssbClient = await openSsb() + const project = await getById(tip) + if (project.author !== ssbClient.id) throw new Error("Unauthorized") + const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : [] + const clean = { + title: String((milestone && milestone.title) || "").trim(), + description: (milestone && milestone.description) || "", + targetPercent: clampPercent(milestone && milestone.targetPercent), + dueDate: milestone && milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null, + done: false + } + if (!clean.title) throw new Error("Milestone title required") + milestones.push(clean) + return publishReplace(ssbClient, project.id, { ...project, milestones }) + }, + + async updateMilestone(id, index, patch) { + const tip = await resolveTipId(id) + const ssbClient = await openSsb() + const project = await getById(tip) + if (project.author !== ssbClient.id) throw new Error("Unauthorized") + const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : [] + if (!milestones[index]) throw new Error("Milestone not found") + + if (patch.title !== undefined) milestones[index].title = String(patch.title || "").trim() + if (patch.description !== undefined) milestones[index].description = patch.description || "" + if (patch.targetPercent !== undefined) milestones[index].targetPercent = clampPercent(patch.targetPercent) + if (patch.dueDate !== undefined) milestones[index].dueDate = patch.dueDate ? new Date(patch.dueDate).toISOString() : null + + let progress = project.progress + if (patch.done !== undefined) { + milestones[index].done = !!patch.done + if (milestones[index].done) { + const target = clampPercent(milestones[index].targetPercent || 0) + const pInt = parseInt(project.progress || 0, 10) + progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target) + } + } + + const updated = { ...project, milestones, ...(progress !== project.progress ? { progress, ...(progress >= 100 ? { status: "COMPLETED" } : {}) } : {}) } + return publishReplace(ssbClient, project.id, updated) + }, + + async claimBounty(id, index, uid) { + const ssbClient = await openSsb() + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + const g = resolveProjectGroup(idx, rootOfIdx(idx, id)) + if (!g || g.tombstoned) throw new Error("Project not found") + const bounties = Array.isArray(g.project.bounties) ? g.project.bounties : [] + if (!bounties[index]) throw new Error("Bounty not found") + if (bounties[index].claimedBy) throw new Error("Already claimed") + if (g.project.author === uid) throw new Error("Authors cannot claim") + const content = { type: "projectClaim", target: g.root, index: parseInt(index, 10), createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m)))) + }, + + async completeBounty(id, index, uid) { + const tip = await resolveTipId(id) + const ssbClient = await openSsb() + const project = await getById(tip) + if (project.author !== uid) throw new Error("Unauthorized") + const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [] + if (!bounties[index]) throw new Error("Bounty not found") + bounties[index].done = true + + const ac = autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex) + const patch = { ...project, bounties } + if (ac && ac.changed) { + patch.milestones = ac.milestones + patch.progress = ac.progress + if (ac.progress >= 100) patch.status = "COMPLETED" + } + + return publishReplace(ssbClient, project.id, patch) + }, + + async completeMilestone(id, index, uid) { + const tip = await resolveTipId(id) + const ssbClient = await openSsb() + const project = await getById(tip) + if (project.author !== uid) throw new Error("Unauthorized") + const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : [] + if (!milestones[index]) throw new Error("Milestone not found") + milestones[index].done = true + const target = clampPercent(milestones[index].targetPercent || 0) + const pInt = parseInt(project.progress || 0, 10) + const progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target) + const patch = { ...project, milestones, progress } + if (progress >= 100) patch.status = "COMPLETED" + return publishReplace(ssbClient, project.id, patch) + }, + + async listProjects(filter) { + const ssbClient = await openSsb() + const currentUserId = ssbClient.id + const idx = buildProjectIndex(await getAllMsgs(ssbClient)) + + const roots = new Set() + for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key)) + + const out = [] + for (const root of roots) { + const g = resolveProjectGroup(idx, root) + if (!g || g.tombstoned) continue + const c = g.project + out.push({ + ...c, + id: g.tip, + status: String(c.status || "ACTIVE").toUpperCase(), + createdAt: c.createdAt || new Date(g.best.ts).toISOString(), + deadline: c.deadline || null + }) + } + + let list = dedupeBy(out, p => p.title ? [norm(p.author), norm(p.title), norm(p.createdAt)].join('|') : null) + const F = String(filter || "ALL").toUpperCase() + + if (F === "MINE") list = list.filter((p) => p && p.author === currentUserId) + else if (F === "APPLIED") list = list.filter((p) => p && p.author !== currentUserId && isParticipant(p, currentUserId)) + else if (F === "ACTIVE") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "ACTIVE") + else if (F === "COMPLETED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "COMPLETED") + else if (F === "PAUSED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "PAUSED") + else if (F === "CANCELLED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "CANCELLED") + else if (F === "RECENT") list = list.filter((p) => p && moment(p.createdAt).isAfter(moment().subtract(24, "hours"))) + else if (F === "FOLLOWING") list = list.filter((p) => Array.isArray(p.followers) && p.followers.includes(currentUserId)) + + if (F === "TOP") { + list.sort((a, b) => (parseFloat(b.pledged || 0) / (parseFloat(b.goal || 1))) - (parseFloat(a.pledged || 0) / (parseFloat(a.goal || 1)))) + } else { + list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + } + + return list + }, + + async getProjectById(id) { + return getById(id) + }, + + async getProjectTipId(id) { + return resolveTipId(id) + } + } +} + diff --git a/src/models/reports_model.js b/src/models/reports_model.js new file mode 100644 index 0000000..8bb1b29 --- /dev/null +++ b/src/models/reports_model.js @@ -0,0 +1,353 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const normU = (v) => String(v || '').trim().toUpperCase(); +const normalizeStatus = (v) => normU(v).replace(/\s+/g, '_').replace(/-+/g, '_'); +const normalizeSeverity = (v) => String(v || '').trim().toLowerCase(); +const ensureArray = (v) => Array.isArray(v) ? v.filter(Boolean) : []; + +const trimStr = (v) => String(v || '').trim(); + +const normalizeTemplate = (category, tpl) => { + const cat = normU(category); + const t = tpl && typeof tpl === 'object' ? tpl : {}; + + const pick = (keys) => { + const out = {}; + for (const k of keys) { + const val = trimStr(t[k]); + if (val) out[k] = val; + } + return out; + }; + + if (cat === 'BUGS') { + const out = pick(['stepsToReproduce', 'expectedBehavior', 'actualBehavior', 'environment', 'reproduceRate']); + if (out.reproduceRate) out.reproduceRate = normU(out.reproduceRate); + return out; + } + + if (cat === 'FEATURES') { + return pick(['problemStatement', 'userStory', 'acceptanceCriteria']); + } + + if (cat === 'ABUSE') { + return pick(['whatHappened', 'reportedUser', 'evidenceLinks']); + } + + if (cat === 'CONTENT') { + return pick(['contentLocation', 'whyInappropriate', 'requestedAction', 'evidenceLinks']); + } + + return {}; +}; + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ); + }); + + const buildIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const confirms = []; + const opinionMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === 'tombstone') continue; + if (c.type === 'reportConfirm') { confirms.push({ target: c.target, author: v.author }); continue; } + if (c.type === 'reportOpinion') { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + if (c.type !== 'report') continue; + + const ts = v.timestamp || m.timestamp || 0; + nodes.set(k, { key: k, ts, c, author: v.author }); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const confirmsByRoot = new Map(), opinionsByRoot = new Map(); + for (const cf of confirms) { if (!nodes.has(cf.target)) continue; const r = rootOf(cf.target); if (!confirmsByRoot.has(r)) confirmsByRoot.set(r, []); confirmsByRoot.get(r).push(cf); } + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + if (!contentNode) return null; + const rootAuthor = nodes.get(root) ? nodes.get(root).author : null; + const lc = contentNode.c || {}; + + const opinions = { ...(lc.opinions || {}) }; + const voterSet = new Set(ensureArray(lc.opinions_inhabitants)); + const confirmSet = new Set(ensureArray(lc.confirmations)); + + const groupKeys = []; { let x = root, g = 0; groupKeys.push(x); while (child.has(x) && g++ < 100000) { x = child.get(x); groupKeys.push(x); } } + for (const k of groupKeys) { + const n = nodes.get(k); if (!n) continue; if (n.author !== rootAuthor) continue; const c = n.c || {}; + for (const cb of ensureArray(c.confirmations)) confirmSet.add(cb); + for (const vv of ensureArray(c.opinions_inhabitants)) voterSet.add(vv); + if (c.opinions && typeof c.opinions === 'object') for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0); + } + + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + for (const cf of (confirmsByRoot.get(root) || [])) confirmSet.add(cf.author); + + return { + contentTip, + contentNode, + opinions, + opinions_inhabitants: [...voterSet], + confirmations: [...confirmSet], + tombstoned: tomb.has(contentTip) + }; + }; + + return { tomb, nodes, parent, child, rootOf, tipByRoot, resolveGroup }; + }; + + const buildReport = (node, agg) => { + const c = node.c || {}; + const cat = normU(c.category); + const opinions = agg ? agg.opinions : (c.opinions || {}); + const voters = agg ? agg.opinions_inhabitants : ensureArray(c.opinions_inhabitants); + const confirmations = agg ? agg.confirmations : ensureArray(c.confirmations); + return { + id: node.key, + ...c, + category: cat, + status: normalizeStatus(c.status || 'OPEN'), + severity: normalizeSeverity(c.severity) || 'low', + confirmations, + tags: ensureArray(c.tags), + template: normalizeTemplate(cat, c.template || {}), + opinions, + opinions_inhabitants: voters + }; + }; + + return { + async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}) { + const ssb = await openSsb(); + const userId = ssb.id; + + let blobId = null; + if (image) { + blobId = String(image).trim() || null; + } + + const tags = Array.isArray(tagsRaw) + ? tagsRaw.filter(Boolean) + : String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean); + + const cat = normU(category); + const content = { + type: 'report', + title, + description, + category: cat, + createdAt: new Date().toISOString(), + author: userId, + image: blobId, + tags, + confirmations: [], + severity: normalizeSeverity(severity) || 'low', + status: 'OPEN', + template: normalizeTemplate(cat, template), + opinions: {}, + opinions_inhabitants: [] + }; + + return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg))); + }, + + async updateReportById(id, updatedContent) { + const ssb = await openSsb(); + const userId = ssb.id; + + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + const root = idx.rootOf(id); + const agg = idx.resolveGroup(root); + if (!agg || agg.tombstoned) throw new Error('Report not found'); + + const report = { content: agg.contentNode.c }; + const tipId = agg.contentTip; + + if (agg.contentNode.author !== userId) throw new Error('Not the author'); + + const tags = Object.prototype.hasOwnProperty.call(updatedContent, 'tags') + ? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean) + : ensureArray(report.content.tags); + + let blobId = report.content.image || null; + if (updatedContent.image) { + blobId = String(updatedContent.image).trim() || null; + } + + const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status') + ? normalizeStatus(updatedContent.status) + : normalizeStatus(report.content.status || 'OPEN'); + + const nextSeverity = Object.prototype.hasOwnProperty.call(updatedContent, 'severity') + ? (normalizeSeverity(updatedContent.severity) || 'low') + : (normalizeSeverity(report.content.severity) || 'low'); + + const nextCategory = Object.prototype.hasOwnProperty.call(updatedContent, 'category') + ? normU(updatedContent.category) + : normU(report.content.category); + + const confirmations = ensureArray(agg.confirmations); + + const baseTemplate = Object.prototype.hasOwnProperty.call(updatedContent, 'template') + ? updatedContent.template + : (report.content.template || {}); + + const nextTemplate = normalizeTemplate(nextCategory, baseTemplate); + + const updated = { + ...report.content, + ...updatedContent, + type: 'report', + replaces: tipId, + image: blobId, + tags, + confirmations, + opinions: agg.opinions, + opinions_inhabitants: agg.opinions_inhabitants, + severity: nextSeverity, + status: nextStatus, + category: nextCategory, + template: nextTemplate, + updatedAt: new Date().toISOString(), + author: report.content.author + }; + + return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result))); + }, + + async deleteReportById(id) { + const ssb = await openSsb(); + const userId = ssb.id; + + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + const root = idx.rootOf(id); + const agg = idx.resolveGroup(root); + if (!agg || agg.tombstoned) throw new Error('Report not found'); + + if (agg.contentNode.author !== userId) throw new Error('Not the author'); + + const tombstone = { type: 'tombstone', target: agg.contentTip, deletedAt: new Date().toISOString(), author: userId }; + return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result))); + }, + + async getReportById(id) { + const ssb = await openSsb(); + + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + const root = idx.rootOf(id); + const agg = idx.resolveGroup(root); + if (!agg || agg.tombstoned) throw new Error('Report not found'); + + return buildReport(agg.contentNode, agg); + }, + + async confirmReportById(id) { + const ssb = await openSsb(); + const userId = ssb.id; + + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + const root = idx.rootOf(id); + const agg = idx.resolveGroup(root); + if (!agg || agg.tombstoned) throw new Error('Report not found'); + + if (agg.contentNode.author === userId) throw new Error('Cannot confirm own report'); + if (ensureArray(agg.confirmations).includes(userId)) throw new Error('Already confirmed'); + + const content = { type: 'reportConfirm', target: root, createdAt: new Date().toISOString() }; + return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result))); + }, + + async createOpinion(id, category) { + const categories = require('../backend/opinion_categories'); + if (!categories.includes(category)) throw new Error('Invalid opinion category'); + const ssb = await openSsb(); + const userId = ssb.id; + + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + const root = idx.rootOf(id); + const agg = idx.resolveGroup(root); + if (!agg || agg.tombstoned) throw new Error('Report not found'); + if (ensureArray(agg.opinions_inhabitants).includes(userId)) throw new Error('Already opined'); + + const content = { type: 'reportOpinion', target: root, category, createdAt: new Date().toISOString() }; + return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result))); + }, + + async listAll() { + const ssb = await openSsb(); + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + + const reports = []; + for (const root of idx.tipByRoot.keys()) { + const agg = idx.resolveGroup(root); + if (!agg || agg.tombstoned) continue; + reports.push(buildReport(agg.contentNode, agg)); + } + + return dedupeBy(reports, x => x.title ? [norm(x.author), norm(x.title), norm(x.category)].join('|') : null); + } + }; +}; diff --git a/src/models/search_model.js b/src/models/search_model.js new file mode 100644 index 0000000..650b736 --- /dev/null +++ b/src/models/search_model.js @@ -0,0 +1,406 @@ +const pull = require('../server/node_modules/pull-stream'); +const moment = require('../server/node_modules/moment'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']); + + const tryDecryptStandalone = (content) => { + if (!tribeCrypto || !content || !content.encryptedPayload) return null; + const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean); + for (const cand of rootCandidates) { + const keys = tribeCrypto.getKeys(cand); + if (!keys || !keys.length) continue; + try { + const dec = tribeCrypto.decryptContent(content, keys.map(k => [k])); + if (dec && !dec._undecryptable) return dec; + } catch (_) {} + } + return null; + }; + + const tryDecryptTribe = async (content) => { + if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null; + try { + const dec = await tribeCrypto.decryptFromTribe(content, tribesModel); + if (dec && !dec._undecryptable) return dec; + } catch (_) {} + return null; + }; + + const searchableTypes = [ + 'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed', + 'votes', 'report', 'task', 'event', 'bookmark', 'document', + 'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim', + 'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad' + ]; + + const getRelevantFields = (type, content) => { + switch (type) { + case 'post': + return [content?.text, content?.contentWarning, ...(content?.tags || [])]; + case 'about': + return [content?.about, content?.name, content?.description]; + case 'feed': + return [content?.text, content?.author, content?.createdAt, ...(content?.tags || []), content?.refeeds]; + case 'event': + return [content?.title, content?.description, content?.date, content?.location, content?.price, content?.eventUrl, ...(content?.tags || []), content?.attendees, content?.organizer, content?.status, content?.isPublic]; + case 'votes': + return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes]; + case 'tribe': + return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author]; + case 'audio': + return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])]; + case 'image': + return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.meme]; + case 'video': + return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])]; + case 'document': + return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.key]; + case 'torrent': + return [content?.title, content?.description, ...(content?.tags || []), content?.url]; + case 'market': + return [content?.item_type, content?.title, content?.description, content?.price, ...(content?.tags || []), content?.status, content?.item_status, content?.deadline, content?.includesShipping, content?.seller, content?.image, content?.auctions_poll, content?.stock]; + case 'bookmark': + return [content?.author, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit]; + case 'task': + return [content?.title, content?.description, content?.startTime, content?.endTime, content?.priority, content?.location, ...(content?.tags || []), content?.isPublic, content?.assignees?.length, content?.status, content?.author]; + case 'report': + return [content?.title, content?.description, content?.category, content?.createdAt, (content?.isAnonymous ? null : content?.author), content?.image, ...(content?.tags || []), content?.confirmations, content?.severity, content?.status]; + case 'transfer': + return [content?.from, content?.to, content?.concept, content?.amount, content?.deadline, content?.status, ...(content?.tags || []), content?.confirmedBy?.length]; + case 'curriculum': + return [content?.author, content?.name, content?.description, content?.photo, ...(content?.personalSkills || []), ...(content?.personalExperiences || []), ...(content?.oasisExperiences || []), ...(content?.oasisSkills || []), ...(content?.educationExperiences || []), ...(content?.educationalSkills || []), ...(content?.languages || []), ...(content?.professionalExperiences || []), ...(content?.professionalSkills || []), content?.location, content?.status, content?.preferences, content?.createdAt]; + case 'bankWallet': + return [content?.address]; + case 'bankClaim': + return [content?.amount, content?.epochId, content?.allocationId, content?.txid]; + case 'project': + return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor]; + case 'job': + return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length]; + case 'forum': + return [content?.root, content?.category, content?.title, content?.text, content?.key]; + case 'vote': + return [content?.vote?.link]; + case 'contact': + return [content?.contact]; + case 'pub': + return [content?.address?.host, content?.address?.key]; + case 'map': + return [content?.title, content?.description, content?.mapType, ...(content?.tags || []), content?.lat, content?.lng]; + case 'shop': + return [content?.title, content?.shortDescription, content?.description, content?.location, ...(content?.tags || []), content?.visibility, content?.url]; + case 'shopProduct': + return [content?.title, content?.description, content?.price, ...(content?.tags || []), content?.shopId]; + case 'chat': + return [content?.title, content?.description, content?.category, ...(content?.tags || []), content?.status, content?.author]; + case 'pad': + return [content?.title, content?.status, content?.deadline, ...(content?.tags || []), content?.author]; + case 'gameScore': + return [content?.game, content?.player]; + default: + return []; + } + }; + + const norm = (v) => String(v == null ? '' : v).trim().toLowerCase(); + + const getDedupeKey = (msg) => { + const c = msg?.value?.content || {}; + const t = c?.type || 'unknown'; + const author = c.author || msg?.value?.author || ''; + + if (t === 'post') return `post:${msg.key}`; + + if (t === 'about') return `about:${c.about || author || msg.key}`; + if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`; + if (t === 'contact') return `contact:${c.contact || msg.key}`; + if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`; + if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`; + if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`; + if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`; + + if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`; + if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`; + if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`; + if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`; + if (t === 'torrent') return `torrent:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`; + if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`; + + if (t === 'tribe') { + return [ + 'tribe', + author, + norm(c.title), + norm(c.location), + norm(c.image) + ].join('|'); + } + + if (t === 'event') { + return [ + 'event', + c.organizer || author, + norm(c.title), + norm(c.date), + norm(c.location) + ].join('|'); + } + + if (t === 'task') { + return [ + 'task', + c.author || author, + norm(c.title), + norm(c.startTime), + norm(c.endTime), + norm(c.location) + ].join('|'); + } + + if (t === 'report') { + return [ + 'report', + c.author || author, + norm(c.title), + norm(c.category), + norm(c.severity) + ].join('|'); + } + + if (t === 'votes') { + return [ + 'votes', + c.createdBy || author, + norm(c.question), + norm(c.deadline) + ].join('|'); + } + + if (t === 'market') { + return [ + 'market', + c.seller || author, + norm(c.title), + norm(c.deadline), + norm(c.item_type), + norm(c.image) + ].join('|'); + } + + if (t === 'transfer') { + const txid = c.txid || c.transactionId || c.id; + if (txid) return `transfer:${txid}`; + return [ + 'transfer', + norm(c.from), + norm(c.to), + norm(c.amount), + norm(c.concept), + norm(c.deadline) + ].join('|'); + } + + if (t === 'feed') { + return [ + 'feed', + c.author || author, + norm(c.text) + ].join('|'); + } + + if (t === 'project') { + return [ + 'project', + c.activityActor || author, + norm(c.title), + norm(c.deadline), + norm(c.goal) + ].join('|'); + } + + if (t === 'job') { + return [ + 'job', + author, + norm(c.title), + norm(c.location), + norm(c.salary), + norm(c.job_type) + ].join('|'); + } + + if (t === 'forum') { + return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`; + } + + if (t === 'map') { + return ['map', author, norm(c.title), norm(c.description), norm(c.lat), norm(c.lng)].join('|'); + } + + if (t === 'shop') { + return ['shop', author, norm(c.title), norm(c.location)].join('|'); + } + + if (t === 'shopProduct') { + return ['shopProduct', author, norm(c.title), norm(c.shopId)].join('|'); + } + + if (t === 'pad') { + return ['pad', author, norm(c.title), norm(c.deadline)].join('|'); + } + + return `${t}:${msg.key}`; + }; + + const dedupeKeepLatest = (msgs) => { + const map = new Map(); + for (const msg of msgs) { + const k = getDedupeKey(msg); + const prev = map.get(k); + const ts = msg?.value?.timestamp || 0; + const pts = prev?.value?.timestamp || 0; + if (!prev || ts > pts) map.set(k, msg); + } + return Array.from(map.values()); + }; + + const search = async ({ query, types = [], resultsPerPage = "10" }) => { + const ssbClient = await openSsb(); + const viewerId = ssbClient.id; + const queryLower = String(query || '').toLowerCase(); + + const messages = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + + const tombstoned = buildValidatedTombstoneSet(messages); + const replacesMap = new Map(); + const latestByKey = new Map(); + + for (const msg of messages) { + const k = msg.key; + const c = msg?.value?.content; + const t = c?.type; + if (!t || !searchableTypes.includes(t)) continue; + if (tombstoned.has(k)) continue; + if (c.replaces) replacesMap.set(c.replaces, k); + latestByKey.set(k, msg); + } + + for (const [oldId, newId] of Array.from(replacesMap.entries())) { + const orig = latestByKey.get(oldId); + const repl = latestByKey.get(newId); + if (!orig) { replacesMap.delete(oldId); continue; } + if (!repl || String(repl?.value?.author) !== String(orig?.value?.author)) { + replacesMap.delete(oldId); + latestByKey.delete(newId); + } + } + + for (const oldId of replacesMap.keys()) { + latestByKey.delete(oldId); + } + + const viewerTribeIds = new Set(); + if (tribesModel && typeof tribesModel.listTribesForViewer === 'function') { + try { + const myTribes = await tribesModel.listTribesForViewer(viewerId); + for (const tr of (myTribes || [])) viewerTribeIds.add(String(tr.rootId || tr.id || tr.key)); + } catch (_) {} + } + + for (const [k, msg] of Array.from(latestByKey.entries())) { + const c = msg?.value?.content; + if (!c) { latestByKey.delete(k); continue; } + if (c.tribeId && !viewerTribeIds.has(String(c.tribeId))) { + latestByKey.delete(k); + continue; + } + if (c.encryptedPayload) { + let dec = null; + if (c.tribeId) dec = await tryDecryptTribe(c); + if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) { + const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : []; + if (keys && keys.length) { + try { + const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk])); + if (out && !out._undecryptable) dec = out; + } catch (_) {} + } + } + if (!dec) dec = tryDecryptStandalone(c); + if (!dec) { latestByKey.delete(k); continue; } + msg.value.content = { ...c, ...dec, encryptedPayload: undefined }; + } + } + + if (padsModel) { + for (const msg of latestByKey.values()) { + const c = msg?.value?.content; + if (c?.type === 'pad') { + const rootId = c.replaces ? msg.key : msg.key; + try { + const decrypted = await padsModel.decryptContent(c, rootId); + if (decrypted && typeof decrypted === 'object') { + if (decrypted.title) c.title = decrypted.title; + if (decrypted.deadline) c.deadline = decrypted.deadline; + if (Array.isArray(decrypted.tags) && decrypted.tags.length) c.tags = decrypted.tags; + } + } catch (_) {} + } + } + } + + let filtered = Array.from(latestByKey.values()).filter(msg => { + const c = msg?.value?.content; + const t = c?.type; + if (!t || (types.length > 0 && !types.includes(t))) return false; + if (t === 'market') { + if (c.stock === 0 && c.status !== 'SOLD') return false; + } + if (!queryLower) return true; + if (queryLower.startsWith('@') && queryLower.length > 1) return (t === 'about' && c?.about === query); + const fields = getRelevantFields(t, c); + if (queryLower.startsWith('#') && queryLower.length > 1) { + const tag = queryLower.substring(1); + const tagArr = Array.isArray(c?.tags) ? c.tags : (typeof c?.tags === 'string' ? c.tags.split(',').map(s => s.trim()).filter(Boolean) : []); + return tagArr.some(x => String(x).toLowerCase() === tag); + } + return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower)); + }); + + filtered = dedupeKeepLatest(filtered); + + filtered.sort((a, b) => (b?.value?.timestamp || 0) - (a?.value?.timestamp || 0)); + + const grouped = filtered.reduce((acc, msg) => { + const t = msg?.value?.content?.type || 'unknown'; + if (!acc[t]) acc[t] = []; + acc[t].push(msg); + return acc; + }, {}); + + if (resultsPerPage !== "all") { + const limit = parseInt(resultsPerPage, 10); + for (const key in grouped) grouped[key] = grouped[key].slice(0, limit); + } + + return grouped; + }; + + return { search }; +}; + diff --git a/src/models/shops_model.js b/src/models/shops_model.js new file mode 100644 index 0000000..5dedbd2 --- /dev/null +++ b/src/models/shops_model.js @@ -0,0 +1,954 @@ +const pull = require("../server/node_modules/pull-stream") +const { getConfig } = require("../configs/config-manager.js") +const categories = require("../backend/opinion_categories") +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeByPreferring, norm } = require('../backend/dedupe') +const logLimit = getConfig().ssbLogStream?.limit || 1000 + +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return [] + if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) + return String(raw).split(",").map(t => t.trim()).filter(Boolean) +} +const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0) + +module.exports = ({ cooler, tribeCrypto }) => { + let ssb + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } + + const readAll = async (ssbClient) => + new Promise((resolve, reject) => + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))) + ) + + const SELLER_STATUSES = ["ACCEPTED", "REJECTED", "PAID", "SHIPPED"] + const BUYER_STATUSES = ["RECEIVED"] + const ORDER_STATUSES = [...SELLER_STATUSES, ...BUYER_STATUSES] + + const buildOrderStatusMap = (ssbClient, messages) => { + const map = new Map() + for (const m of messages) { + if (typeof m.value?.content !== "string") continue + try { + const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 }) + const dc = dec?.value?.content + if (!dc || dc.type !== "shop-purchase-status" || !dc.orderId) continue + const ts = dec.value.timestamp || m.timestamp || 0 + const prev = map.get(dc.orderId) + if (!prev || ts >= prev.ts) map.set(dc.orderId, { status: String(dc.status || "").toUpperCase(), ts }) + } catch (_) {} + } + return map + } + + const decryptBuyers = (val, key) => { + if (Array.isArray(val)) return val + if (typeof val === 'string' && tribeCrypto && key) { + try { return JSON.parse(tribeCrypto.decryptWithKey(val, key)) } catch {} + } + return [] + } + + const isEncrypted = (c) => !!(c && c.encryptedPayload) + const productShopId = (c) => (c && (c.shopId || c.tribeId)) || "" + + const keysForRoot = (rootId) => { + if (!tribeCrypto || !rootId) return [] + const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || [] + if (ks.length) return ks + const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null + return k ? [k] : [] + } + + const decryptShopContent = (c, keyRootId) => { + if (!isEncrypted(c)) return c + if (!tribeCrypto) return { ...c, _undecryptable: true } + const keys = keysForRoot(keyRootId) + if (!keys.length) return { ...c, _undecryptable: true } + return tribeCrypto.decryptContent(c, keys.map(k => [k])) + } + + const encryptForShop = (plainContent, shopRootId) => { + const keys = keysForRoot(shopRootId) + if (!keys.length) throw new Error("Missing shop key") + return tribeCrypto.encryptContent(plainContent, [keys[0]], true) + } + + const buildIndex = (messages) => { + const tomb = new Set() + const nodes = new Map() + const parent = new Map() + const child = new Map() + const strictChild = new Map() + const opinionMsgs = [] + const purchaseMsgs = [] + + for (const m of messages) { + const k = m.key + const v = m.value || {} + const c = v.content + if (!c) continue + if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue } + if (c.type === "shopOpinion" && c.target) { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue } + if (c.type === "shopPurchase" && c.target) { purchaseMsgs.push({ target: c.target, author: v.author }); continue } + if (c.type === "shop" || c.type === "shopProduct") { + nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) } + } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k) } + } + + const rootOf = (id) => { let cur = id, g = 0; while (parent.has(cur) && g++ < 100000) cur = parent.get(cur); return cur } + const tipOf = (id) => { let cur = id, g = 0; while (child.has(cur) && g++ < 100000) cur = child.get(cur); return cur } + const strictTipOf = (id) => { + const root = rootOf(id) + let cur = root, g = 0 + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur) + const n = nodes.get(cur), rn = nodes.get(root) + return (n && rn && n.author === rn.author) ? cur : root + } + + const roots = new Set() + for (const id of nodes.keys()) roots.add(rootOf(id)) + + const opinionsByRoot = new Map() + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op) } + const purchasesByRoot = new Map() + for (const pu of purchaseMsgs) { if (!nodes.has(pu.target)) continue; const r = rootOf(pu.target); if (!purchasesByRoot.has(r)) purchasesByRoot.set(r, []); purchasesByRoot.get(r).push(pu) } + + const tipByRoot = new Map() + for (const r of roots) tipByRoot.set(r, strictTipOf(r)) + + const aggregateFor = (rootId, decrypted) => { + const opinions = { ...((decrypted && decrypted.opinions) || {}) } + const voters = safeArr(decrypted && decrypted.opinions_inhabitants).slice() + const voterSet = new Set(voters) + for (const op of (opinionsByRoot.get(rootId) || [])) { + if (voterSet.has(op.author)) continue + voterSet.add(op.author); voters.push(op.author) + if (op.category) opinions[op.category] = (opinions[op.category] || 0) + 1 + } + return { opinions, opinions_inhabitants: voters } + } + + const purchaseCountFor = (rootId) => (purchasesByRoot.get(rootId) || []).length + const purchaseAuthorsFor = (rootId) => (purchasesByRoot.get(rootId) || []).map(p => p.author) + + return { tomb, nodes, parent, child, strictChild, rootOf, tipOf, strictTipOf, tipByRoot, aggregateFor, purchaseCountFor, purchaseAuthorsFor } + } + + const buildShop = (node, rootId, idx) => { + const raw = node.c || {} + if (raw.type !== "shop") return null + const encrypted = isEncrypted(raw) + const c = encrypted ? decryptShopContent(raw, rootId) : raw + const undecryptable = !!c._undecryptable + const agg = idx ? idx.aggregateFor(rootId, c) : { opinions: c.opinions || {}, opinions_inhabitants: safeArr(c.opinions_inhabitants) } + return { + key: node.key, + rootId, + title: c.title || "", + shortDescription: c.shortDescription || "", + description: c.description || "", + image: c.image || null, + url: c.url || "", + location: c.location || "", + tags: safeArr(c.tags), + visibility: c.visibility || (encrypted ? "CLOSED" : "OPEN"), + clearnetPublic: !!c.clearnetPublic, + author: raw.author || c.author || node.author, + createdAt: c.createdAt || raw.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || raw.updatedAt || null, + opinions: agg.opinions, + opinions_inhabitants: agg.opinions_inhabitants, + mapUrl: c.mapUrl || "", + encrypted, + undecryptable + } + } + + const buildProduct = (node, rootId, idx) => { + const raw = node.c || {} + if (raw.type !== "shopProduct") return null + const encrypted = isEncrypted(raw) + const shopRoot = productShopId(raw) + const c = encrypted ? decryptShopContent(raw, shopRoot) : raw + const undecryptable = !!c._undecryptable + const agg = idx ? idx.aggregateFor(rootId, c) : { opinions: c.opinions || {}, opinions_inhabitants: safeArr(c.opinions_inhabitants) } + const sellerStock = Number(c.stock) || 0 + const purchaseCount = idx ? idx.purchaseCountFor(rootId) : 0 + const sellerBuyers = encrypted ? safeArr(c.buyers) : decryptBuyers(c.buyers, tribeCrypto ? tribeCrypto.getKey(rootId) : null) + const buyers = Array.from(new Set(sellerBuyers.concat(idx ? idx.purchaseAuthorsFor(rootId) : []))) + return { + key: node.key, + rootId, + shopId: shopRoot, + title: c.title || "", + description: c.description || "", + image: c.image || null, + price: c.price || "0.000000", + stock: Math.max(0, sellerStock - purchaseCount), + featured: !!c.featured, + author: raw.author || c.author || node.author, + createdAt: c.createdAt || raw.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || raw.updatedAt || null, + opinions: agg.opinions, + opinions_inhabitants: agg.opinions_inhabitants, + buyers, + encrypted, + undecryptable + } + } + + const countProductsFromIndex = (idx, shopRootId) => { + let count = 0 + for (const tipId of idx.tipByRoot.values()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "shopProduct") continue + if (productShopId(node.c) === shopRootId) count++ + } + return count + } + + return { + type: "shop", + + async resolveRootId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const tip = idx.strictTipOf(id) + if (idx.tomb.has(tip)) throw new Error("Not found") + return idx.rootOf(tip) + }, + + async resolveCurrentId(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const tip = idx.strictTipOf(id) + if (idx.tomb.has(tip)) throw new Error("Not found") + return tip + }, + + async createShop(title, shortDescription, description, image, url, location, tagsRaw, visibility, mapUrl, clearnetPublic) { + const ssbClient = await openSsb() + const blobId = image ? String(image).trim() || null : null + const tags = normalizeTags(tagsRaw) + const vis = String(visibility || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN" + const now = new Date().toISOString() + + const content = { + type: "shop", + title: safeText(title), + shortDescription: safeText(shortDescription), + description: safeText(description), + image: blobId, + url: safeText(url), + location: safeText(location), + tags, + visibility: vis, + clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on', + mapUrl: safeText(mapUrl), + author: ssbClient.id, + createdAt: now, + updatedAt: now, + opinions: {}, + opinions_inhabitants: [] + } + + const wantsEncryption = vis === "CLOSED" && !!tribeCrypto + return new Promise((resolve, reject) => { + if (wantsEncryption) { + const shopKey = tribeCrypto.generateTribeKey() + const encrypted = tribeCrypto.encryptContent(content, [shopKey], true) + ssbClient.publish(encrypted, (err, msg) => { + if (err) return reject(err) + if (msg && msg.key) tribeCrypto.setKey(msg.key, shopKey, 1) + resolve(msg) + }) + } else { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + } + }) + }, + + async updateShopById(id, data) { + const tipId = await this.resolveCurrentId(id) + const rootId = await this.resolveRootId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item?.content) return reject(new Error("Shop not found")) + const raw = item.content + if (raw.author !== userId) return reject(new Error("Not the author")) + + const encrypted = isEncrypted(raw) + const c = encrypted ? decryptShopContent(raw, rootId) : raw + if (c._undecryptable) return reject(new Error("Cannot decrypt shop")) + + const plain = { + ...c, + title: data.title !== undefined ? safeText(data.title) : c.title, + shortDescription: data.shortDescription !== undefined ? safeText(data.shortDescription) : c.shortDescription, + description: data.description !== undefined ? safeText(data.description) : c.description, + image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image, + url: data.url !== undefined ? safeText(data.url) : c.url, + location: data.location !== undefined ? safeText(data.location) : c.location, + tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags, + visibility: encrypted ? "CLOSED" : c.visibility, + clearnetPublic: data.clearnetPublic !== undefined ? (data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on') : !!c.clearnetPublic, + updatedAt: new Date().toISOString(), + replaces: tipId + } + delete plain._decrypted + delete plain._undecryptable + delete plain.encryptedPayload + + let updated + try { + updated = encrypted ? encryptForShop(plain, rootId) : plain + } catch (e) { return reject(e) } + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (e1) => { + if (e1) return reject(e1) + ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res)) + }) + }) + }) + }, + + async deleteShopById(id) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item?.content) return reject(new Error("Shop not found")) + if (item.content.author !== userId) return reject(new Error("Not the author")) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve()) + }) + }) + }, + + async getShopById(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const tip = idx.strictTipOf(id) + if (idx.tomb.has(tip)) return null + + const node = idx.nodes.get(tip) + if (!node || node.c.type !== "shop") return null + + const root = idx.rootOf(tip) + + const shop = buildShop(node, root, idx) + if (!shop) return null + shop.productCount = countProductsFromIndex(idx, root) + return shop + }, + + async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) { + const ssbClient = await openSsb() + const uid = viewerId || ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const items = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "shop") continue + const shop = buildShop(node, rootId, idx) + if (!shop) continue + if (shop.encrypted) { + if (shop.undecryptable) continue + } else if (shop.visibility === "CLOSED" && shop.author !== uid) continue + shop.productCount = countProductsFromIndex(idx, rootId) + items.push(shop) + } + + let list = dedupeByPreferring(items, (s) => (s.author && s.createdAt) ? norm(s.author) + "|" + norm(s.createdAt) : null, (s) => (Number(s.productCount) || 0) + voteSum(s.opinions)) + const now = Date.now() + + if (filter === "mine") list = list.filter(s => s.author === uid) + else if (filter === "recent") list = list.filter(s => new Date(s.createdAt).getTime() >= now - 86400000) + else if (filter === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)) + + if (q) { + const qq = q.toLowerCase() + list = list.filter(s => { + const t = String(s.title || "").toLowerCase() + const d = String(s.description || "").toLowerCase() + const loc = String(s.location || "").toLowerCase() + const tags = safeArr(s.tags).join(" ").toLowerCase() + return t.includes(qq) || d.includes(qq) || loc.includes(qq) || tags.includes(qq) + }) + } + + if (filter !== "top") { + if (sort === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)) + else list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + } + + return list + }, + + async createProduct(shopId, title, description, image, price, stock, featured) { + const ssbClient = await openSsb() + const blobId = image ? String(image).trim() || null : null + const p = parseFloat(String(price || "").replace(",", ".")) + if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price") + const s = parseInt(String(stock || "1"), 10) + if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock") + const now = new Date().toISOString() + + const content = { + type: "shopProduct", + shopId, + title: safeText(title), + description: safeText(description), + image: blobId, + price: p.toFixed(6), + stock: s, + featured: !!featured, + author: ssbClient.id, + createdAt: now, + updatedAt: now, + opinions: {}, + opinions_inhabitants: [] + } + + const shopKeys = keysForRoot(shopId) + const e2e = shopKeys.length > 0 + if (e2e) content.tribeId = shopId + + return new Promise((resolve, reject) => { + if (e2e) { + let enc + try { enc = tribeCrypto.encryptContent(content, [shopKeys[0]], true) } catch (e) { return reject(e) } + ssbClient.publish(enc, (err, msg) => err ? reject(err) : resolve(msg)) + } else { + ssbClient.publish(content, (err, msg) => { + if (err) return reject(err) + if (msg && msg.key && tribeCrypto) { + const key = tribeCrypto.generateTribeKey() + tribeCrypto.setKey(msg.key, key, 1) + } + resolve(msg) + }) + } + }) + }, + + async updateProductById(id, data) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item?.content) return reject(new Error("Product not found")) + const raw = item.content + if (raw.author !== userId) return reject(new Error("Not the author")) + + const encrypted = isEncrypted(raw) + const shopRoot = productShopId(raw) + const c = encrypted ? decryptShopContent(raw, shopRoot) : raw + if (c._undecryptable) return reject(new Error("Cannot decrypt product")) + + const pRaw = data.price !== undefined ? parseFloat(String(data.price || "").replace(",", ".")) : null + const sRaw = data.stock !== undefined ? parseInt(String(data.stock || "0"), 10) : null + + const plain = { + ...c, + title: data.title !== undefined ? safeText(data.title) : c.title, + description: data.description !== undefined ? safeText(data.description) : c.description, + image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image, + price: pRaw !== null && Number.isFinite(pRaw) && pRaw > 0 ? pRaw.toFixed(6) : c.price, + stock: sRaw !== null && Number.isFinite(sRaw) && sRaw >= 0 ? sRaw : c.stock, + featured: data.featured !== undefined ? !!data.featured : !!c.featured, + updatedAt: new Date().toISOString(), + replaces: tipId + } + delete plain._decrypted + delete plain._undecryptable + delete plain.encryptedPayload + + let updated + try { + updated = encrypted ? encryptForShop({ ...plain, tribeId: shopRoot }, shopRoot) : plain + } catch (e) { return reject(e) } + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (e1) => { + if (e1) return reject(e1) + ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res)) + }) + }) + }) + }, + + async deleteProductById(id) { + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + + return new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => { + if (err || !item?.content) return reject(new Error("Product not found")) + if (item.content.author !== userId) return reject(new Error("Not the author")) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve()) + }) + }) + }, + + async getProductById(id) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const tip = idx.strictTipOf(id) + if (idx.tomb.has(tip)) return null + + const node = idx.nodes.get(tip) + if (!node || node.c.type !== "shopProduct") return null + + const root = idx.rootOf(tip) + + return buildProduct(node, root, idx) + }, + + async listProducts(shopRootId) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const items = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "shopProduct") continue + if (productShopId(node.c) !== shopRootId) continue + const prod = buildProduct(node, rootId, idx) + if (prod && !prod.undecryptable) items.push(prod) + } + + return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + }, + + async listFeaturedProducts(shopRootId) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const items = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "shopProduct") continue + if (productShopId(node.c) !== shopRootId) continue + if (!node.c.featured) continue + const prod = buildProduct(node, rootId, idx) + if (prod && !prod.undecryptable) items.push(prod) + } + + return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 4) + }, + + async listAllProducts({ filter = "all", sort = "recent" } = {}) { + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const items = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node || node.c.type !== "shopProduct") continue + const prod = buildProduct(node, rootId, idx) + if (prod && !prod.undecryptable) items.push(prod) + } + + if (filter === "top") return items.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)) + return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + }, + + async buyProduct(productId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const tipId = idx.strictTipOf(productId) + if (idx.tomb.has(tipId)) throw new Error("Product not found") + const rootId = idx.rootOf(tipId) + + const node = idx.nodes.get(tipId) + if (!node) throw new Error("Product not found") + const raw = node.c + if (raw.author === userId) throw new Error("Cannot buy your own product") + + const encrypted = isEncrypted(raw) + const shopRoot = productShopId(raw) + const c = encrypted ? decryptShopContent(raw, shopRoot) : raw + if (c._undecryptable) throw new Error("Cannot access product") + const stock = Math.max(0, (Number(c.stock) || 0) - idx.purchaseCountFor(rootId)) + if (stock <= 0) throw new Error("Out of stock") + + const content = { type: "shopPurchase", target: rootId, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m))) + }, + + async createPurchaseOrder(productId, deliveryDetails = {}) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const tipId = idx.strictTipOf(productId) + if (idx.tomb.has(tipId)) throw new Error("Product not found") + const rootId = idx.rootOf(tipId) + + const node = idx.nodes.get(tipId) + if (!node) throw new Error("Product not found") + const raw = node.c + const shopOwner = raw.author + if (shopOwner === userId) throw new Error("Cannot buy your own product") + const shopRoot = productShopId(raw) + const c = isEncrypted(raw) ? decryptShopContent(raw, shopRoot) : raw + + const content = { + type: "shop-purchase", + productId: rootId, + productTipId: tipId, + shopId: shopRoot, + seller: shopOwner, + title: String(c.title || ""), + price: c.price || "", + deliveryAddress: String(deliveryDetails.deliveryAddress || ""), + contact: String(deliveryDetails.contact || ""), + notes: String(deliveryDetails.notes || ""), + createdAt: new Date().toISOString() + } + + const recps = [userId, shopOwner] + return new Promise((res, rej) => ssbClient.private.publish(content, recps, (e, m) => e ? rej(e) : res(m))) + }, + + async listMyPurchases() { + const ssbClient = await openSsb() + const me = ssbClient.id + const messages = await readAll(ssbClient) + const out = [] + for (const m of messages) { + if (typeof m.value?.content !== "string") continue + try { + const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 }) + if (!dec?.value?.content) continue + const dc = dec.value.content + if (dc.type !== "shop-purchase") continue + if (dec.value.author !== me) continue + out.push({ id: m.key, ...dc, buyer: dec.value.author, ts: dec.value.timestamp || m.timestamp || 0 }) + } catch (_) {} + } + const statusMap = buildOrderStatusMap(ssbClient, messages) + const sellerCache = new Map() + for (const o of out) { + if (!o.seller && o.shopId) { + if (!sellerCache.has(o.shopId)) { + const shop = await this.getShopById(o.shopId).catch(() => null) + sellerCache.set(o.shopId, (shop && shop.author) || null) + } + o.seller = sellerCache.get(o.shopId) || null + } + } + return out + .map(o => ({ ...o, status: (statusMap.get(o.id) || {}).status || "PENDING" })) + .sort((a, b) => b.ts - a.ts) + }, + + async listShopOrders(shopRootId) { + const ssbClient = await openSsb() + const me = ssbClient.id + const shop = await this.getShopById(shopRootId).catch(() => null) + if (!shop) throw new Error("Shop not found") + if (shop.author !== me) throw new Error("Not the shop owner") + + const messages = await readAll(ssbClient) + const out = [] + for (const m of messages) { + if (typeof m.value?.content !== "string") continue + try { + const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 }) + if (!dec?.value?.content) continue + const dc = dec.value.content + if (dc.type !== "shop-purchase") continue + if (dc.shopId !== shopRootId) continue + out.push({ id: m.key, ...dc, buyer: dec.value.author, ts: dec.value.timestamp || m.timestamp || 0 }) + } catch (_) {} + } + const statusMap = buildOrderStatusMap(ssbClient, messages) + return out + .map(o => ({ ...o, status: (statusMap.get(o.id) || {}).status || "PENDING" })) + .sort((a, b) => b.ts - a.ts) + }, + + async setOrderStatus(orderId, status) { + const ssbClient = await openSsb() + const me = ssbClient.id + const st = String(status || "").toUpperCase() + if (!ORDER_STATUSES.includes(st)) throw new Error("Invalid status") + const messages = await readAll(ssbClient) + let order = null + for (const m of messages) { + if (m.key !== orderId) continue + if (typeof m.value?.content !== "string") break + try { + const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 }) + const dc = dec?.value?.content + if (dc && dc.type === "shop-purchase") order = { ...dc, buyer: dec.value.author } + } catch (_) {} + break + } + if (!order) throw new Error("Order not found") + const seller = order.seller || (await this.getShopById(order.shopId).catch(() => null) || {}).author || null + if (BUYER_STATUSES.includes(st)) { + if (me !== order.buyer) throw new Error("Only the buyer can set this status") + } else { + if (me !== seller) throw new Error("Only the seller can set this status") + } + const recps = Array.from(new Set([order.buyer, seller].filter(Boolean))) + const content = { type: "shop-purchase-status", orderId, shopId: order.shopId, status: st, updatedAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.private.publish(content, recps, (e, m) => e ? rej(e) : res(m))) + }, + + async countPendingOrders(shopRootId) { + try { + const orders = await this.listShopOrders(shopRootId) + return orders.filter(o => (o.status || "PENDING") === "PENDING").length + } catch (_) { return 0 } + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error("Invalid category") + const ssbClient = await openSsb() + const userId = ssbClient.id + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + + const tipId = idx.strictTipOf(id) + if (idx.tomb.has(tipId)) throw new Error("Not found") + const rootId = idx.rootOf(tipId) + + const node = idx.nodes.get(tipId) + if (!node) throw new Error("Not found") + const raw = node.c + const encrypted = isEncrypted(raw) + const keyRoot = raw.type === "shop" ? rootId : productShopId(raw) + const c = encrypted ? decryptShopContent(raw, keyRoot) : raw + if (c._undecryptable) throw new Error("Cannot decrypt item") + + const voters = idx.aggregateFor(rootId, c).opinions_inhabitants + if (voters.includes(userId)) throw new Error("Already voted") + const myOrders = await this.listMyPurchases() + const received = myOrders.some(o => o.productId === rootId && String(o.status || "").toUpperCase() === "RECEIVED") + if (!received) throw new Error("You can rate only after confirming you received the item") + + const content = { type: "shopOpinion", target: rootId, category, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m))) + }, + + async generateInvite(shopId) { + if (!tribeCrypto) throw new Error("Shop crypto unavailable") + const rootId = await this.resolveRootId(shopId) + const ssbClient = await openSsb() + const userId = ssbClient.id + const raw = await new Promise((res, rej) => ssbClient.get(rootId, (e, m) => e ? rej(e) : res(m && m.content))) + if (!raw) throw new Error("Shop not found") + if (!isEncrypted(raw)) throw new Error("Only private shops use invitation codes") + if (raw.author !== userId) throw new Error("Only the author can generate invites") + const keys = keysForRoot(rootId) + if (!keys.length) throw new Error("Missing shop key") + const code = require("crypto").randomBytes(16).toString("hex") + const salt = tribeCrypto.generateInviteSalt() + const ek = tribeCrypto.encryptForInvite(keys[0], code, salt) + await new Promise((resolve, reject) => + ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: tribeCrypto.hashInviteCode(code, salt) }, (e) => e ? reject(e) : resolve()) + ) + return { code, shopId: rootId } + }, + + async getOpenInvite(shopId) { + const ssbClient = await openSsb() + const rootId = await this.resolveRootId(shopId).catch(() => shopId) + const messages = await readAll(ssbClient) + const markerTomb = new Set() + const invTomb = new Set() + for (const m of messages) { + const c = m.value && m.value.content + if (!c) continue + if (c.type === "shop-open-invite-tombstone" && typeof c.target === "string") markerTomb.add(c.target) + if (c.type === "shop-invite-tombstone" && typeof c.target === "string") invTomb.add(c.target) + } + let best = null + for (const m of messages) { + const c = m.value && m.value.content + if (!c || c.type !== "shop-open-invite" || c.v !== 1) continue + if (c.target !== rootId || typeof c.code !== "string") continue + if (markerTomb.has(m.key)) continue + if (c.inviteKey && invTomb.has(c.inviteKey)) continue + const ts = (m.value && m.value.timestamp) || 0 + if (!best || ts > best.ts) best = { code: c.code, by: c.by || m.value.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts } + } + return best ? { code: best.code, by: best.by, markerKey: best.markerKey, inviteKey: best.inviteKey } : null + }, + + async generateOpenInvite(shopId) { + if (!tribeCrypto) throw new Error("Shop crypto unavailable") + const rootId = await this.resolveRootId(shopId) + const ssbClient = await openSsb() + const userId = ssbClient.id + const raw = await new Promise((res, rej) => ssbClient.get(rootId, (e, m) => e ? rej(e) : res(m && m.content))) + if (!raw) throw new Error("Shop not found") + if (!isEncrypted(raw)) throw new Error("Only private shops use invitation codes") + if (raw.author !== userId) throw new Error("Only the author can generate invites") + if (await this.getOpenInvite(shopId)) throw new Error("An open invitation already exists") + const keys = keysForRoot(rootId) + if (!keys.length) throw new Error("Missing shop key") + const code = require("crypto").randomBytes(16).toString("hex") + const salt = tribeCrypto.generateInviteSalt() + const ek = tribeCrypto.encryptForInvite(keys[0], code, salt) + const invitePub = await new Promise((resolve, reject) => + ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: tribeCrypto.hashInviteCode(code, salt), multi: 1 }, (e, r) => e ? reject(e) : resolve(r)) + ) + await new Promise((resolve, reject) => + ssbClient.publish({ type: "shop-open-invite", v: 1, target: rootId, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (e) => e ? reject(e) : resolve()) + ) + return { code, shopId: rootId } + }, + + async removeOpenInvite(shopId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const rec = await this.getOpenInvite(shopId) + if (!rec) return + let author = null + try { + const rootId = await this.resolveRootId(shopId) + const raw = await new Promise((res, rej) => ssbClient.get(rootId, (e, m) => e ? rej(e) : res(m && m.content))) + author = raw && raw.author + } catch (_) {} + if (rec.by !== userId && author !== userId) throw new Error("Not allowed to remove this invitation") + await new Promise((resolve, reject) => ssbClient.publish({ type: "shop-open-invite-tombstone", target: rec.markerKey, ts: new Date().toISOString() }, (e) => e ? reject(e) : resolve())) + if (rec.inviteKey) await new Promise((resolve, reject) => ssbClient.publish({ type: "shop-invite-tombstone", target: rec.inviteKey, ts: new Date().toISOString() }, (e) => e ? reject(e) : resolve())) + }, + + async joinByCode(code) { + if (!tribeCrypto) throw new Error("Shop crypto unavailable") + const ssbClient = await openSsb() + const messages = await readAll(ssbClient) + const invTomb = new Set() + for (const m of messages) { + const c = m.value && m.value.content + if (c && c.type === "shop-invite-tombstone" && typeof c.target === "string") invTomb.add(c.target) + } + let matched = null + for (const m of messages) { + const c = m.value && m.value.content + if (!c || c.type !== "shop-invite") continue + if (invTomb.has(m.key)) continue + try { + if (tribeCrypto.hashInviteCode(code, c.salt) === c.codeHash) { matched = c; break } + } catch (_) {} + } + if (!matched) throw new Error("Invalid or expired invite code") + const shopKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt) + if (!shopKey) throw new Error("Could not decrypt invite") + if (tribeCrypto.addNewKey) tribeCrypto.addNewKey(matched.target, shopKey) + else tribeCrypto.setKey(matched.target, shopKey, 1) + return { ok: true, shopId: matched.target } + }, + + async setShopVisibility(id, target) { + const tgt = String(target || "").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN" + const rootId = await this.resolveRootId(id) + const tipId = await this.resolveCurrentId(id) + const ssbClient = await openSsb() + const userId = ssbClient.id + + const shopRaw = await new Promise((res, rej) => ssbClient.get(tipId, (e, m) => e ? rej(e) : res(m && m.content))) + if (!shopRaw) throw new Error("Shop not found") + if (shopRaw.author !== userId) throw new Error("Not the author") + const curShop = isEncrypted(shopRaw) ? decryptShopContent(shopRaw, rootId) : shopRaw + if (curShop._undecryptable) throw new Error("Cannot decrypt shop") + + const wantEncrypted = tgt === "CLOSED" && !!tribeCrypto + + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + const products = [] + for (const tip of idx.tipByRoot.values()) { + if (idx.tomb.has(tip)) continue + const node = idx.nodes.get(tip) + if (!node || node.c.type !== "shopProduct") continue + if (productShopId(node.c) !== rootId) continue + products.push({ tip, raw: node.c }) + } + + if (wantEncrypted) { + const newKey = tribeCrypto.generateTribeKey() + if (tribeCrypto.addNewKey) tribeCrypto.addNewKey(rootId, newKey) + else tribeCrypto.setKey(rootId, newKey, 1) + } + + const publish = (c) => new Promise((res, rej) => ssbClient.publish(c, (e, m) => e ? rej(e) : res(m))) + const tombstone = (t) => ({ type: "tombstone", target: t, deletedAt: new Date().toISOString(), author: userId }) + + const shopPlain = { ...curShop, visibility: tgt, updatedAt: new Date().toISOString(), replaces: tipId } + delete shopPlain._decrypted + delete shopPlain._undecryptable + delete shopPlain.encryptedPayload + delete shopPlain.tribeId + const shopMsg = wantEncrypted ? encryptForShop(shopPlain, rootId) : shopPlain + await publish(tombstone(tipId)) + await publish(shopMsg) + + for (const p of products) { + const pPlain0 = isEncrypted(p.raw) ? decryptShopContent(p.raw, rootId) : p.raw + if (pPlain0._undecryptable) continue + const pPlain = { ...pPlain0, shopId: rootId, updatedAt: new Date().toISOString(), replaces: p.tip } + delete pPlain._decrypted + delete pPlain._undecryptable + delete pPlain.encryptedPayload + delete pPlain.tribeId + let pMsg + if (wantEncrypted) { + pPlain.tribeId = rootId + pMsg = encryptForShop(pPlain, rootId) + } else { + pMsg = pPlain + } + await publish(tombstone(p.tip)) + await publish(pMsg) + } + + return { ok: true, shopId: rootId, visibility: tgt, encrypted: wantEncrypted } + } + } +} diff --git a/src/models/stats_model.js b/src/models/stats_model.js new file mode 100644 index 0000000..bc14f72 --- /dev/null +++ b/src/models/stats_model.js @@ -0,0 +1,622 @@ +const pull = require('../server/node_modules/pull-stream'); +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const STORAGE_DIR = path.join(__dirname, "..", "configs"); +const ADDR_FILE = path.join(STORAGE_DIR, "wallet_addresses.json"); + +function readAddrMap() { + try { + if (!fs.existsSync(ADDR_FILE)) return {}; + const raw = fs.readFileSync(ADDR_FILE, 'utf8'); + const obj = JSON.parse(raw || '{}'); + return obj && typeof obj === 'object' ? obj : {}; + } catch { + return {}; + } +} + +const listPubsFromEbt = () => { + try { + const ebtDir = path.join(os.homedir(), '.ssb', 'ebt'); + const files = fs.readdirSync(ebtDir); + return files.filter(f => f.endsWith('.ed25519')); + } catch { + return []; + } +}; + +const HIDDEN_ENVELOPE_TYPES = new Set([ + 'tribe-keys-distrib', + 'tribe-invite-msg', + 'tribe-invite-tombstone' +]); + +module.exports = ({ cooler, tribeCrypto, tribesModel }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + const buildAccessibleTribeIds = async () => { + const set = new Set(); + if (!tribesModel) return set; + try { + const list = await tribesModel.listAll(); + for (const t of list) { + if (!t || !t.id) continue; + set.add(t.id); + try { + const chain = await tribesModel.getChainIds(t.id); + for (const cid of chain) set.add(cid); + } catch (_) {} + } + } catch (_) {} + return set; + }; + + const types = [ + 'bookmark','event','task','votes','report','feed','project', + 'image','torrent','audio','video','document','transfer','post','tribe', + 'market','forum','job','aiExchange','map','shop','shopProduct','chat','chatMessage', + 'pad','padEntry','gameScore','calendar','calendarDate','calendarNote','log', + 'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw', + 'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote' + ]; + + const getFolderSize = (folderPath) => { + let files; + try { files = fs.readdirSync(folderPath); } catch (_) { return 0; } + let totalSize = 0; + for (const file of files) { + const filePath = `${folderPath}/${file}`; + try { + const st = fs.statSync(filePath); + totalSize += st.isDirectory() ? getFolderSize(filePath) : st.size; + } catch (_) {} + } + return totalSize; + }; + + const formatSize = (sizeInBytes) => { + if (sizeInBytes < 1024) return `${sizeInBytes} B`; + const kb = 1024, mb = kb * 1024, gb = mb * 1024, tb = gb * 1024; + if (sizeInBytes < mb) return `${(sizeInBytes / kb).toFixed(2)} KB`; + if (sizeInBytes < gb) return `${(sizeInBytes / mb).toFixed(2)} MB`; + if (sizeInBytes < tb) return `${(sizeInBytes / gb).toFixed(2)} GB`; + return `${(sizeInBytes / tb).toFixed(2)} TB`; + }; + + const N = s => String(s || '').toUpperCase(); + const sum = arr => arr.reduce((a, b) => a + b, 0); + const median = arr => { + if (!arr.length) return 0; + const a = [...arr].sort((x, y) => x - y); + const m = Math.floor(a.length / 2); + return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2; + }; + + const parseAuctionMax = auctions_poll => { + if (!Array.isArray(auctions_poll) || auctions_poll.length === 0) return 0; + const amounts = auctions_poll.map(s => { + const parts = String(s).split(':'); + const amt = parseFloat(parts[1]); + return isNaN(amt) ? 0 : amt; + }); + return amounts.length ? Math.max(...amounts) : 0; + }; + + const dayKey = ts => new Date(ts || 0).toISOString().slice(0, 10); + const lastNDays = (n) => { + const out = []; + const today = new Date(); + today.setUTCHours(0,0,0,0); + for (let i = n - 1; i >= 0; i--) { + const d = new Date(today); + d.setUTCDate(today.getUTCDate() - i); + out.push(d.toISOString().slice(0, 10)); + } + return out; + }; + + const norm = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim(); + const bestContentTs = (c, fallbackTs = 0) => + Number(c?.updatedAt ? Date.parse(c.updatedAt) : 0) || + Number(c?.createdAt ? Date.parse(c.createdAt) : 0) || + Number(c?.timestamp || 0) || + Number(fallbackTs || 0); + const dedupeTribesNodes = (nodes = []) => { + const pick = new Map(); + for (const n of nodes) { + const c = n?.content || {}; + const title = c.title || c.name || ''; + const author = n?.author || ''; + const key = `${norm(title)}::${author}`; + const ts = bestContentTs(c, n?.ts || 0); + const prev = pick.get(key); + if (!prev || ts > prev._ts) pick.set(key, { ...n, _ts: ts }); + } + return Array.from(pick.values()); + }; + + const inferType = (c = {}) => { + if (c.vote) return 'vote'; + if (c.votes) return 'votes'; + if (c.address && c.coin === 'ECO' && c.type === 'wallet') return 'bankWallet'; + if (typeof c.amount !== 'undefined' && c.epochId && c.allocationId) return 'bankClaim'; + if (typeof c.item_type !== 'undefined' && typeof c.status !== 'undefined') return 'market'; + if (typeof c.goal !== 'undefined' && typeof c.progress !== 'undefined') return 'project'; + if (typeof c.members !== 'undefined' && typeof c.isAnonymous !== 'undefined') return 'tribe'; + if (typeof c.date !== 'undefined' && typeof c.location !== 'undefined') return 'event'; + if (typeof c.priority !== 'undefined' && typeof c.status !== 'undefined' && c.title) return 'task'; + if (typeof c.confirmations !== 'undefined' && typeof c.severity !== 'undefined') return 'report'; + if (typeof c.job_type !== 'undefined' && typeof c.status !== 'undefined') return 'job'; + if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'audio') return 'audio'; + if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'video') return 'video'; + if (typeof c.url !== 'undefined' && c.title && c.key) return 'document'; + if (typeof c.text !== 'undefined' && typeof c.refeeds !== 'undefined') return 'feed'; + if (typeof c.text !== 'undefined' && typeof c.contentWarning !== 'undefined') return 'post'; + if (typeof c.contact !== 'undefined') return 'contact'; + if (typeof c.about !== 'undefined') return 'about'; + if (typeof c.concept !== 'undefined' && typeof c.amount !== 'undefined' && c.status) return 'transfer'; + if (c.type === 'map') return 'map'; + if (c.type === 'shop') return 'shop'; + if (c.type === 'shopProduct') return 'shopProduct'; + if (c.type === 'chat') return 'chat'; + if (c.type === 'chatMessage') return 'chatMessage'; + return ''; + }; + + const normalizeActionType = (a) => { + const t = a.type || a.content?.type || inferType(a.content) || ''; + return String(t).toLowerCase(); + }; + + const priorityBump = (p) => { + const s = String(p || '').toUpperCase(); + if (s === 'HIGH') return 3; + if (s === 'MEDIUM') return 1; + return 0; + }; + + const severityBump = (s) => { + const x = String(s || '').toUpperCase(); + if (x === 'CRITICAL') return 6; + if (x === 'HIGH') return 4; + if (x === 'MEDIUM') return 2; + return 0; + }; + + const calculateOpinionScore = (content) => { + const cats = content?.opinions || {}; + let s = 0; + for (const k in cats) { + if (!Object.prototype.hasOwnProperty.call(cats, k)) continue; + if (k === 'interesting' || k === 'inspiring') s += 5; + else if (k === 'boring' || k === 'spam' || k === 'propaganda') s -= 3; + else s += 1; + } + return s; + }; + + const scoreMarketItem = (c) => { + const st = String(c.status || '').toUpperCase(); + let s = 5; + if (st === 'SOLD') s += 8; + else if (st === 'ACTIVE') s += 3; + const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0; + s += Math.min(10, bids); + return s; + }; + + const scoreProjectItem = (c) => { + const st = String(c.status || 'ACTIVE').toUpperCase(); + const prog = Number(c.progress || 0); + let s = 8 + Math.min(10, prog / 10); + if (st === 'FUNDED') s += 10; + return s; + }; + + const computeKarmaFromMsgs = (msgs) => { + let score = 0; + for (const m of msgs) { + const c = m.value?.content || {}; + const t = normalizeActionType({ type: c.type, content: c }); + const rawType = String(c.type || '').toLowerCase(); + if (t === 'post') score += 10; + else if (t === 'comment') score += 5; + else if (t === 'like') score += 2; + else if (t === 'image') score += 8; + else if (t === 'video') score += 12; + else if (t === 'audio') score += 8; + else if (t === 'document') score += 6; + else if (t === 'map') score += 6; + else if (t === 'bookmark') score += 2; + else if (t === 'feed') score += 6; + else if (t === 'forum') score += c.root ? 5 : 10; + else if (t === 'vote') score += 3 + calculateOpinionScore(c); + else if (t === 'votes') score += Math.min(10, Number(c.totalVotes || 0)); + else if (t === 'market') score += scoreMarketItem(c); + else if (t === 'project') score += scoreProjectItem(c); + else if (t === 'tribe') score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0); + else if (t === 'event') score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0); + else if (t === 'task') score += 3 + priorityBump(c.priority); + else if (t === 'report') score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity); + else if (t === 'curriculum') score += 5; + else if (t === 'aiexchange') score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0; + else if (t === 'job') score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0); + else if (t === 'bankclaim') score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5); + else if (t === 'bankwallet') score += 2; + else if (t === 'transfer') score += 1; + else if (t === 'about') score += 1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0); + else if (t === 'contact') score += 1; + else if (t === 'pub') score += 1; + else if (t === 'parliamentcandidature' || rawType === 'parliamentcandidature') score += 12; + else if (t === 'parliamentterm' || rawType === 'parliamentterm') score += 25; + else if (t === 'parliamentproposal' || rawType === 'parliamentproposal') score += 8; + else if (t === 'parliamentlaw' || rawType === 'parliamentlaw') score += 16; + else if (t === 'parliamentrevocation' || rawType === 'parliamentrevocation') score += 10; + else if (t === 'courts_case' || t === 'courtscase' || rawType === 'courts_case') score += 4; + else if (t === 'courts_evidence' || t === 'courtsevidence' || rawType === 'courts_evidence') score += 3; + else if (t === 'courts_answer' || t === 'courtsanswer' || rawType === 'courts_answer') score += 4; + else if (t === 'courts_verdict' || t === 'courtsverdict' || rawType === 'courts_verdict') score += 10; + else if (t === 'courts_settlement' || t === 'courtssettlement' || rawType === 'courts_settlement') score += 8; + else if (t === 'courts_nomination' || t === 'courtsnomination' || rawType === 'courts_nomination') score += 6; + else if (t === 'courts_nom_vote' || t === 'courtsnomvote' || rawType === 'courts_nom_vote') score += 3; + else if (t === 'courts_public_pref' || t === 'courtspublicpref' || rawType === 'courts_public_pref') score += 1; + else if (t === 'courts_mediators' || t === 'courtsmediators' || rawType === 'courts_mediators') score += 6; + else if (t === 'courts_open_support' || t === 'courtsopensupport' || rawType === 'courts_open_support') score += 2; + else if (t === 'courts_verdict_vote' || t === 'courtsverdictvote' || rawType === 'courts_verdict_vote') score += 3; + else if (t === 'courts_judge_assign' || t === 'courtsjudgeassign' || rawType === 'courts_judge_assign') score += 5; + else if (t === 'larpjoinhouse') score += 15; + else if (t === 'larphousepost') score += 6; + else if (t === 'larptestattempt') score += 3; + else if (t === 'torrent') score += 6; + else if (t === 'shop' || t === 'shopproduct') score += 6; + else if (t === 'shop-purchase') score += 2; + else if (t === 'pad' || t === 'padentry') score += 3; + else if (t === 'calendar' || t === 'calendarnote' || t === 'calendardate') score += 3; + else if (t === 'chat') score += 1; + else if (t === 'gamescore') score += 2; + else if (t === 'pixelia') score += 2; + } + return Math.max(0, Math.round(score)); + }; + + const getStats = async (filter = 'ALL') => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const messages = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit, reverse: true }), + pull.collect((err, msgs) => err ? rej(err) : res(msgs)) + ); + }); + + const allMsgs = messages.filter(m => { + const c = m.value && m.value.content; + if (!c) return false; + if (typeof c === 'string' && c.endsWith('.box')) return false; + if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) return false; + return true; + }); + const tombTargets = buildValidatedTombstoneSet(allMsgs); + + const scopedMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs; + + const byType = {}; + const parentOf = {}; + for (const t of types) { + byType[t] = new Map(); + parentOf[t] = new Map(); + } + + const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; + const accessibleTribeIds = await buildAccessibleTribeIds(); + for (const m of scopedMsgs) { + const k = m.key; + let c = m.value.content; + if (tribeCrypto && tribeCrypto.isTribeMsg(c)) { + const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null; + if (!r || !r.body) continue; + const inner = r.body; + if (inner.k === 'tribe') { + c = { ...inner, type: 'tribe' }; + } else if (inner.k === 'tribe-content' && inner.contentType) { + c = { ...inner, type: inner.contentType }; + } else { + continue; + } + } else if (c && c.tribeId && !accessibleTribeIds.has(c.tribeId)) { + continue; + } + let theType = c.type; + if (!types.includes(theType)) continue; + byType[theType].set(k, { key: k, ts: m.value.timestamp, content: c, author: m.value.author }); + if (c.replaces) parentOf[theType].set(k, c.replaces); + } + + const findRoot = (t, id) => { + let cur = id; + const pMap = parentOf[t]; + while (pMap.has(cur)) cur = pMap.get(cur); + return cur; + }; + + const tipOf = {}; + for (const t of types) { + tipOf[t] = new Map(); + const pMap = parentOf[t]; + const fwd = new Map(); + for (const [child, parent] of pMap.entries()) fwd.set(parent, child); + const allMap = byType[t]; + const roots = new Set(Array.from(allMap.keys()).map(id => findRoot(t, id))); + for (const root of roots) { + let tip = root; + while (fwd.has(tip)) tip = fwd.get(tip); + if (tombTargets.has(tip)) continue; + const node = allMap.get(tip) || allMap.get(root); + if (node) tipOf[t].set(root, node); + } + } + + const tribeTipNodes = Array.from(tipOf['tribe'].values()); + const tribeDedupNodes = dedupeTribesNodes(tribeTipNodes); + const tribeDedupContents = tribeDedupNodes.map(n => n.content); + + const tribePublic = tribeDedupContents.filter(c => c.isAnonymous === false); + const tribePrivate = tribeDedupContents.filter(c => c.isAnonymous !== false); + const tribePublicNames = tribePublic.map(c => c.name || c.title || c.id).filter(Boolean); + const tribePublicCount = tribePublicNames.length; + const tribePrivateCount = tribePrivate.length; + + const allTribesPublic = tribeDedupNodes + .filter(n => n.content?.isAnonymous === false) + .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key })); + + const allTribes = allTribesPublic.map(t => t.name); + + const memberTribesDetailed = tribeDedupNodes + .filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId)) + .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key })); + + const myPrivateTribesDetailed = tribeDedupNodes + .filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId)) + .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key })); + + const content = {}; + const opinions = {}; + for (const t of types) { + let vals; + if (t === 'tribe') { + vals = tribeDedupContents; + } else { + vals = Array.from(tipOf[t].values()).map(v => v.content); + if (t === 'forum') vals = vals.filter(c => !(c.root && tombTargets.has(c.root))); + } + content[t] = vals.length || 0; + opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length || 0; + } + + if (filter === 'MINE') { + const myMsgs = allMsgs.filter(m => m.value.author === userId); + content['karmaScore'] = computeKarmaFromMsgs(myMsgs); + } else { + const msgsByAuthor = new Map(); + for (const m of allMsgs) { + const a = m.value.author; + if (!msgsByAuthor.has(a)) msgsByAuthor.set(a, []); + msgsByAuthor.get(a).push(m); + } + let sumKarma = 0; + for (const authorMsgs of msgsByAuthor.values()) { + sumKarma += computeKarmaFromMsgs(authorMsgs); + } + content['karmaScore'] = sumKarma; + } + + const inhabitants = new Set(allMsgs.map(m => m.value.author)).size; + + const secretStat = fs.statSync(`${os.homedir()}/.ssb/secret`); + const createdAt = secretStat.birthtime.toLocaleString(); + + const folderSize = getFolderSize(`${os.homedir()}/.ssb`); + const flumeSize = getFolderSize(`${os.homedir()}/.ssb/flume`); + const blobsSize = getFolderSize(`${os.homedir()}/.ssb/blobs`); + + const allTs = scopedMsgs.map(m => m.value.timestamp || 0).filter(Boolean); + const lastTs = allTs.length ? Math.max(...allTs) : 0; + + const mapDay = new Map(); + for (const m of scopedMsgs) { + const dk = dayKey(m.value.timestamp || 0); + mapDay.set(dk, (mapDay.get(dk) || 0) + 1); + } + const days7 = lastNDays(7).map(d => ({ day: d, count: mapDay.get(d) || 0 })); + const days30 = lastNDays(30).map(d => ({ day: d, count: mapDay.get(d) || 0 })); + const daily7Total = sum(days7.map(o => o.count)); + const daily30Total = sum(days30.map(o => o.count)); + + const jobsVals = Array.from(tipOf['job'].values()).map(v => v.content); + const jobOpen = jobsVals.filter(j => N(j.status) === 'OPEN').length; + const jobClosed = jobsVals.filter(j => N(j.status) === 'CLOSED').length; + const jobSalaries = jobsVals.map(j => parseFloat(j.salary)).filter(n => isFinite(n)); + const jobVacantsOpen = jobsVals.filter(j => N(j.status) === 'OPEN').map(j => parseInt(j.vacants || 0, 10) || 0); + const jobSubsTotal = jobsVals.map(j => Array.isArray(j.subscribers) ? j.subscribers.length : 0); + + const marketVals = Array.from(tipOf['market'].values()).map(v => v.content); + const mkForSale = marketVals.filter(m => N(m.status) === 'FOR SALE').length; + const mkReserved = marketVals.filter(m => N(m.status) === 'RESERVED').length; + const mkClosed = marketVals.filter(m => N(m.status) === 'CLOSED').length; + const mkSold = marketVals.filter(m => N(m.status) === 'SOLD').length; + let revenueECO = 0; + const soldPrices = []; + for (const m of marketVals) { + if (N(m.status) !== 'SOLD') continue; + let price = 0; + if (String(m.item_type || '').toLowerCase() === 'auction') { + price = parseAuctionMax(m.auctions_poll); + } else { + price = parseFloat(m.price || 0) || 0; + } + soldPrices.push(price); + revenueECO += price; + } + + const projectVals = Array.from(tipOf['project'].values()).map(v => v.content); + const prActive = projectVals.filter(p => N(p.status) === 'ACTIVE').length; + const prCompleted = projectVals.filter(p => N(p.status) === 'COMPLETED').length; + const prPaused = projectVals.filter(p => N(p.status) === 'PAUSED').length; + const prCancelled = projectVals.filter(p => N(p.status) === 'CANCELLED').length; + const prGoals = projectVals.map(p => parseFloat(p.goal || 0) || 0); + const prPledged = projectVals.map(p => parseFloat(p.pledged || 0) || 0); + const prProgress = projectVals.map(p => parseFloat(p.progress || 0) || 0); + const activeFundingRates = projectVals + .filter(p => N(p.status) === 'ACTIVE' && parseFloat(p.goal || 0) > 0) + .map(p => (parseFloat(p.pledged || 0) / parseFloat(p.goal || 1)) * 100); + + const projectsKPIs = { + total: projectVals.length, + active: prActive, + completed: prCompleted, + paused: prPaused, + cancelled: prCancelled, + ecoGoalTotal: sum(prGoals), + ecoPledgedTotal: sum(prPledged), + successRate: projectVals.length ? (prCompleted / projectVals.length) * 100 : 0, + avgProgress: prProgress.length ? (sum(prProgress) / prProgress.length) : 0, + medianProgress: median(prProgress), + activeFundingAvg: activeFundingRates.length ? (sum(activeFundingRates) / activeFundingRates.length) : 0 + }; + + const topAuthorsMap = new Map(); + for (const m of scopedMsgs) { + const a = m.value.author; + topAuthorsMap.set(a, (topAuthorsMap.get(a) || 0) + 1); + } + const topAuthors = Array.from(topAuthorsMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([id, count]) => ({ id, count })); + + const tagCount = new Map(); + for (const t of types) { + for (const v of Array.from(tipOf[t].values())) { + const tags = v.content && Array.isArray(v.content.tags) ? v.content.tags : []; + for (const tg of tags) { + const key = String(tg || '').trim(); + if (!key) continue; + tagCount.set(key, (tagCount.get(key) || 0) + 1); + } + } + } + const topTags = Array.from(tagCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([tag, count]) => ({ tag, count })); + + const totalTypeCount = types.reduce((s, t) => s + (Array.from(tipOf[t].values()).length || 0), 0); + const topTypeBlacklist = new Set(['shopProduct','chatMessage','padEntry','calendarDate','calendarNote','log']); + const topTypes = types + .filter(t => !topTypeBlacklist.has(t)) + .map(t => ({ type: t, count: Array.from(tipOf[t].values()).length || 0 })) + .filter(o => o.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const myMsgsAll = allMsgs.filter(m => m.value.author === userId); + const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0; + const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 0; + const validatedTombstoneCount = tombTargets.size; + const tombstoneRatio = allMsgs.length ? (validatedTombstoneCount / allMsgs.length) * 100 : 0; + + const totalsTs = allMsgs.map(m => m.value.timestamp || 0).filter(Boolean).sort((a, b) => a - b); + const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0; + const networkMsgsPerDay = networkSpanDays > 0 ? (allMsgs.length / networkSpanDays) : 0; + + const addrMap = readAddrMap(); + const myAddress = addrMap[userId] || null; + const banking = { + ecoWalletConfigured: !!myAddress, + myAddress, + myAddressCount: myAddress ? 1 : 0, + totalAddresses: Object.keys(addrMap).length + }; + const pubsCount = listPubsFromEbt().length; + + const stats = { + id: userId, + createdAt, + inhabitants, + content, + opinions, + memberTribes: memberTribesDetailed.map(t => t.name), + memberTribesDetailed, + myPrivateTribesDetailed, + allTribes, + allTribesPublic, + tribePublicNames, + tribePublicCount, + tribePrivateCount, + userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone' && m.value.author === userId).length, + networkTombstoneCount: validatedTombstoneCount, + folderSize: formatSize(folderSize), + statsBlockchainSize: formatSize(flumeSize), + statsBlobsSize: formatSize(blobsSize), + pubsCount, + activity: { + lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null, + daily7: days7, + daily30Total, + daily7Total + }, + jobsKPIs: { + total: jobsVals.length, + open: jobOpen, + closed: jobClosed, + avgSalary: jobSalaries.length ? (sum(jobSalaries) / jobSalaries.length) : 0, + medianSalary: median(jobSalaries), + openVacants: sum(jobVacantsOpen), + subscribersTotal: sum(jobSubsTotal) + }, + marketKPIs: { + total: marketVals.length, + forSale: mkForSale, + reserved: mkReserved, + closed: mkClosed, + sold: mkSold, + revenueECO, + avgSoldPrice: soldPrices.length ? (sum(soldPrices) / soldPrices.length) : 0 + }, + projectsKPIs, + usersKPIs: { + totalInhabitants: inhabitants, + topAuthors + }, + tombstoneKPIs: { + networkTombstoneCount: validatedTombstoneCount, + ratio: tombstoneRatio + }, + networkKPIs: { + totalMsgs: allMsgs.length, + myMsgs: myMsgsAll.length, + myShare, + avgMsgsPerInhabitant, + networkSpanDays, + networkMsgsPerDay + }, + topTags, + topTypes, + totalTypeCount, + banking + }; + + return stats; + }; + + return { getStats }; +}; + diff --git a/src/models/tags_model.js b/src/models/tags_model.js new file mode 100644 index 0000000..f074453 --- /dev/null +++ b/src/models/tags_model.js @@ -0,0 +1,207 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler, padsModel, tribesModel }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const norm = (v) => String(v == null ? '' : v).trim().toLowerCase(); + + const normalizeTag = (tag) => String(tag == null ? '' : tag).trim().replace(/^#/, ''); + const tagKey = (tag) => normalizeTag(tag).toLowerCase(); + + const getDedupeKey = (msg) => { + const c = msg?.value?.content || {}; + const t = c?.type || 'unknown'; + const author = msg?.value?.author || c.author || ''; + + if (t === 'post') return `post:${msg.key}`; + + if (t === 'about') return `about:${c.about || author || msg.key}`; + if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`; + if (t === 'contact') return `contact:${c.contact || msg.key}`; + if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`; + if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`; + if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`; + if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`; + + if (t === 'document') return `document:${author}|${c.key || c.url || norm(c.title) || msg.key}`; + if (t === 'image') return `image:${author}|${c.url || `${norm(c.title)}|${norm(c.description)}` || msg.key}`; + if (t === 'audio') return `audio:${author}|${c.url || `${norm(c.title)}|${norm(c.description)}` || msg.key}`; + if (t === 'video') return `video:${author}|${c.url || `${norm(c.title)}|${norm(c.description)}` || msg.key}`; + if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`; + + if (t === 'tribe') { + return ['tribe', author, norm(c.title), norm(c.location), norm(c.image)].join('|'); + } + + if (t === 'event') { + return ['event', c.organizer || author, norm(c.title), norm(c.date), norm(c.location)].join('|'); + } + + if (t === 'task') { + return ['task', c.author || author, norm(c.title), norm(c.startTime), norm(c.endTime), norm(c.location)].join('|'); + } + + if (t === 'report') { + return ['report', c.author || author, norm(c.title), norm(c.category), norm(c.severity)].join('|'); + } + + if (t === 'votes') { + return ['votes', c.createdBy || author, norm(c.question), norm(c.deadline)].join('|'); + } + + if (t === 'market') { + return ['market', c.seller || author, norm(c.title), norm(c.deadline), norm(c.item_type), norm(c.image)].join('|'); + } + + if (t === 'transfer') { + const txid = c.txid || c.transactionId || c.id; + if (txid) return `transfer:${txid}`; + return ['transfer', norm(c.from), norm(c.to), norm(c.amount), norm(c.concept), norm(c.deadline)].join('|'); + } + + if (t === 'feed') { + return ['feed', c.author || author, norm(c.text)].join('|'); + } + + if (t === 'project') { + return ['project', c.activityActor || author, norm(c.title), norm(c.deadline), norm(c.goal)].join('|'); + } + + if (t === 'job') { + return ['job', author, norm(c.title), norm(c.location), norm(c.salary), norm(c.job_type)].join('|'); + } + + if (t === 'forum') { + return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`; + } + + return `${t}:${msg.key}`; + }; + + const dedupeKeepLatest = (msgs) => { + const map = new Map(); + for (const msg of msgs) { + const k = getDedupeKey(msg); + const prev = map.get(k); + const ts = msg?.value?.timestamp || 0; + const pts = prev?.value?.timestamp || 0; + if (!prev || ts > pts) map.set(k, msg); + } + return Array.from(map.values()); + }; + + return { + async listTags(filter = 'all') { + const ssbClient = await openSsb(); + + const messages = await new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ); + }); + + const tombstoned = buildValidatedTombstoneSet(messages); + + const replacesMap = new Map(); + const latestByKey = new Map(); + + for (const msg of messages) { + const k = msg?.key; + const c = msg?.value?.content; + const t = c?.type; + if (!k || !c || !t) continue; + if (tombstoned.has(k)) continue; + if (t === 'tombstone') continue; + if (c.replaces) replacesMap.set(c.replaces, k); + latestByKey.set(k, msg); + } + + for (const [oldId, newId] of Array.from(replacesMap.entries())) { + const oldMsg = latestByKey.get(oldId); + if (!oldMsg) { replacesMap.delete(oldId); continue; } + const newMsg = latestByKey.get(newId); + if (!newMsg || String(newMsg?.value?.author) !== String(oldMsg?.value?.author)) { + replacesMap.delete(oldId); + latestByKey.delete(newId); + } + } + + for (const oldId of replacesMap.keys()) latestByKey.delete(oldId); + + const viewerId = ssbClient.id; + const viewerVisibleTribeIds = new Set(); + if (tribesModel) { + const visibleTribes = await tribesModel.listTribesForViewer(viewerId).catch(() => []); + for (const tribe of visibleTribes) viewerVisibleTribeIds.add(tribe.id); + } + + let filtered = Array.from(latestByKey.values()).filter(msg => { + const c = msg?.value?.content; + if (!c || c.type === 'tombstone') return false; + if (tombstoned.has(msg.key)) return false; + if (c.encryptedPayload) return false; + if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false; + if (c.tribeId && !viewerVisibleTribeIds.has(c.tribeId)) return false; + if (c.type === 'event' && c.isPublic === 'private') return false; + if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false; + if ((c.type === 'chat' || c.type === 'pad' || c.type === 'map' || c.type === 'calendar') && c.status === 'INVITE-ONLY' && c.author !== viewerId && !(Array.isArray(c.members) && c.members.includes(viewerId))) return false; + if (c.type === 'shop' && c.visibility === 'CLOSED') return false; + return true; + }); + + filtered = dedupeKeepLatest(filtered); + + const counts = new Map(); + + for (const record of filtered) { + const tagsArr = record?.value?.content?.tags || []; + const uniqueTags = new Set(tagsArr.map(tagKey).filter(Boolean)); + for (const k of uniqueTags) { + const display = normalizeTag(tagsArr.find(t => tagKey(t) === k) || k) || k; + const prev = counts.get(k); + if (!prev) counts.set(k, { name: display, count: 1 }); + else counts.set(k, { name: prev.name || display, count: prev.count + 1 }); + } + } + + if (padsModel) { + const ssbClient2 = await openSsb(); + const viewerId = ssbClient2.id; + const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []); + for (const pad of pads) { + if (pad.status === 'INVITE-ONLY' && pad.author !== viewerId && !(Array.isArray(pad.members) && pad.members.includes(viewerId))) continue; + if (!Array.isArray(pad.tags)) continue; + const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean)); + for (const k of uniquePadTags) { + const display = normalizeTag(pad.tags.find(t => tagKey(t) === k) || k) || k; + const prev = counts.get(k); + if (!prev) counts.set(k, { name: display, count: 1 }); + else counts.set(k, { name: prev.name || display, count: prev.count + 1 }); + } + } + } + + let tags = Array.from(counts.values()); + + if (filter === 'top') { + tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + } else if (filter === 'cloud') { + const max = Math.max(...tags.map(t => t.count), 1); + tags = tags.map(t => ({ ...t, weight: t.count / max })); + } else { + tags.sort((a, b) => a.name.localeCompare(b.name)); + } + + return tags; + } + }; +}; + diff --git a/src/models/tasks_model.js b/src/models/tasks_model.js new file mode 100644 index 0000000..a750a3b --- /dev/null +++ b/src/models/tasks_model.js @@ -0,0 +1,369 @@ +const pull = require('../server/node_modules/pull-stream'); +const moment = require('../server/node_modules/moment'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +module.exports = ({ cooler, pmModel }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length))); + + const normalizeVisibility = (v) => { + const vv = String(v || 'PUBLIC').toUpperCase(); + return (vv === 'PUBLIC' || vv === 'PRIVATE') ? vv : 'PUBLIC'; + }; + + const normalizeStatus = (v, fallback) => { + const vv = String(v || '').toUpperCase(); + if (vv === 'OPEN' || vv === 'IN-PROGRESS' || vv === 'CLOSED') return vv; + return fallback; + }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) + ); + }); + + const getMsg = async (ssbClient, key) => + new Promise((resolve, reject) => { + ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg))); + }); + + const buildIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const opinionMsgs = []; + const assignMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === 'tombstone') continue; + if (c.type === 'taskOpinion') { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + if (c.type === 'taskAssign') { assignMsgs.push({ target: c.target, author: v.author, on: c.on !== false, ts: v.timestamp || m.timestamp || 0 }); continue; } + if (c.type !== 'task') continue; + + const ts = v.timestamp || m.timestamp || 0; + nodes.set(k, { key: k, ts, c, author: v.author }); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const opinionsByRoot = new Map(); + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const assignByRoot = new Map(); + for (const as of assignMsgs) { if (!nodes.has(as.target)) continue; const r = rootOf(as.target); if (!assignByRoot.has(r)) assignByRoot.set(r, new Map()); const m2 = assignByRoot.get(r); const p = m2.get(as.author); if (!p || as.ts >= p.ts) m2.set(as.author, { on: as.on, ts: as.ts }); } + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + const ownerNode = nodes.get(root); + const oc = ownerNode ? ownerNode.c : {}; + + const opinions = { ...(oc.opinions || {}) }; + const voters = uniq(oc.opinions_inhabitants).slice(); + const voterSet = new Set(voters); + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); voters.push(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + + const creator = ownerNode ? ownerNode.author : null; + const asg = new Set(uniq(oc.assignees)); + if (creator) asg.add(creator); + for (const [au, st] of (assignByRoot.get(root) || new Map())) { if (st.on) asg.add(au); else if (au !== creator) asg.delete(au); } + const assignees = [...asg]; + + return { contentTip, contentNode, opinions, voters, assignees }; + }; + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + return { tomb, nodes, parent, child, rootOf, tipByRoot, resolveGroup }; + }; + + const buildTask = (node, rootId, agg, now) => { + const c = node.c || {}; + const opinions = agg ? agg.opinions : (c.opinions || {}); + const voters = agg ? agg.voters : (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []); + const assignees = agg ? agg.assignees : (Array.isArray(c.assignees) ? c.assignees : []); + const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status; + return { + id: node.key, + rootId, + ...c, + assignees, + opinions, + opinions_inhabitants: voters, + status + }; + }; + + return { + async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic) { + const ssb = await openSsb(); + const userId = ssb.id; + + const start = moment(startTime); + const end = moment(endTime); + if (!start.isValid() || !end.isValid()) throw new Error('Invalid dates'); + + const nowFloor = moment().startOf('minute'); + if (start.isBefore(nowFloor) || end.isBefore(start)) throw new Error('Invalid time range'); + + const tags = Array.isArray(tagsRaw) + ? tagsRaw.filter(Boolean) + : String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean); + + const visibility = normalizeVisibility(isPublic); + + const content = { + type: 'task', + title, + description, + startTime: start.toISOString(), + endTime: end.toISOString(), + priority, + location, + tags, + isPublic: visibility, + assignees: [userId], + createdAt: new Date().toISOString(), + status: 'OPEN', + author: userId, + opinions: {}, + opinions_inhabitants: [] + }; + + return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg))); + }, + + async deleteTaskById(taskId) { + const ssb = await openSsb(); + const userId = ssb.id; + const task = await new Promise((res, rej) => ssb.get(taskId, (err, task) => err ? rej(new Error('Task not found')) : res(task))); + if (task.content.author !== userId) throw new Error('Not the author'); + const tombstone = { type: 'tombstone', target: taskId, deletedAt: new Date().toISOString(), author: userId }; + return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result))); + }, + + async updateTaskById(taskId, updatedData) { + const ssb = await openSsb(); + const userId = ssb.id; + + const keys = Object.keys(updatedData || {}).filter(k => updatedData[k] !== undefined); + if (keys.length === 1 && keys[0] === 'assignees') return this.toggleAssignee(taskId); + + const old = await new Promise((res, rej) => + ssb.get(taskId, (err, msg) => err || !msg ? rej(new Error('Task not found')) : res(msg)) + ); + + const c = old.content; + if (c.type !== 'task') throw new Error('Invalid type'); + + const taskCreator = c.author || old.author; + if (taskCreator !== userId) throw new Error('Not the author'); + + if (c.status === 'CLOSED') throw new Error('Cannot edit a closed task'); + + const nextAssignees = Array.isArray(c.assignees) ? uniq(c.assignees) : []; + + let newStart = c.startTime; + if (updatedData.startTime != null && updatedData.startTime !== '') { + const m = moment(updatedData.startTime); + if (!m.isValid()) throw new Error('Invalid startTime'); + newStart = m.toISOString(); + } + + let newEnd = c.endTime; + if (updatedData.endTime != null && updatedData.endTime !== '') { + const m = moment(updatedData.endTime); + if (!m.isValid()) throw new Error('Invalid endTime'); + newEnd = m.toISOString(); + } + + if (moment(newEnd).isBefore(moment(newStart))) throw new Error('Invalid time range'); + + let newTags = c.tags || []; + if (updatedData.tags !== undefined) { + if (Array.isArray(updatedData.tags)) newTags = updatedData.tags.filter(Boolean); + else if (typeof updatedData.tags === 'string') newTags = updatedData.tags.split(',').map(t => t.trim()).filter(Boolean); + else newTags = []; + } + + let newVisibility = c.isPublic; + if (updatedData.isPublic !== undefined) { + newVisibility = normalizeVisibility(updatedData.isPublic); + } + + let newStatus = c.status; + if (updatedData.status !== undefined) { + const normalized = normalizeStatus(updatedData.status, null); + if (!normalized) throw new Error('Invalid status'); + newStatus = normalized; + } + + const updated = { + ...c, + title: updatedData.title ?? c.title, + description: updatedData.description ?? c.description, + startTime: newStart, + endTime: newEnd, + priority: updatedData.priority ?? c.priority, + location: updatedData.location ?? c.location, + tags: newTags, + isPublic: newVisibility, + status: newStatus, + assignees: nextAssignees, + updatedAt: new Date().toISOString(), + replaces: taskId + }; + + return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result))); + }, + + async updateTaskStatus(taskId, status) { + const normalized = String(status || '').toUpperCase(); + if (!['OPEN', 'IN-PROGRESS', 'CLOSED'].includes(normalized)) throw new Error('Invalid status'); + return this.updateTaskById(taskId, { status: normalized }); + }, + + async getTaskById(taskId) { + const ssb = await openSsb(); + const now = moment(); + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + const rootId = idx.rootOf(taskId); + const agg = idx.resolveGroup(rootId); + if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error('Task not found'); + return buildTask(agg.contentNode, rootId, agg, now); + }, + + async createOpinion(id, category) { + const categories = require('../backend/opinion_categories'); + if (!categories.includes(category)) throw new Error('Invalid opinion category'); + const ssb = await openSsb(); + const userId = ssb.id; + const task = await this.getTaskById(id); + const voters = Array.isArray(task.opinions_inhabitants) ? task.opinions_inhabitants : []; + if (voters.includes(userId)) throw new Error('Already opined'); + const content = { type: 'taskOpinion', target: task.rootId, category, createdAt: new Date().toISOString() }; + return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result))); + }, + + async toggleAssignee(taskId) { + const ssb = await openSsb(); + const userId = ssb.id; + const task = await this.getTaskById(taskId); + if (task.status === 'CLOSED') throw new Error('Cannot assign users to a closed task'); + const on = !(Array.isArray(task.assignees) && task.assignees.includes(userId)); + const content = { type: 'taskAssign', target: task.rootId, on, createdAt: new Date().toISOString() }; + return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result))); + }, + + async listAll() { + const ssb = await openSsb(); + const now = moment(); + const messages = await getAllMessages(ssb); + const idx = buildIndex(messages); + + const tasks = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + tasks.push(buildTask(node, rootId, idx.resolveGroup(rootId), now)); + } + + return dedupeBy(tasks, t => t.title ? [norm(t.author), norm(t.title), norm(t.startTime)].join('|') : null); + }, + + async checkDueReminders() { + if (!pmModel) return; + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const now = Date.now(); + const sent = new Set(); + for (const m of messages) { + const c = m.value && m.value.content; + if (!c) continue; + if (c.type === 'taskReminderSent' && c.target) { sent.add(c.target); continue; } + } + const idx = buildIndex(messages); + const tasks = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + tasks.push(buildTask(node, rootId, idx.resolveGroup(rootId))); + } + const publishMarker = (target) => new Promise((resolve, reject) => { + ssbClient.publish({ type: 'taskReminderSent', target, sentAt: new Date().toISOString() }, err => err ? reject(err) : resolve()); + }); + for (const t of tasks) { + if (sent.has(t.id)) continue; + const status = String(t.status || '').toUpperCase(); + if (status !== 'OPEN') continue; + const endTime = t.endTime || t.deadline; + if (!endTime) continue; + const endTs = new Date(endTime).getTime(); + if (!endTs || endTs > now) continue; + const assignees = Array.isArray(t.assignees) ? t.assignees.filter(a => typeof a === 'string' && a.length > 0) : []; + if (assignees.length === 0) continue; + const subject = `Task Reminder: ${t.title || 'Task'}`; + const text = + `Task: ${t.title || ''}\n` + + (t.description ? `Description: ${t.description}\n` : '') + + `Deadline: ${endTime}\n` + + (t.priority ? `Priority: ${t.priority}\n` : '') + + `\nVisit Task: /tasks/${t.id}`; + try { + const chunkSize = 6; + for (let i = 0; i < assignees.length; i += chunkSize) { + await pmModel.sendMessage(assignees.slice(i, i + chunkSize), subject, text); + } + await publishMarker(t.id); + } catch (_) {} + } + } + }; +}; diff --git a/src/models/tombstone_validator.js b/src/models/tombstone_validator.js new file mode 100644 index 0000000..a3a5b4d --- /dev/null +++ b/src/models/tombstone_validator.js @@ -0,0 +1,39 @@ +function buildValidatedTombstoneSet(messages) { + if (!Array.isArray(messages)) return new Set(); + const tombClaims = new Map(); + const targetAuthors = new Map(); + const targetRoots = new Map(); + for (const m of messages) { + if (!m || !m.value) continue; + const v = m.value; + const c = v.content; + if (!c) continue; + if (typeof c === 'object' && c.type === 'tombstone' && typeof c.target === 'string') { + const ts = v.timestamp || 0; + const prev = tombClaims.get(c.target); + if (!prev || ts > prev.ts) tombClaims.set(c.target, { author: v.author, ts, rootId: c._rootId || null }); + continue; + } + if (m.key) { + targetAuthors.set(m.key, v.author); + if (typeof c === 'object' && c._rootId) targetRoots.set(m.key, c._rootId); + } + } + const out = new Set(); + for (const [target, { author, rootId }] of tombClaims.entries()) { + if (targetAuthors.get(target) !== author) continue; + if (rootId) { + const targetRoot = targetRoots.get(target); + if (targetRoot !== rootId) continue; + } + out.add(target); + } + return out; +} + +function isTombstoneFromAuthor(tombstoneMsg, targetAuthor) { + if (!tombstoneMsg || !tombstoneMsg.value) return false; + return tombstoneMsg.value.author === targetAuthor; +} + +module.exports = { buildValidatedTombstoneSet, isTombstoneFromAuthor }; diff --git a/src/models/torrents_model.js b/src/models/torrents_model.js new file mode 100644 index 0000000..9dc74cb --- /dev/null +++ b/src/models/torrents_model.js @@ -0,0 +1,378 @@ +const pull = require("../server/node_modules/pull-stream"); +const { getConfig } = require("../configs/config-manager.js"); +const categories = require("../backend/opinion_categories"); + +const { dedupeBy, norm } = require('../backend/dedupe'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const safeArr = (v) => (Array.isArray(v) ? v : []); + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return undefined; + if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean); + return String(raw).split(",").map((t) => t.trim()).filter(Boolean); +}; + +const parseBlobId = (blobMarkdown) => { + const s = String(blobMarkdown || ""); + const match = s.match(/\((&[^)]+\.sha256)\)/); + if (match) return match[1]; + const fallback = s.match(/\(([^)]+)\)/g); + return fallback ? fallback[fallback.length - 1].slice(1, -1) : s || null; +}; + +const voteSum = (opinions = {}) => + Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); + +module.exports = ({ cooler, tribeCrypto, tribesModel }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null; + const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c; + const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c; + const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}; + const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {}; + const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, 'torrent') : msgs; + const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))); + }); + + const getMsg = async (ssbClient, key) => + new Promise((resolve) => { + ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg))); + }); + + const buildIndex = (messages) => { + const tomb = new Set(); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const authorByKey = new Map(); + const tombRequests = []; + const opinionMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === "tombstone" && c.target) { + tombRequests.push({ target: c.target, author: v.author }); + continue; + } + + if (c.type === "torrentOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + + if (c.type !== "torrent") continue; + + const ts = v.timestamp || m.timestamp || 0; + let sizeBytes = 0; + try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; } + nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author }); + authorByKey.set(k, v.author); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + for (const t of tombRequests) { + const targetAuthor = authorByKey.get(t.target); + if (targetAuthor && t.author === targetAuthor) tomb.add(t.target); + } + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const opinionsByRoot = new Map(); + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + const forward = new Map(); + for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId); + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + const lc = contentNode ? contentNode.c : {}; + const opinions = { ...(lc.opinions || {}) }; + const voters = safeArr(lc.opinions_inhabitants).slice(); + const voterSet = new Set(voters); + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); voters.push(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + return { contentTip, contentNode, opinions, voters }; + }; + + return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup }; + }; + + const buildTorrent = (node, rootId, viewerId, agg) => { + const c = node.c || {}; + const undec = c.encryptedPayload && c._decrypted === false; + const opinions = agg ? agg.opinions : (c.opinions || {}); + const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants); + return { + key: node.key, + rootId, + url: undec ? "" : c.url, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + tags: safeArr(c.tags), + author: node.author, + title: undec ? "" : (c.title || ""), + description: undec ? "" : (c.description || ""), + size: c.size || 0, + opinions, + opinions_inhabitants: voters, + hasVoted: viewerId ? voters.includes(viewerId) : false, + tribeId: c.tribeId || null, + encrypted: !!undec, + sizeBytes: node.sizeBytes || 0 + }; + }; + + + return { + type: "torrent", + + async resolveCurrentId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + + const tip = idx.tipByRoot.get(idx.rootOf(id)) || id; + if (idx.tomb.has(tip)) throw new Error("Torrent not found"); + return tip; + }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + + let tip = id; + while (idx.forward.has(tip)) tip = idx.forward.get(tip); + if (idx.tomb.has(tip)) throw new Error("Torrent not found"); + + let root = tip; + while (idx.parent.has(root)) root = idx.parent.get(root); + return root; + }, + + async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId) { + const ssbClient = await openSsb(); + const blobId = parseBlobId(blobMarkdown); + const tags = normalizeTags(tagsRaw) || []; + const now = new Date().toISOString(); + + let content = { + type: "torrent", + url: blobId, + createdAt: now, + updatedAt: null, + author: ssbClient.id, + tags, + title: title || "", + description: description || "", + size: Number(size) || 0, + opinions: {}, + opinions_inhabitants: [], + ...(tribeId ? { tribeId } : {}) + }; + + content = await encryptIfTribe(content); + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async updateTorrentById(id, blobMarkdown, tagsRaw, title, description) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const oldMsg = await getMsg(ssbClient, tipId); + + if (!oldMsg) throw new Error("Torrent not found"); + const oldDec = await decryptIfTribe(oldMsg.content); + if (!oldDec || oldDec.type !== "torrent") throw new Error("Torrent not found"); + assertReadable(oldDec, "Torrent"); + if ((oldDec.author || oldMsg.content.author) !== userId) throw new Error("Not the author"); + const aggTorrent = await this.getTorrentById(id, userId); + if (aggTorrent && Object.keys(aggTorrent.opinions || {}).some(k => (aggTorrent.opinions[k] || 0) > 0)) throw new Error("Cannot edit torrent after it has received opinions."); + + const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDec.tags); + const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null; + const now = new Date().toISOString(); + + let updated = { + type: "torrent", + replaces: tipId, + url: blobId || oldDec.url, + tags, + title: title !== undefined ? title || "" : oldDec.title || "", + description: description !== undefined ? description || "" : oldDec.description || "", + size: oldDec.size || 0, + opinions: oldDec.opinions || {}, + opinions_inhabitants: oldDec.opinions_inhabitants || [], + author: oldDec.author || userId, + ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}), + createdAt: oldDec.createdAt, + updatedAt: now + }; + + updated = await encryptIfTribe(updated); + + const result = await new Promise((resolve, reject) => { + ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res))); + }); + const tombstone = await tombFor(tipId, oldDec.tribeId || oldMsg.content.tribeId, userId); + await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); + return result; + }, + + async deleteTorrentById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const msg = await getMsg(ssbClient, tipId); + + if (!msg) throw new Error("Torrent not found"); + const dec = await decryptIfTribe(msg.content); + if (!dec || dec.type !== "torrent") throw new Error("Torrent not found"); + if ((dec.author || msg.content.author) !== userId) throw new Error("Not the author"); + + const tombstone = await tombFor(tipId, dec.tribeId || msg.content.tribeId, userId); + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async listAll(filterOrOpts = "all", maybeOpts = {}) { + const ssbClient = await openSsb(); + + const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {}; + const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all"; + const q = String(opts.q || "").trim().toLowerCase(); + const sort = String(opts.sort || "recent").trim(); + const viewerId = opts.viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + await decryptIndexNodes(idx); + + const items = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + items.push(buildTorrent(node, rootId, viewerId, idx.resolveGroup(rootId))); + } + + let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null); + const now = Date.now(); + + if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId)); + else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000); + else if (filter === "top") { + list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } + + if (q) { + list = list.filter((a) => { + const title = String(a.title || "").toLowerCase(); + const desc = String(a.description || "").toLowerCase(); + const tags = safeArr(a.tags).join(" ").toLowerCase(); + const author = String(a.author || "").toLowerCase(); + return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q); + }); + } + + if (sort === "top") { + list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } else if (sort === "oldest") { + list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + } else { + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + return list; + }, + + async getTorrentById(id, viewerId = null) { + const ssbClient = await openSsb(); + const viewer = viewerId || ssbClient.id; + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(unwrapForIndex(messages)); + await decryptIndexNodes(idx); + + const root = idx.rootOf(id); + const agg = idx.resolveGroup(root); + if (agg.contentNode && !idx.tomb.has(agg.contentTip)) return buildTorrent(agg.contentNode, root, viewer, agg); + + const tip = idx.tipByRoot.get(root) || id; + if (idx.tomb.has(tip)) throw new Error("Torrent not found"); + + const msg = await getMsg(ssbClient, tip); + if (!msg) throw new Error("Torrent not found"); + let c = msg.content; + if (tribeCrypto && (c?.encryptedPayload || tribeCrypto.isTribeMsg(c)) && tribesModel) { + const dec = await tribeCrypto.decryptFromTribe(c, tribesModel); + c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false }; + } + if (!c || c.type !== "torrent") throw new Error("Torrent not found"); + return buildTorrent({ key: tip, ts: msg.timestamp || 0, c, author: msg.author }, root, viewer, agg); + }, + + async createOpinion(id, category) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + if (!categories.includes(category)) throw new Error("Invalid voting category"); + + const torrent = await this.getTorrentById(id, userId); + if (!torrent) throw new Error("Torrent not found"); + if (safeArr(torrent.opinions_inhabitants).includes(userId)) throw new Error("Already voted"); + + const content = { type: "torrentOpinion", target: torrent.rootId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + } + }; +}; diff --git a/src/models/transfers_model.js b/src/models/transfers_model.js new file mode 100644 index 0000000..8e012e2 --- /dev/null +++ b/src/models/transfers_model.js @@ -0,0 +1,430 @@ +const pull = require("../server/node_modules/pull-stream") +const moment = require("../server/node_modules/moment") +const { getConfig } = require("../configs/config-manager.js") +const categories = require("../backend/opinion_categories") +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeByPreferring, norm } = require('../backend/dedupe') +const logLimit = getConfig().ssbLogStream?.limit || 1000 + +const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || "")) + +const parseNum = (v) => { + const n = parseFloat(String(v ?? "").replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return [] + if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) + return String(raw).split(",").map(t => t.trim()).filter(Boolean) +} + +const CATEGORIES = ["ECONOMIC", "TIME", "TRUST"] +const normalizeCategory = (raw) => { + const c = String(raw || "ECONOMIC").trim().toUpperCase() + return CATEGORIES.includes(c) ? c : "ECONOMIC" +} + +module.exports = ({ cooler }) => { + let ssb + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ) + }) + + const getMsg = async (ssbClient, key) => + new Promise((resolve, reject) => { + ssbClient.get(key, (err, msg) => err ? reject(err) : resolve(msg)) + }) + + const buildIndex = (messages) => { + const tomb = new Set() + const nodes = new Map() + const parent = new Map() + const child = new Map() + const strictChild = new Map() + const confirms = [] + const opinionMsgs = [] + const ubiByPub = new Map() + const ubiByUser = new Map() + const ubiClaimNodes = [] + + for (const m of messages) { + const k = m.key + const v = m.value || {} + const c = v.content + if (!c) continue + + if (c.type === "tombstone" && c.target) { + tomb.add(c.target) + continue + } + if (c.type === "transferConfirm") { confirms.push({ target: c.target, author: v.author, ts: v.timestamp || 0 }); continue } + if (c.type === "transferOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category, ts: v.timestamp || 0 }); continue } + + if (c.type === "transfer") { + nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + if (c.replaces) { + parent.set(k, c.replaces) + child.set(c.replaces, k) + } + const tags = Array.isArray(c.tags) ? c.tags.map(t => String(t).toUpperCase()) : [] + if (tags.includes("UBI") && c.to && c.concept) { + const key = `${c.to}::${c.concept}` + if (v.author === c.from) ubiByPub.set(key, k) + else ubiByUser.set(key, k) + } + } + + if (c.type === "ubiClaim") { + ubiClaimNodes.push({ k, v, c, ts: v.timestamp || m.timestamp || 0 }) + } + } + + for (const [key, userMsgKey] of ubiByUser.entries()) { + if (ubiByPub.has(key)) tomb.add(userMsgKey) + } + + for (const { k, v, c, ts } of ubiClaimNodes) { + if (tomb.has(k)) continue + const claimantId = v.author + const epochId = c.epochId || "" + const concept = `UBI ${epochId} ${claimantId}`.trim() + const key = `${claimantId}::${concept}` + if (ubiByPub.has(key) || ubiByUser.has(key)) continue + const synthetic = { + type: "transfer", + from: c.pubId || "", + to: claimantId, + concept, + amount: String(c.amount || 0), + createdAt: c.claimedAt || new Date(ts).toISOString(), + updatedAt: c.claimedAt || new Date(ts).toISOString(), + deadline: null, + confirmedBy: [c.pubId || ""].filter(Boolean), + status: "UNCONFIRMED", + tags: ["UBI", "PENDING"], + opinions: {}, + opinions_inhabitants: [] + } + nodes.set(k, { key: k, ts, c: synthetic, author: claimantId }) + } + + for (const [k, t] of Array.from(parent.entries())) { + const cn = nodes.get(k) + const pn = nodes.get(t) + if (!pn) { parent.delete(k); if (child.get(t) === k) child.delete(t); continue } + if (!cn || cn.author !== pn.author) { parent.delete(k); if (child.get(t) === k) child.delete(t); nodes.delete(k) } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k) } + } + + const rootOf = (id) => { + let cur = id, g = 0 + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur) + return cur + } + + + const contentTipOf = (root) => { + let cur = root, g = 0 + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur) + const n = nodes.get(cur), rn = nodes.get(root) + return (n && rn && n.author === rn.author) ? cur : root + } + + const roots = new Set() + for (const id of nodes.keys()) roots.add(rootOf(id)) + + const confirmsByRoot = new Map(), opinionsByRoot = new Map() + for (const cf of confirms) { if (!nodes.has(cf.target)) continue; const r = rootOf(cf.target); if (!confirmsByRoot.has(r)) confirmsByRoot.set(r, []); confirmsByRoot.get(r).push(cf) } + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op) } + + const tipByRoot = new Map() + for (const r of roots) tipByRoot.set(r, contentTipOf(r)) + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root) + const best = nodes.get(contentTip) || nodes.get(root) + if (!best) return null + const rootAuthor = nodes.get(root) ? nodes.get(root).author : null + const groupKeys = []; { let x = root, g = 0; groupKeys.push(x); while (child.has(x) && g++ < 100000) { x = child.get(x); groupKeys.push(x) } } + const confirmedBy = new Set() + const opinions = {}; const opinionSet = new Set() + for (const k of groupKeys) { + const n = nodes.get(k); if (!n) continue; if (n.author !== rootAuthor) continue; const c = n.c + for (const cb of (Array.isArray(c.confirmedBy) ? c.confirmedBy : [])) confirmedBy.add(cb) + for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) opinionSet.add(v) + if (c.opinions && typeof c.opinions === "object") for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0) + } + if (best.c.from) confirmedBy.add(best.c.from) + for (const cf of (confirmsByRoot.get(root) || [])) confirmedBy.add(cf.author) + for (const op of (opinionsByRoot.get(root) || [])) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } } + return { contentTip, best, confirmedBy: [...confirmedBy], opinions, opinions_inhabitants: [...opinionSet], tombstoned: tomb.has(contentTip) } + } + + return { tomb, nodes, parent, child, rootOf, tipByRoot, resolveGroup } + } + + const deriveStatus = (t) => { + const status = String(t.status || "").toUpperCase() + const required = t.from === t.to ? 1 : 2 + const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0 + + if (status === "DISCARDED") return "DISCARDED" + if (confirmedCount >= required) return "CLOSED" + const dl = t.deadline ? moment(t.deadline) : null + if (dl && dl.isValid() && dl.isBefore(moment())) return "DISCARDED" + return status === "CLOSED" ? "CLOSED" : "UNCONFIRMED" + } + + const buildTransfer = (node, agg) => { + const c = node.c || {} + const confirmedBy = agg ? agg.confirmedBy : (Array.isArray(c.confirmedBy) ? c.confirmedBy : []) + const opinions = agg ? agg.opinions : (c.opinions || {}) + const opinions_inhabitants = agg ? agg.opinions_inhabitants : (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []) + return { + id: node.key, + author: node.author, + from: c.from, + to: c.to, + concept: c.concept, + amount: c.amount, + category: normalizeCategory(c.category), + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + deadline: c.deadline, + confirmedBy, + status: deriveStatus({ ...c, confirmedBy }), + tags: Array.isArray(c.tags) ? c.tags : [], + opinions, + opinions_inhabitants + } + } + + return { + type: "transfer", + + async resolveCurrentId(id) { + const ssbClient = await openSsb() + const messages = await getAllMessages(ssbClient) + const idx = buildIndex(messages) + + const tip = idx.tipByRoot.get(idx.rootOf(id)) || id + if (idx.tomb.has(tip)) throw new Error("Not found") + return tip + }, + + async createTransfer(to, concept, amount, deadline, tagsRaw = [], category) { + const ssbClient = await openSsb() + const userId = ssbClient.id + + if (!isValidId(to)) throw new Error("Invalid recipient ID") + + const num = parseNum(amount) + if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive") + + const dl = moment(deadline, moment.ISO_8601, true) + if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future") + + const tags = normalizeTags(tagsRaw) + const cat = normalizeCategory(category) + const isSelf = to === userId + const now = new Date().toISOString() + + const content = { + type: "transfer", + from: userId, + to, + concept: String(concept || ""), + amount: num.toFixed(6), + category: cat, + createdAt: now, + updatedAt: now, + deadline: dl.toISOString(), + confirmedBy: [userId], + status: isSelf ? "CLOSED" : "UNCONFIRMED", + tags, + opinions: {}, + opinions_inhabitants: [] + } + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + }, + + async updateTransferById(id, to, concept, amount, deadline, tagsRaw = [], category) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const tipId = await this.resolveCurrentId(id) + const old = await getMsg(ssbClient, tipId) + + if (!old?.content || old.content.type !== "transfer") throw new Error("Transfer not found") + + const current = old.content + const idxU = buildIndex(await getAllMessages(ssbClient)) + const gU = idxU.resolveGroup(idxU.rootOf(tipId)) + const aggU = gU ? buildTransfer(gU.best, gU) : buildTransfer({ key: tipId, c: current }) + const currentStatus = aggU.status + + if (Object.keys(aggU.opinions || {}).some(k => (aggU.opinions[k] || 0) > 0)) throw new Error("Cannot edit transfer after it has received opinions.") + if (current.from !== userId) throw new Error("Not the author") + if (currentStatus !== "UNCONFIRMED") throw new Error("Can only edit unconfirmed") + + const dlOld = current.deadline ? moment(current.deadline) : null + if (dlOld && dlOld.isValid() && dlOld.isBefore(moment())) throw new Error("Cannot edit expired") + + if (!isValidId(to)) throw new Error("Invalid recipient ID") + + const num = parseNum(amount) + if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive") + + const dl = moment(deadline, moment.ISO_8601, true) + if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future") + + const tags = normalizeTags(tagsRaw) + const cat = normalizeCategory(category !== undefined ? category : current.category) + const isSelf = to === userId + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + await new Promise((res, rej) => ssbClient.publish(tombstone, (err) => err ? rej(err) : res())) + + const updated = { + type: "transfer", + from: userId, + to, + concept: String(concept || ""), + amount: num.toFixed(6), + category: cat, + createdAt: current.createdAt, + deadline: dl.toISOString(), + confirmedBy: [userId], + status: isSelf ? "CLOSED" : "UNCONFIRMED", + tags, + opinions: {}, + opinions_inhabitants: [], + updatedAt: new Date().toISOString(), + replaces: tipId + } + + return new Promise((resolve, reject) => { + ssbClient.publish(updated, (err, msg) => err ? reject(err) : resolve(msg)) + }) + }, + + async confirmTransferById(id) { + const ssbClient = await openSsb() + const userId = ssbClient.id + let tipId + try { tipId = await this.resolveCurrentId(id) } catch (_) { tipId = id } + const msg = await getMsg(ssbClient, tipId) + + if (!msg?.content) throw new Error("Not found") + + if (msg.content.type !== "transfer") throw new Error("Not found") + + const idx = buildIndex(await getAllMessages(ssbClient)) + const root = idx.rootOf(tipId) + const g = idx.resolveGroup(root) + const t = g ? buildTransfer(g.best, g) : buildTransfer({ key: tipId, c: msg.content }) + + if (t.status !== "UNCONFIRMED") throw new Error("Not unconfirmed") + if (t.to !== userId) throw new Error("Not the recipient") + + const dl = t.deadline ? moment(t.deadline) : null + if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Expired") + + if ((Array.isArray(t.confirmedBy) ? t.confirmedBy : []).includes(userId)) throw new Error("Already confirmed") + + const content = { type: "transferConfirm", target: root, createdAt: new Date().toISOString() } + return new Promise((resolve, reject) => { + ssbClient.publish(content, (e2, result) => e2 ? reject(e2) : resolve(result)) + }) + }, + + async deleteTransferById(id) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const tipId = await this.resolveCurrentId(id) + const msg = await getMsg(ssbClient, tipId) + + if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found") + + const idxD = buildIndex(await getAllMessages(ssbClient)) + const gD = idxD.resolveGroup(idxD.rootOf(tipId)) + const t = gD ? buildTransfer(gD.best, gD) : buildTransfer({ key: tipId, c: msg.content }) + const st = t.status + const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0 + const required = t.from === t.to ? 1 : 2 + + if (t.from !== userId) throw new Error("Not the author") + if (st !== "UNCONFIRMED") throw new Error("Not editable") + if (confirmedCount >= required) throw new Error("Not editable") + + const dl = t.deadline ? moment(t.deadline) : null + if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Cannot delete expired") + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve()) + }) + }, + + async listAll(filter = "all") { + const ssbClient = await openSsb() + const messages = await getAllMessages(ssbClient) + const idx = buildIndex(messages) + + const out = [] + for (const root of idx.tipByRoot.keys()) { + const g = idx.resolveGroup(root) + if (!g || g.tombstoned) continue + out.push(buildTransfer(g.best, g)) + } + return dedupeByPreferring(out, (t) => (t.author && t.createdAt) ? norm(t.author) + "|" + norm(t.createdAt) : null, (t) => (Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0)) + }, + + async getTransferById(id) { + const ssbClient = await openSsb() + const messages = await getAllMessages(ssbClient) + const idx = buildIndex(messages) + + const root = idx.rootOf(id) + const g = idx.resolveGroup(root) + if (g && !g.tombstoned) return buildTransfer(g.best, g) + if (g && g.tombstoned) throw new Error("Not found") + + const msg = await getMsg(ssbClient, id) + if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found") + + const tmpNode = { key: id, ts: msg.timestamp || 0, c: msg.content, author: msg.author } + return buildTransfer(tmpNode) + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error("Invalid voting category") + const ssbClient = await openSsb() + const userId = ssbClient.id + const idx = buildIndex(await getAllMessages(ssbClient)) + const root = idx.rootOf(id) + const g = idx.resolveGroup(root) + if (!g || g.tombstoned) throw new Error("Transfer not found") + if ((g.opinions_inhabitants || []).includes(userId)) throw new Error("Already voted") + + const content = { type: "transferOpinion", target: root, category, createdAt: new Date().toISOString() } + return new Promise((resolve, reject) => { + ssbClient.publish(content, (e2, result) => e2 ? reject(e2) : resolve(result)) + }) + } + } +} + diff --git a/src/models/trending_model.js b/src/models/trending_model.js new file mode 100644 index 0000000..0528d61 --- /dev/null +++ b/src/models/trending_model.js @@ -0,0 +1,226 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; +const opinionCategories = require('../backend/opinion_categories'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { buildVoteTally } = require('../backend/vote_tally'); + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const hasBlob = async (ssbClient, url) => { + return new Promise(resolve => { + ssbClient.blobs.has(url, (err, has) => resolve(!err && has)); + }); + }; + + const types = [ + 'bookmark', 'votes', 'feed', + 'image', 'audio', 'video', 'document', 'torrent', 'transfer' + ]; + + const categories = opinionCategories; + + const listTrending = async (filter = 'ALL') => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const messages = await new Promise((res, rej) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, xs) => err ? rej(err) : res(xs)) + ); + }); + + const tombstoned = buildValidatedTombstoneSet(messages); + const replaces = new Map(); + const itemsById = new Map(); + + for (const m of messages) { + const k = m.key; + const c = m.value?.content; + if (!c) continue; + + if (c.type === 'tombstone' && c.target) { + tombstoned.add(c.target); + itemsById.delete(c.target); + continue; + } + + if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) { + if (c.replaces) replaces.set(c.replaces, k); + itemsById.set(k, m); + } + + if (c.type === 'feed' && !tombstoned.has(k) && !itemsById.has(k)) { + if (c.replaces) replaces.set(c.replaces, k); + itemsById.set(k, m); + } + } + + for (const [replacedId, newId] of replaces.entries()) { + const oldMsg = itemsById.get(replacedId); + const newMsg = itemsById.get(newId); + if (!oldMsg) continue; + if (!newMsg || String(newMsg.value?.author) !== String(oldMsg.value?.author)) { + itemsById.delete(newId); + continue; + } + itemsById.delete(replacedId); + } + + let rawItems = Array.from(itemsById.values()).filter(m => types.includes(m.value?.content?.type)); + const blobTypes = ['document', 'image', 'audio', 'video']; + + let items = await Promise.all( + rawItems.map(async m => { + const c = m.value?.content; + if (blobTypes.includes(c.type) && c.url) { + const valid = await hasBlob(ssbClient, c.url); + if (!valid) return null; + } + return m; + }) + ); + items = items.filter(Boolean); + + const signatureOf = (m) => { + const c = m.value?.content || {}; + switch (c.type) { + case 'document': + case 'image': + case 'audio': + case 'video': + return `${c.type}::${(c.url || '').trim()}`; + case 'bookmark': + return `bookmark::${(c.url || '').trim().toLowerCase()}`; + case 'feed': + return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`; + case 'votes': + return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`; + case 'transfer': + return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`; + default: + return `key::${m.key}`; + } + }; + + const voteTally = buildVoteTally(messages); + const votesPrev = new Map(); + for (const m of messages) { + const c = m.value?.content; + if (c && c.type === 'votes' && typeof c.replaces === 'string') votesPrev.set(m.key, c.replaces); + } + const resolveTallyKey = (k) => { + let cur = k; + let g = 0; + while (g++ < 1000 && !voteTally.has(cur) && votesPrev.has(cur)) cur = votesPrev.get(cur); + return cur; + }; + items = items.map(m => { + if (m.value?.content?.type !== 'votes') return m; + const t = voteTally.get(resolveTallyKey(m.key)); + return t ? { ...m, value: { ...m.value, content: { ...m.value.content, ...t } } } : m; + }); + + const bySig = new Map(); + for (const m of items) { + const sig = signatureOf(m); + const prev = bySig.get(sig); + if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) { + bySig.set(sig, m); + } + } + items = Array.from(bySig.values()); + + if (filter === 'MINE') { + items = items.filter(m => m.value.author === userId); + } else if (filter === 'RECENT') { + const now = Date.now(); + items = items.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000); + } + + if (types.includes(filter)) { + items = items.filter(m => m.value.content.type === filter); + } + + if (filter !== 'ALL' && !types.includes(filter)) { + items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0); + } + + if (filter === 'TOP') { + items.sort((a, b) => { + const aLen = (a.value.content.opinions_inhabitants || []).length; + const bLen = (b.value.content.opinions_inhabitants || []).length; + if (bLen !== aLen) return bLen - aLen; + return b.value.timestamp - a.value.timestamp; + }); + } else { + items.sort((a, b) => { + const aLen = (a.value.content.opinions_inhabitants || []).length; + const bLen = (b.value.content.opinions_inhabitants || []).length; + return bLen - aLen; + }); + } + + return { filtered: items }; + }; + + const getMessageById = async (id) => { + const ssbClient = await openSsb(); + return new Promise((res, rej) => { + ssbClient.get(id, (err, msg) => err ? rej(err) : res(msg)); + }); + }; + + const createVote = async (contentId, category) => { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + if (!categories.includes(category)) throw new Error('Invalid voting category'); + + const msg = await getMessageById(contentId); + if (!msg || !msg.content) throw new Error('Content not found'); + + const type = msg.content.type; + if (!types.includes(type) || ['task', 'event', 'report'].includes(type)) { + throw new Error('Voting not allowed on this content type'); + } + + const inhabitants = Array.isArray(msg.content.opinions_inhabitants) ? msg.content.opinions_inhabitants : []; + if (inhabitants.includes(userId)) throw new Error('Already voted'); + + const tombstone = { + type: 'tombstone', + target: contentId, + deletedAt: new Date().toISOString(), + author: userId + }; + + const updated = { + ...msg.content, + opinions: { + ...(msg.content.opinions || {}), + [category]: ((msg.content.opinions && msg.content.opinions[category]) || 0) + 1 + }, + opinions_inhabitants: inhabitants.concat(userId), + updatedAt: new Date().toISOString(), + replaces: contentId + }; + + await new Promise((res, rej) => { + ssbClient.publish(tombstone, err => err ? rej(err) : res()); + }); + + return new Promise((res, rej) => { + ssbClient.publish(updated, (err, result) => err ? rej(err) : res(result)); + }); + }; + + return { listTrending, getMessageById, createVote, types, categories }; +}; + diff --git a/src/models/tribe_crypto.js b/src/models/tribe_crypto.js new file mode 100644 index 0000000..f5576b7 --- /dev/null +++ b/src/models/tribe_crypto.js @@ -0,0 +1 @@ +module.exports = (configPath) => require('./crypto')(configPath, 'tribes'); diff --git a/src/models/tribes_content_model.js b/src/models/tribes_content_model.js new file mode 100644 index 0000000..83fe943 --- /dev/null +++ b/src/models/tribes_content_model.js @@ -0,0 +1,362 @@ +const pull = require('../server/node_modules/pull-stream'); +const { getConfig } = require('../configs/config-manager.js'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; +const tribeLogLimit = Math.max(logLimit, 100000); + +const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia']; +const categories = require('../backend/opinion_categories'); +const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS']; +const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; + +module.exports = ({ cooler, tribeCrypto, tribesModel }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + const readLog = async () => { + const client = await openSsb(); + return new Promise((resolve, reject) => + pull( + client.createLogStream({ limit: tribeLogLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ) + ); + }; + + const fingerprintsForRoot = (rootId) => { + const fps = new Set(); + for (const k of tribeCrypto.getKeys(rootId)) fps.add(tribeCrypto.fingerprint(k)); + return fps; + }; + + const wrapAndPublishContent = async (rootId, body) => { + const client = await openSsb(); + const key = tribeCrypto.getKey(rootId); + if (!key) throw new Error('Missing tribe key for ' + rootId); + const envelope = tribeCrypto.wrapMsg(body, key); + return new Promise((resolve, reject) => + client.publish(envelope, (err, r) => err ? reject(err) : resolve(r)) + ); + }; + + const decodeContentMsgs = async (msgs, opts) => { + const fpIdx = tribeCrypto.buildFingerprintIndex(); + const targetRootId = opts && opts.rootId ? opts.rootId : null; + const wantType = opts && opts.contentType ? opts.contentType : null; + const allowedFps = targetRootId ? fingerprintsForRoot(targetRootId) : null; + + const content = new Map(); + const tombRequests = []; + const collabMsgs = []; + + for (const m of msgs) { + const c = m.value && m.value.content; + if (!c) continue; + if (!tribeCrypto.isTribeMsg(c)) continue; + if (allowedFps && !allowedFps.has(c.fp)) continue; + + const r = tribeCrypto.unwrapMsg(c, fpIdx); + if (!r || !r.body) continue; + const b = r.body; + + if (b.k === 'tombstone') { + tombRequests.push({ target: b.target, author: m.value.author }); + continue; + } + if (b.k === 'tc-collab') { + collabMsgs.push({ sub: b.sub, target: b.target, author: m.value.author, ts: m.value.timestamp || 0, option: b.option, on: b.on, category: b.category }); + continue; + } + if (b.k !== 'tribe-content') continue; + content.set(m.key, { author: m.value.author, body: b, ts: m.value.timestamp }); + } + + const naiveNext = new Map(); + const strictNext = new Map(); + for (const [key, node] of content) { + const t = node.body.replaces; + if (!t) continue; + naiveNext.set(t, key); + const orig = content.get(t); + if (orig && orig.author === node.author) strictNext.set(t, key); + } + for (const [t, key] of Array.from(naiveNext.entries())) { + const orig = content.get(t); + if (!orig) { naiveNext.delete(t); continue; } + const node = content.get(key); + if (!node || node.author !== orig.author) { naiveNext.delete(t); content.delete(key); } + } + const naivePrev = new Map(); + for (const [t, key] of naiveNext) naivePrev.set(key, t); + const followTo = (start, nextMap) => { let x = start, g = 0; while (nextMap.has(x) && g++ < 100000) x = nextMap.get(x); return x; }; + const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && content.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x; }; + + const roots = new Set(); + for (const [key, node] of content) { const t = node.body.replaces; if (!t || !content.has(t)) roots.add(key); } + + const tombstoned = new Set(); + for (const t of tombRequests) { const orig = content.get(t.target); if (orig && t.author === orig.author) tombstoned.add(t.target); } + + const collabByRoot = new Map(); + const getCB = (root) => { if (!collabByRoot.has(root)) collabByRoot.set(root, { vote: new Map(), attend: new Map(), assign: new Map(), refeed: new Set(), opinion: new Map() }); return collabByRoot.get(root); }; + for (const cm of collabMsgs) { + if (!content.has(cm.target)) continue; + const a = getCB(rootOf(cm.target)); + if (cm.sub === 'vote' && Number.isInteger(cm.option)) { if (!a.vote.has(cm.author)) a.vote.set(cm.author, cm.option); } + else if (cm.sub === 'attend') { const p = a.attend.get(cm.author); if (!p || cm.ts >= p.ts) a.attend.set(cm.author, { on: cm.on !== false, ts: cm.ts }); } + else if (cm.sub === 'assign') { const p = a.assign.get(cm.author); if (!p || cm.ts >= p.ts) a.assign.set(cm.author, { on: cm.on !== false, ts: cm.ts }); } + else if (cm.sub === 'refeed') { a.refeed.add(cm.author); } + else if (cm.sub === 'opinion' && cm.category) { if (!a.opinion.has(cm.author)) a.opinion.set(cm.author, cm.category); } + } + + const items = new Map(); + const tipOf = new Map(); + const EMPTY = { vote: new Map(), attend: new Map(), assign: new Map(), refeed: new Set(), opinion: new Map() }; + + for (const root of roots) { + const contentTip = followTo(root, strictNext); + const tipNode = content.get(contentTip); + if (!tipNode) continue; + const body = tipNode.body; + const lb = tipNode.body; + const a = collabByRoot.get(root) || EMPTY; + + const item = { id: contentTip, ...body, author: tipNode.author, _ts: tipNode.ts }; + + const voteAuthors = new Set(); const votes = {}; + const legacyVotes = lb.votes && typeof lb.votes === 'object' ? lb.votes : {}; + for (const k of Object.keys(legacyVotes)) { const arr = Array.isArray(legacyVotes[k]) ? legacyVotes[k] : []; for (const au of arr) if (!voteAuthors.has(au)) { voteAuthors.add(au); (votes[k] = votes[k] || []).push(au); } } + for (const [au, opt] of a.vote) if (!voteAuthors.has(au)) { voteAuthors.add(au); const k = String(opt); (votes[k] = votes[k] || []).push(au); } + item.votes = votes; + + const att = new Set(Array.isArray(lb.attendees) ? lb.attendees : []); + for (const [au, st] of a.attend) { if (st.on) att.add(au); else att.delete(au); } + item.attendees = [...att]; + + const asg = new Set(Array.isArray(lb.assignees) ? lb.assignees : []); + for (const [au, st] of a.assign) { if (st.on) asg.add(au); else asg.delete(au); } + item.assignees = [...asg]; + + const rf = new Set(Array.isArray(lb.refeeds_inhabitants) ? lb.refeeds_inhabitants : []); + for (const au of a.refeed) rf.add(au); + item.refeeds_inhabitants = [...rf]; item.refeeds = rf.size; + + const opInh = new Set(Array.isArray(lb.opinions_inhabitants) ? lb.opinions_inhabitants : []); + const opinions = { ...(lb.opinions && typeof lb.opinions === 'object' ? lb.opinions : {}) }; + for (const [au, cat] of a.opinion) if (!opInh.has(au)) { opInh.add(au); opinions[cat] = (opinions[cat] || 0) + 1; } + item.opinions_inhabitants = [...opInh]; item.opinions = opinions; + + let cur = root, g = 0; tipOf.set(root, contentTip); + while (naiveNext.has(cur) && g++ < 100000) { cur = naiveNext.get(cur); tipOf.set(cur, contentTip); } + + if (tombstoned.has(contentTip)) continue; + if (targetRootId && body.rootId !== targetRootId) continue; + if (wantType && body.contentType !== wantType) continue; + items.set(contentTip, item); + } + + return { items, tipOf, tombstoned }; + }; + + return { + async create(tribeId, contentType, data) { + if (!VALID_CONTENT_TYPES.includes(contentType)) throw new Error('Invalid content type'); + if (data.status && !VALID_STATUSES.includes(data.status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS'); + if (data.priority && !VALID_PRIORITIES.includes(data.priority)) throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL'); + + const client = await openSsb(); + const rootId = await tribesModel.getRootId(tribeId); + const now = new Date().toISOString(); + const body = { + k: 'tribe-content', + rootId, + contentType, + replaces: null, + title: data.title || '', + description: data.description || '', + status: data.status || 'OPEN', + date: data.date || null, + location: data.location || null, + price: data.price || null, + salary: data.salary || null, + priority: data.priority || null, + assignees: data.assignees || [], + options: data.options || [], + votes: data.votes || {}, + category: data.category || null, + parentId: data.parentId || null, + tags: data.tags || [], + image: data.image || null, + mediaType: data.mediaType || null, + url: data.url || null, + attendees: data.attendees || [], + deadline: data.deadline || null, + goal: data.goal || null, + funded: data.funded || 0, + refeeds: data.refeeds || 0, + refeeds_inhabitants: data.refeeds_inhabitants || [], + opinions: data.opinions || {}, + opinions_inhabitants: data.opinions_inhabitants || [], + author: client.id, + createdAt: now, + updatedAt: now + }; + return wrapAndPublishContent(rootId, body); + }, + + async update(contentId, data, existing) { + if (!existing) existing = await this.getById(contentId); + if (!existing) throw new Error('Content not found'); + if (data.status && !VALID_STATUSES.includes(data.status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS'); + if (data.priority && !VALID_PRIORITIES.includes(data.priority)) throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL'); + + const rootId = existing.rootId || (await tribesModel.getRootId(existing.tribeId || existing.rootId)); + const now = new Date().toISOString(); + const body = { + k: 'tribe-content', + rootId, + contentType: existing.contentType, + replaces: contentId, + title: data.title !== undefined ? data.title : existing.title, + description: data.description !== undefined ? data.description : existing.description, + status: data.status !== undefined ? data.status : existing.status, + date: data.date !== undefined ? data.date : existing.date, + location: data.location !== undefined ? data.location : existing.location, + price: data.price !== undefined ? data.price : existing.price, + salary: data.salary !== undefined ? data.salary : existing.salary, + priority: data.priority !== undefined ? data.priority : existing.priority, + assignees: data.assignees !== undefined ? data.assignees : existing.assignees, + options: data.options !== undefined ? data.options : existing.options, + votes: data.votes !== undefined ? data.votes : existing.votes, + category: data.category !== undefined ? data.category : existing.category, + parentId: data.parentId !== undefined ? data.parentId : existing.parentId, + tags: data.tags !== undefined ? data.tags : existing.tags, + image: data.image !== undefined ? data.image : existing.image, + mediaType: data.mediaType !== undefined ? data.mediaType : existing.mediaType, + url: data.url !== undefined ? data.url : existing.url, + attendees: data.attendees !== undefined ? data.attendees : existing.attendees, + deadline: data.deadline !== undefined ? data.deadline : existing.deadline, + goal: data.goal !== undefined ? data.goal : existing.goal, + funded: data.funded !== undefined ? data.funded : existing.funded, + refeeds: data.refeeds !== undefined ? data.refeeds : existing.refeeds, + refeeds_inhabitants: data.refeeds_inhabitants !== undefined ? data.refeeds_inhabitants : existing.refeeds_inhabitants, + opinions: data.opinions !== undefined ? data.opinions : existing.opinions, + opinions_inhabitants: data.opinions_inhabitants !== undefined ? data.opinions_inhabitants : existing.opinions_inhabitants, + author: existing.author, + createdAt: existing.createdAt, + updatedAt: now + }; + return wrapAndPublishContent(rootId, body); + }, + + async deleteById(contentId) { + const existing = await this.getById(contentId); + if (!existing) throw new Error('Content not found'); + const rootId = existing.rootId || (await tribesModel.getRootId(existing.tribeId || existing.rootId)); + const client = await openSsb(); + const body = { + k: 'tombstone', + rootId, + target: contentId, + author: client.id, + deletedAt: new Date().toISOString() + }; + return wrapAndPublishContent(rootId, body); + }, + + async getById(contentId) { + const msgs = await readLog(); + const { items, tipOf } = await decodeContentMsgs(msgs, {}); + const tip = tipOf.get(contentId) || contentId; + return items.get(tip) || null; + }, + + async listByTribe(tribeId, contentType, filter) { + const rootId = await tribesModel.getRootId(tribeId).catch(() => tribeId); + const msgs = await readLog(); + const { items } = await decodeContentMsgs(msgs, { rootId, contentType }); + + let result = [...items.values()]; + if (filter === 'open') result = result.filter(i => i.status === 'OPEN'); + else if (filter === 'closed') result = result.filter(i => i.status === 'CLOSED'); + else if (filter === 'in-progress') result = result.filter(i => i.status === 'IN-PROGRESS'); + + return result.sort((a, b) => { + const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0; + const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0; + return tb - ta; + }); + }, + + async toggleAttendee(contentId) { + const client = await openSsb(); + const userId = client.id; + const item = await this.getById(contentId); + if (!item) throw new Error('Content not found'); + const on = !(Array.isArray(item.attendees) && item.attendees.includes(userId)); + return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'attend', rootId: item.rootId, target: item.id, on }); + }, + + async toggleAssignee(contentId) { + const client = await openSsb(); + const userId = client.id; + const item = await this.getById(contentId); + if (!item) throw new Error('Content not found'); + const on = !(Array.isArray(item.assignees) && item.assignees.includes(userId)); + return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'assign', rootId: item.rootId, target: item.id, on }); + }, + + async updateStatus(contentId, status) { + if (!VALID_STATUSES.includes(status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS'); + return this.update(contentId, { status }); + }, + + async castVote(votationId, optionIndex) { + const client = await openSsb(); + const userId = client.id; + const item = await this.getById(votationId); + if (!item) throw new Error('Votation not found'); + if (item.status === 'CLOSED') throw new Error('Votation is closed'); + const options = item.options || []; + if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) { + throw new Error('Invalid option index'); + } + const votes = item.votes || {}; + for (const key of Object.keys(votes)) { + const arr = Array.isArray(votes[key]) ? votes[key] : []; + if (arr.includes(userId)) throw new Error('Already voted'); + } + return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'vote', rootId: item.rootId, target: item.id, option: optionIndex }); + }, + + async toggleRefeed(contentId) { + const client = await openSsb(); + const userId = client.id; + const item = await this.getById(contentId); + if (!item) throw new Error('Content not found'); + if (Array.isArray(item.refeeds_inhabitants) && item.refeeds_inhabitants.includes(userId)) return item; + return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'refeed', rootId: item.rootId, target: item.id }); + }, + + async castOpinion(contentId, category) { + if (!categories.includes(category)) throw new Error('Invalid opinion category'); + const client = await openSsb(); + const userId = client.id; + const item = await this.getById(contentId); + if (!item) throw new Error('Content not found'); + if (Array.isArray(item.opinions_inhabitants) && item.opinions_inhabitants.includes(userId)) throw new Error('Already voted'); + return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'opinion', rootId: item.rootId, target: item.id, category }); + }, + + async getThread(forumId) { + const msgs = await readLog(); + const { items } = await decodeContentMsgs(msgs, {}); + const all = [...items.values()]; + const parent = all.find(i => i.id === forumId); + if (!parent) return { parent: null, replies: [] }; + const replies = all + .filter(i => i.parentId === forumId && i.contentType === 'forum-reply') + .sort((a, b) => (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0)); + return { parent, replies }; + } + }; +}; diff --git a/src/models/tribes_model.js b/src/models/tribes_model.js new file mode 100644 index 0000000..7d2a799 --- /dev/null +++ b/src/models/tribes_model.js @@ -0,0 +1,939 @@ +const pull = require('../server/node_modules/pull-stream'); +const crypto = require('crypto'); +const { getConfig } = require('../configs/config-manager.js'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; +const tribeLogLimit = Math.max(logLimit, 100000); + +const INVITE_CODE_BYTES = 16; +const VALID_INVITE_MODES = ['strict', 'open']; + +const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl']; + +module.exports = ({ cooler, tribeCrypto }) => { + let ssb; + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + + let tribeIndex = null; + let tribeIndexTs = 0; + + const arraysEqual = (a, b) => { + const aa = Array.isArray(a) ? a : []; + const bb = Array.isArray(b) ? b : []; + if (aa.length !== bb.length) return false; + for (let i = 0; i < aa.length; i++) if (aa[i] !== bb[i]) return false; + return true; + }; + + const validMembershipDelta = (prev, next, author) => { + const a = Array.isArray(prev) ? prev : []; + const b = Array.isArray(next) ? next : []; + const added = b.filter(m => !a.includes(m)); + const removed = a.filter(m => !b.includes(m)); + if (added.length === 0 && removed.length === 0) return true; + if (added.length === 1 && removed.length === 0 && added[0] === author) return true; + if (removed.length === 1 && added.length === 0 && removed[0] === author) return true; + return false; + }; + + const validInvitesDelta = (prev, next, author, rootAuthor) => { + if (author === rootAuthor) return true; + const prevHashes = new Set((prev || []).map(i => i && i.codeHash).filter(Boolean)); + const nextHashes = new Set((next || []).map(i => i && i.codeHash).filter(Boolean)); + for (const h of nextHashes) if (!prevHashes.has(h)) return false; + return true; + }; + + const structuralFieldsEqual = (a, b) => { + for (const f of STRUCTURAL_FIELDS) { + const x = a[f]; + const y = b[f]; + if (Array.isArray(x) || Array.isArray(y)) { if (!arraysEqual(x, y)) return false; continue; } + if (x !== y && !(x == null && y == null)) return false; + } + return true; + }; + + const streamLog = async () => { + const client = await openSsb(); + return new Promise((resolve, reject) => { + pull( + client.createLogStream({ limit: tribeLogLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ); + }); + }; + + const buildTribeIndex = async () => { + subscribeInvalidation().catch(() => {}); + if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex; + const fpIdx = tribeCrypto.buildFingerprintIndex(); + const msgs = await streamLog(); + + const tribeMsgs = new Map(); + const tombstones = new Map(); + + for (const m of msgs) { + const c = m.value && m.value.content; + if (!c) continue; + const author = m.value.author; + const ts = m.value.timestamp; + + let body = null; + if (tribeCrypto.isTribeMsg(c)) { + const r = tribeCrypto.unwrapMsg(c, fpIdx); + if (!r || !r.body || !r.body.k) continue; + body = r.body; + } else if (c.type === 'tribe' && typeof c === 'object') { + body = { + k: 'tribe', + op: c.op || (c.replaces ? 'update' : 'create'), + rootId: c.rootId || null, + replaces: c.replaces || null, + title: c.title, + description: c.description, + image: c.image, + location: c.location, + tags: c.tags, + isAnonymous: c.isAnonymous, + members: c.members, + invites: c.invites, + inviteMode: c.inviteMode, + status: c.status, + parentTribeId: c.parentTribeId, + mapUrl: c.mapUrl, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + author: c.author + }; + } else if (c.type === 'tombstone' && c.target) { + tombstones.set(c.target, { author, ts }); + continue; + } else { + continue; + } + + if (body.k === 'tombstone' && body.target) { + tombstones.set(body.target, { author, ts }); + continue; + } + if (body.k === 'tribe') { + tribeMsgs.set(m.key, { + id: m.key, + rootId: body.rootId || null, + replaces: body.replaces || null, + op: body.op || (body.rootId ? 'update' : 'create'), + content: body, + author, + _ts: ts + }); + } + } + + const tribes = new Map(); + const parent = new Map(); + const child = new Map(); + const rootByTip = new Map(); + + for (const [k, entry] of tribeMsgs.entries()) { + if (!entry.replaces) { + tribes.set(k, entry); + rootByTip.set(k, k); + } + } + + let progress = true; + while (progress) { + progress = false; + const candidatesByReplaces = new Map(); + for (const [k, entry] of tribeMsgs.entries()) { + if (tribes.has(k)) continue; + const replaces = entry.replaces; + if (!replaces) continue; + const parentEntry = tribes.get(replaces); + if (!parentEntry) continue; + if (child.has(replaces)) continue; + const root = rootByTip.get(replaces); + const rootEntry = tribes.get(root); + const rootAuthor = rootEntry && rootEntry.author; + const isRootAuthor = entry.author === rootAuthor; + const prevMembers = Array.isArray(parentEntry.content.members) ? parentEntry.content.members : []; + if (!isRootAuthor) { + if (!prevMembers.includes(entry.author) && !(entry.content.members || []).includes(entry.author)) continue; + if (!validMembershipDelta(prevMembers, entry.content.members, entry.author)) continue; + if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue; + if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue; + } + if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []); + candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, root }); + } + for (const [replaces, candidates] of candidatesByReplaces.entries()) { + if (child.has(replaces)) continue; + let winner = candidates[0]; + for (let i = 1; i < candidates.length; i++) { + const c = candidates[i]; + if (c.isRootAuthor && !winner.isRootAuthor) { winner = c; continue; } + if (winner.isRootAuthor && !c.isRootAuthor) continue; + const wt = winner.entry._ts || 0; + const ct = c.entry._ts || 0; + if (ct < wt) winner = c; + else if (ct === wt && c.k < winner.k) winner = c; + } + parent.set(winner.k, replaces); + child.set(replaces, winner.k); + tribes.set(winner.k, winner.entry); + rootByTip.set(winner.k, winner.root); + progress = true; + } + } + + const tombstoned = new Set(); + for (const [target, t] of tombstones.entries()) { + const e = tribes.get(target); + if (!e) continue; + const root = rootByTip.get(target); + const rootAuthor = tribes.get(root) && tribes.get(root).author; + if (t.author === rootAuthor) tombstoned.add(target); + } + + const rootOf = (id) => rootByTip.get(id) || id; + const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; }; + const tipByRoot = new Map(); + for (const k of tribes.keys()) { + const root = rootOf(k); + const tip = tipOf(root); + tipByRoot.set(root, tip); + } + + const effectivelyTombstoned = new Set(tombstoned); + let cascade = true; + const seen = new Set(); + while (cascade) { + cascade = false; + for (const k of tribes.keys()) { + if (effectivelyTombstoned.has(k)) continue; + const root = rootOf(k); + if (effectivelyTombstoned.has(root)) { effectivelyTombstoned.add(k); cascade = true; continue; } + const e = tribes.get(k); + const pid = e && e.content && e.content.parentTribeId; + if (!pid) continue; + if (seen.has(`${k}:${pid}`)) continue; + seen.add(`${k}:${pid}`); + const parentRoot = rootOf(pid); + if (effectivelyTombstoned.has(parentRoot) || effectivelyTombstoned.has(pid)) { + effectivelyTombstoned.add(k); + cascade = true; + } + } + } + + tribeIndex = { tribes, tombstoned, effectivelyTombstoned, parent, child, tipByRoot, rootByTip }; + tribeIndexTs = Date.now(); + return tribeIndex; + }; + + const subscribeInvalidation = (() => { + let started = false; + return async () => { + if (started) return; + started = true; + try { + const client = await openSsb(); + pull( + client.createLogStream({ live: true, old: false }), + pull.drain((m) => { + const c = m && m.value && m.value.content; + if (!c) return; + if (typeof c === 'string' && c.endsWith('.box')) { tribeIndex = null; return; } + if (tribeCrypto.isTribeMsg(c)) { tribeIndex = null; return; } + if (c.type === 'tribe-invite-msg' || c.type === 'tribe-invite-tombstone') tribeIndex = null; + }, () => { started = false; }) + ); + } catch (_) { started = false; } + }; + })(); + + const normalizeTribe = (entry) => { + const c = entry.content; + return { + id: entry.id, + title: c.title || '', + description: c.description || '', + image: c.image || null, + location: c.location || null, + tags: Array.isArray(c.tags) ? c.tags : [], + isAnonymous: c.isAnonymous !== false, + members: Array.isArray(c.members) ? c.members : [], + invites: Array.isArray(c.invites) ? c.invites : [], + inviteMode: c.inviteMode || 'strict', + status: c.status || 'OPEN', + parentTribeId: c.parentTribeId || null, + mapUrl: c.mapUrl || '', + createdAt: c.createdAt, + updatedAt: c.updatedAt, + author: c.author || entry.author, + _ts: entry._ts + }; + }; + + const wrapAndPublish = async (rootId, body) => { + const client = await openSsb(); + const key = tribeCrypto.getKey(rootId); + if (!key) throw new Error('Missing tribe key for ' + rootId); + const envelope = tribeCrypto.wrapMsg(body, key); + const result = await new Promise((resolve, reject) => + client.publish(envelope, (err, r) => err ? reject(err) : resolve(r)) + ); + tribeIndex = null; + return result; + }; + + const scanOpenInviteMarkers = async () => { + const msgs = await streamLog(); + const tombstoned = new Set(); + for (const m of msgs) { + const c = m.value && m.value.content; + if (!c) continue; + if (c.type === 'tribe-open-invite-tombstone' && typeof c.target === 'string') tombstoned.add(c.target); + if (c.type === 'tribe-invite-tombstone' && typeof c.target === 'string') tombstoned.add('inv:' + c.target); + } + const byRoot = new Map(); + for (const m of msgs) { + const v = m.value; + const c = v && v.content; + if (!c || c.type !== 'tribe-open-invite' || c.v !== 1) continue; + if (typeof c.rootId !== 'string' || typeof c.code !== 'string') continue; + if (tombstoned.has(m.key)) continue; + if (c.inviteKey && tombstoned.has('inv:' + c.inviteKey)) continue; + const ts = (v && v.timestamp) || 0; + const prev = byRoot.get(c.rootId); + if (!prev || ts > prev.ts) byRoot.set(c.rootId, { code: c.code, by: c.by || v.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts }); + } + return byRoot; + }; + + return { + type: 'tribe', + + async createTribe(title, description, image, location, tagsRaw = [], isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') { + if (!VALID_INVITE_MODES.includes(inviteMode)) throw new Error('Invalid invite mode. Must be "strict" or "open"'); + const client = await openSsb(); + const userId = client.id; + const blobId = image ? (String(image).trim() || null) : null; + const tags = Array.isArray(tagsRaw) + ? tagsRaw.filter(Boolean) + : String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean); + + const isPrivate = Boolean(isAnonymous); + const newKey = tribeCrypto.generateTribeKey(); + const now = new Date().toISOString(); + const baseFields = { + title, + description, + image: blobId, + location, + tags, + isAnonymous: isPrivate, + members: [userId], + invites: [], + inviteMode, + status: status || 'OPEN', + parentTribeId: parentTribeId || null, + mapUrl: String(mapUrl || '').trim(), + createdAt: now, + updatedAt: now, + author: userId + }; + let envelope; + if (isPrivate) { + envelope = tribeCrypto.wrapMsg({ k: 'tribe', op: 'create', rootId: null, replaces: null, ...baseFields }, newKey); + } else { + envelope = { type: 'tribe', op: 'create', ...baseFields }; + } + const result = await new Promise((resolve, reject) => + client.publish(envelope, (err, r) => err ? reject(err) : resolve(r)) + ); + tribeCrypto.setKey(result.key, newKey, 1); + tribeIndex = null; + subscribeInvalidation().catch(() => {}); + return result; + }, + + async getRootId(tribeId) { + const idx = await buildTribeIndex(); + let cur = tribeId; + while (idx.parent.has(cur)) cur = idx.parent.get(cur); + return cur; + }, + + async getChainIds(tribeId) { + const idx = await buildTribeIndex(); + let root = tribeId; + while (idx.parent.has(root)) root = idx.parent.get(root); + const ids = [root]; + let cur = root; + while (idx.child.has(cur)) { cur = idx.child.get(cur); ids.push(cur); } + return ids; + }, + + async getAncestryChain(tribeId) { + const rootId = await this.getRootId(tribeId); + let tribe; + try { tribe = await this.getTribeById(tribeId); } catch (_) { return [rootId]; } + const chain = [rootId]; + let cur = tribe; + const seen = new Set([rootId]); + while (cur && cur.parentTribeId) { + const pRoot = await this.getRootId(cur.parentTribeId).catch(() => null); + if (!pRoot || seen.has(pRoot)) break; + chain.push(pRoot); + seen.add(pRoot); + try { cur = await this.getTribeById(cur.parentTribeId); } catch (_) { break; } + } + return chain; + }, + + async getTribeById(tribeId) { + const idx = await buildTribeIndex(); + let latestId = tribeId; + while (idx.child.has(latestId)) latestId = idx.child.get(latestId); + if (idx.tombstoned.has(latestId) || idx.effectivelyTombstoned.has(latestId)) throw new Error('Tribe not found'); + const entry = idx.tribes.get(latestId); + if (!entry) throw new Error('Tribe not found'); + return normalizeTribe(entry); + }, + + async listAll() { + const idx = await buildTribeIndex(); + const items = []; + for (const [root, tip] of idx.tipByRoot) { + if (idx.tombstoned.has(root) || idx.tombstoned.has(tip)) continue; + if (idx.effectivelyTombstoned.has(root) || idx.effectivelyTombstoned.has(tip)) continue; + const entry = idx.tribes.get(tip); + if (!entry) continue; + items.push(normalizeTribe(entry)); + } + return items; + }, + + async listTribesForViewer(userId) { + const all = await this.listAll(); + const out = []; + for (const t of all) { + if (!t.isAnonymous) { out.push(t); continue; } + if (t.author === userId || (Array.isArray(t.members) && t.members.includes(userId))) out.push(t); + } + return out; + }, + + async getViewerTribeScope(userId) { + const all = await this.listAll(); + const memberOf = new Set(); + const createdBy = new Set(); + for (const t of all) { + if (t.author === userId) { createdBy.add(t.id); memberOf.add(t.id); continue; } + if (Array.isArray(t.members) && t.members.includes(userId)) memberOf.add(t.id); + } + return { memberOf, createdBy, allTribes: all }; + }, + + async listSubTribes(parentId, userId) { + const idx = await buildTribeIndex(); + const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; }; + const parentRoot = rootOf(parentId); + const all = await this.listAll(); + const subs = all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot); + if (!userId) return subs; + const out = []; + for (const sub of subs) { + const ok = await this.canAccessTribe(userId, sub.id).catch(() => false); + if (ok) out.push(sub); + } + return out; + }, + + async isTribeMember(userId, tribeId) { + if (!userId || !tribeId) return false; + try { + const tribe = await this.getTribeById(tribeId); + if (!tribe) return false; + if (tribe.author === userId) return true; + return Array.isArray(tribe.members) && tribe.members.includes(userId); + } catch (_) { return false; } + }, + + async canAccessTribe(userId, tribeId) { + if (!userId || !tribeId) return false; + try { + const tribe = await this.getTribeById(tribeId); + if (!tribe) return false; + if (tribe.author === userId) return true; + if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true; + const effective = await this.getEffectiveStatus(tribeId); + return !effective.isPrivate; + } catch (_) { return false; } + }, + + async getEffectiveStatus(tribeId) { + let current; + try { current = await this.getTribeById(tribeId); } catch (_) { return { isPrivate: true, chain: [] }; } + const chain = [{ id: current.id, isAnonymous: !!current.isAnonymous, author: current.author }]; + let cursor = current; + const seen = new Set([current.id]); + while (cursor.parentTribeId && !seen.has(cursor.parentTribeId)) { + seen.add(cursor.parentTribeId); + try { + cursor = await this.getTribeById(cursor.parentTribeId); + chain.push({ id: cursor.id, isAnonymous: !!cursor.isAnonymous, author: cursor.author }); + } catch (_) { break; } + } + const isPrivate = chain.some(c => c.isAnonymous); + return { isPrivate, chain }; + }, + + async updateTribeById(tribeId, updatedContent) { + const tribe = await this.getTribeById(tribeId); + if (!tribe) throw new Error('Tribe not found'); + const rootId = await this.getRootId(tribeId); + const tipId = tribe.id; + const now = new Date().toISOString(); + const fields = { + title: updatedContent.title !== undefined ? updatedContent.title : tribe.title, + description: updatedContent.description !== undefined ? updatedContent.description : tribe.description, + image: updatedContent.image !== undefined ? updatedContent.image : tribe.image, + location: updatedContent.location !== undefined ? updatedContent.location : tribe.location, + tags: updatedContent.tags !== undefined ? updatedContent.tags : tribe.tags, + isAnonymous: updatedContent.isAnonymous !== undefined ? updatedContent.isAnonymous : tribe.isAnonymous, + members: updatedContent.members !== undefined ? updatedContent.members : tribe.members, + invites: updatedContent.invites !== undefined ? updatedContent.invites : tribe.invites, + inviteMode: updatedContent.inviteMode !== undefined ? updatedContent.inviteMode : tribe.inviteMode, + status: updatedContent.status !== undefined ? updatedContent.status : tribe.status, + parentTribeId: updatedContent.parentTribeId !== undefined ? updatedContent.parentTribeId : tribe.parentTribeId, + mapUrl: updatedContent.mapUrl !== undefined ? updatedContent.mapUrl : tribe.mapUrl, + createdAt: tribe.createdAt, + updatedAt: now, + author: tribe.author + }; + if (fields.isAnonymous) { + return wrapAndPublish(rootId, { k: 'tribe', op: 'update', rootId, replaces: tipId, ...fields }); + } + const client = await openSsb(); + const content = { type: 'tribe', op: 'update', rootId, replaces: tipId, ...fields }; + const result = await new Promise((resolve, reject) => + client.publish(content, (err, r) => err ? reject(err) : resolve(r)) + ); + tribeIndex = null; + return result; + }, + + async publishUpdatedTribe(tribeId, updated) { + return this.updateTribeById(tribeId, updated); + }, + + async updateTribeMembers(tribeId, members) { + const tribe = await this.getTribeById(tribeId); + const old = tribe.members || []; + await this.updateTribeById(tribeId, { members }); + const removed = old.filter(m => !members.includes(m)); + const added = members.filter(m => !old.includes(m)); + if (removed.length > 0) { + await this.rotateTribeKey(tribeId, members); + } else if (added.length > 0) { + await this.distributeTribeKey(tribeId, added); + } + }, + + async updateTribeInvites(tribeId, invites) { + return this.updateTribeById(tribeId, { invites }); + }, + + async generateInvite(tribeId) { + const client = await openSsb(); + const userId = client.id; + const tribe = await this.getTribeById(tribeId); + if (tribe.inviteMode === 'strict' && tribe.author !== userId) { + throw new Error('Only the author can generate invites in strict mode'); + } + if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) { + throw new Error('Only tribe members can generate invites in open mode'); + } + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex'); + const targetRoot = await this.getRootId(tribeId); + if (!targetRoot) throw new Error('Cannot resolve tribe root'); + const salt = tribeCrypto.generateInviteSalt(); + const ekChain = tribeCrypto.encryptChainForInvite([targetRoot], code, salt); + if (!ekChain) throw new Error('Cannot encrypt invite chain — missing keys'); + const codeHash = tribeCrypto.hashInviteCode(code, salt); + const inviteMsg = { + type: 'tribe-invite-msg', + v: 1, + ch: codeHash, + s: salt, + ek: ekChain + }; + const invitePub = await new Promise((resolve, reject) => + client.publish(inviteMsg, (err, r) => err ? reject(err) : resolve(r)) + ); + const inviteRef = { + codeHash, + salt, + gen: tribeCrypto.getGen(targetRoot), + msgKey: invitePub.key + }; + const invites = Array.isArray(tribe.invites) ? [...tribe.invites, inviteRef] : [inviteRef]; + await this.updateTribeInvites(tribeId, invites); + return code; + }, + + async getOpenInvite(tribeId) { + const root = await this.getRootId(tribeId).catch(() => tribeId); + const map = await scanOpenInviteMarkers(); + const rec = map.get(root); + return rec ? { code: rec.code, by: rec.by, markerKey: rec.markerKey, inviteKey: rec.inviteKey } : null; + }, + + async enrichOpenInvites(tribes) { + const list = Array.isArray(tribes) ? tribes : []; + if (!list.length) return list; + const map = await scanOpenInviteMarkers(); + const idx = await buildTribeIndex(); + for (const t of list) { + const root = idx.rootByTip.get(t.id) || t.id; + const rec = map.get(root); + if (rec) { t.openInviteCode = rec.code; t.openInviteBy = rec.by; } + } + return list; + }, + + async generateOpenInvite(tribeId) { + const client = await openSsb(); + const userId = client.id; + const tribe = await this.getTribeById(tribeId); + if (tribe.inviteMode === 'strict' && tribe.author !== userId) { + throw new Error('Only the author can generate invites in strict mode'); + } + if (tribe.inviteMode === 'open' && tribe.author !== userId && !tribe.members.includes(userId)) { + throw new Error('Only tribe members can generate invites in open mode'); + } + const existing = await this.getOpenInvite(tribeId); + if (existing) throw new Error('An open invitation already exists'); + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex'); + const targetRoot = await this.getRootId(tribeId); + if (!targetRoot) throw new Error('Cannot resolve tribe root'); + const salt = tribeCrypto.generateInviteSalt(); + const ekChain = tribeCrypto.encryptChainForInvite([targetRoot], code, salt); + if (!ekChain) throw new Error('Cannot encrypt invite chain — missing keys'); + const codeHash = tribeCrypto.hashInviteCode(code, salt); + const invitePub = await new Promise((resolve, reject) => + client.publish({ type: 'tribe-invite-msg', v: 1, multi: 1, ch: codeHash, s: salt, ek: ekChain }, (err, r) => err ? reject(err) : resolve(r)) + ); + await new Promise((resolve, reject) => + client.publish({ type: 'tribe-open-invite', v: 1, rootId: targetRoot, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err, r) => err ? reject(err) : resolve(r)) + ); + tribeIndex = null; + return code; + }, + + async removeOpenInvite(tribeId) { + const client = await openSsb(); + const userId = client.id; + const tribe = await this.getTribeById(tribeId).catch(() => null); + const rec = await this.getOpenInvite(tribeId); + if (!rec) return; + if (rec.by !== userId && !(tribe && tribe.author === userId)) { + throw new Error('Not allowed to remove this invitation'); + } + await new Promise((resolve, reject) => + client.publish({ type: 'tribe-open-invite-tombstone', v: 1, target: rec.markerKey, ts: new Date().toISOString() }, (err, r) => err ? reject(err) : resolve(r)) + ); + if (rec.inviteKey) await this.publishInviteTombstone(rec.inviteKey).catch(() => {}); + tribeIndex = null; + }, + + async joinByInvite(rawCode) { + const code = String(rawCode || '').trim(); + if (!code) throw new Error('Invalid or expired invite code'); + const client = await openSsb(); + const userId = client.id; + const msgs = await streamLog(); + const inviteTombstoned = new Set(); + for (const m of msgs) { + const v = m.value; + const c = v && v.content; + if (!c || c.type !== 'tribe-invite-tombstone' || typeof c.target !== 'string') continue; + inviteTombstoned.add(c.target); + } + let matched = null; + for (const m of msgs) { + const v = m.value; + const c = v && v.content; + if (!c || c.type !== 'tribe-invite-msg' || c.v !== 1) continue; + if (typeof c.ch !== 'string' || typeof c.s !== 'string' || typeof c.ek !== 'string') continue; + if (inviteTombstoned.has(m.key)) continue; + if (tribeCrypto.hashInviteCode(code, c.s) !== c.ch) continue; + const chain = tribeCrypto.decryptChainFromInvite(c.ek, code, c.s); + if (Array.isArray(chain) && chain.length) { + matched = { msgKey: m.key, codeHash: c.ch, chain, multi: c.multi === 1 || c.multi === true }; + break; + } + } + if (!matched) throw new Error('Invalid or expired invite code'); + for (const entry of matched.chain) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length); + } + tribeIndex = null; + const rootId = matched.chain[0].rootId; + let tribe; + try { tribe = await this.getTribeById(rootId); } catch (_) { tribe = null; } + if (!tribe) throw new Error('Tribe not found after key import'); + if (tribe.members.includes(userId)) throw new Error('Already a member of this tribe'); + const members = [...tribe.members, userId]; + if (matched.multi) { + await this.updateTribeById(tribe.id, { members }); + } else { + const invites = (tribe.invites || []).filter(inv => inv.codeHash !== matched.codeHash); + await this.updateTribeById(tribe.id, { members, invites }); + await this.publishInviteTombstone(matched.msgKey).catch(() => {}); + } + await this.ensureFollowTribeMembers(tribe.id).catch(() => {}); + subscribeInvalidation().catch(() => {}); + return rootId; + }, + + async publishInviteTombstone(inviteMsgKey) { + const client = await openSsb(); + return new Promise((resolve, reject) => + client.publish({ + type: 'tribe-invite-tombstone', + v: 1, + target: inviteMsgKey, + ts: new Date().toISOString() + }, (err, r) => err ? reject(err) : resolve(r)) + ); + }, + + async leaveTribe(tribeId, opts = {}) { + const client = await openSsb(); + const userId = client.id; + const tribe = await this.getTribeById(tribeId); + if (!tribe) throw new Error('Tribe not found'); + const isAuthor = tribe.author === userId; + if (isAuthor && !opts.force) throw new Error('Tribe author cannot leave their own tribe'); + const members = Array.isArray(tribe.members) ? [...tribe.members] : []; + const idx = members.indexOf(userId); + if (idx === -1) throw new Error('User is not a member of this tribe'); + members.splice(idx, 1); + if (isAuthor && members.length === 0) { + await this.publishTombstone(tribeId).catch(() => {}); + return; + } + await this.updateTribeById(tribeId, { members }); + if (members.length > 0) { + await this.rotateTribeKey(tribeId, members).catch(() => {}); + } + }, + + async distributeTribeKey(tribeId, toMembers) { + if (!Array.isArray(toMembers) || !toMembers.length) return; + const client = await openSsb(); + const rootId = await this.getRootId(tribeId); + const keys = tribeCrypto.getKeys(rootId); + const gen = tribeCrypto.getGen(rootId); + if (!keys.length) return; + const payload = tribeCrypto.buildKeyDistribPayload(rootId, keys, gen); + const batch = tribeCrypto.KEY_DISTRIB_BATCH; + for (let i = 0; i < toMembers.length; i += batch) { + const recps = toMembers.slice(i, i + batch); + await new Promise((resolve, reject) => + client.publish({ ...payload, recps }, (err) => err ? reject(err) : resolve()) + ); + } + }, + + async rotateTribeKey(tribeId, remainingMembers) { + const rootId = await this.getRootId(tribeId); + const oldKey = tribeCrypto.getKey(rootId); + if (!oldKey) return; + const newKey = tribeCrypto.generateTribeKey(); + tribeCrypto.addNewKey(rootId, newKey); + if (Array.isArray(remainingMembers) && remainingMembers.length > 0) { + await this.distributeTribeKey(tribeId, remainingMembers).catch(() => {}); + } + }, + + async ensureTribeKeyDistribution(tribeId) { + const client = await openSsb(); + const userId = client.id; + let tribe; + try { tribe = await this.getTribeById(tribeId); } catch (_) { return; } + if (!tribe || tribe.author !== userId) return; + const rootId = await this.getRootId(tribeId); + if (!tribeCrypto.getKey(rootId)) return; + const others = (tribe.members || []).filter(m => m !== userId); + if (!others.length) return; + const ssbKeys = require('../server/node_modules/ssb-keys'); + const config = require('../server/ssb_config'); + const msgs = await streamLog(); + const distributed = new Set(); + for (const m of msgs) { + if (m.value && m.value.author !== userId) continue; + const c = m.value && m.value.content; + const dec = tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys); + if (!dec || dec.rootId !== rootId) continue; + const recps = Array.isArray(dec.recps) ? dec.recps : []; + for (const r of recps) distributed.add(r); + } + const missing = others.filter(m => !distributed.has(m)); + if (missing.length > 0) await this.distributeTribeKey(tribeId, missing).catch(() => {}); + }, + + async processIncomingKeys() { + const client = await openSsb(); + const ssbKeys = require('../server/node_modules/ssb-keys'); + const config = require('../server/ssb_config'); + const msgs = await streamLog(); + const byTribe = new Map(); + for (const m of msgs) { + const c = m.value && m.value.content; + const dec = tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys); + if (!dec) continue; + const list = byTribe.get(dec.rootId) || []; + list.push({ generation: dec.gen || dec.keys.length, keys: dec.keys, ts: m.value.timestamp }); + byTribe.set(dec.rootId, list); + } + for (const [rootId, entries] of byTribe.entries()) { + entries.sort((a, b) => b.generation - a.generation); + const top = entries[0]; + if (top && Array.isArray(top.keys) && top.keys.length) { + tribeCrypto.mergeKeys(rootId, top.keys, top.generation); + } + } + }, + + async ensureFollowTribeMembers(tribeId) { + const client = await openSsb(); + const me = client.id; + let tribe; + try { tribe = await this.getTribeById(tribeId); } catch (_) { return; } + const fpIdx = tribeCrypto.buildFingerprintIndex(); + const rootId = await this.getRootId(tribeId).catch(() => tribeId); + const tribeChainIds = await this.getChainIds(tribeId).catch(() => [tribeId]); + const tribeRootSet = new Set([rootId]); + const tribeChainSet = new Set(tribeChainIds); + tribeChainSet.add(tribeId); + const ssbKeys = require('../server/node_modules/ssb-keys'); + const config = require('../server/ssb_config'); + const discovered = new Set(); + const myFollows = new Map(); + const myInviteMsgKeys = new Set(); + const msgs = await streamLog(); + for (const m of msgs) { + const v = m.value; + if (!v) continue; + const c = v.content; + if (!c) continue; + if (v.author === me && c.type === 'tribe-invite-msg') { + myInviteMsgKeys.add(m.key); + } + if (v.author === me && c && c.type === 'contact' && c.contact && typeof c.following === 'boolean') { + myFollows.set(c.contact, c.following); + continue; + } + if (c.type === 'tribe-invite-tombstone' && typeof c.target === 'string' && v.author && v.author !== me) { + if (myInviteMsgKeys.has(c.target)) discovered.add(v.author); + continue; + } + const dec = tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys); + if (dec && tribeRootSet.has(dec.rootId)) { + const recps = Array.isArray(dec.recps) ? dec.recps : []; + for (const r of recps) discovered.add(r); + if (v.author) discovered.add(v.author); + continue; + } + if (tribeCrypto.isTribeMsg(c)) { + const r = tribeCrypto.unwrapMsg(c, fpIdx); + if (!r || !r.body) continue; + if (r.body.k !== 'tribe') continue; + if (!tribeChainSet.has(m.key) && !tribeChainSet.has(r.body.replaces || '') && r.rootId !== rootId) continue; + const mems = Array.isArray(r.body.members) ? r.body.members : []; + for (const fid of mems) if (fid) discovered.add(fid); + if (v.author) discovered.add(v.author); + } + } + for (const m of msgs) { + const v = m.value; + if (!v || v.author !== me) continue; + const c = v.content; + if (!c || c.type !== 'tribe-invite-msg') continue; + for (const m2 of msgs) { + const v2 = m2.value; + if (!v2) continue; + const c2 = v2.content; + if (!c2 || c2.type !== 'tribe-invite-tombstone') continue; + if (c2.target === m.key && v2.author && v2.author !== me) discovered.add(v2.author); + } + } + const baseMembers = Array.isArray(tribe.members) ? tribe.members : []; + for (const fid of baseMembers) discovered.add(fid); + if (tribe.author) discovered.add(tribe.author); + discovered.delete(me); + const members = [...discovered].filter(Boolean); + if (!members.length) return; + for (const memberId of members) { + if (myFollows.get(memberId) === true) continue; + await new Promise((resolve) => + client.publish({ type: 'contact', contact: memberId, following: true }, () => resolve()) + ); + } + }, + + async forceSync() { + try { + const client = await openSsb(); + if (client.replicate && typeof client.replicate.upto === 'function') { + await new Promise((resolve) => { try { client.replicate.upto(() => resolve()); } catch (_) { resolve(); } }); + } + } catch (_) {} + }, + + async deleteTribeById(tribeId) { + return this.publishTombstone(tribeId); + }, + + async publishTombstone(tribeId) { + const client = await openSsb(); + const userId = client.id; + const rootId = await this.getRootId(tribeId); + const idx = await buildTribeIndex(); + let tipId = rootId; + while (idx.child.has(tipId)) tipId = idx.child.get(tipId); + let tribe; + try { tribe = await this.getTribeById(tribeId); } catch (_) { tribe = null; } + const isPrivate = tribe ? !!tribe.isAnonymous : !!tribeCrypto.getKey(rootId); + let result; + if (isPrivate) { + result = await wrapAndPublish(rootId, { k: 'tombstone', rootId, target: tipId, author: userId, deletedAt: new Date().toISOString() }); + } else { + const tomb = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId }; + result = await new Promise((resolve, reject) => + client.publish(tomb, (err, r) => err ? reject(err) : resolve(r)) + ); + } + tribeIndex = null; + return result; + }, + + async pruneOrphanKeys() { + if (!tribeCrypto || typeof tribeCrypto.getAllRootIds !== 'function') return 0; + const idx = await buildTribeIndex(); + const all = tribeCrypto.getAllRootIds(); + let removed = 0; + for (const rid of all) { + if (!idx.tribes.has(rid) || idx.effectivelyTombstoned.has(rid)) { + try { tribeCrypto.dropKey(rid); removed++; } catch (_) {} + } + } + return removed; + } + }; +}; diff --git a/src/models/videos_model.js b/src/models/videos_model.js new file mode 100644 index 0000000..24b8a99 --- /dev/null +++ b/src/models/videos_model.js @@ -0,0 +1,332 @@ +const pull = require("../server/node_modules/pull-stream"); +const { getConfig } = require("../configs/config-manager.js"); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { dedupeBy, norm } = require('../backend/dedupe'); +const categories = require("../backend/opinion_categories"); + +const logLimit = getConfig().ssbLogStream?.limit || 1000; + +const safeArr = (v) => (Array.isArray(v) ? v : []); + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return undefined; + if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean); + return String(raw).split(",").map((t) => t.trim()).filter(Boolean); +}; + +const parseBlobId = (blobMarkdown) => { + const s = String(blobMarkdown || ""); + const match = s.match(/\(([^)]+)\)/); + return match ? match[1] : s || null; +}; + +const voteSum = (opinions = {}) => + Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); + +module.exports = ({ cooler }) => { + let ssb; + + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const getAllMessages = async (ssbClient) => + new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) + ); + }); + + const getMsg = async (ssbClient, key) => + new Promise((resolve, reject) => { + ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg))); + }); + + const buildIndex = (messages) => { + const tomb = buildValidatedTombstoneSet(messages); + const nodes = new Map(); + const parent = new Map(); + const child = new Map(); + const strictChild = new Map(); + const opinionMsgs = []; + + for (const m of messages) { + const k = m.key; + const v = m.value || {}; + const c = v.content; + if (!c) continue; + + if (c.type === "tombstone") continue; + if (c.type === "videoOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } + if (c.type !== "video") continue; + + const ts = v.timestamp || m.timestamp || 0; + let sizeBytes = 0; + try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; } + nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author }); + + if (c.replaces) { + parent.set(k, c.replaces); + child.set(c.replaces, k); + } + } + + for (const [k, node] of nodes) { + const t = node.c.replaces; + if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); } + } + + const rootOf = (id) => { + let cur = id, g = 0; + while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur); + return cur; + }; + + const contentTipOf = (root) => { + let cur = root, g = 0; + while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur); + const n = nodes.get(cur), rn = nodes.get(root); + return (n && rn && n.author === rn.author) ? cur : root; + }; + + const roots = new Set(); + for (const id of nodes.keys()) roots.add(rootOf(id)); + + const opinionsByRoot = new Map(); + for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } + + const forward = new Map(); + for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId); + + const resolveGroup = (root) => { + const contentTip = contentTipOf(root); + const contentNode = nodes.get(contentTip) || nodes.get(root); + const lc = contentNode ? contentNode.c : {}; + const opinions = { ...(lc.opinions || {}) }; + const voters = safeArr(lc.opinions_inhabitants).slice(); + const voterSet = new Set(voters); + for (const op of (opinionsByRoot.get(root) || [])) { + if (voterSet.has(op.author)) continue; + voterSet.add(op.author); voters.push(op.author); + opinions[op.category] = (opinions[op.category] || 0) + 1; + } + return { contentTip, contentNode, opinions, voters }; + }; + + const tipByRoot = new Map(); + for (const r of roots) tipByRoot.set(r, contentTipOf(r)); + + return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup }; + }; + + const buildVideo = (node, rootId, viewerId, agg) => { + const c = node.c || {}; + const opinions = agg ? agg.opinions : (c.opinions || {}); + const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants); + return { + key: node.key, + rootId, + url: c.url, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + tags: safeArr(c.tags), + author: c.author, + title: c.title || "", + description: c.description || "", + mapUrl: c.mapUrl || "", + opinions, + opinions_inhabitants: voters, + hasVoted: viewerId ? voters.includes(viewerId) : false, + sizeBytes: node.sizeBytes || 0 + }; + }; + + return { + type: "video", + + async resolveCurrentId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const tip = idx.tipByRoot.get(idx.rootOf(id)) || id; + if (idx.tomb.has(tip)) throw new Error("Video not found"); + return tip; + }, + + async resolveRootId(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + let tip = id; + while (idx.forward.has(tip)) tip = idx.forward.get(tip); + if (idx.tomb.has(tip)) throw new Error("Video not found"); + + let root = tip; + while (idx.parent.has(root)) root = idx.parent.get(root); + return root; + }, + + async createVideo(blobMarkdown, tagsRaw, title, description, mapUrl) { + const ssbClient = await openSsb(); + const blobId = parseBlobId(blobMarkdown); + const tags = normalizeTags(tagsRaw) || []; + const now = new Date().toISOString(); + + const content = { + type: "video", + url: blobId, + createdAt: now, + updatedAt: now, + author: ssbClient.id, + tags, + title: title || "", + description: description || "", + mapUrl: mapUrl || "", + opinions: {}, + opinions_inhabitants: [] + }; + + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); + }); + }, + + async updateVideoById(id, blobMarkdown, tagsRaw, title, description, mapUrl) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const oldMsg = await getMsg(ssbClient, tipId); + + if (!oldMsg || oldMsg.content?.type !== "video") throw new Error("Video not found"); + if (oldMsg.content.author !== userId) throw new Error("Not the author"); + const aggV = await this.getVideoById(id, userId); + if (aggV && Object.keys(aggV.opinions || {}).some(k => (aggV.opinions[k] || 0) > 0)) throw new Error("Cannot edit video after it has received opinions."); + + const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags); + const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null; + const now = new Date().toISOString(); + + const updated = { + ...oldMsg.content, + replaces: tipId, + url: blobId || oldMsg.content.url, + tags, + title: title !== undefined ? title || "" : oldMsg.content.title || "", + description: description !== undefined ? description || "" : oldMsg.content.description || "", + mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "", + createdAt: oldMsg.content.createdAt, + updatedAt: now + }; + + const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId }; + await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); + + return new Promise((resolve, reject) => { + ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result))); + }); + }, + + async deleteVideoById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await this.resolveCurrentId(id); + const msg = await getMsg(ssbClient, tipId); + + if (!msg || msg.content?.type !== "video") throw new Error("Video not found"); + if (msg.content.author !== userId) throw new Error("Not the author"); + + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }; + + return new Promise((resolve, reject) => { + ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res))); + }); + }, + + async listAll(filterOrOpts = "all", maybeOpts = {}) { + const ssbClient = await openSsb(); + + const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {}; + const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all"; + const q = String(opts.q || "").trim().toLowerCase(); + const sort = String(opts.sort || "recent").trim(); + const viewerId = opts.viewerId || ssbClient.id; + + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + + const items = []; + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue; + const node = idx.nodes.get(tipId); + if (!node) continue; + items.push(buildVideo(node, rootId, viewerId, idx.resolveGroup(rootId))); + } + + let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null); + const now = Date.now(); + + if (filter === "mine") list = list.filter((v) => String(v.author) === String(viewerId)); + else if (filter === "recent") list = list.filter((v) => new Date(v.createdAt).getTime() >= now - 86400000); + else if (filter === "top") { + list = list + .slice() + .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } + + if (q) { + list = list.filter((v) => { + const t = String(v.title || "").toLowerCase(); + const d = String(v.description || "").toLowerCase(); + const tags = safeArr(v.tags).join(" ").toLowerCase(); + const a = String(v.author || "").toLowerCase(); + return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q); + }); + } + + if (sort === "top") { + list = list + .slice() + .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); + } else if (sort === "oldest") { + list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + } else { + list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } + + return list; + }, + + async getVideoById(id, viewerId = null) { + const ssbClient = await openSsb(); + const viewer = viewerId || ssbClient.id; + const messages = await getAllMessages(ssbClient); + const idx = buildIndex(messages); + const rootId = idx.rootOf(id); + const agg = idx.resolveGroup(rootId); + if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Video not found"); + + return buildVideo(agg.contentNode, rootId, viewer, agg); + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error("Invalid voting category"); + + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const video = await this.getVideoById(id, userId); + if (!video) throw new Error("Video not found"); + if (safeArr(video.opinions_inhabitants).includes(userId)) throw new Error("Already voted"); + + const content = { type: "videoOpinion", target: video.rootId, category, createdAt: new Date().toISOString() }; + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result))); + }); + } + }; +}; + diff --git a/src/models/viewer_filters.js b/src/models/viewer_filters.js new file mode 100644 index 0000000..cef023e --- /dev/null +++ b/src/models/viewer_filters.js @@ -0,0 +1,130 @@ +const fs = require('fs'); +const path = require('path'); +const { getConfig } = require('../configs/config-manager.js'); + +const COOLDOWN_MS = 5 * 60 * 1000; +const statePath = path.join(__dirname, '../configs/follow_state.json'); + +const readJson = (p, fallback) => { + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return fallback; } +}; +const writeJson = (p, data) => { + try { fs.writeFileSync(p, JSON.stringify(data, null, 2)); } catch (e) {} +}; + +const loadState = () => { + const s = readJson(statePath, { pending: [], accepted: [], lastAcceptMs: 0 }); + if (!Array.isArray(s.pending)) s.pending = []; + if (!Array.isArray(s.accepted)) s.accepted = []; + if (typeof s.lastAcceptMs !== 'number') s.lastAcceptMs = 0; + return s; +}; +const saveState = (s) => writeJson(statePath, s); + +const wishMutualsOnly = () => getConfig().wish === 'mutuals'; +const pmMutualsOnly = () => getConfig().pmVisibility === 'mutuals'; +const isFrictionActive = () => wishMutualsOnly() || pmMutualsOnly(); + +const listPending = () => loadState().pending; +const enqueuePending = (followerId, extra = {}) => { + if (!followerId) return false; + const s = loadState(); + if (s.pending.some(x => x.followerId === followerId)) return false; + s.pending.push({ followerId, at: new Date().toISOString(), ...extra }); + saveState(s); + return true; +}; +const removePending = (followerId) => { + const s = loadState(); + s.pending = s.pending.filter(x => x.followerId !== followerId); + saveState(s); +}; + +const loadAccepted = () => loadState().accepted; +const isAccepted = (followerId) => loadState().accepted.includes(followerId); +const addAccepted = (followerId) => { + if (!followerId) return; + const s = loadState(); + if (!s.accepted.includes(followerId)) { s.accepted.push(followerId); saveState(s); } +}; +const removeAccepted = (followerId) => { + const s = loadState(); + s.accepted = s.accepted.filter(x => x !== followerId); + saveState(s); +}; + +const canAutoAcceptNow = () => (Date.now() - loadState().lastAcceptMs) >= COOLDOWN_MS; +const markAutoAccept = () => { + const s = loadState(); + s.lastAcceptMs = Date.now(); + saveState(s); +}; + +const makeMutualCache = (friendModel) => { + const cache = new Map(); + const frictionActive = isFrictionActive(); + return async (otherId) => { + if (!otherId) return false; + if (cache.has(otherId)) return cache.get(otherId); + try { + const rel = await friendModel.getRelationship(otherId); + const basic = !!(rel && rel.following && rel.followsMe); + const mutual = frictionActive ? (basic && isAccepted(otherId)) : basic; + cache.set(otherId, mutual); + return mutual; + } catch (e) { + cache.set(otherId, false); + return false; + } + }; +}; + +const authorOf = (item) => { + if (!item) return null; + if (item.value && item.value.author) return item.value.author; + if (item.author) return item.author; + if (item.feed) return item.feed; + if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id; + return null; +}; + +const applyMutualSupportFilter = async (items, viewerId, friendModel) => { + if (!wishMutualsOnly()) return items; + if (!Array.isArray(items)) return items; + const isMutual = makeMutualCache(friendModel); + const out = []; + for (const it of items) { + const a = authorOf(it); + if (!a || a === viewerId) { out.push(it); continue; } + if (await isMutual(a)) out.push(it); + } + return out; +}; + +const canSendPmTo = async (viewerId, recipientId, friendModel) => { + if (!pmMutualsOnly()) return { allowed: true }; + if (viewerId === recipientId) return { allowed: true }; + const isMutual = makeMutualCache(friendModel); + if (await isMutual(recipientId)) return { allowed: true }; + return { allowed: false, reason: 'non-mutual' }; +}; + +module.exports = { + COOLDOWN_MS, + wishMutualsOnly, + pmMutualsOnly, + isFrictionActive, + listPending, + enqueuePending, + removePending, + loadAccepted, + isAccepted, + addAccepted, + removeAccepted, + canAutoAcceptNow, + markAutoAccept, + makeMutualCache, + applyMutualSupportFilter, + canSendPmTo, + authorOf, +}; diff --git a/src/models/votes_model.js b/src/models/votes_model.js new file mode 100644 index 0000000..7802606 --- /dev/null +++ b/src/models/votes_model.js @@ -0,0 +1,382 @@ +const pull = require('../server/node_modules/pull-stream'); +const moment = require('../server/node_modules/moment'); +const { buildValidatedTombstoneSet } = require('./tombstone_validator'); +const { getConfig } = require('../configs/config-manager.js'); +const { dedupeBy, norm } = require('../backend/dedupe'); +const { buildVoteTally } = require('../backend/vote_tally'); +const categories = require('../backend/opinion_categories'); +const logLimit = getConfig().ssbLogStream?.limit || 1000; +const MIN_VOTE_DAYS = 7; + +module.exports = ({ cooler }) => { + let ssb; + const openSsb = async () => { + if (!ssb) ssb = await cooler.open(); + return ssb; + }; + + const TYPE = 'votes'; + + async function getAllMessages(ssbClient) { + return new Promise((resolve, reject) => { + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, results) => (err ? reject(err) : resolve(results))) + ); + }); + } + + function buildIndex(messages) { + const tombstoned = buildValidatedTombstoneSet(messages); + const votes = new Map(); + const naivePrev = new Map(); + const collabVotes = []; + const collabOpinions = []; + + for (const m of messages) { + const key = m.key; + const v = m.value; + const c = v && v.content; + if (!c) continue; + + if (c.type === 'tombstone') continue; + if (c.type === 'votesVote') { collabVotes.push({ target: c.target, author: v.author, choice: c.choice, ts: v.timestamp || 0 }); continue; } + if (c.type === 'votesOpinion') { collabOpinions.push({ target: c.target, author: v.author, category: c.category, ts: v.timestamp || 0 }); continue; } + if (c.type !== TYPE) continue; + + votes.set(key, { key, ts: v.timestamp || m.timestamp || 0, content: c, author: v.author }); + } + + for (const [key, node] of votes) { + const t = node.content.replaces; + if (t) { naivePrev.set(key, t); } + } + + return { tombstoned, votes, naivePrev, collabVotes, collabOpinions, voteTallyByKey: buildVoteTally(messages) }; + } + + const rootOfIn = (naivePrev, votes, key) => { let x = key, g = 0; while (naivePrev.has(x) && votes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x; }; + + function statusFromContent(content, now) { + const raw = String(content.status || 'OPEN').toUpperCase(); + if (raw === 'OPEN') { + const dl = content.deadline ? moment(content.deadline) : null; + if (dl && dl.isValid() && dl.isBefore(now)) return 'CLOSED'; + } + return raw; + } + + function computeActiveVotes(index) { + const { tombstoned, votes, naivePrev, collabVotes, collabOpinions } = index; + const rootOf = key => rootOfIn(naivePrev, votes, key); + + const groups = new Map(); + for (const [key, node] of votes.entries()) { + const root = rootOf(key); + if (!groups.has(root)) groups.set(root, []); + groups.get(root).push(node); + } + + const cvByRoot = new Map(); const coByRoot = new Map(); + for (const cv of collabVotes) { if (!votes.has(cv.target)) continue; const r = rootOf(cv.target); if (!cvByRoot.has(r)) cvByRoot.set(r, []); cvByRoot.get(r).push(cv); } + for (const co of collabOpinions) { if (!votes.has(co.target)) continue; const r = rootOf(co.target); if (!coByRoot.has(r)) coByRoot.set(r, []); coByRoot.get(r).push(co); } + + const now = moment(); + const result = []; + + for (const [root, nodes] of groups.entries()) { + const rootNode = votes.get(root); + const rootAuthor = rootNode ? rootNode.author : null; + const ownerNodes = nodes.filter(n => n.author === rootAuthor); + const pool = ownerNodes.length ? ownerNodes : (rootNode ? [rootNode] : nodes); + + let best = pool[0]; + let bestStatus = statusFromContent(best.content, now); + for (let i = 1; i < pool.length; i++) { + const candidate = pool[i]; + const cStatus = statusFromContent(candidate.content, now); + if (cStatus === bestStatus) { + const bestTime = new Date(best.content.updatedAt || best.content.createdAt || best.ts || 0); + const cTime = new Date(candidate.content.updatedAt || candidate.content.createdAt || candidate.ts || 0); + if (cTime > bestTime) { best = candidate; bestStatus = cStatus; } + } else if (cStatus === 'CLOSED' && bestStatus !== 'CLOSED') { best = candidate; bestStatus = cStatus; } + else if (cStatus === 'OPEN' && bestStatus !== 'OPEN') { best = candidate; bestStatus = cStatus; } + } + + if (tombstoned.has(best.key)) continue; + + const lc = best.content; + const options = Array.isArray(best.content.options) ? best.content.options : []; + + const tally = index.voteTallyByKey ? index.voteTallyByKey.get(best.key) : null; + let votesMap; + let voters; + let totalVotes; + if (tally) { + votesMap = Object.assign({}, tally.votes || {}); + for (const o of options) if (!(o in votesMap)) votesMap[o] = 0; + voters = tally.voters.slice(); + totalVotes = tally.totalVotes; + } else { + votesMap = Object.assign({}, lc.votes || {}); + for (const o of options) if (!(o in votesMap)) votesMap[o] = 0; + voters = Array.isArray(lc.voters) ? lc.voters.slice() : []; + const voterSet = new Set(voters); + totalVotes = parseInt(lc.totalVotes || 0, 10) || voters.length; + for (const cv of (cvByRoot.get(root) || [])) { + if (voterSet.has(cv.author)) continue; + if (!options.includes(cv.choice)) continue; + voterSet.add(cv.author); voters.push(cv.author); + votesMap[cv.choice] = (votesMap[cv.choice] || 0) + 1; + totalVotes += 1; + } + } + + const opinions = Object.assign({}, lc.opinions || {}); + const opInh = Array.isArray(lc.opinions_inhabitants) ? lc.opinions_inhabitants.slice() : []; + const opSet = new Set(opInh); + for (const co of (coByRoot.get(root) || [])) { + if (opSet.has(co.author)) continue; + opSet.add(co.author); opInh.push(co.author); + opinions[co.category] = (opinions[co.category] || 0) + 1; + } + + result.push({ + id: best.key, + latestId: best.key, + ...best.content, + votes: votesMap, + voters, + totalVotes, + opinions, + opinions_inhabitants: opInh, + status: bestStatus + }); + } + + return result; + } + + async function resolveCurrentId(voteId) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const nodeByKey = new Map(); + for (const m of messages) { const c = m.value && m.value.content; if (c && c.type === TYPE) nodeByKey.set(m.key, { author: m.value.author, replaces: c.replaces }); } + const strictForward = new Map(); + for (const [key, n] of nodeByKey) { if (n.replaces && nodeByKey.has(n.replaces) && nodeByKey.get(n.replaces).author === n.author) strictForward.set(n.replaces, key); } + let cur = voteId, g = 0; + while (strictForward.has(cur) && g++ < 100000) cur = strictForward.get(cur); + return cur; + } + + return { + async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const parsedDeadline = moment(deadline, moment.ISO_8601, true); + if (!parsedDeadline.isValid()) throw new Error('Invalid deadline'); + if (parsedDeadline.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) throw new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`); + + const tags = Array.isArray(tagsRaw) + ? tagsRaw.filter(Boolean) + : String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean); + + const content = { + type: TYPE, + question, + options, + deadline: parsedDeadline.toISOString(), + createdBy: userId, + status: 'OPEN', + votes: options.reduce((acc, opt) => { + acc[opt] = 0; + return acc; + }, {}), + totalVotes: 0, + voters: [], + tags, + opinions: {}, + opinions_inhabitants: [], + createdAt: new Date().toISOString(), + updatedAt: null + }; + + return new Promise((res, rej) => + ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg))) + ); + }, + + async deleteVoteById(id) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await resolveCurrentId(id); + const vote = await new Promise((res, rej) => + ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg))) + ); + if (!vote.content || vote.content.createdBy !== userId) throw new Error('Not the author'); + const tombstone = { + type: 'tombstone', + target: tipId, + deletedAt: new Date().toISOString(), + author: userId + }; + return new Promise((res, rej) => + ssbClient.publish(tombstone, (err, result) => (err ? rej(err) : res(result))) + ); + }, + + async updateVoteById(id, payload) { + const { question, deadline, options, tags } = payload || {}; + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const tipId = await resolveCurrentId(id); + + const oldMsg = await new Promise((res, rej) => + ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg))) + ); + + const c = oldMsg.content; + if (!c || c.type !== TYPE) throw new Error('Invalid type'); + if (c.createdBy !== userId) throw new Error('Not the author'); + const agg = await this.getVoteById(id); + const aggTotalVotes = agg ? (parseInt(agg.totalVotes || 0, 10) || 0) : 0; + if (agg && Object.keys(agg.opinions || {}).some(k => (agg.opinions[k] || 0) > 0)) throw new Error('Cannot edit vote after it has received opinions.'); + + let newDeadline = c.deadline; + if (deadline != null && deadline !== '') { + const parsed = moment(deadline, moment.ISO_8601, true); + if (!parsed.isValid()) throw new Error('Invalid deadline'); + if (parsed.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) throw new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`); + newDeadline = parsed.toISOString(); + } + + let newOptions = c.options || []; + let newVotesMap = c.votes || {}; + let newTotalVotes = c.totalVotes || 0; + + const optionsChanged = Array.isArray(options) && ( + options.length !== newOptions.length || + options.some((o, i) => o !== newOptions[i]) + ); + + if (optionsChanged) { + if (aggTotalVotes > 0) { + throw new Error('Cannot change options after voting has started'); + } + newOptions = options; + newVotesMap = newOptions.reduce((acc, opt) => { + acc[opt] = 0; + return acc; + }, {}); + newTotalVotes = 0; + } + + let newTags = c.tags || []; + if (Array.isArray(tags)) { + newTags = tags.filter(Boolean); + } else if (typeof tags === 'string') { + newTags = tags.split(',').map(t => t.trim()).filter(Boolean); + } + + const updated = { + ...c, + replaces: tipId, + question: question != null ? question : c.question, + deadline: newDeadline, + options: newOptions, + votes: newVotesMap, + totalVotes: newTotalVotes, + tags: newTags, + updatedAt: new Date().toISOString() + }; + + return new Promise((res, rej) => + ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result))) + ); + }, + + async voteOnVote(id, choice) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const vote = await this.getVoteById(id); + if (!vote) throw new Error('Vote not found'); + if (vote.createdBy === userId) throw new Error('Creator cannot vote'); + const options = Array.isArray(vote.options) ? vote.options : []; + if (!options.includes(choice)) throw new Error('Invalid choice'); + if (Array.isArray(vote.voters) && vote.voters.includes(userId)) throw new Error('Already voted'); + + const content = { type: 'votesVote', target: vote.id, choice, createdAt: new Date().toISOString() }; + return new Promise((res, rej) => + ssbClient.publish(content, (err, result) => (err ? rej(err) : res(result))) + ); + }, + + async getVoteById(id) { + const ssbClient = await openSsb(); + const messages = await getAllMessages(ssbClient); + const index = buildIndex(messages); + const activeList = computeActiveVotes(index); + const byId = new Map(activeList.map(v => [v.id, v])); + + if (byId.has(id)) { + return byId.get(id); + } + + const rootOf = key => rootOfIn(index.naivePrev, index.votes, key); + const root = rootOf(id); + const candidate = activeList.find(v => rootOf(v.id) === root); + if (candidate) { + return candidate; + } + + const msg = await new Promise((res, rej) => + ssbClient.get(id, (err, vote) => (err || !vote ? rej(new Error('Vote not found')) : res(vote))) + ); + + const content = msg.content || {}; + const status = statusFromContent(content, moment()); + + return { + id, + latestId: id, + ...content, + status + }; + }, + + async listAll(filter = 'all') { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const messages = await getAllMessages(ssbClient); + const index = buildIndex(messages); + let list = computeActiveVotes(index); + + if (filter === 'mine') { + list = list.filter(v => v.createdBy === userId); + } else if (filter === 'open') { + list = list.filter(v => v.status === 'OPEN'); + } else if (filter === 'closed') { + list = list.filter(v => v.status === 'CLOSED'); + } + + const deduped = dedupeBy(list, v => v.question ? [norm(v.createdBy), norm(v.question), norm(v.deadline)].join('|') : null); + return deduped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error('Invalid voting category'); + const ssbClient = await openSsb(); + const userId = ssbClient.id; + + const vote2 = await this.getVoteById(id); + if (!vote2) throw new Error('Vote not found'); + if (Array.isArray(vote2.opinions_inhabitants) && vote2.opinions_inhabitants.includes(userId)) throw new Error('Already voted'); + + const content = { type: 'votesOpinion', target: vote2.id, category, createdAt: new Date().toISOString() }; + return new Promise((res, rej) => + ssbClient.publish(content, (err, result) => (err ? rej(err) : res(result))) + ); + } + }; +}; + diff --git a/src/models/wallet_model.js b/src/models/wallet_model.js new file mode 100644 index 0000000..cafa6cf --- /dev/null +++ b/src/models/wallet_model.js @@ -0,0 +1,61 @@ +const { + RequestManager, + HTTPTransport, + Client +} = require("../server/node_modules/@open-rpc/client-js"); + +async function makeClient(url, user, pass) { + const headers = {}; + if (user !== undefined || pass !== undefined) { + headers['Authorization'] = 'Basic ' + Buffer.from(`${user || ''}:${pass || ''}`).toString('base64'); + } + const transport = new HTTPTransport(url, { headers }); + return new Client(new RequestManager([transport])); +} + +module.exports = { + client: async (url, user, pass) => { + return makeClient(url, user, pass); + }, + execute: async (url, user, pass, method, params = []) => { + try { + const clientrpc = await makeClient(url, user, pass); + return await clientrpc.request({ method, params }); + } catch (error) { + throw new Error("ECOin wallet disconnected. Check your wallet settings or connection status."); + } + }, + getBalance: async (url, user, pass) => { + return Number(await module.exports.execute(url, user, pass, "getbalance")) || 0; + }, + getAddress: async (url, user, pass) => { + try { + const addrs = await module.exports.execute(url, user, pass, "getaddressesbyaccount", [""]); + if (Array.isArray(addrs) && addrs.length > 0) return addrs[0]; + } catch {} + try { + const addr = await module.exports.execute(url, user, pass, "getnewaddress", [""]); + if (typeof addr === "string" && addr) return addr; + } catch {} + return ""; + }, + listTransactions: async (url, user, pass) => { + return await module.exports.execute(url, user, pass, "listtransactions", ["", 1000000, 0]); + }, + sendToAddress: async (url, user, pass, address, amount) => { + return await module.exports.execute(url, user, pass, "sendtoaddress", [address, Number(amount)]); + }, + validateSend: async (url, user, pass, address, amount, fee) => { + let isValid = false; + const errors = []; + const addrInfo = await module.exports.execute(url, user, pass, "validateaddress", [address]); + const addressValid = !!addrInfo?.isvalid; + const amountValid = Number(amount) > 0; + const feeValid = Number(fee) >= 0; + if (!addressValid) errors.push("invalid_dest"); + if (!amountValid) errors.push("invalid_amount"); + if (!feeValid) errors.push("invalid_fee"); + if (errors.length === 0) isValid = true; + return { isValid, errors }; + } +}; diff --git a/src/server/SSB_server.js b/src/server/SSB_server.js new file mode 100644 index 0000000..cd69637 --- /dev/null +++ b/src/server/SSB_server.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +const moduleAlias = require('module-alias'); +moduleAlias.addAlias('punycode', 'punycode/'); + +const fs = require('fs'); +const path = require('path'); +const SecretStack = require('secret-stack'); +const caps = require('ssb-caps'); +const SSB = require('ssb-db'); +const config = require('./ssb_config'); +const { printMetadata } = require('./ssb_metadata'); + +(() => { + const realErr = console.error; + const SHS_NOISE = /shs\.server:|they dailed a wrong number|client hello invalid|invalid challenge|wrong application cap/i; + const EBT_NOISE = /stream ended with:\s*\d+\s+but wanted:\s*\d+/i; + const EBT_HANDSHAKE_NOISE = /does not support RPC ebt\./i; + const isEbtReplicateException = (args) => + args.length >= 2 && + typeof args[0] === 'string' && + /rpc\.ebt\.replicate exception/i.test(args[0]) && + args[1] && typeof args[1].message === 'string' && EBT_NOISE.test(args[1].message); + const parsePeer = (addr) => { + if (typeof addr !== 'string') return 'unknown'; + const m = /net:(.+?):(\d+)(?:~|$)/.exec(addr); + if (!m) return addr; + return `${m[1].replace(/^::ffff:/, '')}:${m[2]}`; + }; + const logRejection = (peer) => { + const ts = new Date().toISOString().replace('T', ' ').slice(0, 19); + realErr.call(console, `[${ts}] REJECTED ${peer} (wrong SHS cap)`); + }; + console.error = function (...args) { + if (args.length >= 2 && args[0] === 'server error, from' && typeof args[1] === 'string' && args[1].includes('~shs:')) { + logRejection(parsePeer(args[1])); + return; + } + if (args.length >= 1 && args[0] && typeof args[0].message === 'string' && SHS_NOISE.test(args[0].message)) { + logRejection(parsePeer(args[0].address)); + return; + } + if (args.length >= 1 && args[0] && typeof args[0].message === 'string' && EBT_NOISE.test(args[0].message)) return; + if (isEbtReplicateException(args)) return; + if (args.length >= 1 && typeof args[0] === 'string' && /rpc\.ebt\.replicate exception:.*stream ended with/i.test(args[0])) return; + return realErr.apply(console, args); + }; + const realWarn = console.warn; + console.warn = function (...args) { + if (args.length >= 1 && typeof args[0] === 'string' && EBT_HANDSHAKE_NOISE.test(args[0])) return; + return realWarn.apply(console, args); + }; +})(); + +require('ssb-plugins').loadUserPlugins(SecretStack({ caps }), config); + +const Server = SecretStack({ caps }) + .use(SSB) + .use(require('ssb-master')) + .use(require('ssb-gossip')) + .use(require('ssb-ebt')) + .use(require('ssb-friends')) + .use(require('ssb-blobs')) + .use(require('ssb-meme')) + .use(require('ssb-plugins')) + .use(require('ssb-conn')) + .use(require('ssb-box')) + .use(require('ssb-search')) + .use(require('ssb-private')) + .use(require('ssb-friend-pub')) + .use(config.pub ? require('ssb-invite') : require('ssb-invite-client')) + .use(require('ssb-logging')) + .use(require('ssb-replication-scheduler')) + .use(require('ssb-partial-replication')) + .use(require('ssb-about')) + .use(require('ssb-onion')) + .use(require('ssb-unix-socket')) + .use(require('ssb-no-auth')) + .use(require('ssb-backlinks')) + .use(require('ssb-links')) + .use(require('ssb-tangle')) + .use(require('ssb-query')); + + +if (config.autofollow && typeof config.autofollow === 'object' && !Array.isArray(config.autofollow)) { + if (config.autofollow.enabled === false) { + config.autofollow = null; + } else { + const feeds = Array.isArray(config.autofollow.feeds) ? config.autofollow.feeds : (Array.isArray(config.autofollow.suggestions) ? config.autofollow.suggestions : []); + config.autofollow = feeds.filter(f => typeof f === 'string' && f.length > 0); + } +} +if (config.autofollow && (Array.isArray(config.autofollow) ? config.autofollow.length > 0 : true)) { + Server.use(require('ssb-autofollow')); +} + +const manifestFile = path.join(config.path, 'manifest.json'); +let server; +const argv = process.argv.slice(2); + +const isLockError = (err) => { + if (!err) return false; + if (err.name === 'OpenError') return true; + const msg = String(err.message || ''); + return /Resource temporarily unavailable/i.test(msg) && /\.ssb\/.*LOCK/i.test(msg); +}; + +const handleFatal = (err) => { + if (isLockError(err)) { + console.log(''); + console.log('Another Oasis instance is already running on this device. Close the other instance (or kill the process) and try again.'); + console.log(''); + process.exit(1); + } + throw err; +}; + +process.on('uncaughtException', handleFatal); + +if (argv[0] === 'start') { + try { + server = Server(config); + } catch (err) { + handleFatal(err); + } + fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2)); + + const { cmdAliases } = require('../client/cli-cmd-aliases'); + const manifest = server.getManifest(); + for (const k in cmdAliases) { + server[k] = server[cmdAliases[k]]; + manifest[k] = manifest[cmdAliases[k]]; + } + + manifest.config = 'sync'; + server.config = cb => { + console.log(JSON.stringify(config, null, 2)); + cb(); + }; + + if (process.stdout.isTTY && config.logging?.level !== 'info') { + const showProgress = () => { + let prog = -1; + const bar = r => '\r' + '*'.repeat(Math.floor(r * 50)) + '.'.repeat(50 - Math.floor(r * 50)); + const percent = r => (Math.round(r * 10000) / 100).toFixed(2) + '%'; + const rate = prog => prog.target === prog.current ? 1 : (prog.current - prog.start) / (prog.target - prog.start); + const interval = setInterval(() => { + const p = server.progress(); + let r = 1; + const tasks = []; + for (const k in p) { + const pr = rate(p[k]); + if (pr < 1) tasks.push(`${k}:${percent(pr)}`); + r = Math.min(r, pr); + } + if (r !== prog) { + prog = r; + process.stdout.write(bar(r) + ` (${tasks.join(', ')})\x1b[K\r`); + } + }, 333); + interval.unref?.(); + }; + showProgress(); + } + + const { printMetadata, colors } = require('./ssb_metadata'); + printMetadata('OASIS Server Only', colors.cyan, null); + + setTimeout(() => { + try { + const pull = require('pull-stream'); + const stream = server.conn && server.conn.hub && server.conn.hub().listen && server.conn.hub().listen(); + if (!stream) return; + pull(stream, pull.drain((ev) => { + if (!ev || !ev.type) return; + const ts = new Date().toISOString().replace('T', ' ').slice(0, 19); + if (ev.type === 'connected') { + console.log(`[${ts}] CONNECTED ${ev.address || ''}`); + } else if (ev.type === 'disconnected') { + console.log(`[${ts}] DISCONNECTED ${ev.address || ''}`); + } + }, () => {})); + } catch (_) {} + }, 1000); + +} + +module.exports = { + config, + server: server || Server(config), + open: async () => server || Server(config) +}; diff --git a/src/server/nodemon.json b/src/server/nodemon.json new file mode 100644 index 0000000..a8b82fb --- /dev/null +++ b/src/server/nodemon.json @@ -0,0 +1,15 @@ +{ + "watch": [ + "../backend", + "../models", + "../views", + "../client" + ], + "exec": "node ../backend/backend.js", + "ext": "js,json", + "ignore": [ + "../client/assets/" + ], + "signal": "SIGTERM", + "delay": 500 +} diff --git a/src/server/package-lock.json b/src/server/package-lock.json new file mode 100644 index 0000000..f8ea18b --- /dev/null +++ b/src/server/package-lock.json @@ -0,0 +1,17813 @@ +{ + "name": "@krakenslab/oasis", + "version": "0.8.7", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krakenslab/oasis", + "version": "0.7.8", + "hasInstallScript": true, + "license": "AGPL-3.0", + "dependencies": { + "@koa/router": "^13.1.0", + "@open-rpc/client-js": "^1.8.1", + "@xenova/transformers": "^2.17.2", + "abstract-level": "^2.0.1", + "archiver": "^7.0.1", + "axios": "^1.10.0", + "cors": "^2.8.5", + "debug": "^4.3.1", + "dompurify": "^3.3.1", + "env-paths": "^2.2.1", + "epidemic-broadcast-trees": "^9.0.4", + "express": "^5.1.0", + "file-type": "^16.5.4", + "highlight.js": "11.0.0", + "hyperaxe": "^2.0.1", + "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz", + "is-svg": "^4.4.0", + "jsdom": "^28.0.0", + "koa": "^2.7.0", + "koa-body": "^6.0.1", + "koa-mount": "^4.0.0", + "koa-static": "^5.0.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "module-alias": "^2.2.3", + "moment": "^2.30.1", + "multiblob": "^1.13.0", + "multiserver": "^3.3.1", + "multiserver-address": "^1.0.1", + "muxrpc": "^8.0.0", + "muxrpc-validation": "^3.0.2", + "muxrpcli": "^3.1.2", + "node-llama-cpp": "^3.10.0", + "non-private-ip": "^2.2.0", + "open": "^8.4.2", + "openpgp": "^6.3.0", + "packet-stream": "^2.0.6", + "packet-stream-codec": "^1.2.0", + "pdfjs-dist": "^5.2.133", + "pretty-ms": "^7.0.1", + "pull-abortable": "^4.1.1", + "pull-cat": "~1.1.5", + "pull-file": "^1.0.0", + "pull-many": "~1.0.6", + "pull-paramap": "^1.2.2", + "pull-pushable": "^2.2.0", + "pull-sort": "^1.0.2", + "pull-stream": "^3.7.0", + "punycode.js": "^2.3.1", + "qrcode": "^1.5.4", + "secret-stack": "^6.3.1", + "ssb-about": "^2.0.1", + "ssb-autofollow": "^1.1.0", + "ssb-backlinks": "^2.1.1", + "ssb-blobs": "^2.0.1", + "ssb-box": "^1.0.1", + "ssb-caps": "^1.0.1", + "ssb-client": "^4.9.0", + "ssb-config": "^3.4.4", + "ssb-conn": "6.0.3", + "ssb-conn-db": "^1.0.5", + "ssb-conn-hub": "^1.2.0", + "ssb-conn-staging": "^1.0.0", + "ssb-db": "^20.4.1", + "ssb-device-address": "^1.1.6", + "ssb-ebt": "^9.1.2", + "ssb-friend-pub": "^1.0.7", + "ssb-friends": "^5.0.0", + "ssb-gossip": "^1.1.1", + "ssb-invite": "^3.0.3", + "ssb-invite-client": "^1.3.3", + "ssb-keys": "^8.0.0", + "ssb-lan": "^1.0.0", + "ssb-legacy-conn": "^1.0.17", + "ssb-links": "^3.0.10", + "ssb-logging": "^1.0.0", + "ssb-markdown": "^3.6.0", + "ssb-master": "^1.0.3", + "ssb-meme": "^1.1.0", + "ssb-mentions": "^0.5.2", + "ssb-msgs": "^5.2.0", + "ssb-no-auth": "^1.0.0", + "ssb-onion": "^1.0.0", + "ssb-partial-replication": "^3.0.1", + "ssb-plugins": "^1.0.2", + "ssb-private": "^1.1.0", + "ssb-query": "^2.4.5", + "ssb-ref": "^2.16.0", + "ssb-replication-scheduler": "^3.0.0", + "ssb-search": "^1.3.0", + "ssb-server": "file:packages/ssb-server", + "ssb-tangle": "^1.0.1", + "ssb-thread-schema": "^1.1.1", + "ssb-threads": "^10.0.4", + "ssb-unix-socket": "^1.0.0", + "ssb-ws": "^6.2.3", + "util": "^0.12.5", + "yargs": "^17.7.2" + }, + "bin": { + "oasis": "npm run start" + }, + "devDependencies": { + "nodemon": "^3.1.0" + }, + "engines": { + "node": "^10.0.0 || >=12.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2", + "sharp": "^0.33.5" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", + "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", + "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", + "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.1.tgz", + "integrity": "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.12.0.tgz", + "integrity": "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "license": "BSD-3-Clause" + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.5", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@koa/router": { + "version": "13.1.0", + "license": "MIT", + "dependencies": { + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "path-to-regexp": "^6.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, + "node_modules/@ljharb/resumer": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@ljharb/through": "^2.3.9" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@ljharb/through": { + "version": "2.3.14", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.70", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.70", + "@napi-rs/canvas-darwin-arm64": "0.1.70", + "@napi-rs/canvas-darwin-x64": "0.1.70", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.70", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.70", + "@napi-rs/canvas-linux-arm64-musl": "0.1.70", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.70", + "@napi-rs/canvas-linux-x64-gnu": "0.1.70", + "@napi-rs/canvas-linux-x64-musl": "0.1.70", + "@napi-rs/canvas-win32-x64-msvc": "0.1.70" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.70", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.70", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@node-llama-cpp/linux-arm64": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-arm64/-/linux-arm64-3.16.2.tgz", + "integrity": "sha512-CxzgPsS84wL3W5sZRgxP3c9iJKEW+USrak1SmX6EAJxW/v9QGzehvT6W/aR1FyfidiIyQtOp3ga0Gg/9xfJPGw==", + "cpu": [ + "arm64", + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/linux-armv7l": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-armv7l/-/linux-armv7l-3.16.2.tgz", + "integrity": "sha512-9G6W/MkQ/DLwGmpcj143NQ50QJg5gQZfzVf5RYx77VczBqhgwkgYHILekYrOs4xanOeqeJ8jnOnQQSp1YaJZUg==", + "cpu": [ + "arm", + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/linux-x64": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64/-/linux-x64-3.16.2.tgz", + "integrity": "sha512-OXYf8rVfoDyvN+YrfKk8F9An9a5GOxVIM8OcR1U911tc0oRNf8yfJrQ8KrM75R26gwq0Y6YZwVTP0vRCInwWOw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/linux-x64-cuda": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda/-/linux-x64-cuda-3.16.2.tgz", + "integrity": "sha512-LTBQFqjin7tyrLNJz0XWTB5QAHDsZV71/qiiRRjXdBKSZHVVaPLfdgxypGu7ggPeBNsv+MckRXdlH5C7yMtE4A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/linux-x64-cuda-ext": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda-ext/-/linux-x64-cuda-ext-3.16.2.tgz", + "integrity": "sha512-47d9myCJauZyzAlN7IK1eIt/4CcBMslF+yHy4q+yJotD/RV/S6qRpK2kGn+ybtdVjkPGNCoPkHKcyla9iIVjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/linux-x64-vulkan": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-vulkan/-/linux-x64-vulkan-3.16.2.tgz", + "integrity": "sha512-HDLAw4ZhwJuhKuF6n4x520yZXAQZahUOXtCGvPubjfpmIOElKrfDvCVlRsthAP0JwcwINzIQlVys3boMIXfBgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/mac-arm64-metal": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-arm64-metal/-/mac-arm64-metal-3.16.2.tgz", + "integrity": "sha512-nEZ74qB0lUohF88yR741YUrUqz/qD+FJFzUTHj0FwxAynSZCjvwtzEDtavRlh3qd3yLD/0ChNn00/RQ54ISImw==", + "cpu": [ + "arm64", + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/mac-x64": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-x64/-/mac-x64-3.16.2.tgz", + "integrity": "sha512-BjA+DgeDt+kRxVMV6kChb9XVXm7U5b90jUif7Z/s6ZXtOOnV6exrTM2W09kbSqAiNhZmctcVY83h2dwNTZ/yIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/win-arm64": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-arm64/-/win-arm64-3.16.2.tgz", + "integrity": "sha512-XHNFQzUjYODtkZjIn4NbQVrBtGB9RI9TpisiALryqfrIqagQmjBh6dmxZWlt5uduKAfT7M2/2vrABGR490FACA==", + "cpu": [ + "arm64", + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/win-x64": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64/-/win-x64-3.16.2.tgz", + "integrity": "sha512-etrivzbyLNVhZlUosFW8JSL0OSiuKQf9qcI3dNdehD907sHquQbBJrG7lXcdL6IecvXySp3oAwCkM87VJ0b3Fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/win-x64-cuda": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda/-/win-x64-cuda-3.16.2.tgz", + "integrity": "sha512-jStDELHrU3rKQMOk5Hs5bWEazyjE2hzHwpNf6SblOpaGkajM/HJtxEZoL0mLHJx5qeXs4oOVkr7AzuLy0WPpNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/win-x64-cuda-ext": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda-ext/-/win-x64-cuda-ext-3.16.2.tgz", + "integrity": "sha512-sdv4Kzn9bOQWNBRvw6B/zcn8dQRfZhjIHv5AfDBIOfRlSCgjebFpBeYUoU4wZPpjr3ISwcqO5MEWsw+AbUdV3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@node-llama-cpp/win-x64-vulkan": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-vulkan/-/win-x64-vulkan-3.16.2.tgz", + "integrity": "sha512-9xuHFCOhCQjZgQSFrk79EuSKn9nGWt/SAq/3wujQSQLtgp8jGdtZgwcmuDUoemInf10en2dcOmEt7t8dQdC3XA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@octokit/app": { + "version": "16.1.2", + "license": "MIT", + "dependencies": { + "@octokit/auth-app": "^8.1.2", + "@octokit/auth-unauthenticated": "^7.0.3", + "@octokit/core": "^7.0.6", + "@octokit/oauth-app": "^8.0.3", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/types": "^16.0.0", + "@octokit/webhooks": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-app": { + "version": "8.2.0", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^9.0.3", + "@octokit/auth-oauth-user": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "toad-cache": "^3.7.0", + "universal-github-app-jwt": "^2.2.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "9.0.3", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.3", + "@octokit/auth-oauth-user": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "8.0.3", + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.3", + "@octokit/oauth-methods": "^6.0.2", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-unauthenticated": { + "version": "7.0.3", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.2", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-app": { + "version": "8.0.3", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^9.0.2", + "@octokit/auth-oauth-user": "^6.0.1", + "@octokit/auth-unauthenticated": "^7.0.2", + "@octokit/core": "^7.0.5", + "@octokit/oauth-authorization-url": "^8.0.0", + "@octokit/oauth-methods": "^6.0.1", + "@types/aws-lambda": "^8.10.83", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "8.0.0", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^8.0.0", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "license": "MIT" + }, + "node_modules/@octokit/openapi-webhooks-types": { + "version": "12.1.0", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-graphql": { + "version": "6.0.0", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "8.0.3", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=7" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.3", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.7", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/webhooks": { + "version": "14.2.0", + "license": "MIT", + "dependencies": { + "@octokit/openapi-webhooks-types": "12.1.0", + "@octokit/request-error": "^7.0.0", + "@octokit/webhooks-methods": "^6.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/webhooks-methods": { + "version": "6.0.0", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@open-rpc/client-js": { + "version": "1.8.1", + "license": "Apache-2.0", + "dependencies": { + "isomorphic-fetch": "^3.0.0", + "isomorphic-ws": "^5.0.0", + "strict-event-emitter-types": "^2.0.0", + "ws": "^7.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@reflink/reflink": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@reflink/reflink/-/reflink-0.1.19.tgz", + "integrity": "sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@reflink/reflink-darwin-arm64": "0.1.19", + "@reflink/reflink-darwin-x64": "0.1.19", + "@reflink/reflink-linux-arm64-gnu": "0.1.19", + "@reflink/reflink-linux-arm64-musl": "0.1.19", + "@reflink/reflink-linux-x64-gnu": "0.1.19", + "@reflink/reflink-linux-x64-musl": "0.1.19", + "@reflink/reflink-win32-arm64-msvc": "0.1.19", + "@reflink/reflink-win32-x64-msvc": "0.1.19" + } + }, + "node_modules/@reflink/reflink-linux-x64-gnu": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@reflink/reflink-linux-x64-gnu/-/reflink-linux-x64-gnu-0.1.19.tgz", + "integrity": "sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@reflink/reflink-linux-x64-musl": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@reflink/reflink-linux-x64-musl/-/reflink-linux-x64-musl-0.1.19.tgz", + "integrity": "sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@sammacbeth/random-access-idb-mutable-file": { + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "buffer": "5.1.0", + "random-access-storage": "1.3.0" + } + }, + "node_modules/@sammacbeth/random-access-idb-mutable-file/node_modules/buffer": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/@sammacbeth/random-access-idb-mutable-file/node_modules/random-access-storage": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3" + } + }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, + "node_modules/@tangle/graph": { + "version": "3.2.0", + "license": "LGPL-3.0-only", + "dependencies": { + "lodash.clone": "^4.5.0", + "lodash.set": "^4.3.2" + } + }, + "node_modules/@tangle/overwrite-fields": { + "version": "2.0.3", + "license": "LGPL-3.0-or-later", + "dependencies": { + "is-my-json-valid": "^2.20.5", + "lodash.isequal": "^4.5.0" + } + }, + "node_modules/@tangle/reduce": { + "version": "5.0.5", + "license": "LGPL-3.0-only", + "dependencies": { + "@tangle/graph": "^3.2.0" + } + }, + "node_modules/@tangle/strategy": { + "version": "4.1.2", + "license": "LGPL-3.0-only", + "dependencies": { + "is-my-json-valid": "^2.20.5", + "lodash.isequal": "^4.5.0" + } + }, + "node_modules/@tinyhttp/content-disposition": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz", + "integrity": "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==", + "license": "MIT", + "engines": { + "node": ">=12.17.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.160", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/co-body": { + "version": "6.1.3", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.1", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.3", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/formidable": { + "version": "2.0.6", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.8", + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.30", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, + "node_modules/@xenova/transformers/node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@xenova/transformers/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/@xenova/transformers/node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/abstract-level": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "is-buffer": "^2.0.5", + "level-supports": "^6.0.0", + "level-transcoder": "^1.0.1", + "maybe-combine-errors": "^1.0.0", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/abstract-leveldown": { + "version": "6.2.3", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "immediate": "^3.2.3", + "level-concat-iterator": "~2.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/abstract-leveldown/node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/abstract-leveldown/node_modules/level-supports": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aligned-block-file": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "hashlru": "^2.1.0", + "int53": "^1.0.0", + "mkdirp": "^0.5.1", + "obv": "^0.0.1", + "rwlock": "^5.0.0", + "uint48be": "^2.0.1" + } + }, + "node_modules/aligned-block-file/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", + "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==", + "license": "ISC", + "dependencies": { + "micromatch": "^2.1.5", + "normalize-path": "^2.0.0" + } + }, + "node_modules/anymatch/node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/append-batch": { + "version": "0.0.2", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "7.0.1", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/arr-diff": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "integrity": "sha512-dtXTVMkh6VkEEA7OhXnN1Ecb8aAGFdZ1LFxtOCoqj4qkyOJMt7+qs6Ahdy6p/NQCPYsRSXXivhSB/J5E9jmYKA==", + "license": "MIT", + "dependencies": { + "arr-flatten": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-unique": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha512-G2n5bG5fSUCpnsXz4+8FUkYsGPkNfLn9YvS66U5qbTIXI2Ynnlo4Bi42bWv+omKUCqz+ejzfClwne0alJWJPhg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "license": "MIT" + }, + "node_modules/async-append-only-log": { + "version": "4.3.10", + "license": "LGPL-3.0", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "atomic-file-rw": "^0.3.0", + "debug": "^4.2.0", + "is-buffer-zero": "^1.0.0", + "lodash.debounce": "^4.0.8", + "looper": "^4.0.0", + "ltgt": "^2.2.1", + "mutexify": "^1.3.1", + "obz": "^1.1.0", + "polyraf": "^1.1.0", + "push-stream": "^11.0.0", + "push-stream-to-pull-stream": "^1.0.3" + } + }, + "node_modules/async-each": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.6.tgz", + "integrity": "sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/async-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/async-single": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/async-write": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/async-write/-/async-write-2.1.0.tgz", + "integrity": "sha512-eKEYj1+jDgQ15jazjmelcPGXO77pa5epzlyXyEQcwwLtnACtBTCrxaW+96uXF2j4rzIikmUtVlzXwejx6iIhWw==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/atomic-file": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "flumecodec": "0.0.1", + "idb-kv-store": "^4.5.0", + "mutexify": "^1.2.0" + } + }, + "node_modules/atomic-file-rw": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "idb-kv-store": "^4.5.0", + "mutexify": "^1.3.1" + } + }, + "node_modules/atomic-file/node_modules/flumecodec": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "level-codec": "^6.2.0" + } + }, + "node_modules/atomic-file/node_modules/level-codec": { + "version": "6.2.0", + "license": "MIT" + }, + "node_modules/attach-ware": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/attach-ware/-/attach-ware-1.1.1.tgz", + "integrity": "sha512-OpavlXWZkyE7m28fpCWF/RmxCukC1edukJp9IKjEpZs/O11H3896DkLpK7lMiL8ZDx2yxo9FrZQaeHkyJGcIuQ==", + "license": "MIT", + "dependencies": { + "unherit": "^1.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.6.7", + "license": "Apache-2.0" + }, + "node_modules/bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "license": "MIT", + "dependencies": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64-url": { + "version": "2.3.3", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/bash-color": { + "version": "0.0.4", + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "license": "Apache-2.0" + }, + "node_modules/bencode": { + "version": "2.0.3", + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/binary-search": { + "version": "1.3.6", + "license": "CC0-1.0" + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bipf": { + "version": "1.9.0", + "license": "MIT", + "dependencies": { + "fast-varint": "^1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/blake2b": { + "version": "2.1.4", + "license": "ISC", + "dependencies": { + "blake2b-wasm": "^2.4.0", + "nanoassert": "^2.0.0" + } + }, + "node_modules/blake2b-wasm": { + "version": "2.4.0", + "license": "MIT", + "dependencies": { + "b4a": "^1.0.1", + "nanoassert": "^2.0.0" + } + }, + "node_modules/blake2s": { + "version": "1.1.0", + "license": "Public Domain" + }, + "node_modules/blake3": { + "version": "2.1.7", + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "integrity": "sha512-xU7bpz2ytJl1bH9cgIurjpg/n8Gohy9GTw81heDYLJQ4RU60dlyJsa+atVF2pI0yMMvKxI9HkKwjePCj5XI1hw==", + "license": "MIT", + "dependencies": { + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/broadcast-stream": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/broadcast-stream/-/broadcast-stream-0.2.2.tgz", + "integrity": "sha512-d6iHc4P5YC2RvXv6qZxH3aoue88BiWhzCPki+i1Dj2igeZyNtUoMInloINRfnyrB7Fm+2f1q1bmQYhXQeKWwkQ==", + "license": "MIT", + "bin": { + "broadcast-stream": "bin.js" + } + }, + "node_modules/browser-split": { + "version": "0.0.0", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buffer-xor": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.1" + } + }, + "node_modules/butt64": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "license": "MIT", + "dependencies": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cache-base/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/camelcase-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", + "integrity": "sha512-Ej37YKYbFUI8QiYlvj9YHb6/Z60dZyPJW0Cs8sFilMbd2lP0bw3ylAq9yJkK4lcTA2dID5fG8LjmJYbO7kWb7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^4.1.0", + "map-obj": "^2.0.0", + "quick-lru": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cat-names": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cat-names/-/cat-names-2.0.0.tgz", + "integrity": "sha512-yjcTHwA7tcro823P5yIfmTvXT8p5YxJd4eASWHAHvqaekuJoGu1qjWoldgfV2mH4tgLp+FNAf13uiDEo/O7LGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "meow": "^5.0.0", + "unique-random-array": "^1.0.0" + }, + "bin": { + "cat-names": "cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chacha20-universal": { + "version": "1.0.4", + "license": "ISC", + "dependencies": { + "nanoassert": "^2.0.0" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-1.1.4.tgz", + "integrity": "sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/charwise": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/chloride": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "sodium-browserify": "^1.2.7", + "sodium-browserify-tweetnacl": "^0.2.5", + "sodium-chloride": "^1.1.2" + }, + "optionalDependencies": { + "sodium-native": "^3.0.0" + } + }, + "node_modules/chloride-test": { + "version": "1.2.4", + "license": "MIT", + "dependencies": { + "json-buffer": "^2.0.11" + } + }, + "node_modules/chloride-test/node_modules/json-buffer": { + "version": "2.0.11", + "license": "MIT" + }, + "node_modules/chmodrp": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", + "integrity": "sha512-mk8fAWcRUOxY7btlLtitj3A45jOwSAxH4tOFOoEGbVsl6cL6pPMWUy7dwZ/canfj3QEdP6FHSnf/l1c6/WkzVg==", + "license": "MIT", + "dependencies": { + "anymatch": "^1.3.0", + "async-each": "^1.0.0", + "glob-parent": "^2.0.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^2.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0" + }, + "optionalDependencies": { + "fsevents": "^1.0.0" + } + }, + "node_modules/chokidar/node_modules/fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "deprecated": "Upgrade to fsevents v2 to mitigate potential security issues", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clarify-error": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/class-list": { + "version": "0.1.1", + "dependencies": { + "indexof": "0.0.1" + } + }, + "node_modules/class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/class-utils/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cmake-js": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz", + "integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "fs-extra": "^11.3.3", + "node-api-headers": "^1.8.0", + "rc": "1.2.8", + "semver": "^7.7.3", + "tar": "^7.5.6", + "url-join": "^4.0.1", + "which": "^6.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cmake-js/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/cmake-js/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/co-body": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "@hapi/bourne": "^3.0.0", + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/collapse-white-space": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz", + "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", + "license": "MIT", + "dependencies": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/compare-at-paths": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "libnested": "^1.3.2", + "tape": "^4.9.1", + "typewiselite": "^1.0.0" + } + }, + "node_modules/compatibility": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/compatibility/-/compatibility-1.0.1.tgz", + "integrity": "sha512-0OzQu7V1TlJvKiEGC4b4TEXypGBHzcRX8ZSJ9svyMIKnE73AFrBAmVyPIabrFKhKc0L0iDLw6voyB3vA3hEXHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "npm-install-package": "^2.1.0", + "semver": "^6.1.0" + }, + "bin": { + "compatibility": "bin.js" + } + }, + "node_modules/compatibility/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/cont": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "continuable": "~1.2.0", + "continuable-para": "~1.2.0", + "continuable-series": "~1.2.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/continuable": { + "version": "1.2.0" + }, + "node_modules/continuable-hash": { + "version": "0.1.4", + "dependencies": { + "continuable": "~1.1.6" + } + }, + "node_modules/continuable-hash/node_modules/continuable": { + "version": "1.1.8" + }, + "node_modules/continuable-list": { + "version": "0.1.6", + "dependencies": { + "continuable": "~1.1.6" + } + }, + "node_modules/continuable-list/node_modules/continuable": { + "version": "1.1.8" + }, + "node_modules/continuable-para": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "continuable-hash": "~0.1.4", + "continuable-list": "~0.1.5" + } + }, + "node_modules/continuable-series": { + "version": "1.2.0", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cpu-percentage": { + "version": "1.0.3", + "license": "MIT", + "engines": { + "node": ">=6.1" + } + }, + "node_modules/crc": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc/node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-find-index": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deferred-leveldown": { + "version": "5.3.0", + "license": "MIT", + "dependencies": { + "abstract-leveldown": "~6.2.1", + "inherits": "^2.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-property/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defined": { + "version": "1.0.1", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detab": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/detab/-/detab-1.0.2.tgz", + "integrity": "sha512-J7vnPWu/Rq5Hi8xPPnwsv55CLkJmzZZCJOs0sAc6QcC7uf1X2iOrAXyGB6bXJBl5s4anllkMBKkHz13/BenuUQ==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.5.2" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dgram-broadcast": { + "version": "1.0.0", + "license": "MIT", + "bin": { + "dgram-broadcast": "bin.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/dog-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/dog-names/-/dog-names-1.0.2.tgz", + "integrity": "sha512-3+2tu1P9L+J3od+yTWQpfXkcUlukfylLYpsSgAMFaUvxk8Ah7kStNdUDcg9D4hpH1D8b+uMGHv5LKlB4uaH4ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "meow": "^3.0.0", + "unique-random-array": "^1.0.0" + }, + "bin": { + "dog-names": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "repeating": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-utf8": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha512-I5iQq6aFMM62fBEAIB/hXzwJD6EEZ0xEGCX2t7oXqaKPIRgt4WruAQ285BISgdkP+HLGWyeGmNJcpIwFeRYRUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-stdin": "^4.0.1" + }, + "bin": { + "strip-indent": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dog-names/node_modules/trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotignore": { + "version": "0.1.2", + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.4" + }, + "bin": { + "ignored": "bin/ignored" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dynamic-dijkstra": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "heap": "^0.2.6", + "rng": "^0.2.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "license": "MIT" + }, + "node_modules/ed2curve": { + "version": "0.1.4", + "license": "Public domain", + "dependencies": { + "tweetnacl": "0.x.x" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/elegant-spinner": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", + "integrity": "sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-named-characters": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "license": "MIT" + }, + "node_modules/emoji-server": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "emoji-named-characters": "~1.0.2" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding-down": { + "version": "6.3.0", + "license": "MIT", + "dependencies": { + "abstract-leveldown": "^6.2.1", + "inherits": "^2.0.3", + "level-codec": "^9.0.0", + "level-errors": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/env-var": { + "version": "7.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/envelope-js": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "buffer-xor": "^2.0.2", + "envelope-spec": "^1.0.0", + "futoin-hkdf": "^1.5.1", + "sodium-universal": "^3.1.0", + "ssb-bfe": "^3.3.0" + } + }, + "node_modules/envelope-spec": { + "version": "1.1.1", + "license": "LGPL-3.0-only", + "dependencies": { + "ssb-bfe": "^1.0.1" + } + }, + "node_modules/envelope-spec/node_modules/ssb-bfe": { + "version": "1.1.0", + "license": "LGPL-3.0", + "dependencies": { + "is-canonical-base64": "^1.1.1", + "ssb-ref": "^2.16.0" + } + }, + "node_modules/epidemic-broadcast-trees": { + "version": "9.0.4", + "license": "MIT", + "dependencies": { + "push-stream": "^11.0.0" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "license": "MIT", + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-map": { + "version": "1.0.6", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-get-iterator": "^1.1.3", + "es-set-tostringtag": "^2.0.3", + "for-each": "^0.3.3", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.4", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "object.entries": "^1.1.8" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/es-shims/es-map?sponsor=1" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-map": "^1.0.6", + "es-set-tostringtag": "^2.0.3", + "for-each": "^0.3.3", + "functions-have-names": "^1.2.3", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterate-value": "^1.0.2", + "object.entries": "^1.1.8", + "stop-iteration-iterator": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/es-shims/Set?sponsor=1" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-brackets": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "integrity": "sha512-hxx03P2dJxss6ceIeri9cmYOT4SRs3Zk3afZwWpOsRqLqprhTR8u++SlC+sFGsQr7WGFPdMF7Gjc1njDLDK6UA==", + "license": "MIT", + "dependencies": { + "is-posix-bracket": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha512-AFASGfIlnIbkKPQwX1yHaDjFvh/1gyKJODme52V6IORh69uEYgZp0o9C+qsIGNVEiuuhQU0CSSl++Rlegg1qvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/explain-error": { + "version": "1.0.4", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend-shallow/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend.js": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/extend.js/-/extend.js-0.0.2.tgz", + "integrity": "sha512-kSK5oO9X2i9qUptwhkilKqBfLG322xXY2ZO6/dlPY/ozt0fc+Ac9Qo6hZE/RiRTau5XUvVv2y6z1G6lNZ8f1WA==" + }, + "node_modules/extglob": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "integrity": "sha512-1FOj1LOwn42TMrruOHGt18HemVnbwAmAak7krWk+wa93KXxGbK+2jpezm+ytJYDaBX0/SPLZFHKM7m+tKobWGg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "license": "MIT" + }, + "node_modules/fast-varint": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", + "integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastintcompression": { + "version": "0.0.4", + "license": "Apache-2.0" + }, + "node_modules/fastpriorityqueue": { + "version": "0.7.5", + "license": "Apache-2.0" + }, + "node_modules/file-type": { + "version": "16.5.4", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, + "node_modules/filename-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "integrity": "sha512-BTCqyBaWBTsauvnHiE8i562+EdJj+oUpkqWp2R1iCoR8f6oo8STRu3of7WJJ0TqWtxN50a5YFpzYK4Jj9esYfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/filename-reserved-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", + "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", + "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz", + "integrity": "sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==", + "license": "MIT", + "dependencies": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^3.0.0", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, + "node_modules/flumecodec": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "level-codec": "^6.2.0" + } + }, + "node_modules/flumecodec/node_modules/level-codec": { + "version": "6.2.0", + "license": "MIT" + }, + "node_modules/flumedb": { + "version": "2.1.8", + "license": "MIT", + "dependencies": { + "cont": "^1.0.3", + "explain-error": "^1.0.3", + "obz": "1.0.2", + "pull-abortable": "^4.1.1", + "pull-cont": "^0.1.1", + "pull-looper": "^1.0.0", + "pull-stream": "^3.6.14" + } + }, + "node_modules/flumedb/node_modules/obz": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/flumelog-offset": { + "version": "3.4.4", + "license": "MIT", + "dependencies": { + "aligned-block-file": "^1.2.0", + "append-batch": "0.0.2", + "hashlru": "^2.3.0", + "int53": "^1.0.0", + "looper": "^4.0.0", + "obv": "0.0.1", + "pull-cursor": "^3.0.0", + "pull-looper": "^1.0.0", + "pull-stream": "^3.6.13", + "uint48be": "^2.0.1" + } + }, + "node_modules/flumeview-hashtable": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flumeview-hashtable/-/flumeview-hashtable-1.1.1.tgz", + "integrity": "sha512-73LAN0qF4zVMHMExYhK0z1swDo6FEh/bt1HF5/UnWkgI5JsECXOK2bTokWVU1levtr9bd+IxYChnJKJNnEzD0A==", + "license": "MIT", + "dependencies": { + "async-single": "^1.0.5", + "atomic-file": "^1.1.3", + "obv": "0.0.1", + "pull-stream": "^3.6.0" + } + }, + "node_modules/flumeview-hashtable/node_modules/atomic-file": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/atomic-file/-/atomic-file-1.1.5.tgz", + "integrity": "sha512-TG+5YFiaKQ6CZiSQsosGMJ/IJzwMZ4V/rSdEXlD6+DwKyv8OyeUcprq34kp4yuS6bfQYXhxBC2Vm8PWo+iKBGQ==", + "license": "MIT" + }, + "node_modules/flumeview-level": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "charwise": "^3.0.1", + "explain-error": "^1.0.4", + "level": "^6.0.1", + "ltgt": "^2.1.3", + "mkdirp": "^1.0.4", + "obz": "^1.0.2", + "pull-level": "^2.0.3", + "pull-paramap": "^1.2.1", + "pull-stream": "^3.6.14", + "pull-write": "^1.1.1" + } + }, + "node_modules/flumeview-links": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "deep-equal": "^2.0.3", + "flumeview-level": "^4.0.3", + "map-filter-reduce": "^3.2.2", + "pull-flatmap": "0.0.1", + "pull-stream": "^3.6.14" + } + }, + "node_modules/flumeview-links/node_modules/deep-equal": { + "version": "2.2.3", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/flumeview-query": { + "version": "8.0.0", + "license": "MIT", + "dependencies": { + "deep-equal": "^1.0.1", + "flumeview-level": "^4.0.3", + "map-filter-reduce": "^3.2.0", + "pull-flatmap": "0.0.1", + "pull-paramap": "^1.1.3", + "pull-sink-through": "0.0.0", + "pull-stream": "^3.4.0" + } + }, + "node_modules/flumeview-reduce": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "async-single": "^1.0.5", + "atomic-file": "^2.0.0", + "deep-equal": "^1.0.1", + "flumecodec": "0.0.0", + "obv": "0.0.1", + "pull-notify": "^0.1.1", + "pull-stream": "^3.5.0" + } + }, + "node_modules/flumeview-search": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "flumeview-level": "^4.0.3", + "pull-paramap": "^1.2.2", + "pull-pushable": "^2.2.0", + "pull-stream": "^3.0.1" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==", + "license": "MIT", + "dependencies": { + "map-cache": "^0.2.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/futoin-hkdf": { + "version": "1.5.3", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generate-object-property": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.5.0", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "integrity": "sha512-ab1S1g1EbO7YzauaJLkgLp7DZVAqj9M/dvKlTt8DkXA2tiOIcSMrlVI2J1RZyB5iJVccEscjGn+kpOG9788MHA==", + "license": "MIT", + "dependencies": { + "glob-parent": "^2.0.0", + "is-glob": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/glob-parent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha512-JDYOvfxio/t42HKdxkAYaCiBN7oYiuxykOxKxdaUW5Qn0zaYN3gRQWolrwdnf0shM9/EP0ebuuTmyoXNr1cC5w==", + "license": "ISC", + "dependencies": { + "is-glob": "^2.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-4.1.0.tgz", + "integrity": "sha512-JPDtMSr0bt25W64q792rvlrSwIaZwqUAhqdYKSr57Wh/xBcQ5JDWLM85ndn+Q1WdBQXLb9YGCl0QN/T0HpqU0A==", + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^6.0.1", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/globby/node_modules/glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gossip-query": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gossip-query/-/gossip-query-2.0.2.tgz", + "integrity": "sha512-17MflOq79BYcK01RrqHhtG5Dl88ztSu7gfD+4i1GrpzkbvLDKkJ7LoKiFKzBWzzd+Jd5VhIiLtQRvjRiKa6PUg==", + "license": "MIT", + "dependencies": { + "obv": "0.0.1", + "pull-stream": "^3.6.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-network": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/has-network2": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==", + "license": "MIT", + "dependencies": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-value/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==", + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/has-values/node_modules/is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/is-number/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/hashlru": { + "version": "2.3.0", + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/he/-/he-0.5.0.tgz", + "integrity": "sha512-DoufbNNOFzwRPy8uecq+j+VCPQ+JyDelHTmSgygrA5TsR8Cbw4Qcir5sGtWiusB4BdT89nmlaVDhSJOqC/33vw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/heap": { + "version": "0.2.7", + "license": "MIT" + }, + "node_modules/hexpp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hexpp/-/hexpp-2.0.0.tgz", + "integrity": "sha512-5qB1Uar+ka6iQm++gOs3rZDH7q3d7gIxgwYRz00KF5OI4saTKEnGMsv5ebqW9XRkBXnkMUghd9b9b5gJpVROtA==", + "dev": true, + "license": "MIT", + "bin": { + "hexpp": "bin.js" + } + }, + "node_modules/highlight.js": { + "version": "11.0.0", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hoox": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-element": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "class-list": "~0.1.1" + }, + "engines": { + "node": ">=4.2" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyperaxe": { + "version": "2.0.1", + "license": "ISC", + "dependencies": { + "html-tags": "^3.0.0", + "hyperscript": "^2.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/hyperscript": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "browser-split": "0.0.0", + "class-list": "~0.1.0", + "html-element": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/idb-kv-store": { + "version": "4.5.0", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "promisize": "^1.1.2" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/immediate": { + "version": "3.3.0", + "license": "MIT" + }, + "node_modules/increment-buffer": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/indexof": { + "version": "0.0.1" + }, + "node_modules/infer-partial-order": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/infer-partial-order/-/infer-partial-order-0.1.1.tgz", + "integrity": "sha512-ahwueME5/IpvDbTWjG+ZawATtmvp7p04/ctMBPhwpvq8myV2FXXbJTJhvB8lgU2+1E2Nzipk+jbOmqZ+VZ3Dpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/inflation": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "license": "ISC" + }, + "node_modules/int53": { + "version": "1.0.0", + "license": "BSD-3-Clause" + }, + "node_modules/interleavings": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/interleavings/-/interleavings-1.0.0.tgz", + "integrity": "sha512-DAzMdwFFmBZcVvi3cXiEXDY3SMZvSedK6qFICOk0A6ietIUMKyZSgXlyvssakingGkSOLN6SaGnQzqrI6KWIYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "infer-partial-order": "~0.1.1", + "rng": "~0.2.2" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip": { + "name": "neoip", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz", + "integrity": "sha512-1iHzEVKfLRwsDuAqrxFZo5uBAhP6TasBxGabk5bEu58hdDcn8wMokep9Ub9Y/DP//gFMtJoNNon3aTeyyGI+JQ==", + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "license": "BSD-3-Clause" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipull": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/ipull/-/ipull-3.9.5.tgz", + "integrity": "sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==", + "license": "MIT", + "dependencies": { + "@tinyhttp/content-disposition": "^2.2.0", + "async-retry": "^1.3.3", + "chalk": "^5.3.0", + "ci-info": "^4.0.0", + "cli-spinners": "^2.9.2", + "commander": "^10.0.0", + "eventemitter3": "^5.0.1", + "filenamify": "^6.0.0", + "fs-extra": "^11.1.1", + "is-unicode-supported": "^2.0.0", + "lifecycle-utils": "^2.0.1", + "lodash.debounce": "^4.0.8", + "lowdb": "^7.0.1", + "pretty-bytes": "^6.1.0", + "pretty-ms": "^8.0.0", + "sleep-promise": "^9.1.0", + "slice-ansi": "^7.1.0", + "stdout-update": "^4.0.1", + "strip-ansi": "^7.1.0" + }, + "bin": { + "ipull": "dist/cli/cli.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/ido-pluto/ipull?sponsor=1" + }, + "optionalDependencies": { + "@reflink/reflink": "^0.1.16" + } + }, + "node_modules/ipull/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ipull/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ipull/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ipull/node_modules/lifecycle-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-2.1.0.tgz", + "integrity": "sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==", + "license": "MIT" + }, + "node_modules/ipull/node_modules/parse-ms": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz", + "integrity": "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ipull/node_modules/pretty-ms": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz", + "integrity": "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==", + "license": "MIT", + "dependencies": { + "parse-ms": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ipull/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/ipull/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/irregular-plurals": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-1.4.0.tgz", + "integrity": "sha512-kniTIJmaZYiwa17eTtWIfm0K342seyugl6vuC8DiiyiRAJWAVlLkqGCI0Im0neo0TkXw+pRcKaBPRdcKHnQJ6Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-accessor-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-buffer-zero": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-canonical-base64": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", + "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-descriptor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", + "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-dotfile": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", + "integrity": "sha512-9YclgOGtN/f8zx0Pr4FQYMdibBiTaH3sn52vjYip4ZSf6C4/6RfTEZ+MR4GvKhCxdPh21Bg42/WL55f6KSnKpg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-equal-shallow": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha512-0EygVC5qPvIyb+gSz7zdD5/AAoS6Qrx1e//6N4yv4oNm30kqvdmG66oZFWVlQHUWe5OjP08FuTw2IdT0EOTcYA==", + "license": "MIT", + "dependencies": { + "is-primitive": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-my-ip-valid": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/is-my-json-valid": { + "version": "2.20.6", + "license": "MIT", + "dependencies": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^5.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/is-my-ssb-valid": { + "version": "1.2.2", + "license": "LGPL-3.0-only", + "dependencies": { + "is-my-json-valid": "^2.20.0", + "ssb-msg-content": "^1.0.1" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-options": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "b4a": "^1.1.1" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-posix-bracket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", + "integrity": "sha512-Yu68oeXJ7LeWNmZ3Zov/xg/oDBnBK2RNxwYY1ilNJX+tKKZqgPK+qOn/Gs9jEu66KDY9Netf5XLKNGzas/vPfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/is-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha512-N3w1tFaRfk3UrPfqeRyD+GYDASU3W5VinKhlORy8EWVf/sIdDL9GAcew85XmktCfH+ngG7SRXEVDoO18WMdB/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/is-property": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-svg": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^4.1.3" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isobject/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz", + "integrity": "sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/generator": "^7.4.0", + "@babel/parser": "^7.4.3", + "@babel/template": "^7.4.0", + "@babel/traverse": "^7.4.3", + "@babel/types": "^7.4.0", + "istanbul-lib-coverage": "^2.0.5", + "semver": "^6.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/iterate-iterator": { + "version": "1.0.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/iterate-value": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-get-iterator": "^1.0.2", + "iterate-iterator": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jitdb": { + "version": "7.0.7", + "license": "LGPL-3.0", + "dependencies": { + "atomic-file-rw": "^0.3.0", + "binary-search-bounds": "^2.0.4", + "bipf": "^1.6.2", + "crc": "3.6.0", + "debug": "^4.2.0", + "fastpriorityqueue": "^0.7.1", + "idb-kv-store": "^4.5.0", + "jsesc": "^3.0.2", + "mkdirp": "^1.0.4", + "multicb": "1.2.2", + "mutexify": "^1.4.0", + "obz": "^1.1.0", + "promisify-4loc": "1.0.0", + "pull-async": "~1.0.0", + "pull-awaitable": "^1.0.0", + "pull-cat": "~1.1.11", + "pull-stream": "^3.6.14", + "push-stream": "^11.2.0", + "push-stream-to-pull-stream": "^1.0.3", + "rimraf": "^3.0.2", + "sanitize-filename": "^1.6.3", + "traverse": "^0.6.6", + "typedarray-to-buffer": "^4.0.0", + "typedfastbitset": "~0.2.1" + }, + "peerDependencies": { + "async-append-only-log": "^4.3.2" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/key-value-file-store": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "atomic-file-rw": "^0.3.0" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kind-of/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/koa": { + "version": "2.16.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.4.tgz", + "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-body": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "@types/co-body": "^6.1.0", + "@types/formidable": "^2.0.5", + "@types/koa": "^2.13.5", + "co-body": "^6.1.0", + "formidable": "^2.0.1", + "zod": "^3.19.1" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "license": "MIT" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa-mount": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "debug": "^4.0.1", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-send": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/koa-send/node_modules/depd": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-send/node_modules/http-errors": { + "version": "1.8.1", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-send/node_modules/statuses": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-static": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa/node_modules/http-errors": { + "version": "1.8.1", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/layered-graph": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "dynamic-dijkstra": "^1.0.0", + "pull-cont": "^0.1.1", + "pull-notify": "^0.1.1", + "pull-stream": "^3.6.9" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/level": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "level-js": "^5.0.0", + "level-packager": "^5.1.0", + "leveldown": "^5.4.0" + }, + "engines": { + "node": ">=8.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/level-codec": { + "version": "9.0.2", + "license": "MIT", + "dependencies": { + "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-codec/node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/level-concat-iterator": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/level-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "errno": "~0.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-iterator-stream": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^3.4.0", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-iterator-stream/node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/level-js": { + "version": "5.0.2", + "license": "MIT", + "dependencies": { + "abstract-leveldown": "~6.2.3", + "buffer": "^5.5.0", + "inherits": "^2.0.3", + "ltgt": "^2.1.2" + } + }, + "node_modules/level-js/node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/level-packager": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "encoding-down": "^6.3.0", + "levelup": "^4.3.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-post": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "ltgt": "^2.1.2" + } + }, + "node_modules/level-supports": { + "version": "6.2.0", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/level-transcoder": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/leveldown": { + "version": "5.6.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abstract-leveldown": "~6.2.1", + "napi-macros": "~2.0.0", + "node-gyp-build": "~4.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/leveldown/node_modules/node-gyp-build": { + "version": "4.1.1", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/levelup": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "deferred-leveldown": "~5.3.0", + "level-errors": "~2.0.0", + "level-iterator-stream": "~4.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/levelup/node_modules/level-supports": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/libnested": { + "version": "1.5.2", + "license": "MIT" + }, + "node_modules/libsodium": { + "version": "0.7.15", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.7.15", + "license": "ISC", + "dependencies": { + "libsodium": "^0.7.15" + } + }, + "node_modules/lifecycle-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-3.1.1.tgz", + "integrity": "sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==", + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.chunk": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/lodash.clone": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "license": "MIT" + }, + "node_modules/lodash.find": { + "version": "4.6.0", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/lodash.set": { + "name": "lodash", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-1.0.2.tgz", + "integrity": "sha512-4vSow8gbiGnwdDNrpy1dyNaXWKSCIPop0EHdE8GrnngHoJujM3QhvHUN/igsYCgPoHo7pFOezlJ61Hlln0KHyA==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^1.0.0", + "cli-cursor": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha512-GZ+g4jayMqzCRMgB2sol7GiCLjKfS1PINkjmx8spcKce1LiVqcbQreXwqs2YAFXC6R03VIG28ZS31t8M866v6A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw==", + "license": "MIT", + "dependencies": { + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/longest-streak": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-1.0.0.tgz", + "integrity": "sha512-84jGpz/1j02Xm/L4y4uEXGxFFPHFabKjMHQ+rEPi0gPQbD5p0J3aZomvk0ZpUPpTtcVqhtSEq+4WNQbJjWiZ1Q==", + "license": "MIT" + }, + "node_modules/looper": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/lossy-store": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lossy-store/-/lossy-store-1.2.4.tgz", + "integrity": "sha512-rvqTCHZxWLvHAJv2w5vdCr5xICAW/FiXQ9wZGaAfBlNGG2FuLX0URNcj/zXC3qr5zQ8lBCbW8EEmDCKi7Dsp1g==", + "license": "MIT", + "dependencies": { + "mkdirp": "^0.5.1", + "tape": "^4.6.3" + } + }, + "node_modules/lossy-store/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loud-rejection/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lowdb": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", + "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", + "license": "MIT", + "dependencies": { + "steno": "^4.0.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "license": "ISC" + }, + "node_modules/ltgt": { + "version": "2.2.1", + "license": "MIT" + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/map-filter-reduce": { + "version": "3.2.2", + "license": "MIT", + "dependencies": { + "binary-search": "^1.2.0", + "compare-at-paths": "^1.0.0", + "pull-sink-through": "0.0.0", + "pull-sort": "^1.0.1", + "pull-stream": "^3.4.3", + "typewiselite": "^1.0.0" + } + }, + "node_modules/map-merge": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/map-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", + "integrity": "sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==", + "license": "MIT", + "dependencies": { + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/markdown-table": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-0.4.0.tgz", + "integrity": "sha512-9i/E3ZtVAoaDulRQjoPseX2X5pBNdeR8MInQb57JFvCAq4glz/w2q31eL0NHMKOntzy2D6X3plZDH4+OGuz5Fw==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-random": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", + "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==", + "license": "MIT" + }, + "node_modules/maybe-combine-errors": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/mdmanifest": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/mdmanifest/-/mdmanifest-1.0.8.tgz", + "integrity": "sha512-x7QEK3/RbANozWnUhmSPzT01yL/PsO2zBDWzDRr+tMRs0o/HQxbDzGb34JiQ2bGnSf88eKuMgrAzMR1+hyRBLw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0", + "remark": "^3.2.2", + "remark-html": "^2.0.2", + "word-wrap": "^1.1.0" + }, + "bin": { + "mdm": "mdm.js" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/meow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz", + "integrity": "sha512-CbTqYU17ABaLefO8vCU153ZZlprKYWDljcndKKDCFcYQITzWCXZAVk4QMFZPgvzrnUQ3uItnIE/LoUOwrT15Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0", + "yargs-parser": "^10.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/meow/node_modules/yargs-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", + "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^4.1.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromatch": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "integrity": "sha512-LnU2XFEk9xxSJ6rfgAry/ty5qwUTyHYOBU0g4R6tIw5ljwgGIBmiKhRWLw5NpMOnrgUNcDJ4WMp8rl3sYVHLNA==", + "license": "MIT", + "dependencies": { + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micromatch/node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-3.0.2.tgz", + "integrity": "sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-deep/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "license": "MIT" + }, + "node_modules/mock-property": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.1", + "functions-have-names": "^1.2.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "hasown": "^2.0.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/module-alias": { + "version": "2.2.3", + "license": "MIT" + }, + "node_modules/module-error": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/monotonic-timestamp": { + "version": "0.0.9", + "license": "MIT" + }, + "node_modules/moo": { + "version": "0.5.2", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/multiblob": { + "version": "1.13.8", + "license": "MIT", + "dependencies": { + "blake2s": "^1.0.1", + "clarify-error": "^1.0.0", + "cont": "^1.0.1", + "mkdirp": "^1.0.4", + "pull-catch": "^1.0.0", + "pull-defer": "^0.2.2", + "pull-file": "^1.0.0", + "pull-glob": "~1.0.6", + "pull-live": "^1.0.0", + "pull-notify": "^0.1.1", + "pull-paramap": "^1.2.2", + "pull-stream": "^3.6.2", + "pull-write-file": "^0.2.1", + "rimraf": "^3.0.2", + "stream-to-pull-stream": "^1.7.2" + } + }, + "node_modules/multiblob-http": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "pull-many": "^1.0.9", + "pull-stream": "^3.6.14", + "range-parser": "^1.2.1", + "stream-to-pull-stream": "^1.7.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/multicb": { + "version": "1.2.2" + }, + "node_modules/multiserver": { + "version": "3.8.2", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "multicb": "^1.2.2", + "multiserver-scopes": "^2.0.0", + "pull-stream": "^3.6.1", + "pull-websocket": "^3.4.0", + "secret-handshake": "^1.1.16", + "separator-escape": "0.0.1", + "socks": "^2.2.3", + "stream-to-pull-stream": "^1.7.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/multiserver-address": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "nearley": "^2.15.1" + } + }, + "node_modules/multiserver-scopes": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "non-private-ip": "^2.0.0" + } + }, + "node_modules/mutexify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "queue-tick": "^1.0.0" + } + }, + "node_modules/muxrpc": { + "version": "8.0.0", + "license": "MIT", + "dependencies": { + "clarify-error": "^1.0.0", + "debug": "^4.3.3", + "packet-stream": "~2.0.0", + "packet-stream-codec": "^1.2.0", + "pull-goodbye": "0.0.3", + "pull-stream": "^3.6.10" + } + }, + "node_modules/muxrpc-usage": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "right-pad": "^1.0.1" + } + }, + "node_modules/muxrpc-validation": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "pull-stream": "^3.6.11", + "zerr": "^1.0.4" + } + }, + "node_modules/muxrpcli": { + "version": "3.1.2", + "license": "MIT", + "dependencies": { + "cont": "^1.0.3", + "minimist": "^1.2.0", + "muxrpc-usage": "^2.0.1", + "pull-stream": "^3.6.9", + "stream-to-pull-stream": "^1.7.3", + "word-wrap": "^1.2.3" + } + }, + "node_modules/mv": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/mv/node_modules/glob": { + "version": "6.0.4", + "license": "ISC", + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mv/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mv/node_modules/rimraf": { + "version": "2.4.5", + "license": "ISC", + "dependencies": { + "glob": "^6.0.1" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, + "node_modules/nano-equal": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/nanoassert": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/napi-macros": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/ncp": { + "version": "2.0.0", + "license": "MIT", + "bin": { + "ncp": "bin/ncp" + } + }, + "node_modules/nearley": { + "version": "2.20.1", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "license": "ISC" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-api-headers": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.8.0.tgz", + "integrity": "sha512-jfnmiKWjRAGbdD1yQS28bknFM1tbHC1oucyuMPjmkEs+kpiu76aRs40WlTmBmyEgzDM76ge1DQ7XJ3R5deiVjQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-llama-cpp": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/node-llama-cpp/-/node-llama-cpp-3.16.2.tgz", + "integrity": "sha512-ovhuTaXSWfcoyfI8ljWxO2Rg63mNxqQQAbDGkXRhlgsL7UjPqm2Nsy1bTNa0ZaQRg3vezG4agnCJTImrICY/0A==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@huggingface/jinja": "^0.5.5", + "async-retry": "^1.3.3", + "bytes": "^3.1.2", + "chalk": "^5.6.2", + "chmodrp": "^1.0.2", + "cmake-js": "^8.0.0", + "cross-spawn": "^7.0.6", + "env-var": "^7.5.0", + "filenamify": "^6.0.0", + "fs-extra": "^11.3.0", + "ignore": "^7.0.4", + "ipull": "^3.9.5", + "is-unicode-supported": "^2.1.0", + "lifecycle-utils": "^3.1.1", + "log-symbols": "^7.0.1", + "nanoid": "^5.1.6", + "node-addon-api": "^8.5.0", + "octokit": "^5.0.5", + "ora": "^9.3.0", + "pretty-ms": "^9.3.0", + "proper-lockfile": "^4.1.2", + "semver": "^7.7.1", + "simple-git": "^3.31.1", + "slice-ansi": "^8.0.0", + "stdout-update": "^4.0.1", + "strip-ansi": "^7.1.2", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1", + "yargs": "^17.7.2" + }, + "bin": { + "nlc": "dist/cli/cli.js", + "node-llama-cpp": "dist/cli/cli.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/giladgd" + }, + "optionalDependencies": { + "@node-llama-cpp/linux-arm64": "3.16.2", + "@node-llama-cpp/linux-armv7l": "3.16.2", + "@node-llama-cpp/linux-x64": "3.16.2", + "@node-llama-cpp/linux-x64-cuda": "3.16.2", + "@node-llama-cpp/linux-x64-cuda-ext": "3.16.2", + "@node-llama-cpp/linux-x64-vulkan": "3.16.2", + "@node-llama-cpp/mac-arm64-metal": "3.16.2", + "@node-llama-cpp/mac-x64": "3.16.2", + "@node-llama-cpp/win-arm64": "3.16.2", + "@node-llama-cpp/win-x64": "3.16.2", + "@node-llama-cpp/win-x64-cuda": "3.16.2", + "@node-llama-cpp/win-x64-cuda-ext": "3.16.2", + "@node-llama-cpp/win-x64-vulkan": "3.16.2" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/node-llama-cpp/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/node-llama-cpp/node_modules/ignore": { + "version": "7.0.5", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/node-llama-cpp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-llama-cpp/node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/node-llama-cpp/node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/node-llama-cpp/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/node-llama-cpp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nodemon/node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nodemon/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/nodemon/node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nodemon/node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nodemon/node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nodemon/node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/non-private-ip": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "ip": "^1.1.5" + }, + "bin": { + "non-private-ip": "bin.js" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-uri": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/normalize-uri/-/normalize-uri-1.1.3.tgz", + "integrity": "sha512-ECNgiM5IAeZNuXYu5kF4JV8t+MSFixHsvjexQtf/bLTOsL+KycDv3pod1a88N8uHtzsktYLRX6cAawg4aHeLVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/npm-install-package": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/npm-install-package/-/npm-install-package-2.1.0.tgz", + "integrity": "sha512-F37OfFN2Fk6ixI3bn8CNllSGMjpRxTqPcnN5j2zFlqNiaf/LGG6fCq3DeBGKRinyVMR5ROS34nWKQmn7tABuJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-prefix": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/npm-prefix/-/npm-prefix-1.2.0.tgz", + "integrity": "sha512-EkGZ7jtA2onsULFpnZ/P5S0DPy8w9qH1TVytPhY54s+dmtLXBmp1evt8W9nfg5JEay24K3bX9WWTIHR8WQcOJA==", + "license": "MIT", + "dependencies": { + "rc": "^1.1.0", + "shellsubstitute": "^1.1.0", + "untildify": "^2.1.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-13.3.0.tgz", + "integrity": "sha512-P+FwIuro2aFG6B0Esd9ZDWUd51uZrAEoGutqZxzrVmYl3qSfkLgcQpBPBjtDFsUQLFY1dvTQJPOyeqr8S9GF8w==", + "bundleDependencies": [ + "archy", + "arrify", + "caching-transform", + "convert-source-map", + "find-cache-dir", + "find-up", + "foreground-child", + "glob", + "istanbul-lib-coverage", + "istanbul-lib-hook", + "istanbul-lib-report", + "istanbul-lib-source-maps", + "istanbul-reports", + "make-dir", + "merge-source-map", + "resolve-from", + "rimraf", + "signal-exit", + "spawn-wrap", + "test-exclude", + "uuid", + "yargs", + "yargs-parser" + ], + "dev": true, + "license": "ISC", + "dependencies": { + "archy": "^1.0.0", + "arrify": "^1.0.1", + "caching-transform": "^3.0.1", + "convert-source-map": "^1.6.0", + "find-cache-dir": "^2.0.0", + "find-up": "^3.0.0", + "foreground-child": "^1.5.6", + "glob": "^7.1.3", + "istanbul-lib-coverage": "^2.0.3", + "istanbul-lib-hook": "^2.0.3", + "istanbul-lib-instrument": "^3.1.0", + "istanbul-lib-report": "^2.0.4", + "istanbul-lib-source-maps": "^3.0.2", + "istanbul-reports": "^2.1.1", + "make-dir": "^1.3.0", + "merge-source-map": "^1.1.0", + "resolve-from": "^4.0.0", + "rimraf": "^2.6.3", + "signal-exit": "^3.0.2", + "spawn-wrap": "^1.4.2", + "test-exclude": "^5.1.0", + "uuid": "^3.3.2", + "yargs": "^12.0.5", + "yargs-parser": "^11.1.1" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/ansi-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/append-transform": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "default-require-extensions": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/archy": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/arrify": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/async": { + "version": "2.6.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.11" + } + }, + "node_modules/nyc/node_modules/balanced-match": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/brace-expansion": { + "version": "1.1.11", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nyc/node_modules/caching-transform": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "hasha": "^3.0.0", + "make-dir": "^1.3.0", + "package-hash": "^3.0.0", + "write-file-atomic": "^2.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/camelcase": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/cliui": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/nyc/node_modules/code-point-at": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/commander": { + "version": "2.17.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/commondir": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/convert-source-map": { + "version": "1.6.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.1" + } + }, + "node_modules/nyc/node_modules/cross-spawn": { + "version": "4.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "node_modules/nyc/node_modules/debug": { + "version": "4.1.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/nyc/node_modules/decamelize": { + "version": "1.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/default-require-extensions": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/end-of-stream": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/nyc/node_modules/error-ex": { + "version": "1.3.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/nyc/node_modules/es6-error": { + "version": "4.1.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/execa": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/nyc/node_modules/find-cache-dir": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^1.0.0", + "pkg-dir": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/find-up": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/foreground-child": { + "version": "1.5.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^4", + "signal-exit": "^3.0.0" + } + }, + "node_modules/nyc/node_modules/fs.realpath": { + "version": "1.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/get-caller-file": { + "version": "1.0.3", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/get-stream": { + "version": "4.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/glob": { + "version": "7.1.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nyc/node_modules/graceful-fs": { + "version": "4.1.15", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/handlebars": { + "version": "4.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "async": "^2.5.0", + "optimist": "^0.6.1", + "source-map": "^0.6.1" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/nyc/node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/has-flag": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/hasha": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "is-stream": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/hosted-git-info": { + "version": "2.7.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/nyc/node_modules/inflight": { + "version": "1.0.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/nyc/node_modules/inherits": { + "version": "2.0.3", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/invert-kv": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/is-arrayish": { + "version": "0.2.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/is-stream": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/istanbul-lib-coverage": { + "version": "2.0.3", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/istanbul-lib-hook": { + "version": "2.0.3", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "append-transform": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/istanbul-lib-report": { + "version": "2.0.4", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^2.0.3", + "make-dir": "^1.3.0", + "supports-color": "^6.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/istanbul-lib-source-maps": { + "version": "3.0.2", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.3", + "make-dir": "^1.3.0", + "rimraf": "^2.6.2", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/istanbul-reports": { + "version": "2.1.1", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "handlebars": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/json-parse-better-errors": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/lcid": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "invert-kv": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/load-json-file": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/lodash": { + "version": "4.17.11", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/lodash.flattendeep": { + "version": "4.4.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/lru-cache": { + "version": "4.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/nyc/node_modules/make-dir": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/map-age-cleaner": { + "version": "0.1.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/mem": { + "version": "4.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^1.0.0", + "p-is-promise": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/merge-source-map": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "source-map": "^0.6.1" + } + }, + "node_modules/nyc/node_modules/merge-source-map/node_modules/source-map": { + "version": "0.6.1", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/mimic-fn": { + "version": "1.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/minimatch": { + "version": "3.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nyc/node_modules/minimist": { + "version": "0.0.10", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/mkdirp": { + "version": "0.5.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minimist": "0.0.8" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/nyc/node_modules/mkdirp/node_modules/minimist": { + "version": "0.0.8", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/ms": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/nice-try": { + "version": "1.0.5", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/normalize-package-data": { + "version": "2.5.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/nyc/node_modules/npm-run-path": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/number-is-nan": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/once": { + "version": "1.4.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/nyc/node_modules/optimist": { + "version": "0.6.1", + "inBundle": true, + "license": "MIT/X11", + "dependencies": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + } + }, + "node_modules/nyc/node_modules/os-homedir": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/os-locale": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/p-defer": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/p-finally": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/p-is-promise": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/p-try": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/package-hash": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^3.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/parse-json": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/path-exists": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/path-is-absolute": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/path-key": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/path-parse": { + "version": "1.0.6", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/path-type": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/pify": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/pkg-dir": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/pseudomap": { + "version": "1.0.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/pump": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/nyc/node_modules/read-pkg": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/read-pkg-up": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0", + "read-pkg": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/release-zalgo": { + "version": "1.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/require-directory": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/require-main-filename": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/resolve": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-parse": "^1.0.6" + } + }, + "node_modules/nyc/node_modules/resolve-from": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/rimraf": { + "version": "2.6.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/nyc/node_modules/safe-buffer": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/semver": { + "version": "5.6.0", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/nyc/node_modules/set-blocking": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/shebang-command": { + "version": "1.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/shebang-regex": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/signal-exit": { + "version": "3.0.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/spawn-wrap": { + "version": "1.4.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^1.5.6", + "mkdirp": "^0.5.0", + "os-homedir": "^1.0.1", + "rimraf": "^2.6.2", + "signal-exit": "^3.0.2", + "which": "^1.3.0" + } + }, + "node_modules/nyc/node_modules/spdx-correct": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/nyc/node_modules/spdx-exceptions": { + "version": "2.2.0", + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/nyc/node_modules/spdx-expression-parse": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/nyc/node_modules/spdx-license-ids": { + "version": "3.0.3", + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/nyc/node_modules/string-width": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/strip-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/strip-bom": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nyc/node_modules/strip-eof": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/test-exclude": { + "version": "5.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "arrify": "^1.0.1", + "minimatch": "^3.0.4", + "read-pkg-up": "^4.0.0", + "require-main-filename": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nyc/node_modules/uglify-js": { + "version": "3.4.9", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "commander": "~2.17.1", + "source-map": "~0.6.1" + }, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/nyc/node_modules/uglify-js/node_modules/source-map": { + "version": "0.6.1", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/uuid": { + "version": "3.3.2", + "inBundle": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/nyc/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/nyc/node_modules/which": { + "version": "1.3.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/nyc/node_modules/which-module": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/wordwrap": { + "version": "0.0.3", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/nyc/node_modules/wrap-ansi": { + "version": "2.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/wrap-ansi/node_modules/string-width": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/wrappy": { + "version": "1.0.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/write-file-atomic": { + "version": "2.4.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "node_modules/nyc/node_modules/y18n": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/yallist": { + "version": "2.1.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/yargs": { + "version": "12.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "node_modules/nyc/node_modules/yargs-parser": { + "version": "11.1.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==", + "license": "MIT", + "dependencies": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-visit/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.omit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "integrity": "sha512-UiAM5mhmIuKLsOvrL+B0U2d1hXHF3bFYWIuH1LMpuV2EJEHG1Ntz06PgLEHjm6VFd87NpH8rastvPoyv6UW2fA==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.4", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/observ": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/observ/-/observ-0.2.0.tgz", + "integrity": "sha512-7SKbYTiFXMvqbeinO/GwTGtXSvbRq5AHKszmq5cx3fV7bKEjNCK7AdkhGzsuQAoT+IPf3mlxDmPrVW8wgshpcQ==" + }, + "node_modules/observ-debounce": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/observ-debounce/-/observ-debounce-1.1.1.tgz", + "integrity": "sha512-vP3EdCDE0+6tftznrvmmfI66qiOEkSV+zJ/TLo/3x5BZjm0a3I9A12V16iPck1v2cwAgGRPtWVF3vUmFN9vtPw==", + "license": "MIT", + "dependencies": { + "observ": "~0.2.0" + } + }, + "node_modules/obv": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/obz": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/octokit": { + "version": "5.0.5", + "license": "MIT", + "dependencies": { + "@octokit/app": "^16.1.2", + "@octokit/core": "^7.0.6", + "@octokit/oauth-app": "^8.0.3", + "@octokit/plugin-paginate-graphql": "^6.0.0", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0", + "@octokit/plugin-retry": "^8.0.3", + "@octokit/plugin-throttling": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "@octokit/webhooks": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/on-change-network": { + "version": "0.0.2", + "license": "MIT" + }, + "node_modules/on-change-network-strict": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-wakeup": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/only": { + "version": "0.0.2" + }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, + "node_modules/open": { + "version": "8.4.2", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/openpgp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/openpgp/-/openpgp-6.3.0.tgz", + "integrity": "sha512-pLzCU8IgyKXPSO11eeharQkQ4GzOKNWhXq79pQarIRZEMt1/ssyr+MIuWBv1mNoenJLg04gvPx+fi4gcKZ4bag==", + "license": "LGPL-3.0+", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/os-homedir": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/osenv": { + "version": "0.1.5", + "license": "ISC", + "dependencies": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-defer": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit/node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "license": "BlueOak-1.0.0" + }, + "node_modules/packet-stream": { + "version": "2.0.6", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/packet-stream-codec": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "pull-reader": "^1.3.1", + "pull-through": "^1.0.18" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/parse-entities": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.2.tgz", + "integrity": "sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, + "node_modules/parse-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "integrity": "sha512-FC5TeK0AwXzq3tUBFtH74naWkPQCEWs4K+xMxWZBlKDWu0bVHXGZa+KKqxKidd7xwhdZ19ZNuF2uO1M/r196HA==", + "license": "MIT", + "dependencies": { + "glob-base": "^0.3.0", + "is-dotfile": "^1.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parse-ms": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.3.31", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.67" + } + }, + "node_modules/peek-readable": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/plur": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/plur/-/plur-2.1.2.tgz", + "integrity": "sha512-WhcHk576xg9y/iv6RWOuroZgsqvCbJN+XGvAypCJwLAYs2iWDp5LUmvaCdV6JR2O0SMBf8l6p7A94AyLCFVMlQ==", + "license": "MIT", + "dependencies": { + "irregular-plurals": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/polyraf": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "random-access-file": "^2.1.0", + "random-access-web": "^2.0.1" + } + }, + "node_modules/posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/preserve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "integrity": "sha512-s/46sYeylUfHNjI+sA/78FAHlmIuKqI9wNnzEOGehAlUUYeObv5C2mOinXBjyUyWmJ2SfcS2/ydApH4hTF4WXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/print-tree": { + "version": "0.1.5", + "license": "MIT" + }, + "node_modules/private-box": { + "version": "0.3.1", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.9" + } + }, + "node_modules/private-group-spec": { + "version": "1.2.0", + "license": "LGPL-3.0-only", + "dependencies": { + "is-my-ssb-valid": "^1.2.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/promisify-4loc": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/promisify-tuple": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/promisize": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC" + }, + "node_modules/protobufjs": { + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/prr": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pull-abortable": { + "version": "4.1.1", + "license": "MIT" + }, + "node_modules/pull-async": { + "version": "1.0.0", + "license": "Apache-2.0" + }, + "node_modules/pull-awaitable": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "pull-thenable": "~1.0.0" + } + }, + "node_modules/pull-bitflipper": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pull-bitflipper/-/pull-bitflipper-0.1.1.tgz", + "integrity": "sha512-MloQIUQkooVrlc4qom1rDeUsnx9Fol4ieMhlJEjpT/v2x37+ydL164jbRkvDFCpWBp+9qLKPgWoQum/zh8iHkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pull-stream": "^3.6.11" + } + }, + "node_modules/pull-box-stream": { + "version": "1.0.13", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.7", + "increment-buffer": "~1.0.0", + "pull-reader": "^1.2.5", + "pull-stream": "^3.2.3", + "pull-through": "^1.0.18", + "split-buffer": "~1.0.0" + } + }, + "node_modules/pull-cat": { + "version": "1.1.11", + "license": "MIT" + }, + "node_modules/pull-catch": { + "version": "1.1.0", + "license": "ISC" + }, + "node_modules/pull-cont": { + "version": "0.1.1", + "license": "MIT" + }, + "node_modules/pull-cursor": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "looper": "^4.0.0", + "ltgt": "^2.2.0", + "pull-stream": "^3.6.0" + } + }, + "node_modules/pull-defer": { + "version": "0.2.3", + "license": "MIT" + }, + "node_modules/pull-drain-gently": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "cpu-percentage": "~1.0.3", + "pull-pause": "~0.0.2" + } + }, + "node_modules/pull-file": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "pull-utf8-decoder": "^1.0.2" + } + }, + "node_modules/pull-flat-merge": { + "version": "2.1.0", + "license": "ISC", + "dependencies": { + "pull-many": "^1.0.8", + "pull-stream": "^3.4.5" + } + }, + "node_modules/pull-flatmap": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/pull-fs": { + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "pull-file": "^0.5.0", + "pull-stream": "^3.3.0", + "pull-traverse": "^1.0.3", + "pull-write-file": "^0.2.1" + } + }, + "node_modules/pull-fs/node_modules/pull-file": { + "version": "0.5.0", + "license": "MIT", + "dependencies": { + "pull-utf8-decoder": "^1.0.2" + } + }, + "node_modules/pull-glob": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "pull-fs": "~1.1.6", + "pull-stream": "^3.3.0" + }, + "bin": { + "pull-glob": "bin.js" + } + }, + "node_modules/pull-goodbye": { + "version": "0.0.3", + "license": "MIT", + "dependencies": { + "pull-stream": "^3.6.14" + } + }, + "node_modules/pull-handshake": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "pull-cat": "^1.1.9", + "pull-pair": "~1.1.0", + "pull-pushable": "^2.0.0", + "pull-reader": "^1.2.3" + } + }, + "node_modules/pull-hash": { + "version": "1.0.0", + "license": "Fair" + }, + "node_modules/pull-inactivity": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "pull-abortable": "^4.1.1", + "pull-stream": "^3.4.5" + } + }, + "node_modules/pull-level": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "level-post": "^1.0.7", + "pull-cat": "^1.1.9", + "pull-live": "^1.0.1", + "pull-pushable": "^2.0.0", + "pull-stream": "^3.4.0", + "pull-window": "^2.1.4", + "stream-to-pull-stream": "^1.7.1" + } + }, + "node_modules/pull-live": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "pull-cat": "^1.1.9", + "pull-stream": "^3.4.0" + } + }, + "node_modules/pull-looper": { + "version": "1.0.0", + "license": "Fair", + "dependencies": { + "looper": "^4.0.0" + } + }, + "node_modules/pull-many": { + "version": "1.0.9", + "license": "MIT", + "dependencies": { + "pull-stream": "^3.4.5" + } + }, + "node_modules/pull-next": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pull-next/-/pull-next-1.0.1.tgz", + "integrity": "sha512-CAOxYSfmFmQ+jxuwnmaXmovYOmVD+XC7X+5uyZh4hVg06DUK7O9uccsIQkGu493eGkAqASlmB5zekEH7oxS0AQ==", + "license": "MIT" + }, + "node_modules/pull-notify": { + "version": "0.1.2", + "license": "MIT", + "dependencies": { + "pull-pushable": "^2.0.0" + } + }, + "node_modules/pull-pair": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/pull-paramap": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "looper": "^4.0.0" + } + }, + "node_modules/pull-pause": { + "version": "0.0.2", + "license": "MIT" + }, + "node_modules/pull-ping": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "pull-pushable": "^2.0.0", + "pull-stream": "^3.4.5", + "statistics": "^3.3.0" + } + }, + "node_modules/pull-pushable": { + "version": "2.2.0", + "license": "MIT" + }, + "node_modules/pull-rate": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "pull-stream": "^3.6.0" + } + }, + "node_modules/pull-reader": { + "version": "1.3.1", + "license": "MIT" + }, + "node_modules/pull-sink-through": { + "version": "0.0.0", + "license": "MIT" + }, + "node_modules/pull-sort": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "pull-defer": "^0.2.3", + "pull-stream": "^3.6.9" + } + }, + "node_modules/pull-stream": { + "version": "3.7.0", + "license": "MIT" + }, + "node_modules/pull-thenable": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "quicktask": "~1.0.0" + } + }, + "node_modules/pull-through": { + "version": "1.0.18", + "license": "MIT", + "dependencies": { + "looper": "~3.0.0" + } + }, + "node_modules/pull-through/node_modules/looper": { + "version": "3.0.0", + "license": "MIT" + }, + "node_modules/pull-traverse": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/pull-utf8-decoder": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/pull-websocket": { + "version": "3.4.2", + "license": "ISC", + "dependencies": { + "relative-url": "^1.0.2", + "typedarray-to-buffer": "^4.0.0", + "ws": "^7.0.0" + } + }, + "node_modules/pull-window": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "looper": "^2.0.0" + } + }, + "node_modules/pull-window/node_modules/looper": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/pull-write": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "looper": "^4.0.0", + "pull-cat": "^1.1.11", + "pull-stream": "^3.4.5" + } + }, + "node_modules/pull-write-file": { + "version": "0.2.4", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/push-stream": { + "version": "11.2.0", + "license": "MIT" + }, + "node_modules/push-stream-to-pull-stream": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "pull-looper": "^1.0.0", + "push-stream": "^11.0.1" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-tick": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", + "integrity": "sha512-tRS7sTgyxMXtLum8L65daJnHUhfDUgboRdcWW2bR9vBfrj2+O5HSMbQOJfJJjIVSPFqbBCF37FpwWXGitDc5tA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/quicktask": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/random-access-chrome-file": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "random-access-storage": "^1.3.0" + } + }, + "node_modules/random-access-file": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "mkdirp-classic": "^0.5.2", + "random-access-storage": "^1.1.1" + } + }, + "node_modules/random-access-idb": { + "version": "1.2.2", + "license": "BSD", + "dependencies": { + "buffer-alloc": "^1.1.0", + "buffer-from": "^0.1.1", + "inherits": "^2.0.3", + "next-tick": "^1.0.0", + "once": "^1.4.0", + "random-access-storage": "^1.3.0" + } + }, + "node_modules/random-access-idb-mutable-file": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "buffer": "5.1.0", + "random-access-storage": "1.3.0" + } + }, + "node_modules/random-access-idb-mutable-file/node_modules/buffer": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/random-access-idb-mutable-file/node_modules/random-access-storage": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3" + } + }, + "node_modules/random-access-idb/node_modules/buffer-from": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/random-access-memory": { + "version": "3.1.4", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-options": "^1.0.1", + "random-access-storage": "^1.1.1" + } + }, + "node_modules/random-access-storage": { + "version": "1.4.3", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "inherits": "^2.0.3", + "queue-tick": "^1.0.0" + } + }, + "node_modules/random-access-web": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "@sammacbeth/random-access-idb-mutable-file": "^0.1.1", + "random-access-chrome-file": "^1.1.2", + "random-access-idb": "^1.2.1", + "random-access-idb-mutable-file": "^0.3.0", + "random-access-memory": "^3.1.1", + "random-access-storage": "^1.3.0" + } + }, + "node_modules/randomatic": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", + "integrity": "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==", + "license": "MIT", + "dependencies": { + "is-number": "^4.0.0", + "kind-of": "^6.0.0", + "math-random": "^1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/randomatic/node_modules/is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/randomatic/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/readdirp/node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "license": "MIT", + "dependencies": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/braces/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==", + "license": "MIT", + "dependencies": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/expand-brackets/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/expand-brackets/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/expand-brackets/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/readdirp/node_modules/extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "license": "MIT", + "dependencies": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/extglob/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/extglob/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/fill-range/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/readdirp/node_modules/is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/is-number/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readdirp/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readdirp/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/redent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz", + "integrity": "sha512-XNwrTx77JQCEMXTeb8movBKuK75MgH0RZkujNuDKCezemx/voapl9i2gCSi8WWm8+ox5ycJi1gxF22fR7c0Ciw==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^3.0.0", + "strip-indent": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regex-cache": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", + "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==", + "license": "MIT", + "dependencies": { + "is-equal-shallow": "^0.1.3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/relative-url": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/remark": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/remark/-/remark-3.2.3.tgz", + "integrity": "sha512-E+WibagMmLjs7y5d6qWRiwbL2/z0zbkLwCqvmNlxBTx2Y1ufZ06sir+ZdyQXTxDiEbkbsrX6kG883ItjrEkaSQ==", + "license": "MIT", + "dependencies": { + "camelcase": "^2.0.0", + "ccount": "^1.0.0", + "chalk": "^1.0.0", + "chokidar": "^1.0.5", + "collapse-white-space": "^1.0.0", + "commander": "^2.0.0", + "concat-stream": "^1.0.0", + "debug": "^2.0.0", + "elegant-spinner": "^1.0.0", + "extend.js": "0.0.2", + "glob": "^6.0.1", + "globby": "^4.0.0", + "he": "^0.5.0", + "log-update": "^1.0.1", + "longest-streak": "^1.0.0", + "markdown-table": "^0.4.0", + "minimatch": "^3.0.0", + "npm-prefix": "^1.0.1", + "parse-entities": "^1.0.0", + "repeat-string": "^1.5.0", + "stringify-entities": "^1.0.0", + "to-vfile": "^1.0.0", + "trim": "^0.0.1", + "trim-trailing-lines": "^1.0.0", + "unified": "^2.0.0", + "user-home": "^2.0.0", + "vfile": "^1.1.0", + "vfile-find-down": "^1.0.0", + "vfile-find-up": "^1.0.0", + "vfile-reporter": "^1.5.0", + "ware": "^1.3.0" + }, + "bin": { + "remark": "bin/remark" + } + }, + "node_modules/remark-html": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/remark-html/-/remark-html-2.0.2.tgz", + "integrity": "sha512-2yj8Oad44uo/IWOssZs3Y4AvfrA9nGZqiHsL7ePyZuntPKjtljHZDcroljlQQ+sJryO3Bjnnj30e7wi8aTp/hQ==", + "license": "MIT", + "dependencies": { + "collapse-white-space": "^1.0.0", + "detab": "^1.0.0", + "normalize-uri": "^1.0.0", + "object-assign": "^4.0.1", + "trim": "0.0.1", + "trim-lines": "^1.0.0", + "unist-util-visit": "^1.0.0" + } + }, + "node_modules/remark/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark/node_modules/camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/remark/node_modules/glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/remark/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "license": "ISC" + }, + "node_modules/repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-finite": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.10", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-path": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-path/node_modules/depd": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/http-errors": { + "version": "1.6.3", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/inherits": { + "version": "2.0.3", + "license": "ISC" + }, + "node_modules/resolve-path/node_modules/setprototypeof": { + "version": "1.1.0", + "license": "ISC" + }, + "node_modules/resolve-path/node_modules/statuses": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", + "deprecated": "https://github.com/lydell/resolve-url#deprecated", + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/right-pad": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rng": { + "version": "0.2.2", + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/run-series": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz", + "integrity": "sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rwlock": { + "version": "5.0.0", + "license": "MIT" + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", + "license": "MIT", + "dependencies": { + "ret": "~0.1.10" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/secret-handshake": { + "version": "1.1.21", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.8", + "clarify-error": "^1.0.0", + "pull-box-stream": "^1.0.13", + "pull-handshake": "^1.1.1", + "pull-stream": "^3.4.5" + } + }, + "node_modules/secret-stack": { + "version": "6.4.2", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "hoox": "0.0.1", + "map-merge": "^1.1.0", + "multiserver": "^3.1.0", + "muxrpc": "^6.5.2", + "pull-inactivity": "~2.1.1", + "pull-rate": "^1.0.2", + "pull-stream": "^3.4.5", + "to-camel-case": "^1.0.0" + }, + "engines": { + "node": ">=5.10.0" + } + }, + "node_modules/secret-stack-decorators": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/secret-stack/node_modules/muxrpc": { + "version": "6.7.3", + "license": "MIT", + "dependencies": { + "clarify-error": "^1.0.0", + "debug": "^4.3.3", + "packet-stream": "~2.0.0", + "packet-stream-codec": "^1.2.0", + "pull-goodbye": "0.0.3", + "pull-stream": "^3.6.10" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/separator-escape": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/set-value/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/set.prototype.difference": { + "version": "1.1.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-set": "^1.1.2", + "is-set": "^2.0.3", + "stop-iteration-iterator": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set.prototype.issubsetof": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set": "^1.1.1", + "is-set": "^2.0.3", + "stop-iteration-iterator": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.5", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, + "node_modules/sha256-universal": { + "version": "1.2.1", + "license": "ISC", + "dependencies": { + "b4a": "^1.0.1", + "sha256-wasm": "^2.2.1" + } + }, + "node_modules/sha256-wasm": { + "version": "2.2.2", + "license": "ISC", + "dependencies": { + "b4a": "^1.0.1", + "nanoassert": "^2.0.0" + } + }, + "node_modules/sha512-universal": { + "version": "1.2.1", + "license": "ISC", + "dependencies": { + "b4a": "^1.0.1", + "sha512-wasm": "^2.3.1" + } + }, + "node_modules/sha512-wasm": { + "version": "2.3.4", + "license": "ISC", + "dependencies": { + "b4a": "^1.0.1", + "nanoassert": "^2.0.0" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shellsubstitute": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shellsubstitute/-/shellsubstitute-1.2.0.tgz", + "integrity": "sha512-CI1ViFC5a3ub86aaBmBVQ7kqg8eFypZLgBh+Bmq+ehHy9g7vu9kqCj5hS82cPzLwfdJRgiPB2hNHnd6oetiakQ==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siphash24": { + "version": "1.3.1", + "license": "MIT", + "dependencies": { + "nanoassert": "^2.0.0" + } + }, + "node_modules/sleep-promise": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/sleep-promise/-/sleep-promise-9.1.0.tgz", + "integrity": "sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "license": "MIT", + "dependencies": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "license": "MIT", + "dependencies": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "license": "MIT", + "dependencies": { + "kind-of": "^3.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sodium-browserify": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "libsodium-wrappers": "^0.7.4", + "sha.js": "2.4.5", + "sodium-browserify-tweetnacl": "^0.2.5", + "tweetnacl": "^0.14.1" + } + }, + "node_modules/sodium-browserify-tweetnacl": { + "version": "0.2.6", + "license": "MIT", + "dependencies": { + "chloride-test": "^1.1.0", + "ed2curve": "^0.1.4", + "sha.js": "^2.4.8", + "tweetnacl": "^1.0.1", + "tweetnacl-auth": "^0.3.0" + } + }, + "node_modules/sodium-browserify-tweetnacl/node_modules/sha.js": { + "version": "2.4.12", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sodium-browserify-tweetnacl/node_modules/tweetnacl": { + "version": "1.0.3", + "license": "Unlicense" + }, + "node_modules/sodium-chloride": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/sodium-javascript": { + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "blake2b": "^2.1.1", + "chacha20-universal": "^1.0.4", + "nanoassert": "^2.0.0", + "sha256-universal": "^1.1.0", + "sha512-universal": "^1.1.0", + "siphash24": "^1.0.1", + "xsalsa20": "^1.0.0" + } + }, + "node_modules/sodium-native": { + "version": "3.4.1", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + } + }, + "node_modules/sodium-universal": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "blake2b": "^2.1.1", + "chacha20-universal": "^1.0.4", + "nanoassert": "^2.0.0", + "resolve": "^1.17.0", + "sha256-universal": "^1.1.0", + "sha512-universal": "^1.1.0", + "siphash24": "^1.0.1", + "sodium-javascript": "~0.8.0", + "sodium-native": "^3.2.0", + "xsalsa20": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "license": "MIT", + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "node_modules/source-map-url": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", + "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", + "deprecated": "See https://github.com/lydell/source-map-url#deprecated", + "license": "MIT" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split-buffer": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssb-about": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "ssb-social-index": "^1.0.0" + } + }, + "node_modules/ssb-about-self": { + "version": "1.1.0", + "license": "LGPL-3.0", + "dependencies": { + "bipf": "^1.6.3", + "clarify-error": "^1.0.0", + "pull-async": "^1.0.0", + "pull-cat": "^1.1.11", + "pull-level": "^2.0.4", + "pull-stream": "^3.6.14" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "ssb-db2": ">=3.4.0" + } + }, + "node_modules/ssb-autofollow": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "pull-stream": "^3.6.9" + } + }, + "node_modules/ssb-backlinks": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "base64-url": "^2.3.3", + "flumeview-links": "^2.1.0", + "pull-stream": "^3.6.14", + "ssb-ref": "^2.14.0" + } + }, + "node_modules/ssb-bendy-butt": { + "version": "1.0.2", + "license": "LGPL-3.0", + "dependencies": { + "bencode": "^2.0.1", + "ssb-bfe": "^3.3.0", + "ssb-keys": "^8.4.0", + "ssb-uri2": "^2.0.0" + } + }, + "node_modules/ssb-bfe": { + "version": "3.7.0", + "license": "LGPL-3.0", + "dependencies": { + "is-canonical-base64": "^1.1.1", + "ssb-bfe-spec": "^0.8.0", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.0" + } + }, + "node_modules/ssb-bfe-spec": { + "version": "0.8.0", + "license": "CC0-1.0" + }, + "node_modules/ssb-blobs": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "level": "^6.0.0", + "multiblob": "^1.12.0", + "obz": "^1.0.2", + "pull-defer": "^0.2.3", + "pull-level": "^2.0.4", + "pull-notify": "^0.1.0", + "pull-stream": "^3.3.0", + "ssb-ref": "^2.3.0" + } + }, + "node_modules/ssb-box": { + "version": "1.0.1", + "license": "LGPL-3.0", + "dependencies": { + "chloride": "^2.4.1", + "private-box": "^0.3.1", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ssb-box2": { + "version": "3.0.1", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.3.0", + "private-group-spec": "^1.2.0", + "ssb-bfe": "^3.7.0", + "ssb-keyring": "^2.2.0", + "ssb-private-group-keys": "^0.4.1", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.0" + } + }, + "node_modules/ssb-buttwoo": { + "version": "0.3.3", + "license": "LGPL-3.0", + "dependencies": { + "base64-url": "^2.3.3", + "bipf": "^1.7.0", + "blake3": "^2.1.7", + "fast-varint": "^1.0.1", + "monotonic-timestamp": "^0.0.9", + "ssb-bfe": "^3.5.0", + "ssb-keys": "^8.4.0", + "ssb-uri2": "^2.0.1" + } + }, + "node_modules/ssb-caps": { + "version": "1.1.0", + "license": "CC0-1.0" + }, + "node_modules/ssb-classic": { + "version": "1.1.0", + "license": "LGPL-3.0", + "dependencies": { + "bipf": "^1.9.0", + "is-canonical-base64": "^1.1.1", + "ssb-keys": "^8.1.0", + "ssb-ref": "^2.16.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ssb-client": { + "version": "4.9.0", + "license": "MIT", + "dependencies": { + "explain-error": "^1.0.1", + "multicb": "^1.2.1", + "multiserver": "^3.1.2", + "muxrpc": "^6.5.0", + "pull-hash": "^1.0.0", + "pull-stream": "^3.6.0", + "ssb-config": "^3.4.4", + "ssb-keys": "^7.2.1" + } + }, + "node_modules/ssb-client/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-client/node_modules/muxrpc": { + "version": "6.7.3", + "license": "MIT", + "dependencies": { + "clarify-error": "^1.0.0", + "debug": "^4.3.3", + "packet-stream": "~2.0.0", + "packet-stream-codec": "^1.2.0", + "pull-goodbye": "0.0.3", + "pull-stream": "^3.6.10" + } + }, + "node_modules/ssb-client/node_modules/ssb-keys": { + "version": "7.2.2", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.8", + "mkdirp": "~0.5.0", + "private-box": "^0.3.0" + } + }, + "node_modules/ssb-config": { + "version": "3.4.6", + "license": "MIT", + "dependencies": { + "deep-extend": "^0.6.0", + "ip": "^1.1.5", + "lodash.get": "^4.4.2", + "os-homedir": "^1.0.1", + "rc": "^1.1.6", + "ssb-caps": "^1.0.1", + "ssb-keys": "^8.2.0" + } + }, + "node_modules/ssb-conn": { + "version": "6.0.3", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "has-network2": ">=0.0.3", + "ip": "^1.1.5", + "on-change-network-strict": "1.0.0", + "on-wakeup": "^1.0.1", + "pull-notify": "^0.1.2", + "pull-pause": "~0.0.2", + "pull-ping": "^2.0.3", + "pull-stream": "^3.6.14", + "secret-stack-decorators": "1.1.0", + "ssb-conn-db": "~1.0.5", + "ssb-conn-hub": "~1.2.0", + "ssb-conn-query": "~1.2.2", + "ssb-conn-staging": "~1.0.0", + "ssb-ref": "^2.14.3", + "ssb-typescript": "^2.8.0", + "statistics": "^3.3.0", + "ziii": "~1.0.2" + }, + "peerDependencies": { + "secret-stack": ">=6.2.0" + } + }, + "node_modules/ssb-conn-db": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "atomic-file-rw": "^0.3.0", + "debug": "^4.3.1", + "multiserver-address": "~1.0.1", + "pull-notify": "~0.1.2", + "ssb-ref": ">=2.13.9" + } + }, + "node_modules/ssb-conn-hub": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "ip": "^1.1.5", + "multiserver": "^3.7.0", + "multiserver-address": "~1.0.1", + "promisify-tuple": "^1.0.1", + "pull-cat": "~1.1.11", + "pull-notify": "~0.1.1", + "pull-stream": "^3.6.14", + "ssb-ref": "^2.14.3" + } + }, + "node_modules/ssb-conn-query": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "ssb-conn-db": "~1.0.3", + "ssb-conn-hub": "~1.2.0", + "ssb-conn-staging": "~1.0.0" + } + }, + "node_modules/ssb-conn-staging": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "multiserver-address": "~1.0.1", + "pull-cat": "~1.1.11", + "pull-notify": "~0.1.1", + "pull-stream": "^3.6.9" + } + }, + "node_modules/ssb-db": { + "version": "20.4.1", + "license": "MIT", + "dependencies": { + "flumedb": "^2.1.8", + "flumelog-offset": "^3.4.2", + "flumeview-level": "^4.0.4", + "flumeview-reduce": "^1.4.0", + "hashlru": "^2.3.0", + "lodash.clonedeep": "^4.5.0", + "ltgt": "^2.2.0", + "mkdirp": "^1.0.4", + "monotonic-timestamp": "~0.0.8", + "muxrpc-validation": "^3.0.0", + "obv": "0.0.1", + "pull-cat": "^1.1.11", + "pull-cont": "^0.1.1", + "pull-notify": "^0.1.1", + "pull-stream": "^3.4.0", + "rimraf": "^3.0.0", + "ssb-keys": "^7.1.3", + "ssb-msgs": "^5.0.0", + "ssb-ref": "^2.14.0", + "ssb-validate": "^4.0.0", + "zerr": "^1.0.0" + } + }, + "node_modules/ssb-db/node_modules/ssb-keys": { + "version": "7.2.2", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.8", + "mkdirp": "~0.5.0", + "private-box": "^0.3.0" + } + }, + "node_modules/ssb-db/node_modules/ssb-keys/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-db2": { + "version": "6.3.3", + "license": "LGPL-3.0", + "dependencies": { + "async-append-only-log": "^4.3.7", + "atomic-file-rw": "^0.3.0", + "binary-search-bounds": "^2.0.4", + "bipf": "^1.9.0", + "clarify-error": "^1.0.0", + "debug": "^4.3.1", + "fastintcompression": "0.0.4", + "flumecodec": "0.0.1", + "flumelog-offset": "3.4.4", + "hoox": "0.0.1", + "jitdb": "^7.0.0", + "level": "^6.0.1", + "level-codec": "^9.0.2", + "lodash.debounce": "^4.0.8", + "mkdirp": "^1.0.4", + "multicb": "1.2.2", + "mutexify": "^1.3.1", + "obz": "^1.1.0", + "p-defer": "^3.0.0", + "pull-cat": "^1.1.11", + "pull-drain-gently": "^1.1.0", + "pull-level": "^2.0.4", + "pull-notify": "^0.1.2", + "pull-paramap": "^1.2.2", + "pull-stream": "^3.6.14", + "push-stream": "^11.0.0", + "rimraf": "^3.0.2", + "ssb-about-self": "^1.0.1", + "ssb-box": "^1.0.0", + "ssb-box2": "^3.0.0", + "ssb-classic": "^1.1.0", + "ssb-keys": "^8.4.0", + "ssb-ref": "^2.14.3", + "ssb-uri2": "^2.2.0", + "too-hot": "^1.0.0", + "typedarray-to-buffer": "^4.0.0" + } + }, + "node_modules/ssb-db2/node_modules/flumecodec": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "level-codec": "^6.2.0" + } + }, + "node_modules/ssb-db2/node_modules/flumecodec/node_modules/level-codec": { + "version": "6.2.0", + "license": "MIT" + }, + "node_modules/ssb-device-address": { + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "pull-stream": "^3.6.9", + "ssb-ref": "^2.13.3" + } + }, + "node_modules/ssb-ebt": { + "version": "9.1.2", + "license": "MIT", + "dependencies": { + "base64-url": "^2.2.0", + "epidemic-broadcast-trees": "^9.0.2", + "key-value-file-store": "^1.1.1", + "pull-defer": "^0.2.3", + "pull-stream": "^3.6.0", + "push-stream-to-pull-stream": "^1.0.5", + "ssb-bendy-butt": "^1.0.1", + "ssb-buttwoo": "^0.3.2", + "ssb-classic": "^1.1.0", + "ssb-index-feeds": "~0.10.1", + "ssb-network-errors": "^1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ssb-feed": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ssb-feed/-/ssb-feed-2.3.0.tgz", + "integrity": "sha512-PKdrDl+KpqpLmIAJA/l7+8njHsDkJ8siDnMDxcGJhr63uwdib74H9BMaV9qbW9kRI7S0UJHvuPcre0pB3tcxgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cont": "~1.0.3", + "monotonic-timestamp": "~0.0.9", + "pull-stream": "^3.4.2", + "ssb-keys": "^7.0.0", + "ssb-ref": "^2.0.0" + } + }, + "node_modules/ssb-feed/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-feed/node_modules/ssb-keys": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/ssb-keys/-/ssb-keys-7.2.2.tgz", + "integrity": "sha512-FPeyYU/3LpxcagnbmVWE+Q/qzg6keqeOBPbD7sEH9UKixUASeufPKiORDgh8nVX7J9Z+0vUaHt/WG999kGjvVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chloride": "^2.2.8", + "mkdirp": "~0.5.0", + "private-box": "^0.3.0" + } + }, + "node_modules/ssb-friend-pub": { + "version": "1.0.7", + "license": "Beerware", + "dependencies": { + "pull-stream": "^3.6.9", + "ssb-ref": "^2.13.9", + "ssb-sort": "^1.1.3" + } + }, + "node_modules/ssb-friends": { + "version": "5.1.7", + "license": "MIT", + "dependencies": { + "bipf": "^1.5.1", + "flumecodec": "0.0.1", + "flumeview-reduce": "^1.3.17", + "layered-graph": "^1.2.0", + "pull-cont": "^0.1.1", + "pull-flatmap": "0.0.1", + "pull-level": "^2.0.4", + "pull-notify": "^0.1.1", + "pull-pushable": "^2.2.0", + "pull-stream": "^3.6.0", + "ssb-db2": ">=4.1.0 <=6", + "ssb-ref": "^2.13.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ssb-friends/node_modules/flumecodec": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "level-codec": "^6.2.0" + } + }, + "node_modules/ssb-friends/node_modules/level-codec": { + "version": "6.2.0", + "license": "MIT" + }, + "node_modules/ssb-generate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ssb-generate/-/ssb-generate-1.0.1.tgz", + "integrity": "sha512-Ube4EVI2QCHLwtxPwi3julUGW1j45oZFCA/keST5JtWLAmt7Tegq7VWwlTHxXRInTZKcT8Q6AJF+KtipR+FC1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "pull-paramap": "^1.2.2", + "pull-stream": "^3.6.0" + } + }, + "node_modules/ssb-gossip": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "atomic-file": "^1.1.5", + "deep-equal": "^1.0.1", + "has-network": "0.0.1", + "ip": "^1.1.5", + "muxrpc-validation": "^3.0.0", + "on-change-network": "0.0.2", + "on-wakeup": "^1.0.1", + "pull-notify": "^0.1.1", + "pull-ping": "^2.0.2", + "pull-stream": "^3.6.9", + "ssb-ref": "^2.13.9", + "statistics": "^3.3.0" + } + }, + "node_modules/ssb-gossip/node_modules/atomic-file": { + "version": "1.1.5", + "license": "MIT" + }, + "node_modules/ssb-index-feeds": { + "version": "0.10.2", + "license": "LGPL-3.0", + "dependencies": { + "debug": "^4.3.2", + "is-canonical-base64": "^1.1.1", + "pull-cat": "^1.1.11", + "pull-stream": "^3.6.14", + "ssb-classic": "^1.0.3", + "ssb-db2": ">=5", + "ssb-keys": ">=8", + "ssb-ref": "^2.16.0", + "ssb-subset-ql": "^1.0.0", + "ssb-uri2": "^2.1.0" + } + }, + "node_modules/ssb-invite": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "cont": "^1.0.3", + "explain-error": "^1.0.4", + "ip": "^1.1.8", + "level": "^6.0.1", + "muxrpc-validation": "^3.0.2", + "ssb-client": "^4.9.0", + "ssb-keys": "^8.5.0", + "ssb-ref": "^2.16.0" + } + }, + "node_modules/ssb-invite-client": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "clarify-error": "^1.0.0", + "promisify-tuple": "^1.2.0", + "secret-stack-decorators": "~1.1.0", + "ssb-ref": "^2.13.0" + }, + "engines": { + "node": ">=8.10" + } + }, + "node_modules/ssb-keyring": { + "version": "2.2.0", + "license": "LGPL-3.0", + "dependencies": { + "charwise": "^3.0.1", + "level": "^6.0.1", + "mkdirp": "^1.0.4", + "private-group-spec": "^1.1.3", + "pull-level": "^2.0.4", + "pull-stream": "^3.6.14", + "ssb-private-group-keys": "^1.0.0", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.0" + } + }, + "node_modules/ssb-keyring/node_modules/ssb-private-group-keys": { + "version": "1.1.2", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.3.2", + "futoin-hkdf": "^1.5.1", + "private-group-spec": "^1.1.3", + "sodium-universal": "^3.1.0", + "ssb-bfe": "^3.5.0" + } + }, + "node_modules/ssb-keys": { + "version": "8.5.0", + "license": "MIT", + "dependencies": { + "chloride": "~2.4.1", + "mkdirp": "~0.5.0", + "private-box": "~0.3.0", + "ssb-uri2": "^2.1.0" + }, + "engines": { + "node": ">=5.10.0" + } + }, + "node_modules/ssb-keys/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-lan": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "dgram-broadcast": "^1.0.0", + "ip": "^1.1.5", + "non-private-ip": "^2.1.0", + "pull-notify": "~0.1.2", + "secret-stack-decorators": "~1.1.0", + "ssb-keys": "^8.0.2", + "ssb-ref": "^2.14.3" + }, + "peerDependencies": { + "secret-stack": ">=6.2.0" + } + }, + "node_modules/ssb-legacy-conn": { + "version": "1.0.25", + "license": "MIT", + "dependencies": { + "debug": "~4.1.1", + "has-network": "0.0.1", + "ip": "^1.1.5", + "multiserver-address": "~1.0.1", + "on-change-network": "0.0.2", + "on-wakeup": "^1.0.1", + "pull-notify": "^0.1.1", + "pull-ping": "^2.0.2", + "pull-stream": "^3.6.9", + "ssb-conn-db": "0.1.0", + "ssb-conn-hub": "0.0.3", + "ssb-conn-staging": "0.0.1", + "ssb-ref": "^2.13.9", + "statistics": "^3.3.0" + } + }, + "node_modules/ssb-legacy-conn/node_modules/atomic-file": { + "version": "1.1.5", + "license": "MIT" + }, + "node_modules/ssb-legacy-conn/node_modules/ssb-conn-db": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "atomic-file": "^1.1.5", + "debug": "~4.1.1", + "multiserver-address": "~1.0.1", + "pull-notify": "~0.1.1", + "ssb-ref": "~2.13.9" + } + }, + "node_modules/ssb-legacy-conn/node_modules/ssb-conn-hub": { + "version": "0.0.3", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "multiserver-address": "~1.0.1", + "promisify-tuple": "^1.0.0", + "pull-notify": "~0.1.1", + "ssb-ref": "~2.13.9" + } + }, + "node_modules/ssb-legacy-conn/node_modules/ssb-conn-staging": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "multiserver-address": "~1.0.1", + "pull-notify": "~0.1.1", + "pull-stream": "^3.6.9", + "ssb-conn-hub": "~0.0.3" + } + }, + "node_modules/ssb-links": { + "version": "3.0.10", + "license": "MIT", + "dependencies": { + "flumeview-links": "^1.0.1", + "map-filter-reduce": "^2.0.0", + "ssb-msgs": "^5.2.0" + } + }, + "node_modules/ssb-links/node_modules/abstract-leveldown": { + "version": "6.0.3", + "license": "MIT", + "dependencies": { + "level-concat-iterator": "~2.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ssb-links/node_modules/deep-equal": { + "version": "2.2.3", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ssb-links/node_modules/flumeview-level": { + "version": "3.0.14", + "license": "MIT", + "dependencies": { + "charwise": "^3.0.1", + "explain-error": "^1.0.4", + "level": "^5.0.0", + "ltgt": "^2.1.3", + "mkdirp": "^0.5.1", + "obv": "0.0.1", + "pull-level": "^2.0.3", + "pull-paramap": "^1.2.1", + "pull-stream": "^3.5.0", + "pull-write": "^1.1.1" + } + }, + "node_modules/ssb-links/node_modules/flumeview-links": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "deep-equal": "^2.0.3", + "flumeview-level": "^3.0.14", + "map-filter-reduce": "^3.2.2", + "pull-flatmap": "0.0.1", + "pull-stream": "^3.6.14" + } + }, + "node_modules/ssb-links/node_modules/flumeview-links/node_modules/map-filter-reduce": { + "version": "3.2.2", + "license": "MIT", + "dependencies": { + "binary-search": "^1.2.0", + "compare-at-paths": "^1.0.0", + "pull-sink-through": "0.0.0", + "pull-sort": "^1.0.1", + "pull-stream": "^3.4.3", + "typewiselite": "^1.0.0" + } + }, + "node_modules/ssb-links/node_modules/immediate": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/ssb-links/node_modules/level": { + "version": "5.0.1", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "level-js": "^4.0.0", + "level-packager": "^5.0.0", + "leveldown": "^5.0.0", + "opencollective-postinstall": "^2.0.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/ssb-links/node_modules/level-js": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "abstract-leveldown": "~6.0.1", + "immediate": "~3.2.3", + "inherits": "^2.0.3", + "ltgt": "^2.1.2", + "typedarray-to-buffer": "~3.1.5" + } + }, + "node_modules/ssb-links/node_modules/map-filter-reduce": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "binary-search": "^1.2.0", + "pull-sink-through": "0.0.0", + "pull-stream": "^3.3.0", + "typewiselite": "^1.0.0" + } + }, + "node_modules/ssb-links/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-links/node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/ssb-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ssb-local/-/ssb-local-1.0.0.tgz", + "integrity": "sha512-aIkz+tDsAQFLbcN5d9itipHG71EG62UdIwojz78BQBxD7kGTwMezZofkMplURp9/njwdqMiqxYEUMvOh8Emh/w==", + "license": "MIT", + "dependencies": { + "broadcast-stream": "^0.2.2", + "ssb-ref": "^2.13.9" + } + }, + "node_modules/ssb-logging": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "bash-color": "0.0.4" + } + }, + "node_modules/ssb-markdown": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "emoji-named-characters": "^1.0.2", + "ssb-marked": "^0.7.3", + "ssb-msgs": "^5.2.0", + "ssb-ref": "^2.3.0" + } + }, + "node_modules/ssb-marked": { + "version": "0.7.4", + "license": "MIT", + "bin": { + "ssb-marked": "bin/marked" + } + }, + "node_modules/ssb-master": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/ssb-meme": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "flumeview-search": "^2.0.0", + "is-my-json-valid": "^2.17.2", + "ssb-ref": "^2.11.1" + } + }, + "node_modules/ssb-mentions": { + "version": "0.5.2", + "license": "MIT", + "dependencies": { + "lodash.isequal": "^4.5.0", + "ssb-marked": "^0.7.0", + "ssb-ref": "^2.11.0" + } + }, + "node_modules/ssb-meta-feeds": { + "version": "0.39.0", + "license": "LGPL-3.0", + "dependencies": { + "bencode": "^2.0.2", + "bipf": "^1.9.0", + "debug": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "futoin-hkdf": "^1.4.2", + "is-canonical-base64": "^1.1.1", + "mutexify": "^1.4.0", + "p-defer": "^3.0.0", + "print-tree": "^0.1.5", + "promisify-tuple": "^1.2.0", + "pull-cat": "^1.1.11", + "pull-defer": "^0.2.3", + "pull-notify": "^0.1.1", + "pull-stream": "^3.6.14", + "ssb-bfe": "^3.6.1", + "ssb-db2": ">=3.0.0 <=6", + "ssb-keys": "^8.5.0", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.1" + } + }, + "node_modules/ssb-msg-content": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/ssb-msgs": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "ssb-ref": "^2.0.0" + } + }, + "node_modules/ssb-network-errors": { + "version": "1.0.1", + "license": "LGPL-3.0" + }, + "node_modules/ssb-no-auth": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "multiserver": "^3.3.3" + } + }, + "node_modules/ssb-onion": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "multiserver": "^3.3.3" + } + }, + "node_modules/ssb-ooo": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/ssb-ooo/-/ssb-ooo-1.3.3.tgz", + "integrity": "sha512-6yI5XBpBHkwFdAELpKwlf1C1V32Z0/AKEJYsoU7lk+Eh88blv8LKydo4a1DnjMiPy4ywn4lRU5oayQaPE5MRtQ==", + "license": "MIT", + "dependencies": { + "flumecodec": "0.0.1", + "flumelog-offset": "^3.4.3", + "flumeview-hashtable": "^1.1.1", + "gossip-query": "^2.0.2", + "mkdirp": "^0.5.1", + "pull-stream": "^3.6.14", + "ssb-keys": "^7.2.0", + "ssb-ref": "^2.13.3", + "ssb-validate": "^4.1.0" + } + }, + "node_modules/ssb-ooo/node_modules/flumecodec": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/flumecodec/-/flumecodec-0.0.1.tgz", + "integrity": "sha512-JT0xivzdV7uTucjsLMw6JhK2e1K5TmU4fGmoQqnrJbydgY/V6+m71QoxX5ZtRR1pKoD48uhPDWWE6G5MlNoGkg==", + "license": "MIT", + "dependencies": { + "level-codec": "^6.2.0" + } + }, + "node_modules/ssb-ooo/node_modules/level-codec": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-6.2.0.tgz", + "integrity": "sha512-J437PvCMZZKNT88+VRh6dkmh1ndZzwGwDzb5ZZl3QEsl+U9wIlt8hG+Y1gXVOhH75gf8JyceKGiG6WFUBbxyGQ==", + "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", + "license": "MIT" + }, + "node_modules/ssb-ooo/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-ooo/node_modules/ssb-keys": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/ssb-keys/-/ssb-keys-7.2.2.tgz", + "integrity": "sha512-FPeyYU/3LpxcagnbmVWE+Q/qzg6keqeOBPbD7sEH9UKixUASeufPKiORDgh8nVX7J9Z+0vUaHt/WG999kGjvVQ==", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.8", + "mkdirp": "~0.5.0", + "private-box": "^0.3.0" + } + }, + "node_modules/ssb-partial-replication": { + "version": "3.0.1", + "license": "Beerware", + "dependencies": { + "pull-cont": "^0.1.1", + "pull-stream": "^3.6.14", + "ssb-sort": "^1.1.3" + } + }, + "node_modules/ssb-plugins": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.5", + "explain-error": "^1.0.4", + "mkdirp": "^0.5.1", + "muxrpc": "^6.4.6", + "muxrpc-validation": "^3.0.0", + "mv": "^2.1.1", + "osenv": "^0.1.5", + "pull-cat": "^1.1.11", + "pull-many": "^1.0.8", + "pull-pushable": "^2.2.0", + "pull-stream": "^3.6.9", + "stream-to-pull-stream": "^1.7.2" + } + }, + "node_modules/ssb-plugins/node_modules/cross-spawn": { + "version": "6.0.6", + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/ssb-plugins/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-plugins/node_modules/muxrpc": { + "version": "6.7.3", + "license": "MIT", + "dependencies": { + "clarify-error": "^1.0.0", + "debug": "^4.3.3", + "packet-stream": "~2.0.0", + "packet-stream-codec": "^1.2.0", + "pull-goodbye": "0.0.3", + "pull-stream": "^3.6.10" + } + }, + "node_modules/ssb-plugins/node_modules/path-key": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ssb-plugins/node_modules/semver": { + "version": "5.7.2", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/ssb-plugins/node_modules/shebang-command": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssb-plugins/node_modules/shebang-regex": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssb-plugins/node_modules/which": { + "version": "1.3.1", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/ssb-private": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "base64-url": "^2.2.0", + "explain-error": "^1.0.4", + "flumeview-links": "^2.0.1", + "ssb-keys": "^7.0.14" + } + }, + "node_modules/ssb-private-group-keys": { + "version": "0.4.1", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.1.2", + "futoin-hkdf": "^1.3.2", + "private-group-spec": "^1.1.0", + "sodium-universal": "^3.0.4", + "ssb-bfe": "^3.1.1" + } + }, + "node_modules/ssb-private/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ssb-private/node_modules/ssb-keys": { + "version": "7.2.2", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.8", + "mkdirp": "~0.5.0", + "private-box": "^0.3.0" + } + }, + "node_modules/ssb-query": { + "version": "2.4.5", + "license": "MIT", + "dependencies": { + "explain-error": "^1.0.1", + "flumeview-query": "^8.0.0", + "pull-stream": "^3.6.2" + } + }, + "node_modules/ssb-ref": { + "version": "2.16.0", + "license": "MIT", + "dependencies": { + "ip": "^1.1.3", + "is-canonical-base64": "^1.1.1", + "is-valid-domain": "~0.0.1", + "multiserver-address": "^1.0.1" + } + }, + "node_modules/ssb-ref/node_modules/is-valid-domain": { + "version": "0.0.20", + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1" + } + }, + "node_modules/ssb-replicate": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ssb-replicate/-/ssb-replicate-1.3.5.tgz", + "integrity": "sha512-XJcsI8xMEpkEF4VF3eUd0edkakBEokakmIPLk2kR6ryNNYo72M+WPB6q+t2JSNkpGIaKFBJTCEMTkNJnLrM9EQ==", + "license": "MIT", + "dependencies": { + "deep-equal": "^2.2.3", + "observ-debounce": "^1.1.1", + "pull-cat": "^1.1.11", + "pull-next": "^1.0.1", + "pull-notify": "^0.1.2", + "pull-paramap": "^1.2.2", + "pull-pushable": "^2.2.0", + "pull-stream": "^3.7.0", + "ssb-ref": "^2.16.0" + } + }, + "node_modules/ssb-replicate/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ssb-replication-scheduler": { + "version": "3.1.1", + "license": "LGPL-3.0", + "dependencies": { + "debug": "^4.3.2", + "pull-cat": "^1.1.11", + "pull-pushable": "^2.2.0", + "pull-stream": "^3.6.0", + "ssb-box2": "^7.1.0", + "ssb-db2": ">=3.0.0 <=6", + "ssb-ebt": "^9.1.2", + "ssb-meta-feeds": "^0.39.0", + "ssb-network-errors": "^1.0.1", + "ssb-subset-ql": "^1.0.1", + "ssb-tribes2": "^1.2.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ssb-replication-scheduler/node_modules/ssb-box2": { + "version": "7.5.0", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.3.2", + "pull-defer": "^0.2.3", + "pull-stream": "^3.6.14", + "ssb-bfe": "^3.7.0", + "ssb-keyring": "^7.0.0", + "ssb-private-group-keys": "^1.1.1", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.1" + } + }, + "node_modules/ssb-replication-scheduler/node_modules/ssb-keyring": { + "version": "7.0.0", + "license": "LGPL-3.0", + "dependencies": { + "charwise": "^3.0.1", + "level": "^6.0.1", + "lodash.find": "^4.6.0", + "mkdirp": "^1.0.4", + "private-group-spec": "^8.0.0", + "pull-level": "^2.0.4", + "pull-stream": "^3.7.0", + "ssb-private-group-keys": "^1.1.2", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.1" + } + }, + "node_modules/ssb-replication-scheduler/node_modules/ssb-keyring/node_modules/private-group-spec": { + "version": "8.1.0", + "license": "LGPL-3.0-only", + "dependencies": { + "is-my-ssb-valid": "^1.2.0" + } + }, + "node_modules/ssb-replication-scheduler/node_modules/ssb-private-group-keys": { + "version": "1.1.2", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.3.2", + "futoin-hkdf": "^1.5.1", + "private-group-spec": "^1.1.3", + "sodium-universal": "^3.1.0", + "ssb-bfe": "^3.5.0" + } + }, + "node_modules/ssb-schema-definitions": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "ssb-ref": "^2.13.6" + } + }, + "node_modules/ssb-search": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "flumeview-search": "^2.0.0", + "pull-cont": "^0.1.1", + "pull-stream": "^3.6.7", + "ssb-msgs": "^5.2.0" + } + }, + "node_modules/ssb-server": { + "resolved": "packages/ssb-server", + "link": true + }, + "node_modules/ssb-social-index": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "bipf": "^1.4.0", + "pull-defer": "^0.2.3", + "pull-stream": "^3.6.9", + "ssb-ref": "^2.7.1" + } + }, + "node_modules/ssb-sort": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "ssb-ref": "^2.3.0" + } + }, + "node_modules/ssb-subset-ql": { + "version": "1.0.1", + "license": "LGPL-3.0", + "dependencies": { + "nano-equal": "^2.0.2", + "ssb-db2": ">=3.0.0", + "ssb-ref": "^2.13.0" + } + }, + "node_modules/ssb-tangle": { + "version": "1.0.1", + "license": "AGPL-3.0", + "dependencies": { + "is-my-json-valid": "^2.20.0", + "pull-stream": "^3.6.11", + "ssb-ref": "^2.13.9", + "ssb-sort": "^1.1.3" + } + }, + "node_modules/ssb-thread-schema": { + "version": "1.1.1", + "license": "AGPL-3.0", + "dependencies": { + "is-my-json-valid": "^2.20.0", + "ssb-msg-content": "^1.0.1", + "ssb-schema-definitions": "^1.1.5" + } + }, + "node_modules/ssb-threads": { + "version": "10.8.0", + "license": "MIT", + "dependencies": { + "bipf": "^1.9.0", + "pull-cat": "^1.1.11", + "pull-level": "^2.0.4", + "pull-stream": "^3.6.2", + "secret-stack-decorators": "1.1.0", + "ssb-db2": ">=3.4.1", + "ssb-ref": "^2.13.0", + "ssb-sort": "1.1.x", + "ssb-typescript": "^2.5.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ssb-tribes2": { + "version": "1.4.0", + "license": "LGPL-3.0-only", + "dependencies": { + "@tangle/overwrite-fields": "^2.0.3", + "@tangle/reduce": "^5.0.5", + "@tangle/strategy": "^4.1.2", + "base64-url": "^2.3.3", + "bipf": "^1.9.0", + "butt64": "^2.0.1", + "clarify-error": "^1.0.0", + "debug": "^4.3.4", + "envelope-js": "^1.3.2", + "envelope-spec": "^1.1.1", + "is-canonical-base64": "^1.1.1", + "jitdb": "^7.0.7", + "lodash.chunk": "^4.2.0", + "lodash.set": "^4.3.2", + "private-group-spec": "^8.0.0", + "pull-abortable": "^4.1.1", + "pull-defer": "^0.2.3", + "pull-flat-merge": "^2.0.3", + "pull-many": "^1.0.9", + "pull-paramap": "^1.2.2", + "pull-stream": "^3.7.0", + "set.prototype.difference": "^1.0.2", + "set.prototype.issubsetof": "^1.0.1", + "ssb-bfe": "^3.7.0", + "ssb-box2": "^7.1.0", + "ssb-db2": "^7.1.1", + "ssb-meta-feeds": "^0.39.0", + "ssb-private-group-keys": "^1.1.2", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ssb-tribes2/node_modules/flumecodec": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "level-codec": "^6.2.0" + } + }, + "node_modules/ssb-tribes2/node_modules/flumecodec/node_modules/level-codec": { + "version": "6.2.0", + "license": "MIT" + }, + "node_modules/ssb-tribes2/node_modules/private-group-spec": { + "version": "8.1.0", + "license": "LGPL-3.0-only", + "dependencies": { + "is-my-ssb-valid": "^1.2.0" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-box2": { + "version": "7.5.0", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.3.2", + "pull-defer": "^0.2.3", + "pull-stream": "^3.6.14", + "ssb-bfe": "^3.7.0", + "ssb-keyring": "^7.0.0", + "ssb-private-group-keys": "^1.1.1", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.1" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-db2": { + "version": "7.2.0", + "license": "LGPL-3.0-only", + "dependencies": { + "async-append-only-log": "^4.3.7", + "atomic-file-rw": "^0.3.0", + "binary-search-bounds": "^2.0.4", + "bipf": "^1.9.0", + "debug": "^4.3.1", + "fastintcompression": "0.0.4", + "flumecodec": "0.0.1", + "flumelog-offset": "3.4.4", + "hoox": "0.0.1", + "jitdb": "^7.0.0", + "level": "^6.0.1", + "level-codec": "^9.0.2", + "lodash.debounce": "^4.0.8", + "mkdirp": "^1.0.4", + "multicb": "1.2.2", + "mutexify": "^1.3.1", + "obz": "^1.1.0", + "p-defer": "^3.0.0", + "pull-cat": "^1.1.11", + "pull-drain-gently": "^1.1.0", + "pull-level": "^2.0.4", + "pull-notify": "^0.1.2", + "pull-paramap": "^1.2.2", + "pull-stream": "^3.6.14", + "push-stream": "^11.0.0", + "rimraf": "^3.0.2", + "ssb-about-self": "^1.0.1", + "ssb-box": "^1.0.0", + "ssb-box2": "^3.0.0", + "ssb-classic": "^1.1.0", + "ssb-keys": "^8.4.0", + "ssb-ref": "^2.14.3", + "ssb-uri2": "^2.2.0", + "too-hot": "^1.0.0", + "typedarray-to-buffer": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-db2/node_modules/private-group-spec": { + "version": "1.2.0", + "license": "LGPL-3.0-only", + "dependencies": { + "is-my-ssb-valid": "^1.2.0" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-db2/node_modules/ssb-box2": { + "version": "3.0.1", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.3.0", + "private-group-spec": "^1.2.0", + "ssb-bfe": "^3.7.0", + "ssb-keyring": "^2.2.0", + "ssb-private-group-keys": "^0.4.1", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.0" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-db2/node_modules/ssb-box2/node_modules/ssb-private-group-keys": { + "version": "0.4.1", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.1.2", + "futoin-hkdf": "^1.3.2", + "private-group-spec": "^1.1.0", + "sodium-universal": "^3.0.4", + "ssb-bfe": "^3.1.1" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-db2/node_modules/ssb-keyring": { + "version": "2.2.0", + "license": "LGPL-3.0", + "dependencies": { + "charwise": "^3.0.1", + "level": "^6.0.1", + "mkdirp": "^1.0.4", + "private-group-spec": "^1.1.3", + "pull-level": "^2.0.4", + "pull-stream": "^3.6.14", + "ssb-private-group-keys": "^1.0.0", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.0" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-keyring": { + "version": "7.0.0", + "license": "LGPL-3.0", + "dependencies": { + "charwise": "^3.0.1", + "level": "^6.0.1", + "lodash.find": "^4.6.0", + "mkdirp": "^1.0.4", + "private-group-spec": "^8.0.0", + "pull-level": "^2.0.4", + "pull-stream": "^3.7.0", + "ssb-private-group-keys": "^1.1.2", + "ssb-ref": "^2.16.0", + "ssb-uri2": "^2.4.1" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-private-group-keys": { + "version": "1.1.2", + "license": "LGPL-3.0-only", + "dependencies": { + "envelope-js": "^1.3.2", + "futoin-hkdf": "^1.5.1", + "private-group-spec": "^1.1.3", + "sodium-universal": "^3.1.0", + "ssb-bfe": "^3.5.0" + } + }, + "node_modules/ssb-tribes2/node_modules/ssb-private-group-keys/node_modules/private-group-spec": { + "version": "1.2.0", + "license": "LGPL-3.0-only", + "dependencies": { + "is-my-ssb-valid": "^1.2.0" + } + }, + "node_modules/ssb-typescript": { + "version": "2.8.0", + "license": "MIT" + }, + "node_modules/ssb-unix-socket": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "multiserver": "^3.3.3" + } + }, + "node_modules/ssb-uri2": { + "version": "2.4.1", + "license": "LGPL-3.0", + "dependencies": { + "ssb-typescript": "^2.5.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ssb-validate": { + "version": "4.1.4", + "license": "MIT", + "dependencies": { + "is-canonical-base64": "^1.1.1", + "monotonic-timestamp": "0.0.9", + "ssb-keys": "^8.1.0", + "ssb-ref": "^2.6.2" + } + }, + "node_modules/ssb-ws": { + "version": "6.2.3", + "license": "MIT", + "dependencies": { + "emoji-server": "^1.0.0", + "multiblob-http": "^1.0.0", + "multiserver": "^3.0.2", + "pull-box-stream": "^1.0.13", + "pull-stream": "^3.6.9", + "ssb-ref": "^2.3.0", + "stack": "^0.1.0" + } + }, + "node_modules/stack": { + "version": "0.1.0", + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", + "license": "MIT", + "dependencies": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/statistics": { + "version": "3.3.0" + }, + "node_modules/statuses": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stdout-update": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/stdout-update/-/stdout-update-4.0.1.tgz", + "integrity": "sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^6.2.0", + "ansi-styles": "^6.2.1", + "string-width": "^7.1.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/stdout-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/stdout-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/stdout-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/stdout-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stdout-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/steno": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", + "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-to-pull-stream": { + "version": "1.7.3", + "license": "MIT", + "dependencies": { + "looper": "^3.0.0", + "pull-stream": "^3.2.3" + } + }, + "node_modules/stream-to-pull-stream/node_modules/looper": { + "version": "3.0.0", + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-1.3.2.tgz", + "integrity": "sha512-nrBAQClJAPN2p+uGCVJRPIPakKeKWZ9GtBCmormE7pWOSlHat7+x5A8gx85M7HM5Dt0BP3pP5RhVW77WdbJJ3A==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", + "integrity": "sha512-RsSNPLpq6YUL7QYy44RnPVTn/lcVZtb48Uof3X5JLbF4zD/Gs7ZFDv2HWol+leoQN2mT86LAzSshGfkTlSOpsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "6.3.0", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tape": { + "version": "4.17.0", + "license": "MIT", + "dependencies": { + "@ljharb/resumer": "~0.0.1", + "@ljharb/through": "~2.3.9", + "call-bind": "~1.0.2", + "deep-equal": "~1.1.1", + "defined": "~1.0.1", + "dotignore": "~0.1.2", + "for-each": "~0.3.3", + "glob": "~7.2.3", + "has": "~1.0.3", + "inherits": "~2.0.4", + "is-regex": "~1.1.4", + "minimist": "~1.2.8", + "mock-property": "~1.0.0", + "object-inspect": "~1.12.3", + "resolve": "~1.22.6", + "string.prototype.trim": "~1.2.8" + }, + "bin": { + "tape": "bin/tape" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tape/node_modules/deep-equal": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tape/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tape/node_modules/is-regex": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tape/node_modules/object-inspect": { + "version": "1.12.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "license": "MIT" + }, + "node_modules/to-buffer": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-camel-case": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "to-space-case": "^1.0.0" + } + }, + "node_modules/to-no-case": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "license": "MIT", + "dependencies": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range/node_modules/is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-space-case": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "to-no-case": "^1.0.0" + } + }, + "node_modules/to-vfile": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-vfile/-/to-vfile-1.0.0.tgz", + "integrity": "sha512-BHc+hdHwULe8x6xmQhSuTsiiPHyTCCf7dtH7l6WkBoYBR2rDfYtoJufKLDDAYGMfA+1XoRq44HfyjoB9vMBr1w==", + "license": "MIT", + "dependencies": { + "vfile": "^1.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/too-hot": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "cpu-percentage": "~1.0.3" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/traverse": { + "version": "0.6.11", + "license": "MIT", + "dependencies": { + "gopd": "^1.2.0", + "typedarray.prototype.slice": "^1.0.5", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/trim": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.3.tgz", + "integrity": "sha512-h82ywcYhHK7veeelXrCScdH7HkWfbIT1D/CgYO+nmDarz3SGNssVBMws6jU16Ga60AJCRAvPV6w6RLuNerQqjg==", + "deprecated": "Use String.prototype.trim() instead" + }, + "node_modules/trim-lines": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-1.1.3.tgz", + "integrity": "sha512-E0ZosSWYK2mkSu+KEtQ9/KqarVjA9HztOSX+9FDdNacRAq29RRV6ZQNgob3iuW8Htar9vAfEa6yyt5qBAHZDBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trim-newlines": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz", + "integrity": "sha512-MTBWv3jhVjTU7XR3IQHllbiJs8sc75a80OEhB6or/q7pLTWgQ0bMGQXXYQSrSuXe6WiKWDZ5txXY5P59a/coVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/trim-trailing-lines": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz", + "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "license": "Unlicense" + }, + "node_modules/tweetnacl-auth": { + "version": "0.3.1", + "license": "Unlicense", + "dependencies": { + "tweetnacl": "0.x.x" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-to-buffer": { + "version": "4.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/typedarray.prototype.slice": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "math-intrinsics": "^1.1.0", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-offset": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedfastbitset": { + "version": "0.2.1", + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.8.3", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typewiselite": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/uint48be": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.2.tgz", + "integrity": "sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "license": "MIT" + }, + "node_modules/unherit": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz", + "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.0", + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unified": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/unified/-/unified-2.1.4.tgz", + "integrity": "sha512-qa4nA26ms49OczPueTt7G46r89TOlwAJ4pEk2U4mwkV1wNhjttItF03SE/YnfkgWg14tzmAHXGhJp2GhDYwn1A==", + "license": "MIT", + "dependencies": { + "attach-ware": "^1.0.0", + "bail": "^1.0.0", + "extend": "^3.0.0", + "unherit": "^1.0.4", + "vfile": "^1.0.0", + "ware": "^1.3.0" + } + }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unique-random": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-random/-/unique-random-1.0.0.tgz", + "integrity": "sha512-K1sUkPf9EXCZFNIlMCoX4icAqcvkR4FMPH4Z61HbyiWhQl1ZGo0zYeV2bJmocK8Cp6tnKYrCnpkeKGebXZoRTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unique-random-array": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unique-random-array/-/unique-random-array-1.0.1.tgz", + "integrity": "sha512-z9J/SV8CUIhIRROcHe9YUoAT6XthUJt0oUyLGgobiXJprDP9O9dsErNevvSaAv5BkhwFEVPn6nIEOKeNE6Ck1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unique-random": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unist-util-is": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-3.0.0.tgz", + "integrity": "sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A==", + "license": "MIT" + }, + "node_modules/unist-util-visit": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-1.4.1.tgz", + "integrity": "sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw==", + "license": "MIT", + "dependencies": { + "unist-util-visit-parents": "^2.0.0" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-2.1.2.tgz", + "integrity": "sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g==", + "license": "MIT", + "dependencies": { + "unist-util-is": "^3.0.0" + } + }, + "node_modules/universal-github-app-jwt": { + "version": "2.2.2", + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", + "license": "MIT", + "dependencies": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==", + "license": "MIT", + "dependencies": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value/node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unset-value/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/untildify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-2.1.0.tgz", + "integrity": "sha512-sJjbDp2GodvkB0FZZcn7k6afVisqX5BZD7Yq3xp4nN2O15BBK0cLm3Vwn2vQaF7UDS0UUsrQMkkplmDI5fskig==", + "license": "MIT", + "dependencies": { + "os-homedir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", + "deprecated": "Please see https://github.com/lydell/urix#deprecated", + "license": "MIT" + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, + "node_modules/use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==", + "license": "MIT", + "dependencies": { + "os-homedir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "license": "(WTFPL OR MIT)" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-1.4.0.tgz", + "integrity": "sha512-7Fz639rwERslMqQCuf1/0H4Tqe2q484Xl6X/jsKqrP7IjFcDODFURhv0GekMnImpbj9pTOojtqL7r39LJJkjGA==", + "license": "MIT" + }, + "node_modules/vfile-find-down": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vfile-find-down/-/vfile-find-down-1.0.0.tgz", + "integrity": "sha512-AOXiJrVKizToYfRosXd1p9Fq8b0u0qchvSwVF1/ue3JE7o7KuQ/UH24bNAPLDUG/RIM1DZ6zWtDsiLShcma4WA==", + "license": "MIT", + "dependencies": { + "to-vfile": "^1.0.0" + } + }, + "node_modules/vfile-find-up": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vfile-find-up/-/vfile-find-up-1.0.0.tgz", + "integrity": "sha512-t97P/jQswvX0n//+RB74Wj43VOg3tel2InzaJYryaBewd4uN4pNXuoH/F00PkI3U1fBp+w/SIyrTjzIzPwDODg==", + "license": "MIT", + "dependencies": { + "to-vfile": "^1.0.0" + } + }, + "node_modules/vfile-reporter": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/vfile-reporter/-/vfile-reporter-1.5.0.tgz", + "integrity": "sha512-VFF1LK0O8/nLmrPcc+5VMEnyP21BTzdVoq1rbxTaVt6cmSVk5MQs1POhkfY/cctndmZheNgirTcAMoiKj3aJYA==", + "license": "MIT", + "dependencies": { + "chalk": "^1.1.0", + "log-symbols": "^1.0.2", + "plur": "^2.0.0", + "repeat-string": "^1.5.0", + "string-width": "^1.0.0", + "text-table": "^0.2.0", + "vfile-sort": "^1.0.0" + } + }, + "node_modules/vfile-reporter/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vfile-reporter/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vfile-reporter/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vfile-reporter/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vfile-reporter/node_modules/log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ==", + "license": "MIT", + "dependencies": { + "chalk": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vfile-reporter/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vfile-reporter/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vfile-sort": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vfile-sort/-/vfile-sort-1.0.0.tgz", + "integrity": "sha512-6qIalNEKUt2YyVFzyJptdEo9sm/pMrSKvOJ35lH4us9YeW08zRs3E9VbdJ0O0n2Thxc1TWINP5QVhucy/YiGPA==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ware": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ware/-/ware-1.3.0.tgz", + "integrity": "sha512-Y2HUDMktriUm+SR2gZWxlrszcgtXExlhQYZ8QJNYbl22jum00KIUcHJ/h/sdAXhWTJcbSkiMYN9Z2tWbWYSrrw==", + "license": "MIT", + "dependencies": { + "wrap-fn": "^0.1.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrap-fn": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/wrap-fn/-/wrap-fn-0.1.5.tgz", + "integrity": "sha512-xDLdGx0M8JQw9QDAC9s5NUxtg9MI09F6Vbxa2LYoSoCvzJnx2n81YMIfykmXEGsUvuLaxnblJTzhSOjUOX37ag==", + "license": "MIT", + "dependencies": { + "co": "3.1.0" + } + }, + "node_modules/wrap-fn/node_modules/co": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/co/-/co-3.1.0.tgz", + "integrity": "sha512-CQsjCRiNObI8AtTsNIBDRMQ4oMR83CzEswHYahClvul7gKk+lDQiOKv+5qh7LQWf5sh6jkZNispz/QlsZxyNgA==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/ws": { + "version": "7.5.10", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/xsalsa20": { + "version": "1.2.0", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ylru": { + "version": "1.4.0", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zerr": { + "version": "1.0.4", + "license": "MIT" + }, + "node_modules/ziii": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zod": { + "version": "3.25.56", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/ssb-server": { + "version": "15.3.0", + "license": "MIT", + "dependencies": { + "bash-color": "~0.0.3", + "broadcast-stream": "^0.2.1", + "cont": "~1.0.3", + "cross-spawn": "^6.0.5", + "debug": "^4.1.1", + "deep-equal": "^1.0.1", + "explain-error": "^1.0.3", + "has-network": "0.0.1", + "ip": "^1.1.5", + "mdmanifest": "^1.0.8", + "minimist": "^1.1.3", + "mkdirp": "~0.5.0", + "multiblob": "^1.13.0", + "multiserver": "^3.3.1", + "multiserver-address": "^1.0.1", + "muxrpc-validation": "^3", + "muxrpcli": "3", + "mv": "^2.1.1", + "osenv": "^0.1.5", + "pull-cat": "~1.1.5", + "pull-file": "^1.0.0", + "pull-many": "~1.0.6", + "pull-pushable": "^2.2.0", + "pull-stream": "^3.6.2", + "rimraf": "^2.4.2", + "secret-stack": "^6.3.1", + "ssb-blobs": "1.2.2", + "ssb-caps": "^1.0.1", + "ssb-client": "^4.7.9", + "ssb-config": "^3.2.5", + "ssb-db": "19.2.0", + "ssb-ebt": "^5.6.1", + "ssb-friends": "^4.1.0", + "ssb-gossip": "^1.1.0", + "ssb-invite": "^2.1.2", + "ssb-keys": "^7.1.1", + "ssb-links": "^3.0.10", + "ssb-local": "^1.0.0", + "ssb-logging": "^1.0.0", + "ssb-master": "^1.0.2", + "ssb-no-auth": "^1.0.0", + "ssb-onion": "^1.0.0", + "ssb-ooo": "^1.3.0", + "ssb-plugins": "1.0.0", + "ssb-query": "^2.4.0", + "ssb-ref": "^2.13.9", + "ssb-replicate": "^1.3.0", + "ssb-unix-socket": "^1.0.0", + "ssb-ws": "^6.2.1", + "stream-to-pull-stream": "^1.6.10", + "zerr": "^1.0.0" + }, + "bin": { + "sbot": "bin.js", + "ssb-server": "bin.js" + }, + "devDependencies": { + "cat-names": "^2.0.0", + "compatibility": "^1.0.0", + "dog-names": "~1.0.2", + "hexpp": "^2.0.0", + "interleavings": "^1.0.0", + "npm-install-package": "^2.1.0", + "nyc": "^13.2.0", + "pull-abortable": "^4.1.1", + "pull-bitflipper": "^0.1.0", + "pull-paramap": "^1.2.2", + "rng": "^0.2.2", + "run-series": "^1.1.8", + "semver": "^5.6.0", + "ssb-feed": "^2.3.0", + "ssb-generate": "^1.0.1", + "ssb-validate": "^4", + "tape": "^4.9.1", + "typewiselite": "^1.0.0" + } + }, + "packages/ssb-server/node_modules/abstract-leveldown": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.0.3.tgz", + "integrity": "sha512-jzewKKpZbaYUa6HTThnrl+GrJhzjEAeuc7hTVpZdzg7kupXZFoqQDFwyOwLNbmJKJlmzw8yiipMPkDiuKkT06Q==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "dependencies": { + "level-concat-iterator": "~2.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "packages/ssb-server/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "packages/ssb-server/node_modules/epidemic-broadcast-trees": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/epidemic-broadcast-trees/-/epidemic-broadcast-trees-7.0.0.tgz", + "integrity": "sha512-nm1o1ncxcJvXtLJVoPl4AJFX7Bh0BhPsTC9XhCRWwdRJXp1Ud1DcGpi/6c3sUA/URmyoIc1WONNdub+/AE5vTQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "push-stream": "^10.0.0" + } + }, + "packages/ssb-server/node_modules/flumecodec": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/flumecodec/-/flumecodec-0.0.1.tgz", + "integrity": "sha512-JT0xivzdV7uTucjsLMw6JhK2e1K5TmU4fGmoQqnrJbydgY/V6+m71QoxX5ZtRR1pKoD48uhPDWWE6G5MlNoGkg==", + "license": "MIT", + "dependencies": { + "level-codec": "^6.2.0" + } + }, + "packages/ssb-server/node_modules/flumedb": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/flumedb/-/flumedb-1.1.0.tgz", + "integrity": "sha512-Bwol+72GU5z2DxZlnaxUA9A8qaRcQcdTprmRcgfqn2ldn147ByVh9Zyp90hVGPlo/oEN/yjOBUXcNkK3SYjbgA==", + "license": "MIT", + "dependencies": { + "cont": "^1.0.3", + "explain-error": "^1.0.3", + "obv": "0.0.1", + "pull-cont": "^0.1.1", + "pull-looper": "^1.0.0", + "pull-stream": "^3.5.0" + } + }, + "packages/ssb-server/node_modules/flumeview-level": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/flumeview-level/-/flumeview-level-3.0.14.tgz", + "integrity": "sha512-Nl0gJOgrDGpJGZDkP6gvo6s1Q9WmRynbHUvI/JY3eQ81YgzUUa2FKLlfu6OHV5ho5NeXP+00F+0K1yBVaEgJOQ==", + "license": "MIT", + "dependencies": { + "charwise": "^3.0.1", + "explain-error": "^1.0.4", + "level": "^5.0.0", + "ltgt": "^2.1.3", + "mkdirp": "^0.5.1", + "obv": "0.0.1", + "pull-level": "^2.0.3", + "pull-paramap": "^1.2.1", + "pull-stream": "^3.5.0", + "pull-write": "^1.1.1" + } + }, + "packages/ssb-server/node_modules/flumeview-level/node_modules/level": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/level/-/level-5.0.1.tgz", + "integrity": "sha512-wcak5OQeA4rURGacqS62R/xNHjCYnJSQDBOlm4KNUGJVE9bWv2B04TclqReYejN+oD65PzD4FsqeWoI5wNC5Lg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "level-js": "^4.0.0", + "level-packager": "^5.0.0", + "leveldown": "^5.0.0", + "opencollective-postinstall": "^2.0.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "packages/ssb-server/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/ssb-server/node_modules/immediate": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.2.3.tgz", + "integrity": "sha512-RrGCXRm/fRVqMIhqXrGEX9rRADavPiDFSoMb/k64i9XMk8uH4r/Omi5Ctierj6XzNecwDbO4WuFbDD1zmpl3Tg==", + "license": "MIT" + }, + "packages/ssb-server/node_modules/ip": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", + "license": "MIT" + }, + "packages/ssb-server/node_modules/level-codec": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-6.2.0.tgz", + "integrity": "sha512-J437PvCMZZKNT88+VRh6dkmh1ndZzwGwDzb5ZZl3QEsl+U9wIlt8hG+Y1gXVOhH75gf8JyceKGiG6WFUBbxyGQ==", + "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", + "license": "MIT" + }, + "packages/ssb-server/node_modules/level-js": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/level-js/-/level-js-4.0.2.tgz", + "integrity": "sha512-PeGjZsyMG4O89KHiez1zoMJxStnkM+oBIqgACjoo5PJqFiSUUm3GNod/KcbqN5ktyZa8jkG7I1T0P2u6HN9lIg==", + "deprecated": "Superseded by browser-level (https://github.com/Level/community#faq)", + "license": "MIT", + "dependencies": { + "abstract-leveldown": "~6.0.1", + "immediate": "~3.2.3", + "inherits": "^2.0.3", + "ltgt": "^2.1.2", + "typedarray-to-buffer": "~3.1.5" + } + }, + "packages/ssb-server/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "packages/ssb-server/node_modules/muxrpc": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/muxrpc/-/muxrpc-6.7.3.tgz", + "integrity": "sha512-yVr66BigJxHIvHOLsdJzuvrHt46Vq9DiDCBTZiS6q+49mXh4mkDjRiXo9FnGXQv1WJOqnRJk5fcfwV6TaSgNvA==", + "license": "MIT", + "dependencies": { + "clarify-error": "^1.0.0", + "debug": "^4.3.3", + "packet-stream": "~2.0.0", + "packet-stream-codec": "^1.2.0", + "pull-goodbye": "0.0.3", + "pull-stream": "^3.6.10" + } + }, + "packages/ssb-server/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "packages/ssb-server/node_modules/push-stream": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/push-stream/-/push-stream-10.1.2.tgz", + "integrity": "sha512-wCDN1KkAWOMXsiV0XxH/CggHqOYeXvyn00t9Zjp5RKsLZ2rzg/lLJIMAVxYLyh79T168W3fBYcG5TRRJAlQr6g==", + "license": "MIT" + }, + "packages/ssb-server/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "packages/ssb-server/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "packages/ssb-server/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "packages/ssb-server/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "packages/ssb-server/node_modules/ssb-blobs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ssb-blobs/-/ssb-blobs-1.2.2.tgz", + "integrity": "sha512-N+X46lE/KaIH9y1w3LQ9pPnt2tQr5VCSj1dAo/pJGYnSwnHz8GhIiboq7UGzrCZwCWgVUs22/2YiytCXSC9ASg==", + "license": "MIT", + "dependencies": { + "cont": "^1.0.3", + "debug": "^4.1.1", + "level": "^5.0.1", + "multiblob": "^1.12.0", + "pull-level": "^2.0.4", + "pull-notify": "^0.1.0", + "pull-stream": "^3.3.0", + "ssb-ref": "^2.3.0" + } + }, + "packages/ssb-server/node_modules/ssb-blobs/node_modules/level": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/level/-/level-5.0.1.tgz", + "integrity": "sha512-wcak5OQeA4rURGacqS62R/xNHjCYnJSQDBOlm4KNUGJVE9bWv2B04TclqReYejN+oD65PzD4FsqeWoI5wNC5Lg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "level-js": "^4.0.0", + "level-packager": "^5.0.0", + "leveldown": "^5.0.0", + "opencollective-postinstall": "^2.0.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "packages/ssb-server/node_modules/ssb-db": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/ssb-db/-/ssb-db-19.2.0.tgz", + "integrity": "sha512-pJAFizB6OcuJLX4RJJuU9HWyPwM2CqLi/vs08lhVIR3TGxacxpavvK5LzbxT+Y3iWkBchOTKS5hHCigA5aaung==", + "license": "MIT", + "dependencies": { + "async-write": "^2.1.0", + "cont": "~1.0.0", + "explain-error": "~1.0.1", + "flumedb": "^1.0.0", + "flumelog-offset": "^3.3.1", + "flumeview-hashtable": "^1.0.3", + "flumeview-level": "^3.0.5", + "flumeview-reduce": "^1.3.9", + "ltgt": "^2.2.0", + "mdmanifest": "^1.0.8", + "mkdirp": "^0.5.1", + "monotonic-timestamp": "~0.0.8", + "muxrpc-validation": "^3.0.0", + "obv": "0.0.1", + "osenv": "^0.1.5", + "pull-cont": "^0.1.1", + "pull-stream": "^3.4.0", + "rimraf": "^2.6.2", + "ssb-keys": "^7.1.3", + "ssb-msgs": "^5.0.0", + "ssb-ref": "^2.12.0", + "ssb-validate": "^4.0.0", + "typewiselite": "^1.0.0", + "zerr": "^1.0.0" + } + }, + "packages/ssb-server/node_modules/ssb-ebt": { + "version": "5.6.7", + "resolved": "https://registry.npmjs.org/ssb-ebt/-/ssb-ebt-5.6.7.tgz", + "integrity": "sha512-ifPgPNmDE8EKuuoqtXibwgYNtDZNry7sJL1epSUb3XgQr62bUV31N9R5LHKDsI2kx96OgWRwWY2PfZ7vf/hU8Q==", + "license": "MIT", + "dependencies": { + "base64-url": "^2.2.0", + "epidemic-broadcast-trees": "7", + "lossy-store": "^1.2.3", + "pull-stream": "^3.5.0", + "push-stream-to-pull-stream": "^1.0.0", + "ssb-ref": "^2.9.1" + } + }, + "packages/ssb-server/node_modules/ssb-friends": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/ssb-friends/-/ssb-friends-4.4.10.tgz", + "integrity": "sha512-APTTt47PRVv5nzo+8VLWymAxSZPiAA3p5wPu9VlCqr4qQVaDkCF7Xn5Lk9j8VkjEnclNt7aeE/YgnXCld/BPNA==", + "license": "MIT", + "dependencies": { + "bipf": "^1.9.0", + "flumecodec": "0.0.1", + "flumeview-reduce": "^1.4.0", + "layered-graph": "^1.2.0", + "pull-flatmap": "0.0.1", + "pull-level": "^2.0.4", + "pull-notify": "^0.1.2", + "pull-stream": "^3.7.0", + "ssb-ref": "^2.16.0" + } + }, + "packages/ssb-server/node_modules/ssb-invite": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/ssb-invite/-/ssb-invite-2.1.8.tgz", + "integrity": "sha512-fuBu0jtZzfsejLWUQXVzNq6s6IekJGZqVpY6EZLx3J2mM0+3rXuwDI/1LtqL2VOfzrPbC4jRJi6I21PosBGveg==", + "license": "MIT", + "dependencies": { + "cont": "^1.0.3", + "explain-error": "^1.0.4", + "ip": "^1.1.8", + "level": "^6.0.1", + "muxrpc-validation": "^3.0.2", + "ssb-client": "^4.9.0", + "ssb-keys": "^7.2.2", + "ssb-ref": "^2.16.0" + } + }, + "packages/ssb-server/node_modules/ssb-keys": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/ssb-keys/-/ssb-keys-7.2.2.tgz", + "integrity": "sha512-FPeyYU/3LpxcagnbmVWE+Q/qzg6keqeOBPbD7sEH9UKixUASeufPKiORDgh8nVX7J9Z+0vUaHt/WG999kGjvVQ==", + "license": "MIT", + "dependencies": { + "chloride": "^2.2.8", + "mkdirp": "~0.5.0", + "private-box": "^0.3.0" + } + }, + "packages/ssb-server/node_modules/ssb-plugins": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ssb-plugins/-/ssb-plugins-1.0.0.tgz", + "integrity": "sha512-eM8vid+K8MhwZwzk/CDUhSNhUoS6wYgq9clJrrKaP0/Otdd3zZzcBQw54Xvm0olMcOgpTSlY3m2rT4iqjZPIBw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.5", + "explain-error": "^1.0.4", + "mdmanifest": "^1.0.8", + "mkdirp": "^0.5.1", + "muxrpc": "^6.4.0", + "muxrpc-validation": "^3.0.0", + "mv": "^2.1.1", + "osenv": "^0.1.5", + "pull-cat": "^1.1.11", + "pull-many": "^1.0.8", + "pull-pushable": "^2.2.0", + "pull-stream": "^3.6.9", + "stream-to-pull-stream": "^1.7.2" + } + }, + "packages/ssb-server/node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "packages/ssb-server/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + } + } +} diff --git a/src/server/package.json b/src/server/package.json new file mode 100644 index 0000000..00bb761 --- /dev/null +++ b/src/server/package.json @@ -0,0 +1,159 @@ +{ + "name": "@krakenslab/oasis", + "version": "0.8.7", + "description": "Oasis - Social Networking Utopia", + "repository": { + "type": "git", + "url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git" + }, + "license": "AGPL-3.0", + "author": "psy ", + "main": "src/index.js", + "bin": { + "oasis": "npm run start" + }, + "scripts": { + "start": "npm run start:ssb && sleep 10 && npm run start:backend", + "start:backend": "node ../backend/backend.js", + "start:ssb": "node SSB_server.js start &", + "dev": "npm run dev:backend", + "dev:backend": "nodemon", + "postinstall": "node ../../scripts/patch-node-modules.js" + }, + "dependencies": { + "@koa/router": "^13.1.0", + "@open-rpc/client-js": "^1.8.1", + "@xenova/transformers": "^2.17.2", + "abstract-level": "^2.0.1", + "archiver": "^7.0.1", + "axios": "^1.10.0", + "cors": "^2.8.5", + "debug": "^4.3.1", + "dompurify": "^3.3.1", + "env-paths": "^2.2.1", + "epidemic-broadcast-trees": "^9.0.4", + "express": "^5.1.0", + "file-type": "^16.5.4", + "highlight.js": "11.0.0", + "hyperaxe": "^2.0.1", + "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz", + "is-svg": "^4.4.0", + "jsdom": "^28.0.0", + "koa": "^2.7.0", + "koa-body": "^6.0.1", + "koa-mount": "^4.0.0", + "koa-static": "^5.0.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "module-alias": "^2.2.3", + "moment": "^2.30.1", + "multiblob": "^1.13.0", + "multiserver": "^3.3.1", + "multiserver-address": "^1.0.1", + "muxrpc": "^8.0.0", + "muxrpc-validation": "^3.0.2", + "muxrpcli": "^3.1.2", + "node-llama-cpp": "^3.10.0", + "non-private-ip": "^2.2.0", + "open": "^8.4.2", + "openpgp": "^6.3.0", + "packet-stream": "^2.0.6", + "packet-stream-codec": "^1.2.0", + "pdfjs-dist": "^5.2.133", + "pretty-ms": "^7.0.1", + "pull-abortable": "^4.1.1", + "pull-cat": "~1.1.5", + "pull-file": "^1.0.0", + "pull-many": "~1.0.6", + "pull-paramap": "^1.2.2", + "pull-pushable": "^2.2.0", + "pull-sort": "^1.0.2", + "pull-stream": "^3.7.0", + "punycode.js": "^2.3.1", + "qrcode": "^1.5.4", + "secret-stack": "^6.3.1", + "ssb-about": "^2.0.1", + "ssb-autofollow": "^1.1.0", + "ssb-backlinks": "^2.1.1", + "ssb-blobs": "^2.0.1", + "ssb-box": "^1.0.1", + "ssb-caps": "^1.0.1", + "ssb-client": "^4.9.0", + "ssb-config": "^3.4.4", + "ssb-conn": "6.0.3", + "ssb-conn-db": "^1.0.5", + "ssb-conn-hub": "^1.2.0", + "ssb-conn-staging": "^1.0.0", + "ssb-db": "^20.4.1", + "ssb-device-address": "^1.1.6", + "ssb-ebt": "^9.1.2", + "ssb-friend-pub": "^1.0.7", + "ssb-friends": "^5.0.0", + "ssb-gossip": "^1.1.1", + "ssb-invite": "^3.0.3", + "ssb-invite-client": "^1.3.3", + "ssb-keys": "^8.0.0", + "ssb-lan": "^1.0.0", + "ssb-legacy-conn": "^1.0.17", + "ssb-links": "^3.0.10", + "ssb-logging": "^1.0.0", + "ssb-markdown": "^3.6.0", + "ssb-master": "^1.0.3", + "ssb-meme": "^1.1.0", + "ssb-mentions": "^0.5.2", + "ssb-msgs": "^5.2.0", + "ssb-no-auth": "^1.0.0", + "ssb-onion": "^1.0.0", + "ssb-partial-replication": "^3.0.1", + "ssb-plugins": "^1.0.2", + "ssb-private": "^1.1.0", + "ssb-query": "^2.4.5", + "ssb-ref": "^2.16.0", + "ssb-replication-scheduler": "^3.0.0", + "ssb-search": "^1.3.0", + "ssb-server": "file:packages/ssb-server", + "ssb-tangle": "^1.0.1", + "ssb-thread-schema": "^1.1.1", + "ssb-threads": "^10.0.4", + "ssb-unix-socket": "^1.0.0", + "ssb-ws": "^6.2.3", + "util": "^0.12.5", + "yargs": "^17.7.2" + }, + "overrides": { + "caller-path": "^4.0.0", + "highlight.js": "11.0.0", + "@babel/traverse": "7.23.2", + "trim": "0.0.3", + "json5": "2.2.2", + "debug": "^4.3.1", + "postcss": "8.4.31", + "punycode": "2.3.1", + "ejs": "3.1.10", + "babel-traverse": "7.0.0-alpha.1", + "ssb-conn": "6.0.3", + "ssb-ref": "^2.16.0", + "secret-stack": "^6.3.1", + "ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz", + "lodash.set": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + }, + "optionalDependencies": { + "fsevents": "^2.3.2", + "sharp": "^0.33.5" + }, + "devDependencies": { + "nodemon": "^3.1.0" + }, + "bugs": { + "url": "https://github.com/epsylon/oasis/issues" + }, + "homepage": "https://github.com/epsylon/oasis", + "directories": { + "doc": "docs" + }, + "keywords": [], + "engines": { + "node": "^10.0.0 || >=12.0.0" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/src/server/ssb_config.js b/src/server/ssb_config.js new file mode 100644 index 0000000..6e9e36b --- /dev/null +++ b/src/server/ssb_config.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +const path = require('path'); +const Config = require('ssb-config/inject'); +const minimist = require('minimist'); + +const configPath = path.resolve(__dirname, '../configs', 'server-config.json'); +const configData = JSON.parse(fs.readFileSync(configPath, 'utf8')); + +const argv = process.argv.slice(2); +const i = argv.indexOf('--'); +const conf = argv.slice(i + 1); +const cliArgs = ~i ? argv.slice(0, i) : argv; + +let config = Config('ssb', minimist(conf)); +config = { ...config, ...configData }; + +const debug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true'; +if (debug) { + config.logging = { ...(config.logging || {}), level: 'debug' }; +} + +const megabyte = Math.pow(2, 20); +config.blobs = config.blobs || {}; +config.blobs.max = 50 * megabyte; + +module.exports = config; diff --git a/src/server/ssb_metadata.js b/src/server/ssb_metadata.js new file mode 100644 index 0000000..37ca6c1 --- /dev/null +++ b/src/server/ssb_metadata.js @@ -0,0 +1,141 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const pkg = require('./package.json'); +const config = require('./ssb_config'); +const updater = require('../backend/updater.js'); + +let printed = false; +let checkedForUpdate = false; +let pendingClearnetModules = null; +let clearnetReady = false; +let pendingWarmup = null; +let warmupReady = false; + +function setClearnetModules(modules) { + pendingClearnetModules = Array.isArray(modules) ? modules : []; + clearnetReady = true; +} + +function setWarmupTime(str) { + pendingWarmup = str; + warmupReady = true; +} + +function waitForWarmup(timeoutMs = 120000) { + return new Promise((resolve) => { + if (warmupReady) return resolve(pendingWarmup); + const started = Date.now(); + const tick = () => { + if (warmupReady) return resolve(pendingWarmup); + if (Date.now() - started >= timeoutMs) return resolve(null); + setTimeout(tick, 250); + }; + tick(); + }); +} + +function getModules() { + const nodeModulesPath = path.resolve(__dirname, 'node_modules'); + try { + return fs.readdirSync(nodeModulesPath) + .filter(m => fs.existsSync(path.join(nodeModulesPath, m, 'package.json'))); + } catch { + return []; + } +} + +const colors = { + blue: '\x1b[38;5;33m', + yellow: '\x1b[38;5;226m', + orange: '\x1b[38;5;214m', + cyan: '\x1b[36m', + reset: '\x1b[0m' +}; + +async function checkForUpdate() { + if (checkedForUpdate) return; + checkedForUpdate = true; + + const updateFlagPath = path.join(__dirname, '../server/.update_required'); + if (fs.existsSync(updateFlagPath)) { + fs.unlinkSync(updateFlagPath); + } + await updater.getRemoteVersion(); +} + +function waitForClearnet(timeoutMs = 25000) { + return new Promise((resolve) => { + if (clearnetReady) return resolve(pendingClearnetModules || []); + const started = Date.now(); + const tick = () => { + if (clearnetReady) return resolve(pendingClearnetModules || []); + if (Date.now() - started >= timeoutMs) return resolve(null); + setTimeout(tick, 250); + }; + tick(); + }); +} + +async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', offline = false, isPublic = false) { + if (printed) return; + printed = true; + + const modules = getModules(); + const version = pkg.version; + const name = pkg.name; + const logLevel = config.logging?.level || 'info'; + const publicKey = config.keys?.public || ''; + const hasHttp = httpPort !== null && httpPort !== false; + const httpUrl = hasHttp ? `http://${httpHost}:${httpPort}` : ''; + const oscLink = hasHttp ? `\x1b]8;;${httpUrl}\x07${httpUrl}\x1b]8;;\x07` : ''; + const ssbPort = config.connections?.incoming?.net?.[0]?.port || config.port || 8008; + const localDiscovery = config.local === true; + const hops = config.conn?.hops ?? config.friends?.hops ?? 2; + + console.log("========================="); + console.log(`Running mode: ${modeColor}${mode}${colors.reset}`); + console.log("========================="); + console.log(`- Package: ${colors.blue}${name} ${colors.yellow}[Version: ${version}]${colors.reset}`); + if (hasHttp) console.log(`- URL: ${colors.cyan}${oscLink}${colors.reset}`); + console.log(`- Oasis ID: [ ${colors.orange}@${publicKey}${colors.reset} ]`); + console.log("- Logging Level:", logLevel); + const ifaces = os.networkInterfaces(); + const isOnline = Object.values(ifaces).some(list => + list && list.some(i => !i.internal && i.family === 'IPv4') + ); + console.log(`- Protocol (port): ${ssbPort}`); + console.log(`- Mode: ${isOnline ? 'online' : 'offline'}`); + console.log(`- Replication (hops): ${hops}`); + console.log(`- LAN Broadcasting (UDP): ${localDiscovery ? 'enabled' : 'disabled'}`); + const clearnetModules = await waitForClearnet(); + const clearnetStatus = (clearnetModules && clearnetModules.length > 0) ? clearnetModules.join(', ') : 'disabled'; + let fediverseConnected = false; + try { + const acc = JSON.parse(fs.readFileSync(path.join(__dirname, '../configs/fediverse-accounts.json'), 'utf8')); + fediverseConnected = !!(acc && acc.mastodon); + } catch (_) {} + console.log(`- Internet Broadcasting:`); + console.log(` - Clearnet: ${clearnetStatus}`); + console.log(` - Fediverse: ${fediverseConnected ? 'enabled' : 'disabled'}`); + console.log(""); + console.log("========================="); + console.log("Modules loaded: [", modules.length, "]"); + console.log("========================="); + + // Check for updates + await checkForUpdate(); + console.log("========================="); + + if (/gui/i.test(mode)) { + const warmup = await waitForWarmup(); + if (warmup) console.log(`- Warmup-time: ${warmup}`); + } +} + +module.exports = { + printMetadata, + setClearnetModules, + setWarmupTime, + colors +}; diff --git a/src/views/AI_view.js b/src/views/AI_view.js new file mode 100644 index 0000000..e7a0d14 --- /dev/null +++ b/src/views/AI_view.js @@ -0,0 +1,147 @@ +const { div, h2, p, section, button, form, textarea, br, span, input, label, select, option } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require('./main_views'); +const { renderUrl } = require('../backend/renderUrl'); + +exports.aiView = (history = [], userPrompt = '') => { + return template( + i18n.aiTitle, + section( + div({ class: "tags-header" }, + h2(i18n.aiTitle), + p(i18n.aiDescription), + userPrompt ? div({ class: 'user-prompt', style: 'margin-bottom: 2em; font-size: 0.95em; color: #888;' }, + `${i18n.aiPromptUsed || 'System Prompt'}: `, + span({ style: 'font-style: italic;' }, `"${userPrompt}"`) + ) : null, + form({ method: 'POST', action: '/ai', style: "margin-bottom: 0;" }, + textarea({ name: 'input', rows: 4, placeholder: i18n.aiInputPlaceholder, required: true }), + br(), + div({ style: "display: flex; gap: 1.5em; justify-content: flex-end; align-items: center; margin-top: 0.7em;" }, + button({ type: 'submit' }, i18n.aiSubmitButton) + ) + ), + div({ style: "display: flex; justify-content: flex-end; margin-bottom: 2em;" }, + form({ method: 'POST', action: '/ai/clear', style: "display: inline;" }, + button({ + type: 'submit', + style: ` + background: #b80c09; + color: #fff; + border: none; + padding: 0.4em 1.2em; + border-radius: 6px; + cursor: pointer; + font-size: 1em; + margin-left: 1em; + ` + }, i18n.aiClearHistory) + ) + ), + br(), + ...history.map(entry => + div({ + class: 'chat-entry', + style: ` + margin-bottom: 2em; + position: relative; + background: #191919; + border-radius: 10px; + box-shadow: 0 0 8px #0004; + padding-top: 1.8em; + ` + }, + entry.timestamp ? span({ + style: ` + position: absolute; + top: 0.5em; + right: 1.3em; + font-size: 0.92em; + color: #888; + ` + }, new Date(entry.timestamp).toLocaleString()) : null, + br(), br(), + div({ class: 'user-question', style: 'margin-bottom: 0.75em;' }, + h2(`${i18n.aiUserQuestion}:`), + p(...renderUrl(entry.question)) + ), + div({ + class: 'ai-response', + style: ` + max-width: 800px; + margin: auto; + background: #111; + padding: 1.25em; + border-radius: 6px; + font-family: sans-serif; + line-height: 1.6; + color: #ffcc00; + ` + }, + h2(`${i18n.aiResponseTitle}:`), + ...String(entry.answer || '') + .split('\n\n') + .flatMap(paragraph => + paragraph + .split('\n') + .map(line => + p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim())) + ) + ) + ), + div({ + class: 'ai-train-bar', + style: ` + display:flex; + align-items:center; + gap:12px; + margin: 12px auto 8px auto; + max-width: 800px; + padding: 8px 0; + border-top: 1px solid #2a2a2a; + flex-wrap: wrap; + ` + }, + Array.isArray(entry.snippets) && entry.snippets.length + ? span({ style: 'color:#9aa; font-size:0.95em;' }, `${i18n.aiSnippetsUsed}: ${entry.snippets.length}`) + : null, + h2(`${i18n.statsAITraining}:`), + entry.trainStatus === 'approved' + ? span({ style: 'color:#5ad25a; font-weight:600;' }, i18n.aiTrainApproved) + : entry.trainStatus === 'rejected' + ? span({ style: 'color:#ff6b6b; font-weight:600;' }, i18n.aiTrainRejected) + : null, + entry.trainStatus === 'approved' || entry.trainStatus === 'rejected' + ? null + : div({ class: 'ai-approve-block' }, + form({ method: 'POST', action: '/ai/approve', class: 'ai-approve-form' }, + input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }), + div({ class: 'ai-approve-meta' }, + label({ class: 'ai-approve-meta-label' }, i18n.aiApproveTagsLabel || 'Tags (comma-separated)'), + input({ type: 'text', name: 'tags', placeholder: i18n.aiApproveTagsPlaceholder || 'e.g. oasis, governance, ecology', maxlength: '160' }), + label({ class: 'ai-approve-meta-label' }, i18n.aiApproveRatingLabel || 'Rating'), + select({ name: 'rating' }, + option({ value: '0' }, '—'), + option({ value: '1' }, '★'), + option({ value: '2' }, '★★'), + option({ value: '3' }, '★★★'), + option({ value: '4' }, '★★★★'), + option({ value: '5' }, '★★★★★') + ) + ), + textarea({ name: 'custom', rows: 3, placeholder: i18n.aiCustomAnswerPlaceholder, class: 'ai-approve-custom' }), + div({ class: 'ai-approve-actions' }, + button({ type: 'submit', class: 'approve-btn' }, i18n.aiApproveTrain) + ) + ), + form({ method: 'POST', action: '/ai/reject', class: 'ai-approve-reject' }, + input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }), + button({ type: 'submit', class: 'reject-btn' }, i18n.aiRejectTrain) + ) + ) + ) + ) + ) + ) + ) + ); +}; diff --git a/src/views/activity_view.js b/src/views/activity_view.js new file mode 100644 index 0000000..6b01f62 --- /dev/null +++ b/src/views/activity_view.js @@ -0,0 +1,1881 @@ +const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th, details, summary } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, userLinkLabel, renderSpreadButton, renderContentActions } = require('./main_views'); +const opinionCategories = require('../backend/opinion_categories'); + +const OPINION_TYPES = new Set(['bookmark','votes','feed','image','audio','video','document','torrent','transfer']); +const OPINION_ROUTES = { + feed: (id) => `/feed/opinions/${encodeURIComponent(id)}`, + bookmark: (id) => `/bookmarks/opinions/${encodeURIComponent(id)}`, + image: (id) => `/images/opinions/${encodeURIComponent(id)}`, + audio: (id) => `/audios/opinions/${encodeURIComponent(id)}`, + torrent: (id) => `/torrents/opinions/${encodeURIComponent(id)}`, + video: (id) => `/videos/opinions/${encodeURIComponent(id)}`, + document: (id) => `/documents/opinions/${encodeURIComponent(id)}`, + votes: (id) => `/votes/opinions/${encodeURIComponent(id)}`, + transfer: (id) => `/transfers/opinions/${encodeURIComponent(id)}` +}; +const moment = require("../server/node_modules/moment"); +const { renderUrl } = require('../backend/renderUrl'); +const { getConfig } = require("../configs/config-manager.js"); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); + +const renderMediaBlob = (value, fallbackSrc = null) => { + if (!value) return fallbackSrc ? img({ src: fallbackSrc, class: 'post-image' }) : null + const s = String(value).trim() + if (!s) return fallbackSrc ? img({ src: fallbackSrc, class: 'post-image' }) : null + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, class: 'post-image' }) + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mVideo) return videoHyperaxe({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }) + const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mAudio) return audioHyperaxe({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: 'post-image' }) + return fallbackSrc ? img({ src: fallbackSrc, class: 'post-image' }) : null +} + +const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1); +const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280); + +function cleanFeedText(t) { + return typeof t === 'string' ? t.trim() : ''; +} + +function isValidFeedText(t) { + const s = cleanFeedText(t); + return s.length >= FEED_TEXT_MIN && s.length <= FEED_TEXT_MAX; +} + +function capitalize(str) { + return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : ''; +} +function sumAmounts(list = []) { + return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0); +} + +function pickActiveParliamentTerm(terms) { + if (!terms.length) return null; + const now = Date.now(); + const getC = t => t.value?.content || t.content || {}; + const isActive = t => { + const c = getC(t); + const s = new Date(c.startAt || 0).getTime(); + const e = new Date(c.endAt || 0).getTime(); + return s && e && s <= now && now < e; + }; + const cmp = (a, b) => { + const ca = getC(a); + const cb = getC(b); + const aAn = String(ca.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0; + const bAn = String(cb.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0; + if (aAn !== bAn) return aAn - bAn; + const aC = new Date(ca.createdAt || ca.startAt || 0).getTime(); + const bC = new Date(cb.createdAt || cb.startAt || 0).getTime(); + if (aC !== bC) return aC - bC; + const aS = new Date(ca.startAt || 0).getTime(); + const bS = new Date(cb.startAt || 0).getTime(); + if (aS !== bS) return aS - bS; + return String(a.id || '').localeCompare(String(b.id || '')); + }; + const active = terms.filter(isActive); + if (active.length) return active.sort(cmp)[0]; + return terms.sort(cmp)[0]; +} + +function safeMsgId(x) { + if (typeof x === 'string') return x; + if (x && typeof x === 'object') return x.key || x.id || x.link || ''; + return ''; +} + +function normalizeSpreadLink(x) { + const s = safeMsgId(x); + return typeof s === 'string' && s.startsWith('thread:') ? s.slice(7) : s; +} + +function stripHtml(s) { + return String(s || '') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function excerptPostText(content, max = 220) { + const raw = stripHtml(content?.text || content?.description || content?.title || ''); + if (!raw) return ''; + return raw.length > max ? raw.slice(0, max - 1) + '…' : raw; +} + +const decodeMaybe = (s) => { + try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); } +}; + +const rewriteHashtagLinks = (html) => { + const s = String(html || ''); + return s.replace(/href=(["'])(?:https?:\/\/[^"']+)?\/hashtag\/([^"'?#]+)([^"']*)\1/g, (m, q, tag, rest) => { + const t = decodeMaybe(tag).replace(/^#/, '').trim().toLowerCase(); + const href = `/search?query=%23${encodeURIComponent(t)}`; + return `href=${q}${href}${q}`; + }); +}; + +function renderUrlPreserveNewlines(text) { + const s = String(text || ''); + const lines = s.split(/\r\n|\r|\n/); + const out = []; + for (let i = 0; i < lines.length; i++) { + if (i) out.push(br()); + out.push(...renderUrl(lines[i])); + } + return out; +} + +function getThreadIdFromPost(action) { + const c = action.value?.content || action.content || {}; + const fork = safeMsgId(c.fork); + const root = safeMsgId(c.root); + return fork || root || safeMsgId(action); +} + +function getReplyToIdFromPost(action, byId) { + const c = action.value?.content || action.content || {}; + const root = safeMsgId(c.root); + const branch = Array.isArray(c.branch) ? c.branch.filter(x => typeof x === 'string') : []; + let best = ''; + let bestTs = -1; + for (const id of branch) { + const a = byId.get(id); + if (a && (a.ts || 0) > bestTs) { best = id; bestTs = a.ts || 0; } + } + return best || root || ''; +} + +function buildActivityItemsWithPostThreads(deduped, allActions) { + const byId = new Map(); + for (const a of allActions) { + const id = safeMsgId(a); + if (id) byId.set(id, a); + } + for (const a of deduped) { + const id = safeMsgId(a); + if (id) byId.set(id, a); + } + + const groups = new Map(); + const out = []; + + for (const a of deduped) { + if (a.type !== 'post') { + out.push(a); + continue; + } + const threadId = getThreadIdFromPost(a); + if (!groups.has(threadId)) groups.set(threadId, []); + groups.get(threadId).push(a); + } + + for (const [threadId, posts] of groups.entries()) { + const sortedDesc = posts.slice().sort((a, b) => (b.ts || 0) - (a.ts || 0)); + const hasReplies = sortedDesc.some(p => getThreadIdFromPost(p) !== safeMsgId(p)) || sortedDesc.length > 1; + + if (!hasReplies || sortedDesc.length === 1) { + out.push(sortedDesc[0]); + continue; + } + + const latest = sortedDesc[0]; + let rootAction = byId.get(threadId); + let rootId = threadId; + + if (!rootAction) { + const asc = posts.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)); + rootAction = asc[0] || null; + rootId = safeMsgId(rootAction) || threadId; + } else { + rootId = safeMsgId(rootAction) || threadId; + } + + const replies = sortedDesc + .filter(p => safeMsgId(p) !== rootId) + .slice() + .sort((a, b) => (a.ts || 0) - (b.ts || 0)); + + out.push({ + id: `thread:${threadId}`, + type: 'postThread', + author: latest.author, + ts: latest.ts, + content: { + threadId, + root: rootAction + ? { + id: safeMsgId(rootAction), + author: rootAction.author, + text: excerptPostText(rootAction.value?.content || rootAction.content || {}, 240) + } + : null, + replies: replies.map(p => ({ + id: safeMsgId(p), + author: p.author, + ts: p.ts, + text: excerptPostText(p.value?.content || p.content || {}, 200) + })) + } + }); + } + + out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); + return out; +} + +exports.renderActionCards = renderActionCards; +function renderActionCards(actions, userId, allActions, spreadMap = new Map()) { + const all = Array.isArray(allActions) ? allActions : actions; + const byIdAll = new Map(); + for (const a0 of all) { + const id0 = safeMsgId(a0); + if (id0) byIdAll.set(id0, a0); + } + const profileById = new Map(); + for (const a0 of all) { + if (!a0 || a0.type !== 'about') continue; + const c0 = a0.value?.content || a0.content || {}; + const id0 = c0.about || a0.author || ''; + if (!id0) continue; + if (a0.author && String(a0.author) !== String(id0)) continue; + const name0 = (typeof c0.name === 'string' && c0.name.trim()) ? c0.name.trim() : id0; + const image0 = (typeof c0.image === 'string' && c0.image.trim()) ? c0.image.trim() : ''; + const ts0 = a0.ts || 0; + const prev = profileById.get(id0); + if (!prev) { + profileById.set(id0, { id: id0, name: name0, image: image0, ts: ts0 }); + continue; + } + const prevHasImg = !!prev.image; + const newHasImg = !!image0; + if (!prevHasImg && newHasImg) profileById.set(id0, { id: id0, name: name0, image: image0, ts: ts0 }); + else if (prevHasImg === newHasImg && ts0 > (prev.ts || 0)) profileById.set(id0, { id: id0, name: name0, image: image0, ts: ts0 }); + } + const getProfile = (id) => { + const p0 = profileById.get(id); + return p0 ? { id: p0.id, name: p0.name, image: p0.image } : { id, name: id, image: '' }; + }; + const validActions = actions + .filter(action => { + const content = action.value?.content || action.content; + if (!content || typeof content !== 'object') return false; + if (content.type === 'tombstone') return false; + if (content.type === 'post' && content.private === true) return false; + if (content.type === 'task' && content.isPublic === "PRIVATE") return false; + if (content.type === 'event' && content.isPublic === "private") return false; + if ((content.type === 'feed' || action.type === 'feed') && !isValidFeedText(content.text)) return false; + if (content.type === 'market') { + if (content.stock === 0 && content.status !== 'SOLD') { + return false; + } + } + return true; + }) + .sort((a, b) => b.ts - a.ts); + + const terms = validActions.filter(a => a.type === 'parliamentTerm'); + let chosenTerm = null; + if (terms.length) chosenTerm = pickActiveParliamentTerm(terms); + const deduped = chosenTerm + ? validActions.filter(a => a.type !== 'parliamentTerm' || a === chosenTerm) + : validActions; + + if (!deduped.length) { + return div({ class: "no-actions" }, p(i18n.noActions)); + } + + const seenDocumentTitles = new Set(); + const items = buildActivityItemsWithPostThreads(deduped, all); + + const spreadOrdinalById = new Map(); + const spreadsByLink = new Map(); + + for (const a of all) { + if (!a || a.type !== 'spread') continue; + const c = a.value?.content || a.content || {}; + const link = normalizeSpreadLink(c.spreadTargetId || c.vote?.link || ''); + const aId = safeMsgId(a); + if (!link || !aId) continue; + if (!spreadsByLink.has(link)) spreadsByLink.set(link, []); + spreadsByLink.get(link).push(a); + } + + for (const list of spreadsByLink.values()) { + list.sort((a, b) => (a.ts || 0) - (b.ts || 0) || String(safeMsgId(a) || '').localeCompare(String(safeMsgId(b) || ''))); + for (let i = 0; i < list.length; i++) { + spreadOrdinalById.set(safeMsgId(list[i]), i + 1); + } + } + + const shopTitleById = new Map(); + for (const a of all) { + if (!a || a.type !== 'shop') continue; + const c = a.value?.content || a.content || {}; + const id = a.id || a.key || ''; + if (id && c.title) { + shopTitleById.set(id, c.title); + if (a.rootId) shopTitleById.set(a.rootId, c.title); + } + } + + const cards = items.map(action => { + const date = action.ts ? new Date(action.ts).toLocaleString() : ""; + const type = action.type || 'unknown'; + let skip = false; + let headerText; + if (type.startsWith('parliament')) { + const sub = type.replace('parliament', ''); + headerText = `[PARLIAMENT · ${sub.toUpperCase()}]`; + } else if (type.startsWith('courts')) { + const rawSub = type.slice('courts'.length); + const pretty = rawSub + .replace(/^[_\s]+/, '') + .replace(/[_\s]+/g, ' · ') + .replace(/([a-z])([A-Z])/g, '$1 · $2'); + const finalSub = pretty || 'EVENT'; + headerText = `[COURTS · ${finalSub.toUpperCase()}]`; + } else if (type === 'taskAssignment') { + headerText = `[${String(i18n.typeTask || 'TASK').toUpperCase()} · ASSIGNMENT]`; + } else if (type === 'shopProduct') { + headerText = `[SHOP · PRODUCT]`; + } else if (type === 'chat') { + headerText = `[CHAT \u00b7 NEW]`; + } else if (type === 'pad') { + headerText = `[PAD · ${String(i18n.padNew || 'NEW').toUpperCase()}]`; + } else if (type === 'ubiClaim') { + headerText = `[UBI · CLAIM]`; + } else if (type === 'ubiclaimresult') { + headerText = `[UBI · RESULT]`; + } else { + const typeLabel = i18n[`type${capitalize(type)}`] || type; + headerText = `[${String(typeLabel).toUpperCase()}]`; + } + + const content = action.value?.content || action.content || {}; + const cardBody = []; + + if (type === 'votes') { + const { question, deadline, status, votes, totalVotes } = content; + const commentCount = + typeof action.commentCount === 'number' + ? action.commentCount + : (typeof content.commentCount === 'number' ? content.commentCount : 0); + + const votesList = votes && typeof votes === 'object' + ? Object.entries(votes).map(([option, count]) => ({ option, count })) + : []; + + const voteOutcome = (() => { + const totalNum = Number(totalVotes || 0); + const noResult = { text: i18n.voteNoQuorum || 'NO QUORUM', color: '#ffcc00' }; + if (totalNum < 2) return noResult; + const entries = Object.entries(votes || {}).filter(([o]) => o !== 'FOLLOW_MAJORITY'); + const maxCount = entries.reduce((m, [, c]) => Math.max(m, Number(c) || 0), 0); + const top = entries.filter(([, c]) => (Number(c) || 0) === maxCount); + if (maxCount === 0 || top.length > 1) return noResult; + const w = top[0][0]; + const label = i18n['vote' + w.split('_').map(x => x.charAt(0) + x.slice(1).toLowerCase()).join('')] || w; + return { text: label, color: w === 'YES' ? '#4caf50' : w === 'NO' ? '#e53935' : '#ffcc00' }; + })(); + + cardBody.push( + div({ class: 'card-section votes' }, + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.question + ':'), + span({ class: 'card-value' }, question) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.deadline + ':'), + span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '') + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), + span({ class: 'card-value' }, totalVotes) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, (i18n.voteResults || 'Results') + ':'), + span({ class: 'card-value', style: `color:${voteOutcome.color};font-weight:bold;` }, voteOutcome.text) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteCommentsLabel + ':'), + span({ class: 'card-value' }, String(commentCount)) + ), + table( + tr(...votesList.map(({ option }) => th(i18n[option] || option))), + tr(...votesList.map(({ count }) => td(count))) + ) + ) + ); + } + + if (type === 'transfer') { + const { from, to, concept, amount, deadline, status, confirmedBy } = content; + cardBody.push( + div({ class: 'card-section transfer' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) + ) + ); + } + + if (type === 'bankWallet') { + const { address } = content; + cardBody.push( + div({ class: 'card-section banking-wallet' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankWalletConnected + ':' ), + span({ class: 'card-value' }, address) + ) + ) + ); + } + + if (type === 'bankClaim') { + const { amount, epochId, allocationId, txid } = content; + const amt = Number(amount || 0); + cardBody.push( + div({ class: 'card-section banking-claim' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiReceived + ':' ), + span({ class: 'card-value' }, `${amt.toFixed(6)} ECO`) + ), + epochId ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankEpochShort + ':' ), + span({ class: 'card-value' }, epochId) + ) : "", + allocationId ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankAllocId + ':' ), + span({ class: 'card-value' }, allocationId) + ) : "", + txid ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankTx + ':' ), + a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${txid}`, target: '_blank' }, txid) + ) : "" + ) + ); + } + + if (type === 'ubiClaim') { + const { pubId, amount, epochId, claimedAt } = content; + const amt = Number(amount || 0); + const inhabitantId = action.author || ''; + cardBody.push( + div({ class: 'card-section banking-ubi' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiInhabitant + ':'), + span({ class: 'card-value' }, userLink(inhabitantId)) + ), + pubId ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiPub + ':'), + span({ class: 'card-value' }, userLink(pubId)) + ) : "", + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiClaimedAmount + ':'), + span({ class: 'card-value' }, `${amt.toFixed(6)} ECO`) + ), + epochId ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankEpochShort + ':'), + span({ class: 'card-value' }, epochId) + ) : "", + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.status + ':'), + span({ class: 'card-value' }, 'UNCONFIRMED') + ), + claimedAt ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.date + ':'), + span({ class: 'card-value' }, moment(claimedAt).format('YYYY-MM-DD HH:mm:ss')) + ) : "" + ) + ); + } + + if (type === 'ubiclaimresult') { + const { txid, userId: inhabitantId, amount, epochId } = content; + const pubAuthor = action.author || action.value?.author || ''; + const amt = Number(amount || 0); + cardBody.push( + div({ class: 'card-section banking-ubi' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiPub + ':'), + span({ class: 'card-value' }, pubAuthor) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiInhabitant + ':'), + span({ class: 'card-value' }, inhabitantId || '') + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiClaimedAmount + ':'), + span({ class: 'card-value' }, `${amt.toFixed(6)} ECO`) + ), + txid ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankTx + ':'), + a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${txid}`, target: '_blank' }, txid) + ) : "" + ) + ); + } + + if (type === 'pixelia') { + const { author } = content; + cardBody.push( + div({ class: 'card-section pixelia' }, + div({ class: 'card-field' }, + a({ href: `/author/${encodeURIComponent(author)}`, class: 'activityVotePost' }, author) + ) + ) + ); + } + + if (type === 'larpHousePost') { + const { text, house } = content; + cardBody.push( + div({ class: 'card-section post' }, + house ? a({ href: `/larp/${encodeURIComponent(house)}`, class: 'larp-house-link' }, + img({ src: `/assets/larp/images/${encodeURIComponent(String(house).toLowerCase())}.jpg`, alt: house, class: 'larp-house-mini' }), + span(`L.A.R.P · ${String(house).toUpperCase()}`) + ) : null, + text ? p({ class: 'post-text post-text-pre' }, ...renderUrlPreserveNewlines(String(text))) : null + ) + ); + } + + if (type === 'tribe') { + const { title, image, description, location, tags, inviteMode, isAnonymous } = content; + if (isAnonymous === true) { skip = true; } + const validTags = Array.isArray(tags) ? tags : []; + cardBody.push( + div({ class: 'card-section tribe' }, + h2({ class: 'tribe-title' }, + a({ href: `/tribe/${encodeURIComponent(action.id)}`, class: "user-link" }, title) + ), + div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' }, + location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeLocationLabel.toUpperCase()) + ':'), span({ class: 'card-value' }, ...renderUrl(location))) : "", + typeof isAnonymous === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel+ ':'), span({ class: 'card-value' }, isAnonymous ? i18n.tribePrivate : i18n.tribePublic)) : "", + inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeModeLabel) + ':'), span({ class: 'card-value' }, inviteMode.toUpperCase())) : "" + ), + image + ? (/^(\/|https?:)/.test(String(image)) + ? img({ src: image, class: 'feed-image tribe-image' }) + : renderMediaBlob(image, null)) + : null, + p({ class: 'tribe-description' }, ...renderUrl(description || '')), + validTags.length + ? div({ class: 'card-tags' }, validTags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) + : "" + ) + ); + } + if (type === 'curriculum') { + const { author, name, description, photo, personalSkills, oasisSkills, educationalSkills, languages, professionalSkills, status, preferences, createdAt, updatedAt} = content; + cardBody.push( + div({ class: 'card-section curriculum' }, + h2(userLink(author, name)), + div( + { class: 'card-fields-container' }, + createdAt ? + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.cvCreatedAt + ':'), + span({ class: 'card-value' }, moment(createdAt).format('YYYY-MM-DD HH:mm:ss')) + ) + : "", + updatedAt ? + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.cvUpdatedAt + ':'), + span({ class: 'card-value' }, moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')) + ) + : "" + ), + status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvStatusLabel + ':'), span({ class: 'card-value' }, status)) : "", + preferences ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.cvPreferencesLabel || 'Preferences') + ':'), span({ class: 'card-value' }, preferences)) : "", + languages ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.cvLanguagesLabel || 'Languages') + ':'), span({ class: 'card-value' }, languages.toUpperCase())) : "", + photo ? + [ + br(), + img({ class: "cv-photo", src: `/blob/${encodeURIComponent(photo)}` }), + br() + ] + : "", + p(...renderUrl(description || "")), + personalSkills && personalSkills.length + ? div({ class: 'card-tags' }, personalSkills.map(skill => + a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`) + )) : "", + oasisSkills && oasisSkills.length + ? div({ class: 'card-tags' }, oasisSkills.map(skill => + a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`) + )) : "", + educationalSkills && educationalSkills.length + ? div({ class: 'card-tags' }, educationalSkills.map(skill => + a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`) + )) : "", + professionalSkills && professionalSkills.length + ? div({ class: 'card-tags' }, professionalSkills.map(skill => + a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`) + )) : "" + ) + ); + } + + if (type === 'image') { + const { url } = content; + cardBody.push( + div({ class: 'card-section image' }, + img({ src: `/blob/${encodeURIComponent(url)}`, class: 'post-image' }) + ) + ); + } + + if (type === 'map') { + const { lat, lng, mapType, title } = content; + const mapKey = action.id || action.key || ''; + cardBody.push( + div({ class: 'card-section map' }, + title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.mapTitle || 'Map') + ':'), span({ class: 'card-value' }, mapKey ? a({ href: `/maps/${encodeURIComponent(mapKey)}`, class: 'user-link' }, title) : title)) : "", + div({ class: 'map-card-info' }, + span({ class: 'map-type-badge' }, mapType || 'SINGLE'), + span({ class: 'map-coords' }, `${(parseFloat(lat) || 0).toFixed(4)}, ${(parseFloat(lng) || 0).toFixed(4)}`) + ), + content.description ? p({ class: 'map-description' }, content.description) : "" + ) + ); + } + + if (type === 'mapMarker') { + const { lat, lng, label, mapId } = content; + cardBody.push( + div({ class: 'card-section map' }, + span({ class: 'map-marker-dot' }, "●"), + span(label || i18n.mapMarkerDefault || 'Marker'), + span({ class: 'map-marker-coords' }, ` (${(parseFloat(lat) || 0).toFixed(2)}, ${(parseFloat(lng) || 0).toFixed(2)})`) + ) + ); + } + + if (type === 'audio') { + const { url, mimeType, title } = content; + cardBody.push( + div({ class: 'card-section audio' }, + title?.trim() ? h2({ class: 'audio-title' }, title) : "", + url + ? div({ class: "audio-container" }, + audioHyperaxe({ + controls: true, + src: `/blob/${encodeURIComponent(url)}`, + type: mimeType + }) + ) + : p(i18n.audioNoFile) + ) + ); + } + + if (type === 'video') { + const { url, mimeType, title } = content; + cardBody.push( + div({ class: 'card-section video' }, + title?.trim() ? h2({ class: 'video-title' }, title) : "", + url + ? div({ class: "video-container" }, + videoHyperaxe({ + controls: true, + src: `/blob/${encodeURIComponent(url)}`, + type: mimeType, + preload: 'metadata', + width: '640', + height: '360' + }) + ) + : p(i18n.videoNoFile) + ) + ); + } + + if (type === 'torrent') { + const { title } = content; + cardBody.push( + div({ class: 'card-section' }, + title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), + span({ class: 'card-value' }, title) + ) : null + ) + ); + } + + if (type === 'document') { + const { url, title, key } = content; + const docKey = title ? `${action.author || ''}::${title.trim()}` : null; + if (docKey && seenDocumentTitles.has(docKey)) { + return null; + } + if (docKey) seenDocumentTitles.add(docKey); + cardBody.push( + div({ class: 'card-section document' }, + title?.trim() ? h2({ class: 'document-title' }, title) : "", + div({ + id: `pdf-container-${key || url}`, + class: 'pdf-viewer-container', + 'data-pdf-url': `/blob/${encodeURIComponent(url)}` + }) + ) + ); + } + + if (type === 'bookmark') { + const { url } = content; + cardBody.push( + div({ class: 'card-section bookmark' }, + h2(url ? p(a({ href: url, target: '_blank', class: "bookmark-url" }, url)) : "") + ) + ); + } + + if (type === 'event') { + const { title, description, date, location, price, attendees, organizer, isPublic } = content; + cardBody.push( + div({ class: 'card-section event' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)), + date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.date + ':'), span({ class: 'card-value' }, new Date(date).toLocaleString())) : "", + location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.location || 'Location') + ':'), span({ class: 'card-value' }, location)) : "", + typeof isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.isPublic || 'Public') + ':'), span({ class: 'card-value' }, isPublic ? 'Yes' : 'No')) : "", + price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.price || 'Price') + ':'), span({ class: 'card-value' }, price + " ECO")) : "", + br(), + organizer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.organizer || 'Organizer') + ': '), userLink(organizer)) : "", + Array.isArray(attendees) ? h2({ class: 'card-label' }, (i18n.attendees || 'Attendees') + ': ' + attendees.length) : "" + ) + ); + } + + if (type === 'task') { + const { title, startTime, endTime, priority, status, author } = content; + cardBody.push( + div({ class: 'card-section task' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)), + priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.priority || 'Priority') + ':'), span({ class: 'card-value' }, priority)) : "", + startTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.taskStartTimeLabel || 'Start') + ':'), span({ class: 'card-value' }, new Date(startTime).toLocaleString())) : "", + endTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.taskEndTimeLabel || 'End') + ':'), span({ class: 'card-value' }, new Date(endTime).toLocaleString())) : "", + status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : "" + ) + ); + } + + if (type === 'taskAssignment') { + const { title, added, removed } = content || {}; + const addList = Array.isArray(added) ? added : []; + const remList = Array.isArray(removed) ? removed : []; + const renderUserList = (ids) => + ids.map((id, i) => [i > 0 ? ', ' : '', userLink(id)]).flat(); + cardBody.push( + div({ class: 'card-section task' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.title + ':'), + span({ class: 'card-value' }, title || '') + ), + addList.length + ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.taskAssignedTo || 'Assigned to') + ':'), + span({ class: 'card-value' }, ...renderUserList(addList)) + ) + : '', + remList.length + ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.taskUnassignedFrom || 'Unassigned from') + ':'), + span({ class: 'card-value' }, ...renderUserList(remList)) + ) + : '' + ) + ); + } + + if (type === 'feed') { + const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); + const { text, refeeds } = content; + if (!isValidFeedText(text)) return null; + const safeText = cleanFeedText(text); + const htmlText = safeText ? rewriteHashtagLinks(renderTextWithStyles(safeText)) : ''; + const refeedsNum = Number(refeeds || 0) || 0; + cardBody.push( + div({ class: 'card-section feed' }, + div({ class: 'feed-text', innerHTML: sanitizeHtml(htmlText) }), + refeedsNum > 0 + ? h2({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), + span({ class: 'card-label' }, String(refeedsNum)) + ) + : null + ) + ); + } + + if (type === 'post') { + const { contentWarning, text } = content || {}; + const rawText = text || ''; + const POST_TRUNCATE_LEN = 300; + const isTruncated = rawText.length > POST_TRUNCATE_LEN; + const displayText = isTruncated ? rawText.slice(0, POST_TRUNCATE_LEN) + '…' : rawText; + const isHtml = typeof displayText === 'string' && /<\/?[a-z][\s\S]*>/i.test(displayText); + let bodyNode; + if (isHtml) { + const hasAnchor = /]*>/i.test(displayText); + const linkified = hasAnchor + ? displayText + : displayText.replace( + /(https?:\/\/[^\s<]+)/g, + (url) => + `${url}` + ); + bodyNode = div({ class: 'post-text', style: 'max-height:180px;overflow:hidden;', innerHTML: sanitizeHtml(linkified) }); + } else { + bodyNode = p({ class: 'post-text post-text-pre', style: 'max-height:180px;overflow:hidden;' }, ...renderUrlPreserveNewlines(displayText)); + } + const threadId = getThreadIdFromPost(action); + const replyToId = getReplyToIdFromPost(action, byIdAll); + const isReply = !!(threadId && threadId !== action.id); + const ctxHref = isReply ? `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(replyToId || threadId)}` : ''; + const parent = isReply ? (byIdAll.get(replyToId) || byIdAll.get(threadId)) : null; + const parentContent = parent ? (parent.value?.content || parent.content || {}) : {}; + const parentAuthor = parent?.author || ''; + const parentName = parent?.authorName || ''; + const parentText = parent ? excerptPostText(parentContent, 220) : ''; + cardBody.push( + div({ class: 'card-section post' }, + isReply + ? div( + { class: 'reply-context', style: 'border-left:3px solid #666;padding-left:10px;margin-bottom:8px;opacity:0.85;' }, + span({ style: 'font-size:0.85em;' }, + a({ href: ctxHref, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'), + parentAuthor ? span(' ', userLink(parentAuthor, parentName)) : '' + ), + parentText ? p({ class: 'post-text reply-context-text post-text-pre', style: 'font-size:0.85em;max-height:80px;overflow:hidden;margin-top:4px;' }, ...renderUrlPreserveNewlines(parentText)) : '' + ) + : '', + contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '', + bodyNode, + isTruncated && threadId + ? div({ style: 'margin-top:6px;' }, + a({ href: `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(action.id || threadId)}`, class: 'filter-btn' }, i18n.keepReading || 'Keep reading...') + ) + : '' + ) + ); + } + + if (type === 'postThread') { + const c = action.content || {}; + const threadId = c.threadId; + const href = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(threadId)}`; + const root = c.root; + const replies = Array.isArray(c.replies) ? c.replies : []; + const repliesAsc = replies.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)); + const limit = 5; + const overflow = repliesAsc.length > limit; + const show = repliesAsc.slice(Math.max(0, repliesAsc.length - limit)); + const lastId = repliesAsc.length ? repliesAsc[repliesAsc.length - 1].id : threadId; + const viewMoreHref = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(lastId)}`; + return div({ class: 'trending-card post-thread' + (String(action.author) === String(userId) ? ' own-content' : '') }, + div({ class: 'card-header activity-card-header' }, + div({ class: 'card-chips-row' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, `${String(i18n.typePost || 'POST').toUpperCase()} · THREAD`) + ) + ), + renderContentActions(threadId, href) + ), + div({ class: 'card-body' }, + root && root.text + ? div({ class: 'card-section' }, + p({ class: 'post-text', style: 'white-space:pre-wrap;' }, ...renderUrlPreserveNewlines(root.text)) + ) + : '', + div({ class: 'card-section' }, + show.map(r => { + const rDate = r.ts ? new Date(r.ts).toLocaleString() : ''; + return div({ class: 'thread-reply-item' }, + div({ class: 'thread-reply' }, + r.text ? p({ class: 'post-text', style: 'white-space:pre-wrap;' }, ...renderUrlPreserveNewlines(r.text)) : '' + ), + div({ class: 'card-footer thread-reply-footer' }, + span({ class: 'date-link' }, rDate), + userLink(r.author, action.authorNames && action.authorNames[r.author]) + ) + ); + }), + ) + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `), + userLink(action.author, action.authorNames && action.authorNames[action.author]) + ) + ); + } + + if (type === 'forum') { + const { root, category, title, text, key, rootTitle, rootKey } = content; + if (!root) { + const linkKey = key || action.id; + const linkText = (title && String(title).trim()) ? title : ''; + cardBody.push( + div({ class: 'card-section forum' }, + div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" }, + span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '), + a({ href: `/forum/${encodeURIComponent(linkKey)}`, style: "font-weight:800;color:#4fc3f7;" }, linkText) + ) + ) + ); + } else { + const rootId = typeof root === 'string' ? root : (root?.key || root?.id || ''); + const parentForum = byIdAll.get(rootId) || actions.find(a => a.type === 'forum' && !a.content?.root && (a.id === rootId || a.content?.key === rootId)); + const parentContent = parentForum ? (parentForum.value?.content || parentForum.content || {}) : {}; + const parentTitle = (parentContent?.title && String(parentContent.title).trim()) ? parentContent.title : ((rootTitle && String(rootTitle).trim()) ? rootTitle : ''); + const parentAuthor = parentForum?.author || ''; + const parentName = parentForum?.authorName || ''; + const hrefKey = rootKey || rootId; + cardBody.push( + div({ class: 'card-section forum' }, + div( + { class: 'reply-context', style: 'border-left:3px solid #666;padding-left:10px;margin-bottom:8px;opacity:0.85;' }, + span({ style: 'font-size:0.85em;' }, + a({ href: `/forum/${encodeURIComponent(hrefKey)}`, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'), + parentAuthor ? span(' ', userLink(parentAuthor, parentName)) : '' + ), + parentTitle ? p({ class: 'post-text reply-context-text', style: 'font-size:0.85em;max-height:60px;overflow:hidden;margin-top:4px;font-weight:bold;color:#4fc3f7;' }, parentTitle) : '' + ), + div({ class: 'card-field', style: 'margin-bottom:12px;' }, + p({ style: "margin:0 0 8px 0; word-break:break-all;" }, ...renderUrl(text)) + ) + ) + ); + } + } + + if (type === 'spread') { + const link = normalizeSpreadLink(content?.spreadTargetId || content?.vote?.link || ''); + const target = link ? (byIdAll.get(link) || byIdAll.get(decodeMaybe(link)) || byIdAll.get(encodeURIComponent(link))) : null; + const tContent = target ? (target.value?.content || target.content || {}) : {}; + const spreadTitle = + (typeof content?.spreadTitle === 'string' && content.spreadTitle.trim()) + ? content.spreadTitle.trim() + : (typeof tContent?.title === 'string' && tContent.title.trim()) + ? tContent.title.trim() + : (typeof tContent?.name === 'string' && tContent.name.trim()) + ? tContent.name.trim() + : ''; + const spreadContentWarning = + (typeof content?.spreadContentWarning === 'string' && content.spreadContentWarning.trim()) + ? content.spreadContentWarning.trim() + : (typeof tContent?.contentWarning === 'string' && tContent.contentWarning.trim()) + ? tContent.contentWarning.trim() + : ''; + const spreadText = + (typeof content?.spreadText === 'string' && content.spreadText.trim()) + ? content.spreadText.trim() + : excerptPostText(tContent, 700); + const spreadOriginalAuthor = + (typeof content?.spreadOriginalAuthor === 'string' && content.spreadOriginalAuthor.trim()) + ? content.spreadOriginalAuthor.trim() + : (target?.author || ''); + const ord = spreadOrdinalById.get(safeMsgId(action)) || 0; + const totalChron = link && spreadsByLink.has(link) ? spreadsByLink.get(link).length : 0; + const label = (i18n.spreadChron || 'Spread') + ':'; + const value = ord && totalChron ? `${ord}/${totalChron}` : (ord ? String(ord) : ''); + const spreadExcerpt = spreadText || excerptPostText(content, 300); + cardBody.push( + div({ class: 'card-section vote' }, + spreadTitle ? h2({ class: 'post-title activity-spread-title' }, spreadTitle) : '', + spreadContentWarning ? h2({ class: 'content-warning' }, spreadContentWarning) : '', + spreadExcerpt + ? div({ class: 'post-text activity-spread-text post-text-pre', style: 'max-height:200px;overflow:hidden;' }, ...renderUrlPreserveNewlines(spreadExcerpt)) + : div({ class: 'post-text activity-spread-text', style: 'opacity:0.6;font-style:italic;' }, i18n.spreadContentUnavailable || 'Content not yet available (pending replication)'), + spreadOriginalAuthor + ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.spreadBy || 'By') + ': '), + span({ class: 'card-value' }, userLink(spreadOriginalAuthor)) + ) + : '', + value + ? div({ class: 'card-field' }, + span({ class: 'card-label' }, label), + span({ class: 'card-value' }, value) + ) + : '', + link + ? div({ style: 'margin-top:6px;' }, + a({ href: `/thread/${encodeURIComponent(link)}#${encodeURIComponent(link)}`, class: 'filter-btn' }, i18n.viewDetails || 'View details') + ) + : '' + ) + ); +} + + if (type === 'vote') { + const { vote } = content; + cardBody.push( + div({ class: 'card-section vote' }, + p( + a({ href: `/thread/${encodeURIComponent(vote.link)}#${encodeURIComponent(vote.link)}`, class: 'activityVotePost' }, vote.link) + ) + ) + ); + } + + if (type === 'about') { + const { about, name, image, description } = content; + const imgId = image ? (typeof image === 'string' ? image : (image.link || '')) : ''; + cardBody.push( + div({ class: 'card-section about' }, + imgId + ? img({ src: `/blob/${encodeURIComponent(imgId)}`, alt: name, class: 'activity-avatar' }) + : img({ src: '/assets/images/default-avatar.png', alt: name, class: 'activity-avatar' }), + h2(userLink(about, name)), + description ? p({ class: 'tribe-side-description' }, ...renderUrlPreserveNewlines(String(description))) : null + ) + ); + } + + if (type === 'pub') { + const { address } = content || {}; + const { key } = address || {}; + const pr = getProfile(key || ''); + const src = pr.image ? `/blob/${encodeURIComponent(pr.image)}` : '/assets/images/default-avatar.png'; + cardBody.push( + div({ class: 'card-section pub activity-pub' }, + br(), + userLink(pr.id, pr.name), + br(), + img({ src, alt: pr.name || pr.id, class: 'activity-avatar' }) + ) + ); + } + + if (type === 'market') { + const { item_type, title, price, status, deadline, stock, image, auctions_poll, seller } = content; + const isSeller = seller && userId && seller === userId; + cardBody.push( + div({ class: 'card-section market' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemTitle + ':'), span({ class: 'card-value' }, title)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, item_type.toUpperCase())), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStatus + ": " ), span({ class: 'card-value' }, status.toUpperCase())), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : "")), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)), + br(), + image + ? renderMediaBlob(image, '/assets/images/default-market.png') + : img({ src: '/assets/images/default-market.png', alt: title, class: 'post-image' }), + br(), + div({ class: "market-card price" }, + p(`${i18n.marketItemPrice}: ${price} ECO`) + ), + item_type === 'auction' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller + ? div({ class: "auction-info" }, + auctions_poll && auctions_poll.length > 0 + ? + table({ class: 'auction-bid-table' }, + tr( + th(i18n.marketAuctionBidTime), + th(i18n.marketAuctionUser), + th(i18n.marketAuctionBidAmount) + ), + ...(auctions_poll || []).map(bid => { + const [bidderId, bidAmount, bidTime] = bid.split(':'); + return tr( + td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')), + td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)), + td(`${parseFloat(bidAmount).toFixed(6)} ECO`) + ); + }) + ) + : p(i18n.marketNoBids), + form({ method: "POST", action: `/market/bid/${encodeURIComponent(action.id)}` }, + input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }), + br(), + button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton) + ) + ) : "", + item_type === 'exchange' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller + ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(action.id)}` }, + button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy) + ) : "" + ) + ); + } + + if (type === 'shop') { + const { title, shortDescription, description, visibility, location } = content; + const shopKey = action.id || action.key || ''; + const displayDesc = shortDescription || (description ? (description.length > 140 ? description.slice(0, 140) + "\u2026" : description) : ""); + cardBody.push( + div({ class: 'card-section shop' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopTitle || 'Shop') + ':'), span({ class: 'card-value' }, shopKey ? a({ href: `/shops/${encodeURIComponent(shopKey)}`, class: 'user-link' }, title || shopKey) : (title || ''))), + displayDesc ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.description || 'Description') + ':'), span({ class: 'card-value' }, displayDesc)) : "", + visibility ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopVisibility || 'Visibility') + ':'), span({ class: 'card-value' }, visibility)) : "", + location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopLocation || 'Location') + ':'), span({ class: 'card-value' }, location)) : "" + ) + ); + } + + if (type === 'shopProduct') { + const { title, price, stock, shopId, image: prodImage } = content; + const resolvedShopTitle = shopId ? (shopTitleById.get(shopId) || null) : null; + const shopLabel = resolvedShopTitle || (shopId ? shopId.slice(0, 10) + '...' : ''); + const prodImageNode = renderMediaBlob(prodImage); + cardBody.push( + div({ class: 'card-section shop' }, + shopId ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopTitle || 'Shop') + ':'), span({ class: 'card-value' }, a({ href: `/shops/${encodeURIComponent(shopId)}`, class: 'user-link' }, shopLabel))) : '', + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopProductTitle || 'Product') + ':'), span({ class: 'card-value' }, title || '')), + prodImageNode ? div({ class: 'card-field' }, prodImageNode) : '', + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopProductPrice || 'Price') + ':'), span({ class: 'card-value' }, `${Number(price || 0).toFixed(6)} ECO`)), + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopProductStock || 'Stock') + ':'), span({ class: 'card-value' }, String(stock || 0))) + ) + ); + } + + if (type === 'chat') { + const { title, description, image, category, status } = content; + const chatKey = action.id || action.key || ''; + const displayDesc = description ? (description.length > 140 ? description.slice(0, 140) + "\u2026" : description) : ""; + const chatImageNode = renderMediaBlob(image); + cardBody.push( + div({ class: 'card-section chat' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, 'Chat:'), span({ class: 'card-value' }, chatKey ? a({ href: `/chats/${encodeURIComponent(chatKey)}`, class: 'user-link' }, title || chatKey) : (title || ''))), + displayDesc ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, displayDesc)) : '', + category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, category)) : '', + status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'Status') + ':'), span({ class: 'card-value' }, status)) : '' + ) + ); + } + + if (type === 'pad') { + const padKey = action.id || action.key || ''; + const padTitle = content.title || action.title || ''; + cardBody.push( + div({ class: 'card-section' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, padKey ? a({ href: `/pads/${encodeURIComponent(padKey)}`, class: 'user-link' }, padTitle || padKey) : '')), + content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : '' + ) + ); + } + + if (type === 'calendar') { + const calKey = action.id || action.key || ''; + const calTitle = content.title || action.title || ''; + cardBody.push( + div({ class: 'card-section' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.calendarTitle || 'Calendar') + ':'), span({ class: 'card-value' }, calKey ? a({ href: `/calendars/${encodeURIComponent(calKey)}`, class: 'user-link' }, calTitle || calKey) : '')), + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.calendarStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : '' + ) + ); + } + + if (type === 'report') { + const { title, confirmations, severity, status } = content; + cardBody.push( + div({ class: 'card-section report' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)), + status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : "", + severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.severity || 'Severity') + ':'), span({ class: 'card-value' }, severity.toUpperCase())) : "", + Array.isArray(confirmations) ? h2({ class: 'card-label' }, (i18n.transfersConfirmations) + ': ' + confirmations.length) : "" + ) + ); + } + + if (type === 'project') { + const { + title, status, progress, goal, pledged, + deadline, followers, backers, milestones, + bounty, bountyAmount, bounty_currency, + activity, activityActor + } = content; + + const ratio = goal ? Math.min(100, Math.round((parseFloat(pledged || 0) / parseFloat(goal)) * 100)) : 0; + const displayStatus = String(status || 'ACTIVE').toUpperCase(); + const followersCount = Array.isArray(followers) ? followers.length : 0; + const backersCount = Array.isArray(backers) ? backers.length : 0; + const backersTotal = sumAmounts(backers || []); + const msCount = Array.isArray(milestones) ? milestones.length : 0; + const lastMs = Array.isArray(milestones) && milestones.length ? milestones[milestones.length - 1] : null; + const bountyVal = typeof bountyAmount !== 'undefined' + ? bountyAmount + : (typeof bounty === 'number' ? bounty : null); + + if (activity && activity.kind) { + const tmpl = + activity.kind === 'follow' + ? (i18n.activityProjectFollow || '%OASIS% is now %ACTION% this project: %PROJECT%') + : activity.kind === 'unfollow' + ? (i18n.activityProjectUnfollow || '%OASIS% is now %ACTION% this project: %PROJECT%') + : '%OASIS% performed an unknown action on %PROJECT%'; + + const actionWord = + activity.kind === 'follow' + ? (i18n.following || 'FOLLOWING') + : activity.kind === 'unfollow' + ? (i18n.unfollowing || 'UNFOLLOWING') + : 'ACTION'; + + const actorId = activity.activityActor || ''; + const msgHtml = tmpl + .replace('%OASIS%', `${userLinkLabel(actorId)}`) + .replace('%PROJECT%', `${title || ''}`) + .replace('%ACTION%', `${actionWord}`); + + return div({ class: 'card card-rpg' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${(i18n.typeProject || 'PROJECT').toUpperCase()}]`), + form({ method: "GET", action: `/projects/${encodeURIComponent(action.tipId || action.id)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.viewDetails) + ) + ), + div( + p({ innerHTML: sanitizeHtml(msgHtml) }) + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `), + userLink(action.author, action.authorNames && action.authorNames[action.author]) + ) + ); + } + + cardBody.push( + div({ class: 'card-section project' }, + title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.title + ':'), + span({ class: 'card-value' }, title) + ) : "", + typeof goal !== 'undefined' ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectGoal + ':'), + span({ class: 'card-value' }, `${goal} ECO`) + ) : "", + typeof progress !== 'undefined' ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectProgress + ':'), + span({ class: 'card-value' }, `${progress || 0}%`) + ) : "", + deadline ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectDeadline + ':'), + span({ class: 'card-value' }, moment(deadline).format('YYYY/MM/DD HH:mm')) + ) : "", + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectStatus + ':'), + span({ class: 'card-value' }, i18n['projectStatus' + displayStatus] || displayStatus) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectFunding + ':'), + span({ class: 'card-value' }, `${ratio}%`) + ), + typeof pledged !== 'undefined' ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectPledged + ':'), + span({ class: 'card-value' }, `${pledged || 0} ECO`) + ) : "", + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectFollowers + ':'), + span({ class: 'card-value' }, `${followersCount}`) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.projectBackers + ':'), + span({ class: 'card-value' }, `${backersCount} · ${backersTotal} ECO`) + ), + msCount ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.projectMilestones || 'Milestones') + ':'), + span({ class: 'card-value' }, `${msCount}${lastMs && lastMs.title ? ' · ' + lastMs.title : ''}`) + ) : "", + bountyVal != null ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.projectBounty || 'Bounty') + ':'), + span({ class: 'card-value' }, `${bountyVal} ${(bounty_currency || 'ECO').toUpperCase()}`) + ) : "" + ) + ); + } + + if (type === 'aiExchange') { + const { ctx, lang, tags, rating } = content; + const helpful = Number(action.helpfulVotes || 0); + cardBody.push( + div({ class: 'card-section ai-exchange' }, + Array.isArray(ctx) && ctx.length + ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiSnippetsLearned || 'Snippets learned') + ':'), span({ class: 'card-value' }, String(ctx.length))) + : null, + lang + ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiExchangeLang || 'Language') + ':'), span({ class: 'card-value' }, String(lang).toUpperCase())) + : null, + Array.isArray(tags) && tags.length + ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiExchangeTags || 'Tags') + ':'), + span({ class: 'card-value' }, tags.map(t => span({ class: 'ai-exchange-tag' }, '#' + t)))) + : null, + rating > 0 + ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiExchangeRating || 'Rating') + ':'), span({ class: 'card-value' }, '★'.repeat(rating) + '☆'.repeat(Math.max(0, 5 - rating)))) + : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.aiExchangeHelpful || 'Helpful') + ':'), + span({ class: 'card-value' }, String(helpful)), + form({ method: 'POST', action: '/ai/exchange/vote', class: 'ai-exchange-vote-form' }, + input({ type: 'hidden', name: 'target', value: action.id }), + input({ type: 'hidden', name: 'helpful', value: 'yes' }), + input({ type: 'hidden', name: 'returnTo', value: '/activity' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.aiExchangeMarkHelpful || '+1 helpful') + ) + ) + ) + ); + } + + if (type === 'karmaScore') { + const { karmaScore } = content; + cardBody.push( + div({ class: 'card-section ai-exchange' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankingUserEngagementScore + ':'), + span({ class: 'card-value' }, karmaScore) + ) + ) + ); + } + + if (type === 'job') { + const { title, job_type, tasks, location, vacants, salary, status, subscribers } = content; + cardBody.push( + div({ class: 'card-section report' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.title + ':'), + span({ class: 'card-value' }, title) + ), + salary && div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.jobSalary + ':'), + span({ class: 'card-value' }, salary + ' ECO') + ), + status && div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.jobStatus + ':'), + span({ class: 'card-value' }, status.toUpperCase()) + ), + job_type && div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.jobType + ':'), + span({ class: 'card-value' }, job_type.toUpperCase()) + ), + location && div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.jobLocation + ':'), + span({ class: 'card-value' }, location.toUpperCase()) + ), + vacants && div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.jobVacants + ':'), + span({ class: 'card-value' }, vacants) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.jobSubscribers + ':'), + span({ class: 'card-value' }, + Array.isArray(subscribers) && subscribers.length > 0 + ? `${subscribers.length}` + : i18n.noSubscribers.toUpperCase() + ) + ) + ) + ); + } + + if (type === 'parliamentCandidature') { + const { targetType, targetId, targetTitle, method } = content; + const link = targetType === 'tribe' + ? a({ href: `/tribe/${encodeURIComponent(targetId)}`, class: 'user-link' }, targetTitle || targetId) + : userLink(targetId); + + const methodUpper = String( + i18n['parliamentMethod' + String(method || '').toUpperCase()] || method + ).toUpperCase(); + + cardBody.push( + div({ class: 'card-section parliament' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, String(i18n.parliamentGovMethod || 'METHOD').toUpperCase() + ':'), span({ class: 'card-value' }, methodUpper)), + div({ class: 'card-field' }, span({ class: 'card-value' }, link)) + ) + ); + } + + if (type === 'parliamentTerm') { + const { method, powerType, powerId, powerTitle, winnerVotes, totalVotes, startAt, endAt } = content; + const powerTypeNorm = String(powerType || '').toLowerCase(); + const winnerLink = + powerTypeNorm === 'tribe' + ? a({ href: `/tribe/${encodeURIComponent(powerId)}`, class: 'user-link' }, powerTitle || powerId) + : powerTypeNorm === 'none' || !powerTypeNorm + ? a({ href: `/parliament?filter=government`, class: 'user-link' }, (i18n.parliamentAnarchy || 'ANARCHY')) + : userLink(powerId, powerTitle); + + const methodUpper = String( + i18n['parliamentMethod' + String(method || '').toUpperCase()] || method + ).toUpperCase(); + + cardBody.push( + div({ class: 'card-section parliament' }, + startAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentElectionsStart.toUpperCase() || 'Elections start') + ':'), span({ class: 'card-value' }, new Date(startAt).toLocaleString())) : '', + endAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentElectionsEnd.toUpperCase() || 'Elections end') + ':'), span({ class: 'card-value' }, new Date(endAt).toLocaleString())) : '', + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentCurrentLeader.toUpperCase() || 'Winning candidature') + ':'), span({ class: 'card-value' }, winnerLink)), + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod.toUpperCase() || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)), + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesReceived.toUpperCase() || 'Votes received') + ':'), span({ class: 'card-value' }, `${Number(winnerVotes || 0)} (${Number(totalVotes || 0)})`)) + ) + ); + } + + if (type === 'parliamentProposal') { + const { title, description, method, status, voteId, createdAt } = content; + + const methodUpper = String( + i18n['parliamentMethod' + String(method || '').toUpperCase()] || method + ).toUpperCase(); + + cardBody.push( + div({ class: 'card-section parliament' }, + title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentProposalTitle.toUpperCase() || 'Title') + ':'), span({ class: 'card-value' }, title)) : '', + description ? p({ style: 'margin:.4rem 0' }, description) : '', + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)), + createdAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.createdAt.toUpperCase() || 'Created at') + ':'), span({ class: 'card-value' }, new Date(createdAt).toLocaleString())) : '', + voteId ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentOpenVote.toUpperCase() || 'Open vote') + ':'), a({ href: `/votes/${encodeURIComponent(voteId)}`, class: 'tag-link' }, i18n.viewDetails || 'View details')) : '', + status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentStatus.toUpperCase() || 'Status') + ':'), span({ class: 'card-value' }, status)) : '' + ) + ); + } + + if (type === 'parliamentRevocation') { + const { title, reasons, method, status, voteId, createdAt } = content; + const methodUpper = String( + i18n['parliamentMethod' + String(method || '').toUpperCase()] || method + ).toUpperCase(); + + cardBody.push( + div({ class: 'card-section parliament' }, + title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentProposalTitle.toUpperCase() || 'Title') + ':'), span({ class: 'card-value' }, title)) : '', + reasons ? p({ style: 'margin:.4rem 0' }, reasons) : '', + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)), + createdAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.createdAt.toUpperCase() || 'Created at') + ':'), span({ class: 'card-value' }, new Date(createdAt).toLocaleString())) : '', + voteId ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentOpenVote.toUpperCase() || 'Open vote') + ':'), a({ href: `/votes/${encodeURIComponent(voteId)}`, class: 'tag-link' }, i18n.viewDetails || 'View details')) : '', + status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentStatus.toUpperCase() || 'Status') + ':'), span({ class: 'card-value' }, status)) : '' + ) + ); + } + + if (type === 'parliamentLaw') { + const { question, description, method, proposer, enactedAt, votes } = content; + const yes = Number(votes?.YES || 0); + const total = Number(votes?.total || votes?.TOTAL || 0); + + const methodUpper = String( + i18n['parliamentMethod' + String(method || '').toUpperCase()] || method + ).toUpperCase(); + + cardBody.push( + div({ class: 'card-section parliament' }, + question ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawQuestion || 'Question') + ':'), span({ class: 'card-value' }, question)) : '', + description ? p({ style: 'margin:.4rem 0' }, description) : '', + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawMethod || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)), + proposer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawProposer || 'Proposer') + ':'), span({ class: 'card-value' }, userLink(proposer))) : '', + enactedAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted || 'Enacted at') + ':'), span({ class: 'card-value' }, new Date(enactedAt).toLocaleString())) : '', + (total || yes) ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawVotes || 'Votes') + ':'), span({ class: 'card-value' }, `${yes}/${total}`)) : '' + ) + ); + } + + if (type.startsWith('courts')) { + if (type === 'courtsCase') { + const { title, method, accuser, status, answerBy, evidenceBy, decisionBy, needed, yes, total, voteId } = content; + cardBody.push( + div({ class: 'card-section courts' }, + title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsCaseTitle.toUpperCase() + ':'), span({ class: 'card-value' }, title)) : '', + status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThStatus.toUpperCase() + ':'), span({ class: 'card-value' }, status)) : '', + method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsMethod.toUpperCase() + ':'), span({ class: 'card-value' }, String(i18n['courtsMethod' + String(method).toUpperCase()] || method).toUpperCase())) : '', + answerBy ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThAnswerBy + ':'), span({ class: 'card-value' }, new Date(answerBy).toLocaleString())) : '', + evidenceBy ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThEvidenceBy + ':'), span({ class: 'card-value' }, new Date(evidenceBy).toLocaleString())) : '', + decisionBy ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThDecisionBy + ':'), span({ class: 'card-value' }, new Date(decisionBy).toLocaleString())) : '', + accuser ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsAccuser + ':'), span({ class: 'card-value' }, userLink(accuser))) : '', + typeof needed !== 'undefined' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsVotesNeeded + ':'), span({ class: 'card-value' }, String(needed))) : '', + (typeof yes !== 'undefined' || typeof total !== 'undefined') ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsVotesSlashTotal + ':'), span({ class: 'card-value' }, `${Number(yes || 0)}/${Number(total || 0)}`)) : '', + voteId ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsOpenVote + ':'), a({ href: `/votes/${encodeURIComponent(voteId)}`, class: 'tag-link' }, i18n.viewDetails || 'View details')) : '' + ) + ); + } else if (type === 'courtsNomination') { + const { judgeId } = content; + cardBody.push( + div({ class: 'card-section courts' }, + judgeId ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsJudge + ':'), span({ class: 'card-value' }, userLink(judgeId))) : '' + ) + ); + } else { + skip = true; + } + } + + const viewHref = getViewDetailsAction(type, action); + const isParliamentTarget = + viewHref === '/parliament?filter=candidatures' || + viewHref === '/parliament?filter=government' || + viewHref === '/parliament?filter=proposals' || + viewHref === '/parliament?filter=revocations' || + viewHref === '/parliament?filter=laws'; + + const isCourtsTarget = + viewHref === '/courts?filter=cases' || + viewHref === '/courts?filter=mycases' || + viewHref === '/courts?filter=actions' || + viewHref === '/courts?filter=judges' || + viewHref === '/courts?filter=history' || + viewHref === '/courts?filter=rules' || + viewHref === '/courts?filter=open'; + + const parliamentFilter = isParliamentTarget ? (viewHref.split('filter=')[1] || '') : ''; + const courtsFilter = isCourtsTarget ? (viewHref.split('filter=')[1] || '') : ''; + + if (skip) { + return null; + } + + const detailHref = (type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet') + ? (isParliamentTarget + ? `/parliament?filter=${encodeURIComponent(parliamentFilter)}` + : isCourtsTarget + ? `/courts?filter=${encodeURIComponent(courtsFilter)}` + : viewHref) + : null; + const msgId = safeMsgId(action.tipId || action.id || action.key); + const isOwn = action.author && String(action.author) === String(userId); + return div({ class: 'trending-card' + (isOwn ? ' own-content' : '') }, + div({ class: 'card-header activity-card-header' }, + div({ class: 'card-chips-row' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, String(type || '').toUpperCase()) + ) + ), + renderContentActions(msgId, detailHref) + ), + ...cardBody, + (() => { + if (!OPINION_TYPES.has(type)) return null; + const routeFn = OPINION_ROUTES[type]; + if (!routeFn) return null; + const ops = (action.value?.content?.opinions) || (action.content?.opinions) || {}; + const opsTotal = Object.values(ops).reduce((s, n) => s + (Number(n) || 0), 0); + return details({ class: 'opinions-voting-collapse' }, + summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${opsTotal})`), + div({ class: 'voting-buttons' }, + opinionCategories.map(cat => + form({ method: 'POST', action: `${routeFn(action.id)}/${cat}` }, + button({ class: 'vote-btn' }, `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${ops[cat] || 0}]`) + ) + ) + ) + ); + })(), + (() => { + const SPREADABLE = new Set([ + 'post','audio','video','image','document','torrent','bookmark', + 'event','calendar','task','votes','vote','market','shop','shopProduct', + 'project','transfer','job','report', + 'chat','chatMessage','pad','padEntry','forum','map' + ]); + if (!SPREADABLE.has(type)) return null; + const btn = renderSpreadButton(action.id, spreadMap.get(action.id)); + return btn ? div({ class: 'card-spread-left' }, btn) : null; + })(), + (() => { + const footerAuthorId = action.author || (content && content.proposer) || ''; + return p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${date} ${i18n.performed} `), + userLink(footerAuthorId, (action.authorNames && action.authorNames[footerAuthorId]) || getProfile(footerAuthorId).name) + ); + })() + ); + }); + + const filteredCards = cards.filter(Boolean); + if (!filteredCards.length) { + return div({ class: "no-actions" }, p(i18n.noActions)); + } + return filteredCards; +} + +function getViewDetailsAction(type, action) { + const id = encodeURIComponent(safeMsgId(action.tipId || action.id || action.key || action)); + switch (type) { + case 'parliamentCandidature': return `/parliament?filter=candidatures`; + case 'parliamentTerm': return `/parliament?filter=government`; + case 'parliamentProposal': return `/parliament?filter=proposals`; + case 'parliamentRevocation': return `/parliament?filter=revocations`; + case 'parliamentLaw': return `/parliament?filter=laws`; + case 'courtsCase': return `/courts/cases/${encodeURIComponent(action.id)}`; + case 'courtsEvidence': return `/courts?filter=actions`; + case 'courtsAnswer': return `/courts?filter=actions`; + case 'courtsVerdict': return `/courts?filter=actions`; + case 'courtsSettlement': return `/courts?filter=actions`; + case 'courtsSettlementProposal':return `/courts?filter=actions`; + case 'courtsSettlementAccepted':return `/courts?filter=actions`; + case 'courtsNomination': return `/courts?filter=judges`; + case 'courtsNominationVote': return `/courts?filter=judges`; + case 'spread': { + const link = normalizeSpreadLink(action.content?.spreadTargetId || action.content?.vote?.link || ''); + return link ? `/thread/${encodeURIComponent(link)}#${encodeURIComponent(link)}` : `/activity`; + } + case 'post': return `/thread/${id}#${id}`; + case 'vote': return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`; + case 'votes': return `/votes/${id}`; + case 'transfer': return `/transfers/${id}`; + case 'pixelia': return `/pixelia`; + case 'larpHousePost': return action.content?.house ? `/larp/${encodeURIComponent(action.content.house)}` : `/activity`; + case 'tribe': return `/tribe/${id}`; + case 'curriculum': return `/inhabitant/${encodeURIComponent(action.author)}`; + case 'karmaScore': return `/author/${encodeURIComponent(action.author)}`; + case 'map': return `/maps/${id}`; + case 'mapMarker': return `/maps/${encodeURIComponent(action.content?.mapId || id)}`; + case 'image': return `/images/${id}`; + case 'audio': return `/audios/${id}`; + case 'video': return `/videos/${id}`; + case 'torrent': return `/torrents/${id}`; + case 'forum': return `/forum/${encodeURIComponent(action.content?.key || action.tipId || action.id)}`; + case 'document': return `/documents/${id}`; + case 'bookmark': return `/bookmarks/${id}`; + case 'event': return `/events/${id}`; + case 'task': return `/tasks/${id}`; + case 'taskAssignment': return `/tasks/${encodeURIComponent(action.content?.taskId || action.tipId || action.id)}`; + case 'about': return `/author/${encodeURIComponent(action.author)}`; + case 'pub': return `/invites`; + case 'market': return `/market/${id}`; + case 'shop': return `/shops/${id}`; + case 'shopProduct': return `/shops/product/${id}`; + case 'chat': return `/chats/${id}`; + case 'pad': return `/pads/${id}`; + case 'calendar': return `/calendars/${id}`; + case 'job': return `/jobs/${id}`; + case 'project': return `/projects/${id}`; + case 'report': return `/reports/${id}`; + case 'bankWallet': return `/wallet`; + case 'bankClaim': return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`; + case 'ubiClaim': return action.content?.transferId ? `/transfers/${encodeURIComponent(action.content.transferId)}` : `/transfers?filter=ubi`; + default: return `/activity`; + } +} + +exports.activityView = (actions, filter, userId, q = '', extras = {}) => { + const spreadMap = (extras && extras.spreadMap) || new Map(); + const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity; + const desc = i18n.activityDesc; + + const activityTypes = [ + { type: 'recent', label: i18n.typeRecent }, + { type: 'all', label: i18n.allButton }, + { type: 'mine', label: i18n.mineButton }, + { type: 'inhabitants', label: i18n.typeInhabitants }, + { type: 'tribe', label: i18n.typeTribe }, + { type: 'larp', label: i18n.typeLarp }, + { type: 'parliament',label: i18n.typeParliament }, + { type: 'courts', label: i18n.typeCourts }, + { type: 'votes', label: i18n.typeVotes }, + { type: 'event', label: i18n.typeEvent }, + { type: 'calendar', label: i18n.typeCalendar }, + { type: 'task', label: i18n.typeTask }, + { type: 'report', label: i18n.typeReport }, + { type: 'post', label: i18n.typePost }, + { type: 'feed', label: i18n.typeFeed }, + { type: 'chat', label: i18n.typeChat }, + { type: 'pad', label: i18n.typePad }, + { type: 'forum', label: i18n.typeForum }, + { type: 'map', label: i18n.typeMap }, + { type: 'banking', label: i18n.typeBanking }, + { type: 'market', label: i18n.typeMarket }, + { type: 'project', label: i18n.typeProject }, + { type: 'job', label: i18n.typeJob }, + { type: 'shop', label: i18n.typeShop }, + { type: 'transfer', label: i18n.typeTransfer }, + { type: 'curriculum',label: i18n.typeCurriculum }, + { type: 'audio', label: i18n.typeAudio }, + { type: 'bookmark', label: i18n.typeBookmark }, + { type: 'document', label: i18n.typeDocument }, + { type: 'image', label: i18n.typeImage }, + { type: 'torrent', label: i18n.typeTorrent }, + { type: 'video', label: i18n.typeVideo } + ]; + + const FILTER_META = new Set(['recent', 'all', 'mine']); + const GROUP_SUBTYPES = { + parliament: ['parliamentCandidature', 'parliamentTerm', 'parliamentProposal', 'parliamentRevocation', 'parliamentLaw'], + courts: ['courtsCase', 'courtsNomination', 'courtsNominationVote'], + banking: ['bankWallet', 'bankClaim', 'ubiClaim'], + task: ['task', 'taskAssignment'], + shop: ['shop', 'shopProduct'], + larp: ['larpHousePost'], + inhabitants:['about'] + }; + const ALLOWED_TYPES = new Set(); + for (const { type } of activityTypes) { + if (FILTER_META.has(type)) continue; + if (GROUP_SUBTYPES[type]) GROUP_SUBTYPES[type].forEach(t => ALLOWED_TYPES.add(t)); + else ALLOWED_TYPES.add(type); + } + actions = actions.filter(action => ALLOWED_TYPES.has(action.type)); + + let filteredActions; + if (filter === 'mine') { + filteredActions = actions.filter(action => action.author === userId && action.type !== 'tombstone'); + } else if (filter === 'recent') { + const now = Date.now(); + filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000); + } else if (filter === 'banking') { + filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim' || action.type === 'ubiClaim')); + } else if (filter === 'tribe') { + filteredActions = actions.filter(action => action.type === 'tribe'); + } else if (filter === 'larp') { + filteredActions = actions.filter(action => action.type === 'larpHousePost'); + } else if (filter === 'inhabitants') { + filteredActions = actions.filter(action => action.type === 'about'); + } else if (filter === 'parliament') { + filteredActions = actions.filter(action => ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(action.type)); + } else if (filter === 'courts') { + filteredActions = actions.filter(action => { + const t = String(action.type || '').toLowerCase(); + return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote'; + }); + } else if (filter === 'task') { + filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'task' || action.type === 'taskAssignment')); + } else if (filter === 'torrent') { + filteredActions = actions.filter(action => action.type === 'torrent'); + } else { + filteredActions = actions.filter(action => (action.type === filter || filter === 'all' || (filter === 'shop' && action.type === 'shopProduct')) && action.type !== 'tombstone'); + } + + const larpEarliest = new Map(); + for (const action of filteredActions) { + if (action.type !== 'tribe') continue; + const c = action.value?.content || action.content || {}; + const larpTag = (Array.isArray(c.tags) ? c.tags : []).find(x => String(x).startsWith('larp-')); + if (!larpTag) continue; + const ts = action.ts || 0; + const prev = larpEarliest.get(larpTag); + if (!prev || ts < prev.ts) larpEarliest.set(larpTag, { id: action.id, ts }); + } + if (larpEarliest.size) { + filteredActions = filteredActions.filter(action => { + if (action.type !== 'tribe') return true; + const c = action.value?.content || action.content || {}; + const larpTag = (Array.isArray(c.tags) ? c.tags : []).find(x => String(x).startsWith('larp-')); + if (!larpTag) return true; + return larpEarliest.get(larpTag).id === action.id; + }); + } + + const aboutLatest = new Map(); + for (const action of filteredActions) { + if (action.type !== 'about') continue; + const c = action.value?.content || action.content || {}; + const subject = c.about || action.author; + if (!subject) continue; + const ts = action.ts || 0; + const prev = aboutLatest.get(subject); + if (!prev || ts > prev.ts) aboutLatest.set(subject, { id: action.id, ts }); + } + if (aboutLatest.size) { + filteredActions = filteredActions.filter(action => { + if (action.type !== 'about') return true; + const c = action.value?.content || action.content || {}; + const subject = c.about || action.author; + if (!subject) return true; + return aboutLatest.get(subject).id === action.id; + }); + } + + const qs = String(q || '').trim(); + if (qs) { + const qn = qs.toLowerCase(); + filteredActions = filteredActions.filter(a0 => { + const t = String(a0.type || '').toLowerCase(); + const author = String(a0.author || '').toLowerCase(); + const id0 = String(a0.id || '').toLowerCase(); + const c0 = a0.value?.content || a0.content || {}; + const blob = [ + t, author, id0, + c0.text, c0.title, c0.name, c0.description, c0.contentWarning, + c0.about, c0.contact, + c0.spreadTitle, c0.spreadText, c0.spreadOriginalAuthor, + c0.vote?.link, + c0.address?.host, c0.address?.key + ].filter(Boolean).join(' ').toLowerCase(); + return blob.includes(qn); + }); + } + + const MODULE_SUB_FILTERS = { + audio: { url: '/audios', filters: ['all', 'mine', 'recent', 'top', 'favorites'] }, + video: { url: '/videos', filters: ['all', 'mine', 'recent', 'top', 'favorites'] }, + image: { url: '/images', filters: ['all', 'mine', 'recent', 'top', 'favorites'] }, + document:{ url: '/documents', filters: ['all', 'mine', 'recent', 'top', 'favorites'] }, + bookmark:{ url: '/bookmarks', filters: ['all', 'mine', 'recent', 'top', 'favorites'] }, + torrent: { url: '/torrents', filters: ['all', 'mine', 'recent', 'top', 'favorites'] }, + map: { url: '/maps', filters: ['all', 'mine', 'recent'] }, + forum: { url: '/forum', filters: ['all', 'mine', 'recent', 'top'] }, + event: { url: '/events', filters: ['all', 'mine', 'recent', 'top'] }, + task: { url: '/tasks', filters: ['all', 'mine', 'assigned', 'open', 'closed'] }, + votes: { url: '/votes', filters: ['all', 'mine', 'recent', 'top'] }, + transfer:{ url: '/transfers', filters: ['all', 'mine', 'pending', 'unconfirmed', 'closed'] }, + market: { url: '/market', filters: ['all', 'mine', 'exchange', 'auctions', 'for sale', 'sold'] }, + shop: { url: '/shops', filters: ['all', 'mine', 'recent'] }, + job: { url: '/jobs', filters: ['ALL', 'MINE', 'REMOTE', 'PRESENCIAL', 'OPEN', 'CLOSED'] }, + project: { url: '/projects', filters: ['all', 'mine', 'active', 'completed'] }, + chat: { url: '/chats', filters: ['all', 'mine'] }, + pad: { url: '/pads', filters: ['all', 'mine'] }, + calendar:{ url: '/calendars', filters: ['all', 'mine'] }, + report: { url: '/reports', filters: ['all', 'mine', 'recent'] }, + curriculum:{ url: '/cv', filters: ['view', 'edit'] } + }; + + const sub = MODULE_SUB_FILTERS[filter]; + + let html = template( + title, + section( + div({ class: 'tags-header' }, + h2(i18n.activityList), + p(desc) + ), + div({ class: 'activity-filter-grid' }, + ...[ + activityTypes.slice(0, 3), + activityTypes.slice(3, 8), + activityTypes.slice(8, 13), + activityTypes.slice(13, 19), + activityTypes.slice(19, 26), + activityTypes.slice(26) + ].map(col => + div({ class: 'activity-filter-col' }, + col.map(({ type, label }) => + form({ method: 'GET', action: '/activity' }, + input({ type: 'hidden', name: 'filter', value: type }), + button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label) + ) + ) + ) + ) + ), + sub + ? div({ class: 'activity-sub-filter' }, + sub.filters.map(f => a({ href: `${sub.url}?filter=${encodeURIComponent(f)}`, class: 'filter-btn' }, String(f).toUpperCase())) + ) + : null, + section({ class: 'feed-container' }, renderActionCards(filteredActions, userId, actions, spreadMap)) + ) + ); + + const hasDocument = actions.some(a => a && a.type === 'document'); + if (hasDocument) { + html += ` + + + `; + } + return html; +}; + diff --git a/src/views/agenda_view.js b/src/views/agenda_view.js new file mode 100644 index 0000000..c13b8e2 --- /dev/null +++ b/src/views/agenda_view.js @@ -0,0 +1,260 @@ +const { div, h2, p, section, button, form, img, textarea, a, br, h1, span } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, renderContentActions } = require('./main_views'); +const moment = require('../server/node_modules/moment'); +const { config } = require('../server/SSB_server.js'); + +const userId = config.keys.id; + +const renderCardField = (labelText, value) => + div({ class: 'card-field' }, + span({ class: 'card-label' }, labelText), + span( + { class: 'card-value' }, + ...(Array.isArray(value) ? value : [value ?? '']) + ) + ); + +function getViewDetailsAction(item) { + switch (item.type) { + case 'transfer': return `/transfers/${encodeURIComponent(item.id)}`; + case 'tribe': return `/tribe/${encodeURIComponent(item.id)}`; + case 'event': return `/events/${encodeURIComponent(item.id)}`; + case 'task': return `/tasks/${encodeURIComponent(item.id)}`; + case 'market': return `/market/${encodeURIComponent(item.id)}`; + case 'report': return `/reports/${encodeURIComponent(item.id)}`; + case 'job': return `/jobs/${encodeURIComponent(item.id)}`; + case 'project': return `/projects/${encodeURIComponent(item.id)}`; + case 'calendar': return `/calendars/${encodeURIComponent(item.id)}`; + default: return `/messages/${encodeURIComponent(item.id)}`; + } +} + +const renderAgendaItem = (item, userId, filter) => { + const fmt = d => moment(d).format('YYYY/MM/DD HH:mm:ss'); + const author = item.seller || item.organizer || item.from || item.author || ''; + + const commonFields = [ + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${item.createdAt ? moment(item.createdAt).format('YYYY/MM/DD HH:mm:ss') : ''} ${i18n.performed} `), + author ? userLink(author) : '' + ) + ]; + + let details = []; + let actionButton = null; + + if (filter === 'discarded') { + actionButton = form({ method: 'POST', action: `/agenda/restore/${encodeURIComponent(item.id)}` }, + button({ type: 'submit', class: 'restore-btn' }, i18n.agendaRestoreButton) + ); + } else { + actionButton = form({ method: 'POST', action: `/agenda/discard/${encodeURIComponent(item.id)}` }, + button({ type: 'submit', class: 'discard-btn' }, i18n.agendaDiscardButton) + ); + } + + if (item.type === 'market') { + details = [ + renderCardField(i18n.marketItemType + ":", String(item.item_type || '').toUpperCase()), + renderCardField(i18n.marketItemStatus + ":", item.status), + renderCardField(i18n.marketItemStock + ":", item.stock), + renderCardField(i18n.marketItemPrice + ":", `${item.price} ECO`), + renderCardField(i18n.marketItemIncludesShipping + ":", item.includesShipping ? i18n.agendaYes : i18n.agendaNo), + renderCardField(i18n.deadline + ":", item.deadline ? new Date(item.deadline).toLocaleString() : '') + ]; + if (String(item.item_type || '').toLowerCase() === 'auction') { + const bids = Array.isArray(item.auctions_poll) ? item.auctions_poll.map(bid => parseFloat(String(bid).split(':')[1])).filter(n => !isNaN(n)) : []; + const maxBid = bids.length ? Math.max(...bids) : 0; + details.push(renderCardField(i18n.marketItemHighestBid + ":", `${maxBid} ECO`)); + } + const seller = author ? p(userLink(author)) : ''; + details.push(br(), div({ class: 'members-list' }, i18n.marketItemSeller + ': ', seller)); + } + + if (item.type === 'tribe') { + details = [ + renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo), + renderCardField(i18n.agendaInviteModeLabel + ":", (item.inviteMode ? String(item.inviteMode).toUpperCase() : i18n.noInviteMode)), + renderCardField(i18n.agendaLocationLabel + ":", item.location || i18n.noLocation), + renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0), + br() + ]; + const membersList = Array.isArray(item.members) ? item.members.map(member => p(userLink(member))) : []; + details.push(div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList)); + } + + if (item.type === 'report') { + details = [ + renderCardField(i18n.agendareportStatus + ":", item.status || i18n.noStatus), + renderCardField(i18n.agendareportCategory + ":", item.category || i18n.noCategory), + renderCardField(i18n.agendareportSeverity + ":", (item.severity ? String(item.severity).toUpperCase() : i18n.noSeverity)) + ]; + } + + if (item.type === 'event') { + details = [ + renderCardField(i18n.eventDateLabel + ":", item.date ? fmt(item.date) : ''), + renderCardField(i18n.eventLocationLabel + ":", item.location || ''), + renderCardField(i18n.eventPriceLabel + ":", `${item.price} ECO`), + renderCardField( + i18n.eventUrlLabel + ":", + item.url ? p(a({ href: item.url, target: "_blank" }, item.url)) : p(i18n.noUrl) + ) + ]; + actionButton = actionButton || form({ method: 'POST', action: `/events/attend/${encodeURIComponent(item.id)}` }, + button({ type: 'submit', class: 'assign-btn' }, `${i18n.eventAttendButton}`) + ); + } + + if (item.type === 'task') { + details = [ + renderCardField(i18n.taskStatus + ":", item.status), + renderCardField(i18n.taskPriorityLabel + ":", item.priority), + renderCardField(i18n.taskStartTimeLabel + ":", item.startTime ? new Date(item.startTime).toLocaleString() : ''), + renderCardField(i18n.taskEndTimeLabel + ":", item.endTime ? new Date(item.endTime).toLocaleString() : ''), + renderCardField(i18n.taskLocationLabel + ":", item.location || '') + ]; + const assigned = Array.isArray(item.assignees) && item.assignees.includes(userId); + actionButton = actionButton || form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(item.id)}` }, + button({ type: 'submit', class: 'assign-btn' }, assigned ? i18n.taskUnassignButton : i18n.taskAssignButton) + ); + } + + if (item.type === 'transfer') { + details = [ + renderCardField(i18n.agendaTransferConcept + ":", item.concept), + renderCardField(i18n.agendaTransferAmount + ":", item.amount), + renderCardField(i18n.agendaTransferDeadline + ":", item.deadline ? fmt(item.deadline) : ''), + br() + ]; + const membersList = item.to ? p(userLink(item.to)) : ''; + details.push(div({ class: 'members-list' }, i18n.to + ': ', membersList)); + } + + if (item.type === 'project') { + details = [ + renderCardField(i18n.projectStatus + ":", item.status || i18n.noStatus), + renderCardField(i18n.projectProgress + ":", `${item.progress || 0}%`), + renderCardField(i18n.projectGoal + ":", `${item.goal} ECO`), + renderCardField(i18n.projectPledged + ":", `${item.pledged || 0} ECO`), + renderCardField(i18n.projectDeadline + ":", item.deadline ? new Date(item.deadline).toLocaleString() : i18n.noDeadline) + ]; + } + + if (item.type === 'calendar') { + details = [ + renderCardField((i18n.calendarStatusLabel || 'Status') + ':', item.isClosed ? (i18n.calendarStatusClosed || 'CLOSED') : (i18n.calendarStatusOpen || 'OPEN')), + renderCardField((i18n.calendarDeadlineLabel || 'Deadline') + ':', item.deadline ? moment(item.deadline).format('YYYY/MM/DD HH:mm') : ''), + renderCardField((i18n.calendarParticipantsLabel || 'Participants') + ':', Array.isArray(item.participants) ? item.participants.length : 0) + ]; + } + + if (item.type === 'job') { + const subs = Array.isArray(item.subscribers) + ? item.subscribers + : (typeof item.subscribers === 'string' + ? item.subscribers.split(',').map(s => s.trim()).filter(Boolean) + : (item.subscribers && typeof item.subscribers.length === 'number' + ? Array.from(item.subscribers) + : [])); + + const subsInterleaved = subs + .map((id, i) => [i > 0 ? ', ' : '', userLink(id)]) + .flat(); + + details = [ + renderCardField(i18n.jobStatus + ":", item.status), + renderCardField(i18n.jobLocation + ":", (item.location || '').toUpperCase()), + renderCardField(i18n.jobType + ":", (item.job_type || '').toUpperCase()), + renderCardField(i18n.jobSalary + ":", `${item.salary} ECO`), + renderCardField(i18n.jobVacants + ":", item.vacants), + renderCardField(i18n.jobLanguages + ":", (item.languages || '').toUpperCase()), + br(), + div( + { class: 'members-list' }, + i18n.jobSubscribers + ': ',br(),br(), + ...(subs.length ? subsInterleaved : [i18n.noSubscribers.toUpperCase()]) + ), + ]; + + const subscribed = subs.includes(userId); + if (!subscribed && String(item.status).toUpperCase() !== 'CLOSED' && item.author !== userId) { + actionButton = form({ method: 'POST', action: `/jobs/subscribe/${encodeURIComponent(item.id)}` }, + button({ type: 'submit', class: 'subscribe-btn' }, i18n.jobSubscribeButton) + ); + } + } + + const isOwn = author && String(author) === String(userId); + return div({ class: 'trending-card agenda-card' + (isOwn ? ' own-content' : '') }, + div({ class: 'card-header activity-card-header' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, String(item.type || '').toUpperCase()) + ), + renderContentActions(item.id, getViewDetailsAction(item)) + ), + div({ class: 'card-section agenda-card-body' }, + actionButton, + br(), + h2(item.title || item.name || item.concept || ''), + ...details, + ...commonFields + ) + ); +}; + +exports.agendaView = async (data, filter) => { + const { items = [], counts: _c = {} } = data || {}; + const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, calendars: 0, discarded: 0, ..._c }; + return template( + i18n.agendaTitle, + section( + div({ class: 'tags-header' }, + h2(i18n.agendaTitle), + p(i18n.agendaDescription) + ), + div({ class: 'filters' }, + form({ method: 'GET', action: '/agenda' }, + button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterAll} (${counts.all})`), + button({ type: 'submit', name: 'filter', value: 'today', class: filter === 'today' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterToday || 'TODAY'} (${counts.today || 0})`), + button({ type: 'submit', name: 'filter', value: 'upcoming', class: filter === 'upcoming' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterUpcoming || 'UPCOMING'} (${counts.upcoming || 0})`), + button({ type: 'submit', name: 'filter', value: 'overdue', class: filter === 'overdue' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterOverdue || 'OVERDUE'} (${counts.overdue || 0})`), + button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterOpen} (${counts.open})`), + button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterClosed} (${counts.closed})`), + button({ type: 'submit', name: 'filter', value: 'events', class: filter === 'events' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterEvents} (${counts.events})`), + button({ type: 'submit', name: 'filter', value: 'tasks', class: filter === 'tasks' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterTasks} (${counts.tasks})`), + button({ type: 'submit', name: 'filter', value: 'reports', class: filter === 'reports' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterReports} (${counts.reports})`), + button({ type: 'submit', name: 'filter', value: 'tribes', class: filter === 'tribes' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterTribes} (${counts.tribes})`), + button({ type: 'submit', name: 'filter', value: 'jobs', class: filter === 'jobs' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterJobs} (${counts.jobs})`), + button({ type: 'submit', name: 'filter', value: 'market', class: filter === 'market' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterMarket} (${counts.market})`), + button({ type: 'submit', name: 'filter', value: 'projects', class: filter === 'projects' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterProjects} (${counts.projects})`), + button({ type: 'submit', name: 'filter', value: 'calendars', class: filter === 'calendars' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterCalendars || 'CALENDARS'} (${counts.calendars})`), + button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterTransfers} (${counts.transfers})`), + button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, + `DISCARDED (${counts.discarded})`) + ) + ), + div({ class: 'agenda-list' }, + items.length + ? items.map(item => renderAgendaItem(item, userId, filter)) + : p(i18n.agendaNoItems) + ) + ) + ); +}; + diff --git a/src/views/audio_view.js b/src/views/audio_view.js new file mode 100644 index 0000000..7b4f57c --- /dev/null +++ b/src/views/audio_view.js @@ -0,0 +1,566 @@ +const { + form, + button, + div, + h2, + p, + section, + input, + br, + a, + audio: audioHyperaxe, + span, + textarea, + select, + label, + option +} = require("../server/node_modules/hyperaxe"); + +const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl") +const { renderMapLocationVisitLabel } = require("./maps_view"); + +const userId = config.keys.id; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all"); + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const parts = [`filter=${encodeURIComponent(f)}`]; + if (q) parts.push(`q=${encodeURIComponent(q)}`); + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`); + return `/audios?${parts.join("&")}`; +}; + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div( + { class: "card-tags" }, + list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; +}; + +const renderAudioFavoriteToggle = (audioObj, returnTo = "") => + form( + { + method: "POST", + action: audioObj.isFavorite + ? `/audios/favorites/remove/${encodeURIComponent(audioObj.key)}` + : `/audios/favorites/add/${encodeURIComponent(audioObj.key)}` + }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button( + { type: "submit", class: "filter-btn" }, + audioObj.isFavorite ? i18n.audioRemoveFavoriteButton : i18n.audioAddFavoriteButton + ) + ); + +const renderTranscodeButton = (audioObj) => + audioObj.isBcs + ? form( + { method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}`, class: "audio-transcode-form" }, + button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE") + ) + : null; + +const renderAudioPlayer = (audioObj, opts = {}) => + audioObj?.url + ? div( + { class: "audio-container" }, + audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" }), + opts.skipTranscode ? null : renderTranscodeButton(audioObj) + ) + : p(i18n.audioNoFile); + +const renderAudioOwnerActions = (filter, audioObj, params = {}) => { + const returnTo = buildReturnTo(filter, params); + const isAuthor = String(audioObj.author) === String(userId); + const hasOpinions = Object.keys(audioObj.opinions || {}).length > 0; + + if (!isAuthor) return []; + + const items = []; + if (!hasOpinions) { + items.push( + form( + { method: "GET", action: `/audios/edit/${encodeURIComponent(audioObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton) + ) + ); + } + items.push( + form( + { method: "POST", action: `/audios/delete/${encodeURIComponent(audioObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton) + ) + ); + + return items; +}; + +const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => { + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + const commentsCount = list.length; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || ""; + const ts = c?.value?.timestamp || c?.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + const content = c?.value?.content || {}; + const rootId = content.fork || content.root || null; + const text = content.text || ""; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ); +}; + +const renderAudioList = exports.renderAudioList = (audios, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params); + + return audios.length + ? audios.map((audioObj) => { + const commentCount = typeof audioObj.commentCount === "number" ? audioObj.commentCount : 0; + const title = safeText(audioObj.title); + const ownerActions = renderAudioOwnerActions(filter, audioObj, params); + + const isOwn = audioObj.author && String(audioObj.author) === String(userId); + return div( + { class: "trending-card audio-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(audioObj.key, `/audios/${encodeURIComponent(audioObj.key)}`) + ), + div( + { class: "card-section audio-card-body" }, + div( + { class: "bookmark-topbar" }, + div( + { class: "bookmark-topbar-left" }, + renderAudioFavoriteToggle(audioObj, returnTo), + audioObj.author && String(audioObj.author) !== String(userId) + ? form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: audioObj.author }), + button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton) + ) + : null + ), + ownerActions.length + ? div({ class: "bookmark-topbar-right" }, div({ class: "bookmark-actions" }, ...ownerActions)) + : null + ), + title ? h2(title) : null, + audioObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(audioObj.lifetime, i18n)) : null, + renderAudioPlayer(audioObj), + div( + { class: "card-comments-summary" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ":"), + span({ class: "card-value" }, String(commentCount)), + br(), + br(), + form( + { method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: filter || "all" }), + params.q ? input({ type: "hidden", name: "q", value: params.q }) : null, + params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null, + button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton) + ) + ), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(Object.values(audioObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) + ), + renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants) + ), + div({ class: "card-spread-left" }, renderSpreadButton(audioObj.key, (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads)), + renderMapLocationVisitLabel(audioObj.mapUrl), + br(), + (() => { + const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN; + const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(audioObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })() + ) + ); + }) + : p(params.q ? i18n.audioNoMatch : i18n.noAudios); +}; + +const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => { + const returnTo = safeText(params.returnTo) || buildReturnTo("all", params); + return div( + { class: "div-center audio-form" }, + form( + { + action: filter === "edit" ? `/audios/update/${encodeURIComponent(audioId)}` : "/audios/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + span(i18n.audioFileLabel), + br(), + input({ type: "file", name: "audio", required: filter !== "edit" }), + br(), + br(), + span(i18n.audioTitleLabel), + br(), + input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }), + br(), + span(i18n.audioDescriptionLabel), + br(), + textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ""), + br(), + span(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: audioToEdit?.mapUrl || "" }), + br(), + span(i18n.audioTagsLabel), + br(), + input({ + type: "text", + name: "tags", + placeholder: i18n.audioTagsPlaceholder, + value: safeArr(audioToEdit?.tags).join(", ") + }), + br(), + br(), + button({ type: "submit" }, filter === "edit" ? i18n.audioUpdateButton : i18n.audioCreateButton) + ) + ); +}; + +exports.audioView = async (audios, filter = "all", audioId = null, params = {}) => { + const title = + filter === "mine" + ? i18n.audioMineSectionTitle + : filter === "create" + ? i18n.audioCreateSectionTitle + : filter === "edit" + ? i18n.audioUpdateSectionTitle + : filter === "recent" + ? i18n.audioRecentSectionTitle + : filter === "top" + ? i18n.audioTopSectionTitle + : filter === "favorites" + ? i18n.audioFavoritesSectionTitle + : i18n.audioAllSectionTitle; + + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + + const list = safeArr(audios); + const audioToEdit = audioId ? list.find((a) => a.key === audioId) : null; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.audioDescription) + ), + (() => { + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetAudios); + return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n)); + })(), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop), + button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.audioFilterFavorites + ), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton) + ) + ) + ), + section( + filter === "create" || filter === "edit" + ? renderAudioForm(filter, audioId, audioToEdit, { ...params, filter }) + : section( + div( + { class: "audios-search" }, + form( + { method: "GET", action: "/audios", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ + type: "text", + name: "q", + value: q, + placeholder: i18n.audioSearchPlaceholder, + class: "filter-box__input" + }), + div( + { class: "filter-box__controls" }, + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.audioSortRecent), + option({ value: "oldest", selected: sort === "oldest" }, i18n.audioSortOldest), + option({ value: "top", selected: sort === "top" }, i18n.audioSortTop) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.audioSearchButton) + ) + ) + ), + div({ class: "audios-list" }, renderAudioList(list, filter, { q, sort })) + ) + ) + ); +}; + +exports.singleAudioView = async (audioObj, filter = "all", comments = [], params = {}) => { + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }); + + const title = safeText(audioObj.title); + const isAuthor = String(audioObj.author) === String(userId); + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetAudios); + + const chips = [ + renderLifespanChip(audioObj.lifetime, i18n), + audioObj.sizeBytes ? renderEcoTax(audioObj.sizeBytes, audioObj.key) : null + ].filter(Boolean); + + const ownerActions = renderAudioOwnerActions(filter, audioObj, { q, sort }); + const sideActions = []; + sideActions.push(renderAudioFavoriteToggle(audioObj, returnTo)); + if (audioObj.author && String(audioObj.author) !== String(userId)) { + sideActions.push(form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: audioObj.author }), + button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton) + )); + } + if (audioObj.isBcs) { + sideActions.push(form( + { method: "GET", action: `/melody/transcode/${encodeURIComponent(audioObj.key)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE") + )); + } + for (const a of ownerActions) sideActions.push(a); + + const tagsNode = renderTags(audioObj.tags); + + const audioSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + title ? h2({ class: "tribe-card-title" }, title) : null, + renderReachChip(isClearnet, i18n) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + safeText(audioObj.description) + ? p({ class: "tribe-side-description" }, ...renderUrl(audioObj.description)) + : null, + tagsNode, + div({ class: "card-spread-centered" }, renderSpreadButton(audioObj.key, params.spreads)), + renderMapLocationVisitLabel(audioObj.mapUrl), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const audioMain = div({ class: "tribe-main" }, + renderAudioPlayer(audioObj), + (() => { + const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN; + const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(audioObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })(), + renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants), + renderAudioCommentsSection(audioObj.key, comments, returnTo) + ); + + return template( + i18n.audioTitle, + section( + div({ class: "tags-header" }, + h2(i18n.audioAllSectionTitle || i18n.audioTitle), + p(i18n.audioDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop), + button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.audioFilterFavorites + ), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton) + ) + ), + div({ class: "tribe-details" }, audioSide, audioMain) + ) + ); +}; + +const { renderCompositionSequence } = require("./melody_view"); + +exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload = null, availableIds = null, itemSize = null }) => { + const title = i18n.audioTranscodeDetailTitle || "Transcode"; + const composition = Array.isArray(audio.bcsComposition) ? audio.bcsComposition : []; + const hasStego = decoded && stegoPayload && (stegoPayload.id || stegoPayload.ts || stegoPayload.msg); + const stegoDate = hasStego && Number.isFinite(stegoPayload.ts) ? moment(stegoPayload.ts).format("YYYY/MM/DD HH:mm:ss") : null; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.audioTranscodeDetailDescription || "Decode the embedded payload and the original blockchain composition map.") + ), + div({ class: "filters" }, + form({ method: "GET", action: "/melody", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "filter", value: "all" }), + button({ type: "submit", class: "filter-btn" }, i18n.audioBackToBcs || "Back to BCS") + ) + ), + div({ class: "bookmark-item card" }, + audio.title ? h2(audio.title) : null, + renderAudioPlayer(audio, { skipTranscode: true }), + p({ class: "transcode-meta card-footer" }, + userLink(audio.author), + span({ class: "melody-meta-sep" }, " · "), + span({ class: "card-value" }, moment(audio.createdAt).format("YYYY/MM/DD HH:mm:ss")), + itemSize ? span({ class: "melody-meta-sep" }, " · ") : null, + itemSize ? renderEcoTax(itemSize, audio.key) : null + ), + safeText(audio.description) ? p({ class: "melody-bcs-desc" }, audio.description) : null, + renderTags(audio.tags), + br(), + form({ method: "POST", action: `/melody/transcode/${encodeURIComponent(audio.key)}`, class: "audio-transcode-run-form" }, + button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE") + ), + br(), + decoded + ? div({ class: "transcode-result" }, + hasStego + ? [ + div({ class: "transcode-stego-field" }, + span({ class: "card-label" }, (i18n.audioTranscodeStegoTimestamp || "Generated at") + ": "), + span({ class: "card-value" }, stegoDate || (i18n.audioTranscodeStegoUnknown || "—")) + ), + div({ class: "transcode-stego-field" }, + span({ class: "card-label" }, (i18n.audioTranscodeStegoOasisId || "By") + ": "), + stegoPayload.id ? userLink(stegoPayload.id) : span({ class: "card-value" }, i18n.audioTranscodeStegoUnknown || "—") + ), + div({ class: "transcode-stego-field transcode-stego-msg" }, + span({ class: "card-label" }, (i18n.audioTranscodeStegoMessage || "TEXT") + ":"), + br(), + stegoPayload.msg + ? p({ class: "transcode-stego-text" }, stegoPayload.msg) + : span({ class: "card-value" }, i18n.audioTranscodeStegoEmpty || "(none)") + ) + ] + : p({ class: "empty" }, i18n.audioTranscodeStegoNotFound || "No steganographic payload could be decoded from this audio."), + composition.length + ? renderCompositionSequence(composition, availableIds) + : p({ class: "empty" }, i18n.audioTranscodeCompositionEmpty || "This audio does not include a stored blockchain composition.") + ) + : null + ) + ) + ); +}; + +exports.audiosTranscodeView = exports.audioTranscodeDetailView; + diff --git a/src/views/banking_views.js b/src/views/banking_views.js new file mode 100644 index 0000000..5928d3e --- /dev/null +++ b/src/views/banking_views.js @@ -0,0 +1,694 @@ +const { div, h2, h3, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br, strong, label } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, formatCarbon } = require("../views/main_views"); +const moment = require("../server/node_modules/moment"); + +const FILTER_LABELS = { + overview: i18n.bankOverview, + exchange: i18n.bankExchange, + taxes: i18n.bankTaxes || "Taxes", + mine: i18n.mine, + pending: i18n.pending, + closed: i18n.closed, + claimed: i18n.bankStatusClaimed, + expired: i18n.bankStatusExpired, + epochs: i18n.bankEpochs, + rules: i18n.bankRules, + addresses: i18n.bankAddresses +}; + +const generateFilterButtons = (filters, currentFilter, action) => + div({ class: "mode-buttons-row" }, + ...filters.map(mode => + form({ method: "GET", action }, + input({ type: "hidden", name: "filter", value: mode }), + button({ type: "submit", class: currentFilter === mode ? "filter-btn active" : "filter-btn" }, (FILTER_LABELS[mode] || mode).toUpperCase()) + ) + ) + ); + +const kvRow = (label, value) => + tr(td({ class: "card-label" }, label), td({ class: "card-value" }, value)); + +const fmtIndex = (value) => { + return value ? value.toFixed(6) : "0.000000"; +}; + +const pct = (value) => { + if (value === undefined || value === null) return "0.000001%"; + const formattedValue = (value).toFixed(6); + const sign = value >= 0 ? "+" : ""; + return `${sign}${formattedValue}%`; +}; + +const fmtDate = (timestamp) => { + return moment(timestamp).format('YYYY-MM-DD HH:mm:ss'); +}; + +const fmtEcoTime = (ms) => { + if (!ms || ms <= 0) return `0 ${i18n.bankUnitMs || 'ms'}`; + if (ms < 1000) return `${Number(ms).toFixed(3)} ${i18n.bankUnitMs || 'ms'}`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(2)} ${i18n.bankUnitSeconds || 'seconds'}`; + const m = s / 60; + if (m < 60) return `${m.toFixed(2)} ${i18n.bankUnitMinutes || 'minutes'}`; + const h = m / 60; + if (h < 24) return `${h.toFixed(2)} ${i18n.bankHoursOfWork || 'hours'}`; + return `${(h / 24).toFixed(2)} ${i18n.bankUnitDays || 'days'}`; +}; + +const escAttr = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const buildEcoValueChartSvg = (history, labels) => { + const arr = Array.isArray(history) ? history.slice(-120) : []; + const W = 720, H = 320; + const padL = 56, padR = 16, padT = 16, padB = 70; + const plotW = W - padL - padR; + const plotH = H - padT - padB; + if (arr.length < 2) { + return `` + + `` + + `${escAttr(labels.empty || 'Not enough samples yet')}` + + ``; + } + const values = arr.map(s => Number(s.ecoValue || 0)); + const supplies = arr.map(s => Number(s.currentSupply || 0)); + const inflations = arr.map(s => Number(s.inflationFactor || 0)); + const minV = Math.min(...values); + const maxV = Math.max(...values); + const rangeV = maxV - minV || 1; + const minS = Math.min(...supplies); + const maxS = Math.max(...supplies); + const rangeS = maxS - minS || 1; + const minI = Math.min(...inflations); + const maxI = Math.max(...inflations); + const rangeI = maxI - minI || 1; + const stepX = arr.length > 1 ? plotW / (arr.length - 1) : plotW; + const xy = (i, v, minR, rangeR) => { + const x = padL + i * stepX; + const y = padT + plotH - ((v - minR) / rangeR) * plotH; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }; + const pointsValue = values.map((v, i) => xy(i, v, minV, rangeV)).join(' '); + const pointsSupply = supplies.map((v, i) => xy(i, v, minS, rangeS)).join(' '); + const pointsInfl = inflations.map((v, i) => xy(i, v, minI, rangeI)).join(' '); + const tsStart = moment(arr[0].ts).format('YYYY-MM-DD HH:mm'); + const tsEnd = moment(arr[arr.length - 1].ts).format('YYYY-MM-DD HH:mm'); + const tsMid = moment(arr[Math.floor(arr.length / 2)].ts).format('YYYY-MM-DD HH:mm'); + const yTicks = 4; + const grid = []; + for (let i = 0; i <= yTicks; i++) { + const y = padT + (plotH / yTicks) * i; + grid.push(``); + const val = maxV - (rangeV / yTicks) * i; + grid.push(`${val.toFixed(4)}`); + } + const xLabelY = padT + plotH + 16; + const xLabels = `${escAttr(tsStart)}` + + `${escAttr(tsMid)}` + + `${escAttr(tsEnd)}`; + const legendY = padT + plotH + 44; + const legendBaseX = padL; + const legend = `` + + `` + + `${escAttr(labels.value || 'Value')}` + + `` + + `${escAttr(labels.supply || 'Supply')}` + + `` + + `${escAttr(labels.inflation || 'Inflation')}` + + ``; + return `` + + `` + + grid.join('') + + `` + + `` + + `` + + xLabels + + legend + + ``; +}; + +const renderExchange = (ex, history, taxStats) => { + if (!ex) return div(p(i18n.bankExchangeNoData)); + const syncStatus = ex.isSynced ? i18n.bankingSyncStatusSynced : i18n.bankingSyncStatusOutdated; + const syncStatusClass = ex.isSynced ? 'synced' : 'outdated'; + const ecoTimeLabel = ex.isSynced ? fmtEcoTime(ex.ecoTimeMs) : fmtEcoTime(0); + const chartLabels = { + value: i18n.bankExchangeChartValue || 'Value (ECO/h)', + supply: i18n.bankExchangeChartSupply || 'Supply', + inflation: i18n.bankExchangeChartInflation || 'Inflation %', + empty: i18n.bankExchangeChartEmpty || 'Not enough samples yet — revisit later' + }; + const hasEnoughSamples = Array.isArray(history) && history.length >= 2 && ex.isSynced; + const totals = (taxStats && taxStats.totals) || {}; + const taxRows = taxStats ? [ + kvRow(i18n.bankExchangeEcoTaxAnnual || 'ECOin Taxes (annual)', `${Number(totals.annualEcoinTax || 0).toFixed(6)} ECO`), + kvRow(i18n.bankExchangeEcoTaxMonthly || 'ECOin Taxes (monthly)', `${Number(totals.monthlyEcoinTax || 0).toFixed(6)} ECO`) + ] : []; + return div( + div({ class: "bank-summary" }, + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankingSyncStatus, + span({ class: syncStatusClass }, syncStatus) + ), + kvRow(i18n.bankExchangeCurrentValue, `${fmtIndex(ex.ecoValue)} ECO`), + kvRow(i18n.bankCurrentSupply, `${Number(ex.currentSupply || 0).toFixed(6)} ECO`), + kvRow(i18n.bankTotalSupply, `${Number(ex.totalSupply || 0).toFixed(6)} ECO`), + kvRow(i18n.bankEcoinHours, ecoTimeLabel), + kvRow(i18n.bankInflation, `${ex.inflationFactor.toFixed(2)}%`), + kvRow(i18n.bankInflationMonthly, `${Number(ex.inflationMonthly || 0).toFixed(2)}%`), + ...taxRows + ) + ) + ), + hasEnoughSamples + ? div({ class: "bank-eco-chart-block" }, + h2({ class: "bank-eco-chart-title" }, i18n.bankExchangeChartTitle || 'ECOin value over time'), + div({ class: "bank-eco-chart-canvas", innerHTML: buildEcoValueChartSvg(history, chartLabels) }) + ) + : null + ); +}; + +const renderTaxes = (data, lookup) => { + const taxStats = data.taxStats || {}; + const userTax = Number(data.userEcoinTax || 0); + const userArchTax = Number(data.userArchTax || 0); + const lookupBlock = lookup && lookup.block ? lookup.block : null; + return div( + (() => { + const selectedTypes = Array.isArray(data.selectedTaxTypes) ? data.selectedTaxTypes : ['eco', 'arch']; + const isOn = (t) => selectedTypes.includes(t); + const eco = (taxStats.byType && taxStats.byType.eco) || { lifetime: 0, annual: 0, monthly: 0 }; + const arch = (taxStats.byType && taxStats.byType.arch) || { lifetime: 0, annual: 0, monthly: 0 }; + const sumField = (field) => + (isOn('eco') ? (eco[field] || 0) : 0) + + (isOn('arch') ? (arch[field] || 0) : 0); + const totalLifetime = sumField('lifetime'); + const totalAnnual = sumField('annual'); + const totalMonthly = sumField('monthly'); + const blockHidden = lookupBlock ? input({ type: 'hidden', name: 'block', value: lookupBlock }) : null; + return [ + div({ class: "bank-summary" }, + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankTaxesTotalBlocks || 'Total blocks', String(taxStats.totalBlocks || 0)), + kvRow(i18n.bankTaxesSpan || 'Sampling span', `${Number(taxStats.spanDays || 0).toFixed(2)} days`), + isOn('eco') ? kvRow(i18n.bankTaxesEcoTaxLifetime || 'ECO Tax (lifetime)', `${Number(eco.lifetime || 0).toFixed(6)} ECO`) : null, + isOn('eco') ? kvRow(i18n.bankTaxesAnnualEcoin || 'ECO Tax (annual)', `${Number(eco.annual || 0).toFixed(6)} ECO`) : null, + isOn('eco') ? kvRow(i18n.bankTaxesMonthlyEcoin || 'ECO Tax (monthly)', `${Number(eco.monthly || 0).toFixed(6)} ECO`) : null, + isOn('arch') ? kvRow(i18n.bankTaxesArchTaxLifetime || 'ARCH Tax (lifetime)', `${Number(arch.lifetime || 0).toFixed(6)} ECO`) : null, + isOn('arch') ? kvRow(i18n.bankTaxesArchTaxAnnual || 'ARCH Tax (annual)', `${Number(arch.annual || 0).toFixed(6)} ECO`) : null, + isOn('arch') ? kvRow(i18n.bankTaxesArchTaxMonthly || 'ARCH Tax (monthly)', `${Number(arch.monthly || 0).toFixed(6)} ECO`) : null, + kvRow(strong(i18n.bankTaxesTotalLifetime || 'Total (lifetime)'), strong(`${Number(totalLifetime).toFixed(6)} ECO`)), + kvRow(strong(i18n.bankTaxesTotalAnnual || 'Total (annual)'), strong(`${Number(totalAnnual).toFixed(6)} ECO`)), + kvRow(strong(i18n.bankTaxesTotalMonthly || 'Total (monthly)'), strong(`${Number(totalMonthly).toFixed(6)} ECO`)) + ) + ), + br(), + form({ method: "GET", action: "/banking", class: "bank-taxes-types-form" }, + input({ type: "hidden", name: "filter", value: "taxes" }), + input({ type: "hidden", name: "types", value: "eco" }), + blockHidden, + span({ class: "bank-taxes-types-label" }, (i18n.bankTaxesTypesLabel || 'Select which taxes you want to pay') + ': '), + label({ class: "bank-taxes-type-toggle bank-taxes-type-toggle-locked" }, + input({ type: "checkbox", checked: 'checked', disabled: 'disabled' }), + ' ', + (i18n.ecoTaxLabel || 'ECO Tax') + ), + label({ class: "bank-taxes-type-toggle" }, + input(Object.assign({ type: "checkbox", name: "types", value: "arch" }, isOn('arch') ? { checked: 'checked' } : {})), + ' ', + (i18n.bankTaxesArchTaxTitle || 'ARCH Tax') + ), + button({ type: "submit", class: "filter-btn" }, i18n.bankTaxesTypesApply || 'Set my taxes') + ) + ) + ]; + })(), + div({ class: "bank-summary" }, + h2(i18n.bankTaxesEcoTaxTitle || 'ECO Tax'), + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankTaxesTotalBytes || 'Total bytes', `${Number(taxStats.totalBytes || 0).toLocaleString()} B`), + kvRow(i18n.bankTaxesTotalCarbon || 'Total carbon', formatCarbon(taxStats.totalBytes || 0)), + kvRow(i18n.bankRulesCarbonFactor || 'Carbon factor', `${Number(0.095).toFixed(4)} g CO₂ / MiB`), + kvRow(i18n.bankTaxesRate || 'Rate', `${Number(taxStats.ecoinPerGramCO2 || 0).toFixed(4)} ECO / g CO₂`), + kvRow(i18n.bankTaxesUserAmount || 'Your ECO tax (price to return)', `${userTax.toFixed(6)} ECO`) + ) + ), + p({ class: "bank-taxes-user-note" }, + (i18n.bankTaxesUserNoteIntro || 'Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to '), + strong(i18n.bankTaxesUserNoteBold || 'reducing the ECO tax directly'), + (i18n.bankTaxesUserNoteOutro || ', and their ongoing contribution may be required by networking consensus.') + ), + p({ class: "bank-taxes-user-note" }, i18n.bankTaxesUserNoteChips || 'The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.'), + p({ class: "bank-taxes-user-note" }, i18n.bankTaxesUserNoteChipsClick || 'Click any chip to inspect its block in the blockexplorer.'), + (() => { + const firstBlock = data.firstBlock || null; + const sampleSize = Number(data.firstBlockSize || 0); + const sampleHref = firstBlock + ? `/blockexplorer?inspect=${encodeURIComponent(firstBlock)}` + : '/blockexplorer'; + const sampleValue = sampleSize > 0 ? formatCarbon(sampleSize) : '— CO₂'; + const label = i18n.ecoTaxLabel || 'ECO Tax'; + return p({ class: "bank-taxes-example-chips" }, + a({ href: sampleHref, class: 'eco-tax-chip eco-tax-chip-low', title: label + ' · low' }, + span({ class: 'eco-tax-chip-label' }, label + ': '), + span({ class: 'eco-tax-chip-value' }, sampleValue) + ), + ' ', + a({ href: sampleHref, class: 'eco-tax-chip eco-tax-chip-mid', title: label + ' · mid' }, + span({ class: 'eco-tax-chip-label' }, label + ': '), + span({ class: 'eco-tax-chip-value' }, sampleValue) + ), + ' ', + a({ href: sampleHref, class: 'eco-tax-chip eco-tax-chip-high', title: label + ' · high' }, + span({ class: 'eco-tax-chip-label' }, label + ': '), + span({ class: 'eco-tax-chip-value' }, sampleValue) + ) + ); + })(), + p({ class: "bank-taxes-user-note" }, i18n.bankTaxesUserNoteOtherParams || 'ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.') + ), + div({ class: "bank-summary" }, + h2(i18n.bankTaxesArchTaxTitle || 'ARCH Tax'), + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankTaxesArchTaxFirstBlock || 'Your first block age', userArchTax > 0 + ? `${(userArchTax / Number(taxStats.ecoinPerDayOfHistory || 0.001)).toFixed(2)} days` + : '—'), + kvRow(i18n.bankTaxesArchTaxRate || 'Rate', `${Number(taxStats.ecoinPerDayOfHistory || 0).toFixed(6)} ECO / day of history`), + kvRow(i18n.bankTaxesArchTaxUserAmount || 'Your ARCH tax (price to return)', `${userArchTax.toFixed(6)} ECO`) + ) + ), + p({ class: "bank-taxes-user-note" }, + (i18n.bankTaxesArchTaxNoteIntro || 'Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to '), + strong(i18n.bankTaxesArchTaxNoteBold || 'reducing the ARCH tax directly'), + (i18n.bankTaxesArchTaxNoteOutro || ' through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.') + ), + p({ class: "bank-taxes-user-note" }, i18n.bankTaxesArchTaxFootprintNote || 'ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.') + ), + ); +}; + +const renderOverviewSummaryTable = (s, rules, userEcoinTax) => { + const score = Number(s.userEngagementScore || 0); + const pool = Number(s.pool || 0); + const W = Math.max(1, Number(s.weightsSum || 1)); + const w = 1 + score / 100; + const cap = rules?.caps?.cap_user_epoch ?? 50; + const floor = rules?.caps?.floor_user ?? 1; + const gross = Math.max(floor, Math.min(pool * (w / W), cap)); + const surplus = Math.max(0, gross - floor); + const tax = Number(userEcoinTax || 0); + const future = floor + Math.max(0, surplus - tax); + const availClass = s.ubiAvailability === "OK" ? "ubi-available" : "ubi-unavailable"; + const availLabel = s.ubiAvailability === "OK" ? i18n.bankUbiAvailableOk : i18n.bankUbiAvailableNo; + return div({ class: "bank-summary" }, + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`), + kvRow(i18n.bankUbiAvailability, span({ class: availClass }, availLabel)), + s.pubId ? kvRow(i18n.pubIdLabel, userLink(s.pubId)) : null, + kvRow(i18n.bankEpoch, String(s.epochId || "-")), + kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`), + kvRow(i18n.bankWeightsSum, String(W.toFixed(6))), + kvRow(i18n.bankingUserEngagementScore, String(score)), + kvRow(i18n.bankOverviewYourTaxes || 'Your taxes', a({ href: '/banking?filter=taxes' }, `${tax.toFixed(6)} ECO`)), + kvRow(i18n.bankUbiThisMonth, `${future.toFixed(6)} ECO`) + ) + ) + ); +}; + +const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId, hasValidWallet, ubiAvailability) => { + if (alreadyClaimed) return ""; + if (!pubId && !isPub) return ""; + if (!isPub && !hasValidWallet) return ""; + if (!isPub && ubiAvailability !== "OK") return ""; + if (!pendingAllocation && !isPub) { + return div({ class: "bank-claim-ubi" }, + div({ class: "bank-claim-card" }, + form({ method: "POST", action: "/banking/claim-ubi" }, + button({ type: "submit", class: "create-button bank-claim-btn" }, i18n.bankClaimUBI) + ) + ) + ); + } + if (!pendingAllocation) return ""; + return div({ class: "bank-claim-ubi" }, + div({ class: "bank-claim-card" }, + p(`${i18n.bankUbiThisMonth}: `, span({ class: "accent" }, `${Number(pendingAllocation.amount || 0).toFixed(6)} ECO`)), + p(`${i18n.bankEpoch}: `, span(pendingAllocation.concept || "")), + form({ method: "POST", action: `/banking/claim/${encodeURIComponent(pendingAllocation.id)}` }, + button({ type: "submit", class: "create-button bank-claim-btn" }, isPub ? i18n.bankClaimAndPay : i18n.bankClaimUBI) + ) + ) + ); +}; + +const filterAllocations = (allocs, filter, userId) => { + if (filter === "mine") return allocs.filter(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED")); + if (filter === "pending") return allocs.filter(a => a.status === "UNCLAIMED" || a.status === "UNCONFIRMED"); + if (filter === "closed") return allocs.filter(a => a.status === "CLOSED"); + if (filter === "claimed") return allocs.filter(a => a.status === "CLAIMED"); + if (filter === "expired") return allocs.filter(a => a.status === "EXPIRED"); + return allocs; +}; + +const allocationsTable = (rows = [], userId) => + rows.length === 0 + ? div(p(i18n.bankNoAllocations)) + : table( + { class: "bank-allocs" }, + thead( + tr( + th(i18n.bankAllocDate), + th(i18n.bankAllocConcept), + th(i18n.bankAllocFrom), + th(i18n.bankAllocTo), + th(i18n.bankAllocAmount), + th(i18n.bankAllocStatus), + th("") + ) + ), + tbody( + ...rows.map(r => + tr( + td(new Date(r.createdAt).toLocaleString()), + td(r.concept || ""), + td(userLink(r.from)), + td(userLink(r.to)), + td(String(Number(r.amount || 0).toFixed(6))), + td(r.status), + td( + (r.status === "UNCLAIMED" || r.status === "UNCONFIRMED") && r.to === userId + ? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(r.id)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow) + ) + : r.status === "CLOSED" && r.txid + ? a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(r.txid)}`, target: "_blank", class: "btn-singleview" }, i18n.bankViewTx) + : null + ) + ) + ) + ) + ); + +const renderEpochList = (epochs = []) => + epochs.length === 0 + ? div(p(i18n.bankNoEpochs)) + : table( + { class: "bank-epochs" }, + thead(tr(th(i18n.bankEpochId), th(i18n.bankPool), th(i18n.bankWeightsSum), th(i18n.bankRuleHash), th(""))), + tbody( + ...epochs + .sort((a, b) => String(b.id).localeCompare(String(a.id))) + .map(e => + tr( + td(e.id), + td(String(Number(e.pool || 0).toFixed(6))), + td(String(Number(e.weightsSum || 0).toFixed(6))), + td(e.hash || "-"), + td( + form({ method: "GET", action: `/banking/epoch/${encodeURIComponent(e.id)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.bankViewEpoch) + ) + ) + ) + ) + ) + ); + +const rulesBlock = (rules, taxRules) => { + const r = rules || {}; + const caps = r.caps || {}; + const tr = taxRules || {}; + const fmt = (n, d = 4) => (Number.isFinite(Number(n)) ? Number(n).toFixed(d) : "—"); + return div({ class: "bank-rules" }, + h2(i18n.bankRulesPoolTitle || "Monthly pool"), + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankRulesEpochKind || "Epoch granularity", String(r.epochKind || "MONTHLY")), + kvRow(i18n.bankRulesAlpha || "Alpha (max share of pub balance per epoch)", `${fmt((r.alpha ?? 0) * 100, 2)} %`), + kvRow(i18n.bankRulesReserveMin || "Reserve minimum (kept in pub balance)", `${fmt(r.reserveMin, 6)} ECO`), + kvRow(i18n.bankRulesCapPerEpoch || "Cap per epoch (absolute)", `${fmt(r.capPerEpoch, 6)} ECO`), + kvRow(i18n.bankRulesGraceDays || "Claim grace period", `${fmt(r.graceDays, 0)} days`) + ) + ), + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesPoolFormula || "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)") + ), + + h2(i18n.bankRulesShareTitle || "Per-user share"), + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankRulesWMin || "Minimum weight (w_min)", fmt(caps.w_min, 4)), + kvRow(i18n.bankRulesWMax || "Maximum weight (w_max)", fmt(caps.w_max, 4)), + kvRow(i18n.bankRulesFloor || "Floor per user (gross)", `${fmt(caps.floor_user, 6)} ECO`), + kvRow(i18n.bankRulesCapUser || "Cap per user per epoch", `${fmt(caps.cap_user_epoch, 6)} ECO`) + ) + ), + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesWeightFormula || "w = clamp(1 + karma/100, w_min, w_max)") + ), + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesShareFormula || "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)") + ), + + h2(i18n.bankRulesKarmaTitle || "Karma (user engagement score)"), + p({ class: "bank-rules-note" }, i18n.bankRulesKarmaNote || "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed."), + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesKarmaFormula || "karma = max(0, round(scoreFromActions − carbonGramsForUser))") + ), + + h2(i18n.bankRulesEcoTaxTitle || "ECO Tax"), + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankRulesEcoTaxRate || "Rate", `${fmt(tr.ecoinPerGramCO2, 4)} ECO / g CO₂`), + kvRow(i18n.bankRulesCarbonFactor || "Carbon factor", `${fmt(tr.gramsCO2PerMiB, 4)} g CO₂ / MiB`) + ) + ), + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesEcoTaxFormula || "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2") + ), + p({ class: "bank-rules-note" }, i18n.bankRulesEcoTaxNote || "ECO Tax is deducted only from the surplus that the network generates above the immovable floor — the base UBI (floor_user) is never reduced. The deducted ECO are not redistributed in this epoch; they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps."), + h2(i18n.bankRulesChipTitle || "ECO Tax chip color band"), + p({ class: "bank-rules-note" }, i18n.bankRulesChipNote || "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band."), + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesChipFormula || "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.") + ), + + h2(i18n.bankRulesArchTaxTitle || "ARCH Tax"), + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankRulesArchTaxRate || "Rate", `${fmt(tr.ecoinPerDayOfHistory, 6)} ECO / day of history`) + ) + ), + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesArchTaxFormula || "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory") + ), + p({ class: "bank-rules-note" }, i18n.bankRulesArchTaxNote || "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost."), + + p({ class: "bank-rules-formula" }, + strong(i18n.bankRulesNetFormula || "surplus = max(0, gross_UBI − floor_user); net_UBI = floor_user + max(0, surplus − (ECO Tax + ARCH Tax))") + ) + ); +}; + +const flashText = (key) => { + if (key === "added") return i18n.bankAddressAdded; + if (key === "updated") return i18n.bankAddressUpdated; + if (key === "exists") return i18n.bankAddressExists; + if (key === "invalid") return i18n.bankAddressInvalid; + if (key === "deleted") return i18n.bankAddressDeleted; + if (key === "not_found") return i18n.bankAddressNotFound; + if (key === "claimed_pending") return i18n.bankClaimedPending; + if (key === "already_claimed") return i18n.bankAlreadyClaimedThisMonth; + if (key === "no_pub_configured") return i18n.bankNoPubConfigured; + if (key === "no_funds") return i18n.bankUbiAvailableNo; + if (key === "forbidden") return i18n.bankAddressForbidden; + return ""; +}; + +const flashBanner = (msgKey) => + !msgKey ? null : div({ class: "flash-banner" }, p(flashText(msgKey) || msgKey)); + +const addressesToolbar = (rows = [], search = "") => + div({ class: "addr-toolbar" }, + div({ class: "addr-counter accent-pill" }, + span({ class: "acc-title accent" }, i18n.bankAddressTotal + ":"), + span({ class: "acc-badge" }, String(rows.length)) + ), + form({ method: "GET", action: "/banking", class: "addr-search" }, + input({ type: "hidden", name: "filter", value: "addresses" }), + input({ type: "text", name: "q", placeholder: i18n.bankAddressSearch, value: search || "" }), + br(), + button({ type: "submit", class: "filter-btn" }, i18n.search) + ) + ); + +const renderAddresses = (data, userId) => { + const rows = data.addresses || []; + const search = data.search || ""; + return div( + data.flash ? flashBanner(data.flash) : null, + addressesToolbar(rows, search), + div({ class: "bank-addresses-stack" }, + div({ class: "addr-form-card wide" }, + h2(i18n.bankAddAddressTitle), + form({ method: "POST", action: "/banking/addresses", class: "addr-form" }, + div({ class: "form-row" }, + span({ class: "form-label accent" }, i18n.bankAddAddressUser + ":"), + input({ + class: "form-input xl", + type: "text", + name: "userId", + required: true, + pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", + placeholder: "@...=.ed25519", + id: "addr-user-id" + }) + ), + div({ class: "form-row" }, + span({ class: "form-label accent" }, i18n.bankAddAddressAddress + ":"), + input({ + class: "form-input xl", + type: "text", + name: "address", + required: true, + pattern: "^[A-Za-z0-9]{20,64}$", + placeholder: "ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX" + }) + ), + div({ class: "form-actions" }, + button({ type: "submit", class: "filter-btn" }, i18n.bankAddAddressSave) + ) + ) + ), + div({ class: "addr-list-card" }, + rows.length === 0 + ? div(p(i18n.bankNoAddresses)) + : table( + { class: "bank-addresses" }, + thead( + tr( + th(i18n.bankUser), + th(i18n.bankAddress), + th(i18n.bankAddressSource), + th(i18n.bankAddressActions) + ) + ), + tbody( + ...rows.map(r => + tr( + td(userLink(r.id)), + td(r.address), + td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis), + td( + div({ class: "row-actions" }, + form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" }, + input({ type: "hidden", name: "userId", value: r.id }), + input({ type: "hidden", name: "source", value: r.source || "local" }), + button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ); +}; + +const renderBankingView = (data, filter, userId, isPub) => + template( + i18n.banking, + section( + div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)), + data.flash ? div({ class: "flash-banner" }, p(flashText(data.flash) || data.flash)) : null, + generateFilterButtons(["overview","exchange","taxes","mine","pending","closed","claimed","expired","epochs","rules","addresses"], filter, "/banking"), + filter === "overview" + ? div( + renderOverviewSummaryTable(data.summary || {}, data.rules, data.userTotalTax || data.userEcoinTax), + renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId, (data.summary || {}).hasValidWallet, (data.summary || {}).ubiAvailability), + allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId) + ) + : filter === "exchange" + ? renderExchange(data.exchange, data.exchangeHistory, data.taxStats) + : filter === "taxes" + ? renderTaxes(data, data.lookup || null) + : filter === "epochs" + ? renderEpochList(data.epochs || []) + : filter === "rules" + ? rulesBlock(data.rules || {}, data.taxRules || {}) + : filter === "addresses" + ? renderAddresses(data, userId) + : allocationsTable( + filterAllocations((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), filter, userId), + userId + ) + ) + ); + +const renderSingleAllocationView = (alloc, userId) => { + if (!alloc) return template(i18n.banking, section(div(p(i18n.bankNoAllocations)))); + return template( + i18n.banking, + section( + div({ class: "tags-header" }, h2(i18n.banking)), + div({ class: "bank-summary" }, + table({ class: "bank-info-table" }, + tbody( + kvRow("ID", alloc.id || "-"), + kvRow(i18n.bankAllocConcept, alloc.concept || "-"), + kvRow(i18n.bankAllocFrom, alloc.from || "-"), + kvRow(i18n.bankAllocTo, alloc.to || "-"), + kvRow(i18n.bankAllocAmount, `${Number(alloc.amount || 0).toFixed(6)} ECO`), + kvRow(i18n.bankAllocStatus, alloc.status || "-"), + kvRow(i18n.bankAllocDate, alloc.createdAt ? fmtDate(alloc.createdAt) : "-"), + alloc.txid ? kvRow("TxID", a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(alloc.txid)}`, target: "_blank" }, alloc.txid)) : null + ) + ) + ), + alloc.status === "UNCONFIRMED" && alloc.to === userId + ? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(alloc.id)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow) + ) + : null, + div(a({ href: "/banking", class: "filter-btn" }, i18n.bankOverview)) + ) + ); +}; + +const renderEpochView = (epoch, allocations) => { + if (!epoch) return template(i18n.banking, section(div(p(i18n.bankNoEpochs)))); + return template( + i18n.banking, + section( + div({ class: "tags-header" }, h2(`${i18n.bankEpoch}: ${epoch.id}`)), + div({ class: "bank-summary" }, + table({ class: "bank-info-table" }, + tbody( + kvRow(i18n.bankEpochId, epoch.id || "-"), + kvRow(i18n.bankPool, `${Number(epoch.pool || 0).toFixed(6)} ECO`), + kvRow(i18n.bankWeightsSum, String(Number(epoch.weightsSum || 0).toFixed(6))), + kvRow(i18n.bankRuleHash, epoch.hash || "-") + ) + ) + ), + h2(i18n.bankEpochAllocations), + allocationsTable(allocations || [], "") + ) + ); +}; + +module.exports = { renderBankingView, renderSingleAllocationView, renderEpochView }; diff --git a/src/views/blockchain_view.js b/src/views/blockchain_view.js new file mode 100644 index 0000000..a70d752 --- /dev/null +++ b/src/views/blockchain_view.js @@ -0,0 +1,554 @@ +const { div, h2, h3, p, section, button, form, a, input, span, pre, table, tr, td, strong } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink } = require("../views/main_views"); +const moment = require("../server/node_modules/moment"); + +const FILTER_LABELS = { + votes: i18n.typeVotes, vote: i18n.typeVote, recent: i18n.recent, all: i18n.all, + mine: i18n.mine, tombstone: i18n.typeTombstone, logs: i18n.typeLog || 'LOGS', pixelia: i18n.typePixelia, + curriculum: i18n.typeCurriculum, document: i18n.typeDocument, bookmark: i18n.typeBookmark, + feed: i18n.typeFeed, event: i18n.typeEvent, task: i18n.typeTask, report: i18n.typeReport, + image: i18n.typeImage, audio: i18n.typeAudio, video: i18n.typeVideo, post: i18n.typePost, + forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub, + transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe, + project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim, + aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts, + map: i18n.typeMap, shop: i18n.typeShop, shopProduct: i18n.typeShopProduct || 'Shop Product', + pad: i18n.typePad || 'PAD', chat: i18n.typeChat || 'CHAT', gameScore: i18n.typeGameScore || 'GAME SCORE', + calendar: i18n.typeCalendar || 'CALENDAR', torrent: i18n.typeTorrent +}; + +const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone', 'logs']; +const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'calendar', 'parliament', 'courts']; +const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange']; +const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore']; +const CAT_BLOCK4 = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audio', 'document', 'map', 'torrent']; + +const SEARCH_FIELDS = ['author','id','from','to']; + +const formatCarbon = (bytes) => { + const n = Number(bytes) || 0; + if (!n) return '0 µg CO₂'; + const grams = (n / (1024 * 1024)) * 0.095; + if (grams >= 1) return `${grams.toFixed(2)} g CO₂`; + const mg = grams * 1000; + if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`; + const ug = mg * 1000; + return `${ug.toFixed(2)} µg CO₂`; +}; + +const hiddenSearchInputs = (search) => + SEARCH_FIELDS.map(k => { + const v = String(search?.[k] ?? '').trim(); + return v ? input({ type: 'hidden', name: k, value: v }) : null; + }).filter(Boolean); + +const toDatetimeLocal = (s) => { + const raw = String(s || '').trim(); + if (!raw) return ''; + const ts = new Date(raw).getTime(); + if (!Number.isFinite(ts)) return ''; + return moment(ts).format('YYYY-MM-DDTHH:mm'); +}; + +const toQueryString = (filter, search = {}) => { + const parts = []; + const f = String(filter || '').trim(); + if (f) parts.push(`filter=${encodeURIComponent(f)}`); + for (const k of SEARCH_FIELDS) { + const v = String(search?.[k] ?? '').trim(); + if (v) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + } + return parts.length ? `?${parts.join('&')}` : ''; +}; + +const filterBlocks = (blocks, filter, userId) => { + if (filter === 'recent') return blocks.filter(b => Date.now() - b.ts < 24*60*60*1000); + if (filter === 'mine') return blocks.filter(b => b.author === userId); + if (filter === 'all') return blocks; + if (filter === 'banking') return blocks.filter(b => b.type === 'bankWallet' || b.type === 'bankClaim'); + if (filter === 'parliament') { + const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']); + return blocks.filter(b => pset.has(b.type)); + } + if (filter === 'courts') { + const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']); + return blocks.filter(b => cset.has(b.type)); + } + if (filter === 'shop') return blocks.filter(b => b.type === 'shop' || b.type === 'shopProduct'); + if (filter === 'logs') return blocks.filter(b => b.type === 'log' && b.author === userId); + return blocks.filter(b => b.type === filter); +}; + +const computeStats = (blocks) => { + const arr = Array.isArray(blocks) ? blocks : []; + const byType = new Map(); + const byAuthor = new Map(); + for (const b of arr) { + if (!b || !b.type) continue; + byType.set(b.type, (byType.get(b.type) || 0) + 1); + if (b.author) byAuthor.set(b.author, (byAuthor.get(b.author) || 0) + 1); + } + const typeBreakdown = Array.from(byType.entries()) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count); + const topAuthors = Array.from(byAuthor.entries()) + .map(([author, count]) => ({ author, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + return { total: arr.length, typeBreakdown, topAuthors }; +}; + +const renderStatsPanel = (stats, currentFilter, search) => { + if (!stats || stats.total === 0) return null; + const topTypes = stats.typeBreakdown.slice(0, 10); + return div({ class: 'blockchain-stats' }, + div({ class: 'tags-header' }, + h3(`${i18n.blockchainStatsTitle || 'Stats'} (${stats.total})`) + ), + topTypes.length + ? div({ class: 'blockchain-stats-types' }, + h3({ class: 'blockchain-stats-subtitle' }, i18n.blockchainStatsTypeBreakdown || 'Type breakdown'), + div({ class: 'mode-buttons-cols' }, + topTypes.map(({ type, count }) => + form({ method: 'GET', action: '/blockexplorer' }, + input({ type: 'hidden', name: 'filter', value: type }), + ...hiddenSearchInputs(search), + button({ + type: 'submit', + class: currentFilter === type ? 'filter-btn active' : 'filter-btn' + }, `${(FILTER_LABELS[type] || type).toUpperCase()} (${count})`) + ) + ) + ) + ) + : null, + stats.topAuthors.length + ? div({ class: 'blockchain-stats-authors' }, + h3({ class: 'blockchain-stats-subtitle' }, i18n.blockchainStatsTopAuthors || 'Top authors'), + table({ class: 'block-info-table' }, + tr( + td({ class: 'card-label' }, i18n.blockchainBlockAuthor), + td({ class: 'card-label' }, i18n.blockchainStatsCount || 'Blocks') + ), + ...stats.topAuthors.map(({ author, count }) => + tr( + td(a({ href: `/blockexplorer?filter=all&author=${encodeURIComponent(author)}`, class: 'user-link block-author' }, author)), + td(String(count)) + ) + ) + ) + ) + : null + ); +}; + +const generateFilterButtons = (filters, currentFilter, action, search = {}) => + div({ class: 'mode-buttons-cols' }, + filters.map(mode => + form({ method: 'GET', action }, + input({ type: 'hidden', name: 'filter', value: mode }), + ...hiddenSearchInputs(search), + button({ + type: 'submit', + class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' + }, (FILTER_LABELS[mode]||mode).toUpperCase()) + ) + ) + ); + +const getViewDetailsAction = (type, block) => { + if (block && block.content && typeof block.content.encryptedPayload === 'string') return null; + switch (type) { + case 'votes': return `/votes/${encodeURIComponent(block.id)}`; + case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`; + case 'pixelia': return `/pixelia`; + case 'tribe': return `/tribe/${encodeURIComponent(block.id)}`; + case 'curriculum': return `/inhabitant/${encodeURIComponent(block.author)}`; + case 'image': return `/images/${encodeURIComponent(block.id)}`; + case 'audio': return `/audios/${encodeURIComponent(block.id)}`; + case 'video': return `/videos/${encodeURIComponent(block.id)}`; + case 'forum': return `/forum/${encodeURIComponent(block.content?.key||block.id)}`; + case 'document': return `/documents/${encodeURIComponent(block.id)}`; + case 'bookmark': return `/bookmarks/${encodeURIComponent(block.id)}`; + case 'event': return `/events/${encodeURIComponent(block.id)}`; + case 'task': return `/tasks/${encodeURIComponent(block.id)}`; + case 'about': return `/author/${encodeURIComponent(block.author)}`; + case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`; + case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`; + case 'contact': return `/inhabitants`; + case 'pub': return `/invites`; + case 'market': return `/market/${encodeURIComponent(block.id)}`; + case 'job': return `/jobs/${encodeURIComponent(block.id)}`; + case 'project': return `/projects/${encodeURIComponent(block.id)}`; + case 'report': return `/reports/${encodeURIComponent(block.id)}`; + case 'calendar': return `/calendars/${encodeURIComponent(block.id)}`; + case 'bankWallet': return `/wallet`; + case 'bankClaim': return `/banking${block.content?.epochId ? `/epoch/${encodeURIComponent(block.content.epochId)}` : ''}`; + case 'parliamentTerm': return `/parliament`; + case 'parliamentProposal': return `/parliament`; + case 'parliamentLaw': return `/parliament`; + case 'parliamentCandidature': return `/parliament`; + case 'parliamentRevocation': return `/parliament`; + case 'courtsCase': return `/courts`; + case 'courtsEvidence': return `/courts`; + case 'courtsAnswer': return `/courts`; + case 'courtsVerdict': return `/courts`; + case 'courtsSettlement': return `/courts`; + case 'courtsSettlementProposal': return `/courts`; + case 'courtsSettlementAccepted': return `/courts`; + case 'courtsNomination': return `/courts`; + case 'courtsNominationVote': return `/courts`; + case 'map': return `/maps/${encodeURIComponent(block.id)}`; + case 'torrent': return `/torrents/${encodeURIComponent(block.id)}`; + case 'mapMarker': return block.content?.mapId ? `/maps/${encodeURIComponent(block.content.mapId)}` : `/maps`; + case 'shop': return `/shops/${encodeURIComponent(block.id)}`; + case 'shopProduct': return `/shops/product/${encodeURIComponent(block.id)}`; + case 'pad': return `/pads/${encodeURIComponent(block.id)}`; + case 'chat': return `/chats/${encodeURIComponent(block.id)}`; + case 'gameScore': return `/games?filter=scoring`; + case 'log': return `/logs/view/${encodeURIComponent(block.id)}`; + case 'calendarDate': + case 'calendarNote': return block.content?.calendarId ? `/calendars/${encodeURIComponent(block.content.calendarId)}` : `/calendars`; + case 'padEntry': return block.content?.padId ? `/pads/${encodeURIComponent(block.content.padId)}` : `/pads`; + case 'chatMessage': return block.content?.roomId ? `/chats/${encodeURIComponent(block.content.roomId)}` : `/chats`; + default: return null; + } +}; + +const TYPE_COLORS = { + post:'#3498db', vote:'#9b59b6', votes:'#9b59b6', about:'#1abc9c', contact:'#16a085', + pub:'#2ecc71', tribe:'#e67e22', event:'#e74c3c', task:'#f39c12', report:'#c0392b', + image:'#2980b9', audio:'#8e44ad', video:'#d35400', document:'#27ae60', bookmark:'#f1c40f', + forum:'#1abc9c', feed:'#95a5a6', transfer:'#e74c3c', market:'#e67e22', job:'#3498db', + project:'#2ecc71', banking:'#f39c12', bankWallet:'#f39c12', bankClaim:'#f39c12', + pixelia:'#9b59b6', curriculum:'#1abc9c', aiExchange:'#3498db', tombstone:'#7f8c8d', + parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad', + parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad', + courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b', + courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b', + map:'#27ae60', mapMarker:'#27ae60', + shop:'#e67e22', shopProduct:'#e67e22', + pad:'#2ecc71', chat:'#3498db', gameScore:'#f39c12', + calendar:'#e74c3c' +}; + +const renderBlockDiagram = (blocks, qs) => { + const last2 = blocks.slice(0, 2); + if (!last2.length) return null; + + return div({ class: 'block-diagram-section' }, + h3({ class: 'block-diagram-title' }, i18n.blockchainLatestDatagram || 'Latest Datagram'), + ...last2.map(block => { + const ts = moment(block.ts).format('YYYY-MM-DD HH:mm:ss'); + const typeLabel = (FILTER_LABELS[block.type] || block.type).toUpperCase(); + const color = TYPE_COLORS[block.type] || '#95a5a6'; + const shortId = block.id.length > 20 ? block.id.slice(0, 10) + '…' + block.id.slice(-8) : block.id; + const shortAuthor = block.author.length > 20 ? block.author.slice(0, 10) + '…' + block.author.slice(-8) : block.author; + const contentKeys = Object.keys(block.content || {}).filter(k => k !== 'type').join(', '); + const flags = [ + block.isTombstoned ? 'TOMBSTONED' : null, + block.isReplaced ? 'REPLACED' : null, + block.content?.replaces ? 'EDIT' : null + ].filter(Boolean).join(' | ') || '—'; + + const datagramQs = qs ? `${qs}&view=datagram` : '?view=datagram'; + const typeClass = `bd-type-${String(block.type || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-')}`; + return a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}${datagramQs}`, class: 'block-diagram-link' }, + div({ class: `block-diagram ${typeClass}` }, + div({ class: 'block-diagram-ruler' }, + span('0'), span('4'), span('8'), span('16'), span('24'), span('31') + ), + div({ class: 'block-diagram-grid' }, + div({ class: 'block-diagram-cell bd-seq' }, + span({ class: 'bd-label' }, 'SEQ:'), + span({ class: 'bd-value' }, String(block.content?.sequence || '—')) + ), + div({ class: 'block-diagram-cell bd-type' }, + span({ class: 'bd-label' }, 'TYPE:'), + span({ class: 'bd-value' }, typeLabel) + ), + div({ class: 'block-diagram-cell bd-ts' }, + span({ class: 'bd-label' }, 'TIMESTAMP:'), + span({ class: 'bd-value' }, ts) + ), + div({ class: 'block-diagram-cell bd-id' }, + span({ class: 'bd-label' }, 'BLOCK ID:'), + span({ class: 'bd-value' }, shortId) + ), + div({ class: 'block-diagram-cell bd-author' }, + span({ class: 'bd-label' }, 'AUTHOR:'), + span({ class: 'bd-value' }, shortAuthor) + ), + div({ class: 'block-diagram-cell bd-flags' }, + span({ class: 'bd-label' }, 'FLAGS:'), + span({ class: 'bd-value' }, flags) + ), + div({ class: 'block-diagram-cell bd-ctype' }, + span({ class: 'bd-label' }, 'CONTENT.TYPE:'), + span({ class: 'bd-value' }, block.content?.type || '—') + ), + div({ class: 'block-diagram-cell bd-data' }, + span({ class: 'bd-label' }, 'CONTENT:'), + span({ class: 'bd-value' }, contentKeys || '—') + ) + ) + ) + ); + }) + ); +}; + +const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block', restricted = false) => { + if (!block || block.notAvailable) { + return template( + i18n.blockchain, + section( + div({ class: 'tags-header' }, + h2(i18n.blockchain), + p(i18n.blockchainDescription) + ), + div({ class: 'block-single' }, + block && block.id ? div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID || 'Block ID'}:`), + span({ class: 'blockchain-card-value' }, block.id) + ) : null, + p({ class: 'empty' }, i18n.blockchainBlockNotAvailable || 'This block is not available in your local feed. It may belong to a peer you are not yet replicating from.'), + div({ class: 'larp-actions' }, + a({ href: '/blockexplorer', class: 'filter-btn' }, i18n.blockchainBackToBlockexplorer || 'Back to blockexplorer') + ) + ) + ) + ); + } + + const qs = toQueryString(filter, search); + const isDatagram = viewMode === 'datagram'; + + const blockContent = restricted + ? div( + div({ class: 'block-single' }, + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`), + span({ class: 'blockchain-card-value' }, block.id) + ), + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`), + span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase()) + ), + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`), + span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')) + ) + ), + div({ class: 'block-row block-row--content' }, + p({ class: 'access-denied-msg' }, i18n.blockAccessRestricted) + ) + ) + : isDatagram + ? renderBlockDiagram([block], qs) + : div( + div({ class: 'block-single' }, + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`), + span({ class: 'blockchain-card-value' }, block.id) + ), + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`), + span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase()) + ), + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.oasisIdLabel || 'OasisID'}:`), + a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author) + ) + ), + div({ class:'block-row block-row--content' }, + div({ class:'block-content-preview' }, + block.content && typeof block.content.encryptedPayload === 'string' + ? (() => { + const { renderEncryptedChip } = require('./clearnet_view'); + return div({ class: 'encrypted-payload-box' }, + div({ class: 'title-with-chip' }, renderEncryptedChip(i18n)), + pre({ class: 'json-content' }, String(block.content.encryptedPayload).slice(0, 128) + (String(block.content.encryptedPayload).length > 128 ? '…' : '')) + ); + })() + : pre({ class:'json-content' }, JSON.stringify(block.content,null,2)) + ) + ), + div({ class: 'block-single' }, + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`), + span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')) + ), + div({ class: 'block-row block-row--meta' }, + span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockCarbon || 'Carbon footprint'}:`), + span({ class: 'blockchain-card-value' }, formatCarbon(block.size)) + ) + ) + ); + + return template( + i18n.blockchain, + section( + div({ class: 'tags-header' }, + h2(i18n.blockchain), + p(i18n.blockchainDescription) + ), + div({ class: 'mode-buttons-row' }, + div({ class: 'filter-column' }, + generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', search) + ), + div({ class: 'filter-column' }, + generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', search), + generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', search) + ), + div({ class: 'filter-column' }, + generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', search), + generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', search) + ) + ), + blockContent, + div({ class:'block-row block-row--back' }, + form({ method:'GET', action:'/blockexplorer' }, + input({ type: 'hidden', name: 'filter', value: filter }), + ...hiddenSearchInputs(search), + button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`) + ), + !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ? + form({ method:'GET', action:getViewDetailsAction(block.type, block) }, + button({ type:'submit', class:'filter-btn' }, i18n.visitContent) + ) + : (block.isTombstoned || block.isReplaced) ? + div({ class: 'deleted-label' }, + i18n.blockchainContentDeleted || "This content has been deleted." + ) + : null + ) + ) + ); +}; + +const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {}) => { + const s = search || {}; + const authorVal = String(s.author || ''); + const idVal = String(s.id || ''); + const fromVal = toDatetimeLocal(s.from); + const toVal = toDatetimeLocal(s.to); + const inspect = extras && extras.inspect ? extras.inspect : null; + const inspectVal = inspect ? String(inspect.block || '') : ''; + + const shown = filterBlocks(blocks, filter, userId); + const qs = toQueryString(filter, s); + + return template( + i18n.blockchain, + section( + div({ class:'tags-header' }, + h2(i18n.blockchain), + p(i18n.blockchainDescription) + ), + div({ class:'mode-buttons-row' }, + div({ class: 'filter-column' }, + generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', s) + ), + div({ class: 'filter-column' }, + generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', s), + generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', s) + ), + div({ class: 'filter-column' }, + generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', s), + generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', s) + ) + ), + div({ class: 'blockexplorer-search' }, + form({ method: 'GET', action: '/blockexplorer', class: 'blockexplorer-search-form' }, + input({ type: 'hidden', name: 'filter', value: filter }), + div({ class: 'blockexplorer-search-row' }, + div({ class: 'blockexplorer-search-pair' }, + input({ type: 'text', name: 'id', value: idVal, placeholder: i18n.blockchainBlockID, class: 'blockexplorer-search-input' }), + input({ type: 'text', name: 'author', value: authorVal, placeholder: i18n.courtsJudgeIdPh, class: 'blockexplorer-search-input' }) + ), + div({ class: 'blockexplorer-search-dates' }, + input({ type: 'datetime-local', name: 'from', value: fromVal, class: 'blockexplorer-search-input' }), + input({ type: 'datetime-local', name: 'to', value: toVal, class: 'blockexplorer-search-input' }) + ), + div({ class: 'blockexplorer-search-actions' }, + button({ type: 'submit', class: 'filter-box__button' }, i18n.searchSubmit) + ) + ) + ) + ), + div({ id: 'inspect', class: 'blockexplorer-inspect' }, + h3(i18n.bankTaxesLookupTitle || 'Inspect a block'), + p(i18n.bankTaxesLookupNote || 'Enter a block ID to look it up in your local feed. The inspector shows the block ID (linked to its detail page), its author, its content type, its size in bytes, its carbon footprint and its ECO tax in ECO.'), + form({ method: 'GET', action: '/blockexplorer', class: 'blockexplorer-search-form' }, + input({ type: 'hidden', name: 'filter', value: filter }), + div({ class: 'blockexplorer-search-row' }, + div({ class: 'blockexplorer-search-pair' }, + input({ type: 'text', name: 'inspect', value: inspectVal, placeholder: i18n.bankTaxesLookupPlaceholder || 'Block ID (e.g. %abc...=.sha256)', class: 'blockexplorer-search-input' }) + ), + div({ class: 'blockexplorer-search-actions' }, + button({ type: 'submit', class: 'filter-box__button' }, i18n.bankTaxesLookupButton || 'Inspect') + ) + ) + ), + inspect + ? (inspect.found + ? div({ class: 'bank-taxes-lookup-result' }, + table({ class: 'bank-info-table' }, + tr(td({ class: 'card-label' }, i18n.blockchainBlockID || 'Block ID'), td({ class: 'card-value' }, a({ href: `/blockexplorer/block/${encodeURIComponent(inspect.block)}` }, inspect.block))), + tr(td({ class: 'card-label' }, i18n.bankTaxesLookupAuthor || 'Author'), td({ class: 'card-value' }, inspect.author ? userLink(inspect.author) : '—')), + tr(td({ class: 'card-label' }, i18n.bankTaxesLookupType || 'Type'), td({ class: 'card-value' }, String(inspect.blockType || '—'))), + tr(td({ class: 'card-label' }, i18n.bankTaxesLookupSize || 'Size'), td({ class: 'card-value' }, `${Number(inspect.size || 0).toLocaleString()} B`)), + tr(td({ class: 'card-label' }, i18n.bankTaxesLookupCarbon || 'Carbon footprint'), td({ class: 'card-value' }, formatCarbon(inspect.size || 0))), + tr(td({ class: 'card-label' }, i18n.bankTaxesLookupEcoin || 'ECO Tax'), td({ class: 'card-value' }, `${Number(inspect.ecoinTax || 0).toFixed(6)} ECO`)) + ) + ) + : p({ class: 'empty' }, i18n.bankTaxesLookupNotFound || 'No block found in your local feed with that ID.')) + : null + ), + renderBlockDiagram(shown, qs), + h2({ class: 'block-diagram-title' }, 'Blockchain Blocks'), + shown.length === 0 + ? div(p(i18n.blockchainNoBlocks)) + : shown + .sort((a,b)=>{ + const ta = a.type==='market'&&a.content.updatedAt + ? new Date(a.content.updatedAt).getTime() + : a.ts; + const tb = b.type==='market'&&b.content.updatedAt + ? new Date(b.content.updatedAt).getTime() + : b.ts; + return tb - ta; + }) + .map(block=> + div({ class:'block' }, + div({ class:'block-buttons' }, + block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'), + block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'), + !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ? + form({ method:'GET', action:getViewDetailsAction(block.type, block) }, + button({ type:'submit', class:'filter-btn' }, i18n.visitContent) + ) + : (block.isTombstoned || block.isReplaced) ? + div({ class: 'deleted-label' }, + i18n.blockchainContentDeleted || "This content has been deleted." + ) + : null + ), + div({ class:'block-row block-row--meta' }, + table({ class:'block-info-table' }, + tr(td({ class:'card-label' }, i18n.blockchainBlockTimestamp), td({ class:'card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))), + tr(td({ class:'card-label' }, i18n.blockchainBlockID), td({ class:'card-value' }, block.id)), + tr(td({ class:'card-label' }, i18n.blockchainBlockType), td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())), + tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor), td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author))), + tr(td({ class:'card-label' }, i18n.blockchainBlockCarbon || 'Carbon footprint'), td({ class:'card-value' }, formatCarbon(block.size))) + ) + ) + ) + ) + ) + ); +}; + +module.exports = { renderBlockchainView, renderSingleBlockView, computeStats }; + diff --git a/src/views/bookmark_view.js b/src/views/bookmark_view.js new file mode 100644 index 0000000..24381ba --- /dev/null +++ b/src/views/bookmark_view.js @@ -0,0 +1,511 @@ +const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } = + require("../server/node_modules/hyperaxe"); + +const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl"); + +const userId = config.keys.id; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all"); + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const parts = [`filter=${encodeURIComponent(f)}`]; + if (q) parts.push(`q=${encodeURIComponent(q)}`); + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`); + return `/bookmarks?${parts.join("&")}`; +}; + +const renderPMButton = (recipient, className = "filter-btn") => { + const r = safeText(recipient); + if (!r) return null; + if (String(r) === String(userId)) return null; + + return form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: r }), + button({ type: "submit", class: className }, i18n.privateMessage) + ); +}; + +const renderBookmarkActions = (filter, bookmark, params = {}) => { + const returnTo = buildReturnTo(filter, params); + const isAuthor = String(bookmark.author) === String(userId); + const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0; + + return isAuthor + ? div( + { class: "bookmark-actions" }, + !hasOpinions + ? form( + { method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton) + ) + : null, + form( + { method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton) + ) + ) + : null; +}; + +const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], returnTo = null) => { + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + const commentsCount = list.length; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/bookmarks/${encodeURIComponent(bookmarkId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || ""; + const ts = c?.value?.timestamp || c?.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c?.value?.content || {}; + const text = content.text || ""; + const threadRoot = content.fork || content.root || null; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && threadRoot + ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate) + : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ); +}; + +const renderCardField = (labelText, value) => + div( + { class: "card-field" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, value) + ); + +const renderFavoriteToggle = (bookmark, returnTo) => + form( + { + method: "POST", + action: bookmark.isFavorite + ? `/bookmarks/favorites/remove/${encodeURIComponent(bookmark.id)}` + : `/bookmarks/favorites/add/${encodeURIComponent(bookmark.id)}`, + class: "bookmark-favorite-form" + }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button( + { type: "submit", class: "filter-btn" }, + bookmark.isFavorite ? i18n.bookmarkRemoveFavoriteButton : i18n.bookmarkAddFavoriteButton + ) + ); + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div( + { class: "card-tags" }, + list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; +}; + +const renderBookmarkList = (filteredBookmarks, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params); + + return filteredBookmarks.length + ? filteredBookmarks.map((bookmark) => { + const commentCount = typeof bookmark.commentCount === "number" ? bookmark.commentCount : 0; + + const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null; + const lastVisitTxt = + lastVisit && lastVisit.isValid() + ? `${lastVisit.format("YYYY/MM/DD HH:mm:ss")} (${lastVisit.fromNow()})` + : i18n.noLastVisit; + + const urlLink = bookmark.url + ? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url) + : i18n.noUrl; + + const isOwn = bookmark.author && String(bookmark.author) === String(userId); + return div( + { class: "trending-card bookmark-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(bookmark.id, `/bookmarks/${encodeURIComponent(bookmark.id)}`) + ), + div( + { class: "card-section bookmark-card-body" }, + div( + { class: "bookmark-topbar" }, + div( + { class: "bookmark-topbar-left" }, + renderPMButton(bookmark.author), + renderFavoriteToggle(bookmark, returnTo) + ), + div( + { class: "bookmark-topbar-right" }, + renderBookmarkActions(filter, bookmark, params) + ) + ), + h2({ class: "bookmark-title" }, bookmark.category || bookmark.url || ""), + bookmark.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(bookmark.lifetime, i18n)) : null, + renderCardField(i18n.bookmarkUrlLabel + ":", urlLink), + renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt), + br, + div( + { class: "card-comments-summary" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ":"), + span({ class: "card-value" }, String(commentCount)), + br(), + br(), + form( + { method: "GET", action: `/bookmarks/${encodeURIComponent(bookmark.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: filter || "all" }), + params.q ? input({ type: "hidden", name: "q", value: params.q }) : null, + params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null, + button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton) + ) + ), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(Object.values(bookmark.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) + ), + renderOpinionsVoting('/bookmarks/opinions', bookmark.id, bookmark.opinions, returnTo, bookmark.opinions_inhabitants) + ), + div({ class: "card-spread-left" }, renderSpreadButton(bookmark.id, (params.spreadMap && params.spreadMap.get(bookmark.id)) || params.spreads)), + (() => { + const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN; + const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(bookmark.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })() + ) + ); + }) + : p(params.q ? i18n.bookmarkNoMatch : i18n.noBookmarks); +}; + +const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags, params = {}) => { + const returnFilter = filter === "create" ? "all" : params.filter || "all"; + const returnTo = params.returnTo || buildReturnTo(returnFilter, params); + + const lastVisitValue = + bookmarkToEdit?.lastVisit && moment(bookmarkToEdit.lastVisit).isValid() + ? moment(bookmarkToEdit.lastVisit).format("YYYY-MM-DDTHH:mm") + : ""; + + const lastVisitMax = moment().format("YYYY-MM-DDTHH:mm"); + + return div( + { class: "div-center bookmark-form" }, + form( + { action: filter === "edit" ? `/bookmarks/update/${encodeURIComponent(bookmarkId)}` : "/bookmarks/create", method: "POST" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + label(i18n.bookmarkUrlLabel), + br(), + input({ + type: "url", + name: "url", + id: "url", + required: true, + placeholder: i18n.bookmarkUrlPlaceholder, + value: filter === "edit" ? bookmarkToEdit.url || "" : "" + }), + br(), + br(), + label(i18n.bookmarkDescriptionLabel), + br(), + textarea( + { name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, rows: "4" }, + filter === "edit" ? bookmarkToEdit.description || "" : "" + ), + br(), + label(i18n.bookmarkLastVisitLabel), + br(), + input({ + type: "datetime-local", + name: "lastVisit", + max: lastVisitMax, + value: filter === "edit" ? lastVisitValue : "" + }), + br(), + br(), + label(i18n.bookmarkCategoryLabel), + br(), + input({ + type: "text", + name: "category", + id: "category", + placeholder: i18n.bookmarkCategoryPlaceholder, + value: filter === "edit" ? bookmarkToEdit.category || "" : "" + }), + br(), + label(i18n.bookmarkTagsLabel), + br(), + input({ + type: "text", + name: "tags", + id: "tags", + placeholder: i18n.bookmarkTagsPlaceholder, + value: filter === "edit" ? safeArr(tags).join(", ") : "" + }), + br(), + br(), + button({ type: "submit" }, filter === "edit" ? i18n.bookmarkUpdateButton : i18n.bookmarkCreateButton) + ) + ); +}; + +exports.bookmarkView = async (bookmarks, filter = "all", bookmarkId = null, params = {}) => { + const title = + filter === "mine" + ? i18n.bookmarkMineSectionTitle + : filter === "create" + ? i18n.bookmarkCreateSectionTitle + : filter === "edit" + ? i18n.bookmarkUpdateSectionTitle + : filter === "recent" + ? i18n.bookmarkRecentSectionTitle + : filter === "top" + ? i18n.bookmarkTopSectionTitle + : filter === "favorites" + ? i18n.bookmarkFavoritesSectionTitle + : i18n.bookmarkAllSectionTitle; + + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + + const list = safeArr(bookmarks); + const bookmarkToEdit = bookmarkId ? list.find((b) => b.id === bookmarkId) : null; + const tags = bookmarkToEdit && Array.isArray(bookmarkToEdit.tags) ? bookmarkToEdit.tags : []; + + return template( + title, + section( + div({ class: "tags-header" }, h2(title), p(i18n.bookmarkDescription)), + (() => { + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetBookmarks); + return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n)); + })(), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterMine), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterTop), + button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterFavorites), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterRecent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton) + ) + ) + ), + section( + filter === "edit" || filter === "create" + ? renderBookmarkForm(filter, bookmarkId, bookmarkToEdit || {}, tags, { ...params, filter }) + : section( + div( + { class: "bookmarks-search" }, + form( + { method: "GET", action: "/bookmarks", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", value: q, placeholder: i18n.bookmarkSearchPlaceholder, class: "filter-box__input" }), + div( + { class: "filter-box__controls" }, + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.bookmarkSortRecent), + option({ value: "oldest", selected: sort === "oldest" }, i18n.bookmarkSortOldest), + option({ value: "top", selected: sort === "top" }, i18n.bookmarkSortTop) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.bookmarkSearchButton) + ) + ) + ), + div({ class: "bookmark-list" }, renderBookmarkList(list, filter, { q, sort })) + ) + ) + ); +}; + +exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], params = {}) => { + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const returnTo = params.returnTo || buildReturnTo(filter, { q, sort }); + + const isAuthor = String(bookmark.author) === String(userId); + const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0; + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetBookmarks); + + const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null; + const lastVisitTxt = + lastVisit && lastVisit.isValid() + ? `${lastVisit.format("YYYY/MM/DD HH:mm:ss")} (${lastVisit.fromNow()})` + : i18n.noLastVisit; + + const urlLink = bookmark.url + ? a({ href: bookmark.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, bookmark.url) + : i18n.noUrl; + + const chips = [ + renderLifespanChip(bookmark.lifetime, i18n), + bookmark.sizeBytes ? renderEcoTax(bookmark.sizeBytes, bookmark.id) : null + ].filter(Boolean); + + const sideActions = []; + sideActions.push(renderFavoriteToggle(bookmark, returnTo)); + if (bookmark.author && String(bookmark.author) !== String(userId)) { + sideActions.push(renderPMButton(bookmark.author)); + } + if (isAuthor && !hasOpinions) { + sideActions.push(form( + { method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.bookmarkUpdateButton) + )); + } + if (isAuthor) { + sideActions.push(form( + { method: "POST", action: `/bookmarks/delete/${encodeURIComponent(bookmark.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.bookmarkDeleteButton) + )); + } + + const tagsNode = renderTags(bookmark.tags); + + const bookmarkSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, bookmark.category || bookmark.url || ""), + renderReachChip(isClearnet, i18n) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + safeText(bookmark.description) + ? p({ class: "tribe-side-description" }, ...renderUrl(bookmark.description)) + : null, + safeText(bookmark.category) + ? renderCardField(i18n.bookmarkCategoryLabel + ":", safeText(bookmark.category)) + : null, + tagsNode, + div({ class: "card-spread-centered" }, renderSpreadButton(bookmark.id, params.spreads)), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const bookmarkMain = div({ class: "tribe-main" }, + renderCardField(i18n.bookmarkUrlLabel + ":", urlLink), + renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt), + (() => { + const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN; + const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(bookmark.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })(), + renderOpinionsVoting('/bookmarks/opinions', bookmark.id, bookmark.opinions, returnTo, bookmark.opinions_inhabitants), + renderBookmarkCommentsSection(bookmark.id, bookmark.rootId, comments, returnTo) + ); + + return template( + i18n.bookmarkTitle, + section( + div({ class: "tags-header" }, + h2(i18n.bookmarkAllSectionTitle || i18n.bookmarkTitle), + p(i18n.bookmarkDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterMine), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterTop), + button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterFavorites), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.bookmarkFilterRecent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.bookmarkCreateButton) + ) + ), + div({ class: "tribe-details" }, bookmarkSide, bookmarkMain) + ) + ); +}; + diff --git a/src/views/calendars_view.js b/src/views/calendars_view.js new file mode 100644 index 0000000..2ae1127 --- /dev/null +++ b/src/views/calendars_view.js @@ -0,0 +1,444 @@ +const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views") +const { renderEncryptedChip } = require("./clearnet_view") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") + +const userId = config.keys.id + +const renderNoteText = (text) => { + if (!text) return [] + const urlRegex = /(https?:\/\/[^\s]+)/g + const result = [] + let last = 0 + text.replace(urlRegex, (match, _g, offset) => { + if (offset > last) result.push(text.slice(last, offset)) + result.push(a({ href: match, target: "_blank" }, match)) + last = offset + match.length + }) + if (last < text.length) result.push(text.slice(last)) + return result +} + +const renderCalendarFavoriteToggle = (cal, returnTo) => + form( + { method: "POST", action: cal.isFavorite ? `/calendars/favorites/remove/${encodeURIComponent(cal.rootId)}` : `/calendars/favorites/add/${encodeURIComponent(cal.rootId)}` }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button({ type: "submit", class: "tribe-action-btn" }, cal.isFavorite ? (i18n.calendarRemoveFavorite || "Remove Favorite") : (i18n.calendarAddFavorite || "Add Favorite")) + ) + +const renderModeButtons = (currentFilter) => + div({ class: "tribe-mode-buttons" }, + ["all", "mine", "recent", "favorites", "open", "closed"].map(f => + form({ method: "GET", action: "/calendars" }, + input({ type: "hidden", name: "filter", value: f }), + button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, + i18n[`calendarFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase()) + ) + ), + form({ method: "GET", action: "/calendars" }, + input({ type: "hidden", name: "filter", value: "create" }), + button({ type: "submit", class: "create-button" }, i18n.calendarCreate || "Create Calendar") + ) + ) + +const renderStatus = (cal) => { + if (cal.isClosed) return span({ class: "pad-status-closed" }, i18n.calendarStatusClosed || "CLOSED") + return span({ class: "pad-status-open" }, i18n.calendarStatusOpen || "OPEN") +} + +const renderCalendarStatusChip = (cal) => { + const isClosed = !!cal.isClosed + const variant = isClosed ? "closed" : "mutuals" + const icon = isClosed ? "\u2717" : "\u2713" + const label = isClosed ? (i18n.calendarStatusClosed || "CLOSED") : (i18n.calendarStatusOpen || "OPEN") + return renderStateChip(variant, icon, label) +} + +const renderCalendarCard = (cal, spreadInfo) => { + const href = `/calendars/${encodeURIComponent(cal.rootId)}` + const chips = [ + renderCalendarStatusChip(cal), + renderEncryptedChip(i18n), + renderLifespanChip(cal.lifetime, i18n) + ].filter(Boolean) + return div({ class: "tribe-card" }, + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, a({ href }, cal.title || "\u2014")) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + cal.deadline + ? p({ class: "job-meta-line" }, `${i18n.calendarDeadlineLabel || "Deadline"}: ${moment(cal.deadline).format("YYYY-MM-DD HH:mm")}`) + : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(cal.rootId, spreadInfo)), + div({ class: "card-visit-btn-centered" }, + a({ href, class: "filter-btn" }, i18n.calendarVisitCalendar || "Visit Calendar") + ) + ) + ) +} + +const renderIntervalBlock = () => + div({ class: "calendar-interval-block" }, + span({ class: "calendar-interval-label" }, i18n.calendarIntervalLabel || "Interval"), + div({ class: "calendar-interval-row" }, + input({ type: "hidden", name: "intervalWeekly", value: "0" }), + label({ class: "calendar-interval-option" }, + input({ type: "checkbox", name: "intervalWeekly", value: "1" }), + " ", i18n.calendarIntervalWeekly || "Weekly" + ), + input({ type: "hidden", name: "intervalMonthly", value: "0" }), + label({ class: "calendar-interval-option" }, + input({ type: "checkbox", name: "intervalMonthly", value: "1" }), + " ", i18n.calendarIntervalMonthly || "Monthly" + ), + input({ type: "hidden", name: "intervalYearly", value: "0" }), + label({ class: "calendar-interval-option" }, + input({ type: "checkbox", name: "intervalYearly", value: "1" }), + " ", i18n.calendarIntervalYearly || "Yearly" + ) + ), + span({ class: "calendar-interval-label calendar-interval-until" }, i18n.calendarIntervalUntil || "Until"), + input({ type: "datetime-local", name: "intervalDeadline" }), + br() + ) + +const renderCreateForm = (calendarToEdit, params) => { + const isEdit = !!calendarToEdit + const tribeId = (params && params.tribeId) || "" + const now = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm") + const action = isEdit ? `/calendars/update/${encodeURIComponent(calendarToEdit.rootId)}` : "/calendars/create" + const sectionTitle = isEdit ? (i18n.calendarUpdateSectionTitle || "Update Calendar") : (i18n.calendarCreateSectionTitle || "Create New Calendar") + return div({ class: "div-center audio-form" }, + h2(sectionTitle), + form({ method: "POST", action }, + tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null, + span(i18n.calendarTitleLabel || "Title"), br(), + input({ type: "text", name: "title", required: true, placeholder: i18n.calendarTitlePlaceholder || "Calendar title...", value: calendarToEdit ? calendarToEdit.title : "" }), + br(), br(), + span(i18n.calendarStatusLabel || "Status"), br(), + select({ name: "status", required: true }, + option({ value: "OPEN", ...((!calendarToEdit || calendarToEdit.status === "OPEN") ? { selected: true } : {}) }, i18n.calendarStatusOpen || "OPEN"), + option({ value: "CLOSED", ...((calendarToEdit && calendarToEdit.status === "CLOSED") ? { selected: true } : {}) }, i18n.calendarStatusClosed || "CLOSED") + ), + br(), br(), + span(i18n.calendarDeadlineLabel || "Deadline"), br(), + input({ type: "datetime-local", name: "deadline", required: true, min: now, value: calendarToEdit && calendarToEdit.deadline ? moment(calendarToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }), + br(), br(), + span(i18n.calendarTagsLabel || "Tags"), br(), + input({ type: "text", name: "tags", placeholder: i18n.calendarTagsPlaceholder || "tag1, tag2...", value: calendarToEdit && Array.isArray(calendarToEdit.tags) ? calendarToEdit.tags.join(", ") : "" }), + br(), br(), + !isEdit + ? [ + span(i18n.calendarFirstDateLabel || "Date"), br(), + input({ type: "datetime-local", name: "firstDate", required: true, min: now }), + br(), br(), + span(i18n.calendarFormDescription || "Description"), br(), + input({ type: "text", name: "firstDateLabel", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }), + br(), br(), + renderIntervalBlock(), + span(i18n.calendarFirstNoteLabel || "Notes"), br(), + textarea({ name: "firstNote", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }), + br(), br() + ] + : null, + button({ type: "submit", class: "create-button" }, isEdit ? (i18n.calendarUpdate || "Update") : (i18n.calendarCreate || "Create Calendar")) + ) + ) +} + +const renderMonthGrid = (year, month, datesMap, calendarId) => { + const DAY_NAMES = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"] + const firstDay = new Date(year, month, 1) + const daysInMonth = new Date(year, month + 1, 0).getDate() + const startPad = (firstDay.getDay() + 6) % 7 + const monthStr = `${year}-${String(month + 1).padStart(2, "0")}` + + const headerCells = DAY_NAMES.map(d => div({ class: "calendar-day-header" }, d)) + const cells = [] + + for (let i = 0; i < startPad; i++) cells.push(div({ class: "calendar-day calendar-day-empty" }, " ")) + + for (let day = 1; day <= daysInMonth; day++) { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` + const marked = datesMap && datesMap[dateStr] && datesMap[dateStr].length > 0 + if (marked) { + cells.push( + div({ class: "calendar-day calendar-day-marked" }, + a({ href: `/calendars/${encodeURIComponent(calendarId)}?month=${monthStr}&day=${dateStr}` }, String(day)) + ) + ) + } else { + cells.push(div({ class: "calendar-day" }, String(day))) + } + } + + return div({ class: "calendar-grid" }, ...headerCells, ...cells) +} + +exports.renderCalendarInvitePage = (code) => { + const pageContent = div({ class: "invite-page" }, + h2(i18n.tribeInviteCodeText, code), + form({ method: "GET", action: "/calendars" }, + input({ type: "hidden", name: "filter", value: "all" }), + button({ type: "submit", class: "filter-btn" }, i18n.walletBack) + ) + ) + return template(i18n.calendarGenerateInvite || "Invite", section(pageContent)) +} + +exports.calendarsView = async (calendars, filter, calendarToEdit, params) => { + const q = (params && params.q) || "" + const showForm = filter === "create" || filter === "edit" || !!calendarToEdit + const headerMap = { + all: i18n.calendarAllSectionTitle || "Calendars", + mine: i18n.calendarMineSectionTitle || "Your Calendars", + recent: i18n.calendarRecentSectionTitle || "Recent Calendars", + favorites: i18n.calendarFavoritesSectionTitle || "Favorites", + open: i18n.calendarOpenSectionTitle || "Open Calendars", + closed: i18n.calendarClosedSectionTitle || "Closed Calendars" + } + const headerText = headerMap[filter] || headerMap.all + + return template( + i18n.calendarsTitle || "Calendars", + section( + div({ class: "tags-header" }, + h2(headerText), + p(i18n.calendarsDescription || "Discover and manage calendars.") + ), + renderModeButtons(filter), + q + ? div({ class: "filters" }, + form({ method: "GET", action: "/calendars" }, + input({ type: "text", name: "q", value: q, placeholder: i18n.calendarSearchPlaceholder || "Search calendars..." }), + button({ type: "submit", class: "filter-btn" }, i18n.searchButton || "Search") + ) + ) + : null + ), + section( + showForm + ? renderCreateForm(calendarToEdit, params) + : (calendars.length > 0 + ? div({ class: "tribe-grid" }, ...calendars.map(c => renderCalendarCard(c, params && params.spreadMap && params.spreadMap.get(c.rootId)))) + : p({ class: "no-content" }, i18n.calendarsNoItems || "No calendars found.")) + ) + ) +} + +exports.singleCalendarView = async (calendar, dates, notesByDate, params) => { + const { month: monthStr, day: selectedDay } = params || {} + const isAuthor = calendar.author === userId + const isParticipant = calendar.participants.includes(userId) + const calClosed = calendar.isClosed + const shareUrl = `/calendars/${encodeURIComponent(calendar.rootId)}` + + const now = moment() + const currentMonth = monthStr ? moment(monthStr, "YYYY-MM") : now.clone().startOf("month") + const prevMonth = currentMonth.clone().subtract(1, "month").format("YYYY-MM") + const nextMonth = currentMonth.clone().add(1, "month").format("YYYY-MM") + const year = currentMonth.year() + const month = currentMonth.month() + + const datesMap = {} + for (const d of dates) { + const dayKey = moment(d.date).format("YYYY-MM-DD") + if (!datesMap[dayKey]) datesMap[dayKey] = [] + datesMap[dayKey].push(d) + } + + const tags = Array.isArray(calendar.tags) && calendar.tags.length > 0 + ? div({ class: "tribe-side-tags" }, ...calendar.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t} `))) + : null + + const detailChips = [ + renderCalendarStatusChip(calendar), + renderEncryptedChip(i18n), + renderLifespanChip(calendar.lifetime, i18n) + ].filter(Boolean) + const calSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, calendar.title || "\u2014") + ), + detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(calendar.rootId, params && params.spreads)), + div({ class: "shop-share" }, + span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"), + input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" }) + ), + table({ class: "tribe-info-table" }, + tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY-MM-DD"))), + tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))), + tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author))), + calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY-MM-DD HH:mm"))) : null + ), + div({ class: "tribe-side-actions" }, + renderCalendarFavoriteToggle(calendar, shareUrl), + isAuthor + ? form({ method: "GET", action: "/calendars" }, + input({ type: "hidden", name: "filter", value: "edit" }), + input({ type: "hidden", name: "id", value: calendar.rootId }), + button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarUpdate || "Update") + ) + : null, + isAuthor && calendar.status !== "OPEN" + ? form({ method: "POST", action: `/calendars/generate-invite/${encodeURIComponent(calendar.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite) + ) + : null, + (() => { + if (!(isAuthor && !calendar.tribeId)) return null + const invs = Array.isArray(calendar.invites) ? calendar.invites : [] + const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code) + if (openInvite) return [ + div({ class: "tribe-open-invite" }, + span({ class: "card-label" }, i18n.tribeInviteCodeText), + span({ class: "tribe-open-invite-code" }, openInvite.code) + ), + form({ method: "POST", action: `/calendars/open-invite/remove/${encodeURIComponent(calendar.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation) + ) + ] + return form({ method: "POST", action: `/calendars/open-invite/create/${encodeURIComponent(calendar.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation) + ) + })(), + isAuthor + ? form({ method: "POST", action: `/calendars/delete/${encodeURIComponent(calendar.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDelete || "Delete") + ) + : null, + !isAuthor + ? a({ href: `/pm?to=${encodeURIComponent(calendar.author)}`, class: "tribe-action-btn" }, "PM") + : null, + !isAuthor && !isParticipant && calendar.status === "OPEN" + ? form({ method: "POST", action: `/calendars/join/${encodeURIComponent(calendar.rootId)}` }, + button({ type: "submit", class: "create-button" }, i18n.calendarJoin || "Join Calendar") + ) + : null, + !isAuthor && !isParticipant && calendar.status !== "OPEN" + ? a({ class: "tribe-action-btn", href: "/invites#invites-calendars" }, i18n.tribeEnterInvite) + : null, + !isAuthor && isParticipant + ? form({ method: "POST", action: `/calendars/leave/${encodeURIComponent(calendar.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeLeaveButton) + ) + : null + ), + tags, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`) + ) + ) + + const minDate = now.add(1, "minute").format("YYYY-MM-DDTHH:mm") + const canAddDate = !calClosed && (calendar.status === "OPEN" || isAuthor) + + const unifiedForm = canAddDate + ? div({ class: "div-center audio-form" }, + h4(i18n.calendarAddEntry || "Add Entry"), + form({ method: "POST", action: `/calendars/add-date/${encodeURIComponent(calendar.rootId)}` }, + span(i18n.calendarDateLabel || "Date"), br(), + input({ type: "datetime-local", name: "date", required: true, min: minDate }), + br(), br(), + span(i18n.calendarFormDescription || "Description"), br(), + input({ type: "text", name: "label", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }), + br(), br(), + renderIntervalBlock(), + br(), + isParticipant + ? [ + span(i18n.calendarNoteLabel || "Note (optional)"), br(), + textarea({ name: "text", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }), + br(), br() + ] + : null, + button({ type: "submit", class: "create-button" }, i18n.calendarAddEntry || "Add Entry") + ) + ) + : null + + const monthLabel = currentMonth.format("MMMM YYYY") + const calNav = div({ class: "calendar-nav" }, + a({ href: `${shareUrl}?month=${prevMonth}`, class: "filter-btn" }, i18n.calendarMonthPrev || "\u2190 Prev"), + span({ class: "tribe-info-label" }, monthLabel), + a({ href: `${shareUrl}?month=${nextMonth}`, class: "filter-btn" }, i18n.calendarMonthNext || "Next \u2192") + ) + + const grid = renderMonthGrid(year, month, datesMap, calendar.rootId) + + const dayEntries = selectedDay + ? dates.filter(d => moment(d.date).format("YYYY-MM-DD") === selectedDay) + : [] + + const dayNotesSection = selectedDay + ? div({ class: "calendar-day-notes" }, + h4(`${selectedDay}${dayEntries.length > 0 && dayEntries[0].label ? " \u2014 " + dayEntries[0].label : ""}`), + dayEntries.length === 0 + ? p({ class: "no-content" }, i18n.calendarNoDates || "No dates added yet.") + : div(null, ...dayEntries.map(d => { + const notes = (notesByDate && notesByDate[d.key]) ? notesByDate[d.key] : [] + return div({ class: "calendar-date-item" }, + (isAuthor || String(d.author) === String(userId)) + ? form({ method: "POST", action: `/calendars/delete-date/${encodeURIComponent(d.key)}`, class: "calendar-date-delete" }, + input({ type: "hidden", name: "calendarId", value: calendar.rootId }), + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteDate || "Delete Date") + ) + : null, + div({ class: "calendar-date-item-header" }, + `${moment(d.date).format("YYYY-MM-DD HH:mm")}${d.label ? " \u2014 " + d.label : ""}` + ), + (() => { + const visibleNotes = notes.filter(n => n.text && String(n.text).trim()) + return visibleNotes.length === 0 + ? p({ class: "no-content" }, i18n.calendarNoNotes || "No notes.") + : div(null, ...visibleNotes.map(n => { + const isSelf = String(n.author) === String(userId) + const dateStr = moment(n.createdAt).format("YYYY/MM/DD HH:mm") + const shortId = n.author ? "@" + n.author.slice(1, 9) + "\u2026" : "?" + return div({ class: (isSelf ? "chat-message chat-message-self" : "chat-message") + " calendar-note-card" }, + isSelf + ? form({ method: "POST", action: `/calendars/delete-note/${encodeURIComponent(n.key)}`, class: "calendar-note-delete" }, + input({ type: "hidden", name: "calendarId", value: calendar.rootId }), + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteNote || "Delete") + ) + : null, + div({ class: "chat-message-meta" }, + span({ class: "chat-message-sender" }, + userLink(n.author) + ), + span({ class: "chat-message-date" }, ` [ ${dateStr} ]`) + ), + span({ class: "chat-message-text" }, ...renderNoteText(n.text || "")) + ) + })) + })() + ) + })) + ) + : null + + const calMain = div({ class: "tribe-main" }, + calNav, + grid, + dayNotesSection, + unifiedForm + ) + + return template( + calendar.title || i18n.calendarsTitle || "Calendar", + section( + div({ class: "tags-header" }, + h2(i18n.calendarsTitle || "Calendars"), + p(i18n.calendarsDescription || "Discover and manage calendars.") + ), + renderModeButtons("all") + ), + section(div({ class: "tribe-details" }, calSide, calMain)) + ) +} diff --git a/src/views/chats_view.js b/src/views/chats_view.js new file mode 100644 index 0000000..a3c2c00 --- /dev/null +++ b/src/views/chats_view.js @@ -0,0 +1,387 @@ +const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views") +const { renderEncryptedChip } = require("./clearnet_view") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") +const { renderUrl } = require("../backend/renderUrl") + +const userId = config.keys.id +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() + +const CAT_BLOCK1 = ["GENERAL", "OASIS", "L.A.R.P.", "POLITICS", "TECH"] +const CAT_BLOCK2 = ["SCIENCE", "MUSIC", "ART", "GAMING", "BOOKS", "FILMS"] +const CAT_BLOCK3 = ["PHILOSOPHY", "SOCIETY", "PRIVACY", "CYBERWARFARE", "SURVIVALISM"] +const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3] + +const catKey = (c) => "forumCat" + String(c || "").replace(/\./g, "").replace(/[\s-]/g, "").toUpperCase() +const catLabel = (c) => i18n[catKey(c)] || c + +const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => { + if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + const s = String(value).trim() + if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + if (s.startsWith("&")) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs }) + return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null +} + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all") + const q = safeText(params.q || "") + const parts = [`filter=${encodeURIComponent(f)}`] + if (q) parts.push(`q=${encodeURIComponent(q)}`) + return `/chats?${parts.join("&")}` +} + +const renderModeButtons = (currentFilter) => + div({ class: "tribe-mode-buttons" }, + ["all", "mine", "recent", "favorites", "open", "closed"].map(f => + form({ method: "GET", action: "/chats" }, + input({ type: "hidden", name: "filter", value: f }), + button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, i18n[`chatFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase()) + ) + ), + form({ method: "GET", action: "/chats" }, + input({ type: "hidden", name: "filter", value: "create" }), + button({ type: "submit", class: "create-button" }, i18n.chatCreate) + ) + ) + +const renderChatStatusChip = (status) => { + const s = String(status || "OPEN").toUpperCase() + const variant = s === "CLOSED" ? "closed" : s === "INVITE-ONLY" ? "whole" : "mutuals" + const icon = s === "CLOSED" ? "\u2717" : s === "INVITE-ONLY" ? "\uD83D\uDD11" : "\u2713" + const label = s === "CLOSED" ? i18n.chatStatusClosed + : s === "INVITE-ONLY" ? i18n.chatStatusInviteOnly + : i18n.chatStatusOpen + return renderStateChip(variant, icon, label) +} + +const renderChatCard = (chat, filter, params = {}) => { + const chips = [ + renderChatStatusChip(chat.status), + renderEncryptedChip(i18n), + renderLifespanChip(chat.lifetime, i18n) + ].filter(Boolean) + + return div({ class: "tribe-card" }, + div({ class: "tribe-card-image-wrapper" }, + a({ href: `/chats/${encodeURIComponent(chat.key)}` }, + renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-card-hero-image" }) + ) + ), + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/chats/${encodeURIComponent(chat.key)}` }, chat.title || i18n.chatUntitled) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + chat.description ? p({ class: "tribe-card-description" }, chat.description) : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`) + ), + (() => { + const btn = renderSpreadButton(chat.key, (params && params.spreadMap && params.spreadMap.get(chat.key)) || (params && params.spreads)); + return btn ? div({ class: "card-spread-left" }, btn) : null; + })(), + div({ class: "visit-btn-centered" }, + a({ href: `/chats/${encodeURIComponent(chat.key)}`, class: "filter-btn" }, i18n.chatVisitChat) + ) + ) + ) +} + +const renderChatForm = (filter, chat = {}, params = {}) => { + const isEdit = filter === "edit" + const returnTo = safeText(params.returnTo) || buildReturnTo("all") + const tribeId = safeText(params.tribeId || "") + return div({ class: "div-center audio-form" }, + h2(isEdit ? i18n.chatUpdate : i18n.chatCreate), + form({ action: isEdit ? `/chats/update/${encodeURIComponent(chat.key || "")}` : "/chats/create", method: "POST", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null, + span(i18n.title || "Title"), br(), + input({ type: "text", name: "title", required: true, placeholder: i18n.chatTitlePlaceholder, value: chat.title || "" }), br(), br(), + span(i18n.chatDescription), br(), + textarea({ name: "description", rows: 4, placeholder: i18n.chatDescriptionPlaceholder }, chat.description || ""), br(), br(), + span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(), + input({ type: "file", name: "image", accept: "image/*" }), br(), br(), + span(i18n.chatCategory), br(), + select({ name: "category" }, + option({ value: "" }, "\u2014"), + ALL_CATS.map(cat => + option({ value: cat, ...(chat.category === cat ? { selected: true } : {}) }, catLabel(cat)) + ) + ), br(), br(), + span(i18n.chatStatusLabel || "Status"), br(), + select({ name: "status" }, + option({ value: "OPEN", ...((!chat.status || chat.status === "OPEN") ? { selected: true } : {}) }, i18n.chatStatusOpen), + option({ value: "INVITE-ONLY", ...(chat.status === "INVITE-ONLY" ? { selected: true } : {}) }, i18n.chatStatusInviteOnly) + ), br(), br(), + span(i18n.shopTags || "Tags"), br(), + input({ type: "text", name: "tags", placeholder: i18n.chatTagsPlaceholder, value: safeArr(chat.tags).join(", ") }), br(), br(), + button({ type: "submit" }, isEdit ? i18n.chatUpdate : i18n.chatCreate) + ) + ) +} + +const renderMessageText = (text) => { + if (!text) return span({ class: "chat-message-text" }, "") + const lines = String(text).split("\n") + const nodes = [] + lines.forEach((line, idx) => { + const rendered = renderUrl(line) + nodes.push(...rendered) + if (idx < lines.length - 1) nodes.push(br()) + }) + return span({ class: "chat-message-text" }, ...nodes) +} + +const renderMessage = (msg, chatAuthor) => { + const isAuthor = String(msg.author) === String(chatAuthor) + const isSelf = String(msg.author) === String(userId) + const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm") + const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?" + const authorLink = msg.author ? userLink(msg.author) : span("?") + + const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null + + return div({ class: isSelf ? "chat-message chat-message-self" : isAuthor ? "chat-message chat-message-author" : "chat-message" }, + div({ class: "chat-message-meta" }, + span({ class: "chat-message-sender" }, authorLink), + span({ class: "chat-message-date" }, ` [ ${dateStr} ]`) + ), + imageNode ? div({ class: "chat-message-image-wrap" }, imageNode) : null, + renderMessageText(msg.text || "") + ) +} + + +exports.renderChatInvitePage = (code) => { + const pageContent = div({ class: "invite-page" }, + h2(i18n.tribeInviteCodeText, code), + form({ method: "GET", action: "/chats" }, + input({ type: "hidden", name: "filter", value: "all" }), + button({ type: "submit", class: "filter-btn" }, i18n.walletBack) + ) + ) + return template(i18n.chatInviteMode || "Invite", section(pageContent)) +} + +exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => { + const q = safeText(params.q || "") + const list = safeArr(chats) + + const isForm = filter === "create" || filter === "edit" + + const chatHeaderMap = { + all: i18n.chatsTitle, + mine: i18n.chatMineSectionTitle || "Your Chats", + recent: i18n.chatRecentTitle || "Recent Chats", + favorites: i18n.chatFavoritesTitle || "Favorites", + open: i18n.chatOpenTitle || "Open Chats", + closed: i18n.chatClosedTitle || "Closed Chats" + } + const headerText = chatHeaderMap[filter] || i18n.chatsTitle + + return template( + i18n.chatsTitle, + section( + div({ class: "tags-header" }, + h2(headerText), + p(i18n.modulesChatsDescription) + ) + ), + section(renderModeButtons(filter)), + !isForm + ? section( + div({ class: "filters" }, + form({ method: "GET", action: "/chats" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", placeholder: i18n.chatSearchPlaceholder, value: q }), + br(), + button({ type: "submit" }, i18n.search), + br() + ) + ) + ) + : null, + section( + isForm + ? renderChatForm(filter, filter === "edit" ? (chatToEdit || {}) : {}, params) + : div({ class: "tribe-grid" }, + list.length + ? list.map(chat => renderChatCard(chat, filter, { q })) + : p(i18n.chatNoItems) + ) + ) + ) +} + +exports.singleChatView = async (chat, filter, messages = [], params = {}) => { + const q = safeText(params.q || "") + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q }) + const isAuthor = String(chat.author) === String(userId) + const isMember = safeArr(chat.members).includes(userId) || (!!chat.tribeId && !!chat.isTribeMember) + const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}` + const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY" + + const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed : + chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen + + const detailChips = [ + renderChatStatusChip(chat.status), + renderEncryptedChip(i18n), + renderLifespanChip(chat.lifetime, i18n) + ].filter(Boolean) + const chatSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, chat.title || i18n.chatUntitled) + ), + detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null, + renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-detail-image" }), + div({ class: "shop-share" }, + span({ class: "tribe-info-label" }, `${i18n.chatShareUrl}: `), + input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" }) + ), + (() => { + const btn = renderSpreadButton(chat.key, params.spreads); + return btn ? div({ class: "card-spread-centered" }, btn) : null; + })(), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`) + ), + table({ class: "tribe-info-table" }, + tr( + td({ class: "tribe-info-label" }, i18n.chatCreatedAt), + td({ class: "tribe-info-value", colspan: "3" }, moment(chat.createdAt).format("YYYY/MM/DD HH:mm")) + ), + isRestrictedInviteOnly ? null : tr( + td({ class: "tribe-info-value", colspan: "4" }, + userLink(chat.author) + ) + ), + !isRestrictedInviteOnly && chat.category ? tr( + td({ class: "tribe-info-label" }, i18n.chatCategoryLabel), + td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category)) + ) : null + ), + isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" }, + isAuthor && chat.status === "INVITE-ONLY" + ? form({ method: "POST", action: `/chats/generate-invite` }, + input({ type: "hidden", name: "chatId", value: chat.key }), + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite) + ) + : null, + (() => { + if (!(isAuthor && chat.status === "INVITE-ONLY")) return null + const openInvite = safeArr(chat.invites).find(inv => typeof inv === "object" && inv && inv.public === true && inv.code) + if (openInvite) return [ + div({ class: "tribe-open-invite" }, + span({ class: "card-label" }, i18n.tribeInviteCodeText), + span({ class: "tribe-open-invite-code" }, openInvite.code) + ), + form({ method: "POST", action: `/chats/open-invite/remove` }, + input({ type: "hidden", name: "chatId", value: chat.key }), + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation) + ) + ] + return form({ method: "POST", action: `/chats/open-invite/create` }, + input({ type: "hidden", name: "chatId", value: chat.key }), + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation) + ) + })(), + form( + { method: "POST", action: chat.isFavorite ? `/chats/favorites/remove/${encodeURIComponent(chat.key)}` : `/chats/favorites/add/${encodeURIComponent(chat.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "tribe-action-btn" }, chat.isFavorite ? i18n.chatRemoveFavorite : i18n.chatAddFavorite) + ), + chat.author && String(chat.author) !== String(userId) + ? form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: chat.author }), + button({ type: "submit", class: "tribe-action-btn" }, i18n.chatPM || i18n.privateMessage) + ) + : null, + isAuthor + ? form({ method: "GET", action: `/chats/edit/${encodeURIComponent(chat.key)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.chatUpdate) + ) + : null, + isAuthor && chat.status !== "CLOSED" + ? form({ method: "POST", action: `/chats/close/${encodeURIComponent(chat.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "tribe-action-btn" }, i18n.chatClose) + ) + : null, + isAuthor + ? form({ method: "POST", action: `/chats/delete/${encodeURIComponent(chat.key)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.chatDelete) + ) + : null, + !isAuthor && isMember + ? form({ method: "POST", action: `/chats/leave/${encodeURIComponent(chat.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeLeaveButton) + ) + : null + ), + !isMember && chat.status === "INVITE-ONLY" + ? div({ class: "chat-join-section" }, + a({ class: "tribe-action-btn", href: "/invites#invites-chats" }, i18n.tribeEnterInvite) + ) + : null, + !isRestrictedInviteOnly && safeArr(chat.tags).length + ? div({ class: "tribe-side-tags" }, + safeArr(chat.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null + ) + + const msgList = safeArr(messages) + const canWrite = (isMember || chat.status === "OPEN") && chat.status !== "CLOSED" + + const chatMain = isRestrictedInviteOnly + ? div({ class: "tribe-main chat-full-width" }, p({ class: "access-denied-msg" }, i18n.chatAccessDenied)) + : div({ class: "tribe-main chat-full-width" }, + canWrite + ? div({ class: "chat-message-form" }, + form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }), + textarea({ name: "text", rows: 3, placeholder: i18n.chatMessagePlaceholder }), br(), + span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(), + input({ type: "file", name: "image", accept: "image/*" }), br(), br(), + button({ type: "submit", class: "filter-btn" }, i18n.chatSendMessage) + ) + ) + : null, + div({ class: "chat-messages-list" }, + (() => { + const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image) + return visible.length + ? visible.map(msg => renderMessage(msg, chat.author)) + : p({ class: "chat-no-messages" }, i18n.chatNoMessages) + })() + ) + ) + + return template( + chat.title || i18n.chatUntitled, + section( + div({ class: "tags-header" }, + h2(i18n.chatsTitle), + p(i18n.modulesChatsDescription) + ), + renderModeButtons(filter || "all") + ), + section( + div({ class: "tribe-details" }, + chatSide, + chatMain + ) + ) + ) +} diff --git a/src/views/cipher_view.js b/src/views/cipher_view.js new file mode 100644 index 0000000..6e3ecb0 --- /dev/null +++ b/src/views/cipher_view.js @@ -0,0 +1,101 @@ +const { form, button, div, h2, p, section, textarea, label, input, br } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require('./main_views'); +const crypto = require('crypto'); + +function generateRandomPassword(length = 32) { + return crypto.randomBytes(length).toString('hex').slice(0, length); +} + +const cipherView = async (encryptedText = "", decryptedText = "", iv = "", password = "") => { + const randomPassword = generateRandomPassword(); + + const header = div({ class: "tags-header" }, + h2(i18n.cipherTitle), + p(i18n.cipherDescription) + ); + + const encryptForm = form( + { action: "/cipher/encrypt", method: "POST", id: "encryptForm" }, + textarea({ + name: "text", + id: "text", + required: true, + placeholder: i18n.cipherTextPlaceholder, + rows: 4 + }), + br(), + label(i18n.cipherPasswordLabel), + br(), + input({ + type: "password", + name: "password", + id: "password", + required: true, + placeholder: i18n.cipherPasswordPlaceholder, + minlength: 32 + }), + br(), + button({ type: "submit" }, i18n.cipherEncryptButton) + ); + + const decryptForm = form( + { action: "/cipher/decrypt", method: "POST", id: "decryptForm" }, + textarea({ + name: "encryptedText", + id: "encryptedText", + required: true, + placeholder: i18n.cipherEncryptedTextPlaceholder, + rows: 4, + value: encryptedText + }), + br(), + label(i18n.cipherPasswordDecryptLabel), + br(), + input({ + type: "password", + name: "password", + id: "password", + required: true, + placeholder: i18n.cipherPasswordPlaceholder, + minlength: 32 + }), + br(), + button({ type: "submit" }, i18n.cipherDecryptButton) + ); + + const encryptResult = encryptedText + ? div({ class: "cipher-result visible encrypted-result" }, + label(i18n.cipherEncryptedMessageLabel), + br(),br(), + div({ class: "cipher-text" }, encryptedText) + ) + : null; + + const decryptResult = decryptedText + ? div({ class: "cipher-result visible decrypted-result" }, + label(i18n.cipherDecryptedMessageLabel), + br(),br(), + div({ class: "cipher-text" }, decryptedText) + ) + : null; + + return template( + i18n.cipherTitle, + section( + header, + div({ id: "randomPassword" }, + h2({ class: "generated-password" }, randomPassword) + ), + div({ class: "div-center" }, + encryptForm, + br(), + encryptResult, + decryptForm, + br(), + decryptResult + ) + ) + ); +}; + +exports.cipherView = cipherView; diff --git a/src/views/clearnet_view.js b/src/views/clearnet_view.js new file mode 100644 index 0000000..a863462 --- /dev/null +++ b/src/views/clearnet_view.js @@ -0,0 +1,310 @@ +const { a, br, div, input, span, strong } = require("../server/node_modules/hyperaxe"); + +const STAT_TYPE_KEYS = { post:'statsPost', event:'statsEvent', task:'statsTask', forum:'statsForum', tribe:'statsTribe', market:'statsMarket', job:'statsJob', project:'statsProject', shop:'statsShop', image:'statsImage', video:'statsVideo', audio:'statsAudio', document:'statsDocument', bookmark:'statsBookmark', transfer:'statsTransfer', map:'statsMap' }; +const STAT_ORDER = ['post','event','task','forum','tribe','market','job','project','shop','image','video','audio','document','bookmark','transfer','map']; +const renderContentStats = (stats, i18nObj = {}) => { + if (!stats || typeof stats !== 'object') return null; + const chips = STAT_ORDER + .filter(t => (stats[t] || 0) > 0) + .map(t => span({ class: 'inhabitant-stat' }, + span({ class: 'inhabitant-stat-label' }, i18nObj[STAT_TYPE_KEYS[t]] || t), + strong({ class: 'inhabitant-stat-value' }, String(stats[t])) + )); + if (!chips.length) return null; + return div({ class: 'inhabitant-stats-box' }, + div({ class: 'inhabitant-stats-grid' }, ...chips) + ); +}; + +const escapeHtml = (s) => String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const blobIdOf = (v) => { + if (!v) return null; + const s = String(v).trim(); + if (!s) return null; + if (s.startsWith('&')) return s; + const m = s.match(/\((&[^)]+\.sha256)\)/); + if (m) return m[1]; + return null; +}; + +const blobUrl = (v) => { + const id = blobIdOf(v); + return id ? `/c/blob/${encodeURIComponent(id)}` : null; +}; + +const renderReachChip = (isClearnet, i18nObj = {}, href = null) => { + const icon = isClearnet ? '🌐' : '🏝'; + const label = isClearnet + ? (i18nObj.shopReachClearnet || 'Clearnet') + : (i18nObj.shopReachOasis || 'Oasis'); + const chip = span({ class: `pm-exposition-chip pm-exposition-${isClearnet ? 'whole' : 'mutuals'}` }, + span({ class: 'pm-exposition-icon' }, icon), + span({ class: 'pm-exposition-text' }, label) + ); + if (href && isClearnet) { + return a({ href, target: '_blank', rel: 'noopener noreferrer', class: 'pm-exposition-chip-link' }, chip); + } + return chip; +}; + +const fediverseProfileUrl = (handle) => { + const h = String(handle || '').trim().replace(/^@/, ''); + const at = h.indexOf('@'); + if (at <= 0) return ''; + const acct = h.slice(0, at), host = h.slice(at + 1); + if (!acct || !host || /[\s/]/.test(host)) return ''; + return `https://${host}/@${acct}`; +}; + +const renderFediverseReach = (prefs, i18nObj = {}) => { + if (!prefs || prefs.fediverse !== true) return null; + const handle = String(prefs.fediverseHandle || '').trim(); + const url = fediverseProfileUrl(handle); + if (!url) return null; + return div({ class: 'profile-reach fediverse-reach' }, + a({ href: url, target: '_blank', rel: 'noopener noreferrer', class: 'pm-exposition-chip-link' }, + span({ class: 'pm-exposition-chip pm-exposition-fediverse' }, + span({ class: 'pm-exposition-icon' }, '🐘'), + span({ class: 'pm-exposition-text' }, i18nObj.fediverse || 'Fediverse') + ) + ), + a({ href: url, target: '_blank', rel: 'noopener noreferrer', class: 'fediverse-reach-url' }, handle) + ); +}; + +const renderEncryptedChip = (i18nObj = {}) => { + return span({ class: 'pm-exposition-chip pm-exposition-encrypted' }, + span({ class: 'pm-exposition-icon' }, '🔒'), + span({ class: 'pm-exposition-text' }, i18nObj.encryptedChipLabel || 'Encrypted') + ); +}; + +const renderDoubleEncryptionChip = (i18nObj = {}) => { + return span({ class: 'pm-exposition-chip pm-exposition-encrypted pm-double-enc-chip' }, + span({ class: 'pm-exposition-icon' }, '🔒'), + span({ class: 'pm-exposition-text' }, i18nObj.pmCrypterChip || '2xE2E') + ); +}; + +const INTERNAL_OASIS_PATHS = [ + 'author','thread','hashtag','inbox','pm','profile','settings','banking','wallet', + 'jobs','events','projects','shops','audios','videos','images','documents','torrents', + 'tribes','tribe','forum','votes','votations','reports','tasks','maps','chats','pads', + 'calendars','trending','opinions','feed','pixelia','cv','invites','peers','stats', + 'blockexplorer','modules','publish','search','tags','mentions','popular','threads', + 'topics','latest','summaries','multiverse','legacy','cipher','graphos','agenda', + 'favorites','logs','games','parliament','courts','market','ai','public','spread', + 'follow','unfollow','block','like','unlike' +]; + +const stripInternalAnchors = (html) => { + if (typeof html !== 'string' || !html) return html; + const list = INTERNAL_OASIS_PATHS.join('|'); + const hrefedClosed = new RegExp(`]*\\bhref=["']\\/(?:${list})\\/[^"']*["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi'); + const hrefedBare = new RegExp(`]*\\bhref=["']\\/(?:${list})["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi'); + return html.replace(hrefedClosed, '$1').replace(hrefedBare, '$1'); +}; + +const renderClearnetSearchForm = ({ authorFeedId = '', query = '', placeholder = 'Search…' }) => { + if (!authorFeedId) return ''; + const safeQuery = escapeHtml(query || ''); + const safePh = escapeHtml(placeholder); + return ``; +}; + +const renderClearnetUrlBlock = ({ baseUrl = '', path, i18nObj = {} }) => { + return div({ class: 'shop-clearnet-url' }, + a({ href: path, target: '_blank', rel: 'noopener noreferrer', class: 'clearnet-link' }, path) + ); +}; + +const CLEARNET_SEARCH_CSS = ` +.cn-search{margin:0} +.cn-search input[type=text]{width:240px;max-width:100%;background:var(--bg-sub);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:14px;font-family:inherit} +.cn-search input[type=text]:focus{outline:none;border-color:var(--fg)} +`; + +const THEME_PALETTES = { + 'Dark-SNH': { + bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222', + fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e', + border: '#333', accent: '#FFDD44', + font: "system-ui,-apple-system,sans-serif" + }, + 'OasisMobile': { + bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222', + fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e', + border: '#333', accent: '#FFDD44', + font: "system-ui,-apple-system,sans-serif" + }, + 'Clear-SNH': { + bg: '#F9F9F9', bgElev: '#FFFFFF', bgSub: '#F0F0F0', + fg: '#2C2C2C', fgSoft: '#555555', fgDim: '#888888', + border: '#E0E0E0', accent: '#FF6F00', + font: "'Roboto',sans-serif" + }, + 'Matrix-SNH': { + bg: '#000000', bgElev: '#0a0a0a', bgSub: '#050505', + fg: '#00FF00', fgSoft: '#00CC00', fgDim: '#008800', + border: '#00FF00', accent: '#66FF66', + font: "'Courier New',monospace" + }, + 'Purple-SNH': { + bg: '#4B0A6D', bgElev: '#39006D', bgSub: '#6A0066', + fg: '#E5E5E5', fgSoft: '#C8C8C8', fgDim: '#9B7CAA', + border: '#9B1C96', accent: '#9B1C96', + font: "'Arial',sans-serif" + } +}; + +const getCurrentPalette = () => { + try { + const { getConfig } = require('../configs/config-manager.js'); + const theme = getConfig()?.themes?.current || 'Dark-SNH'; + return THEME_PALETTES[theme] || THEME_PALETTES['Dark-SNH']; + } catch (_) { + return THEME_PALETTES['Dark-SNH']; + } +}; + +const buildBaseCss = (p) => ` +:root{ + --bg:${p.bg}; --bg-elev:${p.bgElev}; --bg-sub:${p.bgSub}; + --fg:${p.fg}; --fg-soft:${p.fgSoft}; --fg-dim:${p.fgDim}; + --border:${p.border}; --border-strong:${p.border}; + --accent:${p.accent}; +} +*{box-sizing:border-box} +body{background:var(--bg);color:var(--fg);font-family:${p.font};max-width:960px;margin:0 auto;padding:32px 24px;line-height:1.5} +a{color:var(--fg);text-decoration:none} +a:hover{color:var(--accent);text-decoration:underline} +header.cn-header{display:flex;align-items:center;gap:16px;padding-bottom:16px;margin-bottom:24px;border-bottom:1px solid var(--border);flex-wrap:wrap} +.cn-brand-block{flex:0 0 auto} +.cn-brand{font-size:20px;font-weight:700;color:var(--fg);letter-spacing:1px} +.cn-brand-sub{color:var(--fg-dim);font-size:12px;text-transform:uppercase;letter-spacing:2px;margin-top:2px} +.cn-header-extra{flex:1 1 auto;display:flex;justify-content:flex-end;align-items:center;min-width:0} +h2.cn-section{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:32px 0 16px 0;padding-bottom:8px;border-bottom:1px solid var(--border)} +footer.cn-footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--border);font-size:12px;color:var(--fg-dim);text-align:center;letter-spacing:0.5px} +footer.cn-footer a{color:var(--fg-soft)} +footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 auto 10px auto;border-radius:6px} +`; + +const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = '', hubFeedId = null }) => { + const safeTitle = escapeHtml(title || 'Oasis'); + const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis'); + const safeOgDesc = escapeHtml(ogDescription || ''); + const palette = getCurrentPalette(); + const baseCss = buildBaseCss(palette); + const brandInner = `
⛱ Oasis HUB
Libre · P2P · Federated
`; + const brandBlock = hubFeedId + ? `${brandInner}` + : `
${brandInner}
`; + return ` + + + + + ${safeTitle} + + + ${ogImage ? `` : ''} + + + + + +
+ ${brandBlock} + ${headerExtra ? `
${headerExtra}
` : ''} +
+ ${stripInternalAnchors(body)} + + +`; +}; + +const renderClearnetNotFound = () => { + return renderClearnetPage({ + title: 'Oasis', + ogTitle: 'Oasis', + ogDescription: '', + extraCss: `.cn-notfound{color:var(--fg-soft);font-size:16px;max-width:480px;margin:80px auto 40px auto;text-align:center;line-height:1.5}`, + body: `

The content is not accessible at this moment.

` + }); +}; + +const renderClearnetMediaView = ({ kind, item }) => { + const blob = blobUrl(item.url); + const title = escapeHtml(item.title || 'Untitled'); + const desc = escapeHtml(item.description || ''); + const dateStr = item.createdAt ? escapeHtml(new Date(item.createdAt).toISOString().slice(0, 10)) : ''; + const extraCss = ` +.cn-media-meta{color:var(--fg-dim);font-size:13px;margin-bottom:16px;display:flex;gap:14px;flex-wrap:wrap;align-items:baseline} +.cn-id-meta{font-family:monospace;font-size:11px;word-break:break-all;color:var(--fg-dim)} +.cn-media-title{color:var(--fg);font-size:26px;font-weight:700;margin:0 0 12px 0} +.cn-media-desc{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;margin:16px 0} +.cn-media-frame{margin:16px 0} +.cn-media-frame img{max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);display:block} +.cn-media-frame audio,.cn-media-frame video{width:100%;max-width:100%;display:block;border-radius:6px;background:#000} +.cn-media-frame .cn-media-doc{display:inline-block;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;padding:10px 18px;color:var(--fg);text-decoration:none} +.cn-media-frame .cn-media-doc:hover{border-color:var(--fg)} +`; + let mediaHtml = ''; + if (blob) { + if (kind === 'image') { + mediaHtml = `${title}`; + } else if (kind === 'audio') { + mediaHtml = ``; + } else if (kind === 'video') { + mediaHtml = ``; + } else if (kind === 'document' || kind === 'torrent') { + mediaHtml = `⇩ ${title}`; + } + } + const body = ` +
+ ${dateStr ? `📅 ${dateStr}` : ''} +
+

${title}

+ ${mediaHtml ? `
${mediaHtml}
` : ''} + ${desc ? `

${desc}

` : ''} +`; + return renderClearnetPage({ + title: `${title} — Oasis`, + ogTitle: item.title || 'Oasis', + ogDescription: item.description || '', + ogImage: (kind === 'image') ? blob : null, + extraCss, + body, + hubFeedId: item.author || null + }); +}; + +module.exports = { + escapeHtml, + blobIdOf, + blobUrl, + renderReachChip, + renderFediverseReach, + renderContentStats, + renderEncryptedChip, + renderDoubleEncryptionChip, + renderClearnetUrlBlock, + renderClearnetSearchForm, + renderClearnetPage, + renderClearnetNotFound, + renderClearnetMediaView +}; diff --git a/src/views/courts_view.js b/src/views/courts_view.js new file mode 100644 index 0000000..ef8d681 --- /dev/null +++ b/src/views/courts_view.js @@ -0,0 +1,1407 @@ +const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); +const moment = require('../server/node_modules/moment'); +const { template, i18n, userLink, renderStateChip } = require('./main_views'); + +const CourtsE2EChip = () => renderStateChip('encrypted', '🔒', i18n.encryptedChipLabel || 'E2E'); + +const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss'); + +const applyEl = (fn, attrs, kids) => fn.apply(null, [attrs || {}].concat(kids || [])); + +const methodKey = (m) => String(m || '').toUpperCase(); + +const methodLabel = (m) => { + const key = `courtsMethod${methodKey(m)}`; + const raw = i18n[key] || ''; + return String(raw).toUpperCase(); +}; + +const showVoteMetrics = (m) => { + const k = methodKey(m); + return k === 'POPULAR' || k === 'KARMATOCRACY'; +}; + +const CASE_TITLE_PRESETS = [ + 'Minor conflict', + 'Moderate conflict', + 'Severe conflict', + 'Harassment or abuse', + 'Content moderation', + 'Ban or restriction', + 'Governance dispute' +]; + +const FILTERS = [ + { value: 'cases', key: 'courtsFilterCases' }, + { value: 'mycases', key: 'courtsFilterMyCases' }, + { value: 'actions', key: 'courtsFilterActions' }, + { value: 'judges', key: 'courtsFilterJudges' }, + { value: 'history', key: 'courtsFilterHistory' }, + { value: 'rules', key: 'courtsFilterRules' }, + { value: 'open', key: 'courtsFilterOpenCase' } +]; + +const Tabs = (active) => + div( + { class: 'filters' }, + form( + { method: 'GET', action: '/courts' }, + FILTERS.map((f) => { + const isOpen = f.value === 'open'; + const cls = + isOpen + ? 'create-button' + : active === f.value + ? 'filter-btn active' + : 'filter-btn'; + return button( + { + type: 'submit', + name: 'filter', + value: f.value, + class: cls + }, + i18n[f.key] + ); + }) + ) + ); + +const CaseForm = () => + div( + { class: 'div-center' }, + h2(i18n.courtsCaseFormTitle), + form( + { + method: 'POST', + action: '/courts/cases/create' + }, + label(i18n.courtsCaseTitle), + br(), + input({ + type: 'text', + name: 'titleSuffix', + required: true, + placeholder: 'Subject or short description' + }), + br(), + label('Case type'), + br(), + select( + { name: 'titlePreset', required: true }, + CASE_TITLE_PRESETS.map((t) => option({ value: t }, t)) + ), + br(), + br(), + label(i18n.courtsCaseRespondent), + br(), + input({ + type: 'text', + name: 'respondentId', + placeholder: i18n.courtsCaseRespondentPh, + required: true, + pattern: '^@[A-Za-z0-9+/]+=*\\.ed25519$', + title: i18n.courtsRespondentInvalid || 'Must be a valid SSB ID (@...ed25519)' + }), + br(), + label(i18n.courtsCaseMethod), + br(), + select( + { name: 'method', required: true }, + ['JUDGE', 'DICTATOR', 'POPULAR', 'MEDIATION', 'KARMATOCRACY'].map((m) => + option({ value: m }, i18n[`courtsMethod${m}`]) + ) + ), + br(), + br(), + button({ type: 'submit', class: 'create-button' }, i18n.courtsCaseSubmit) + ) + ); + +const NominateJudgeForm = () => + div( + { class: 'div-center' }, + h2(i18n.courtsNominateJudge), + form( + { method: 'POST', action: '/courts/judges/nominate' }, + label(i18n.courtsJudgeId), + br(), + input({ + type: 'text', + name: 'judgeId', + placeholder: i18n.courtsJudgeIdPh, + required: true + }), + br(), + br(), + button({ type: 'submit', class: 'create-button' }, i18n.courtsNominateBtn) + ) + ); + +const EvidenceForm = (caseId) => + div( + { class: 'div-center' }, + h2(i18n.courtsAddEvidence), + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(caseId)}/evidence/add`, + enctype: 'multipart/form-data' + }, + label(i18n.courtsEvidenceText), + br(), + textarea({ name: 'text', rows: 3, maxlength: 1000 }), + br(), + br(), + label(i18n.courtsEvidenceLink), + br(), + input({ + type: 'url', + name: 'link', + placeholder: i18n.courtsEvidenceLinkPh + }), + br(), + br(), + label(i18n.uploadMedia || 'Upload media (max-size: 50MB)'), + br(), + input({ type: 'file', name: 'image' }), + br(), + br(), + button({ type: 'submit', class: 'create-button' }, i18n.courtsEvidenceSubmit) + ) + ); + +const AnswerForm = (caseId) => + div( + { class: 'div-center' }, + h2(i18n.courtsAnswerTitle), + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(caseId)}/answer` + }, + label(i18n.courtsAnswerText), + br(), + textarea({ name: 'answer', rows: 4, maxlength: 2000 }), + br(), + br(), + select( + { name: 'stance' }, + ['DENY', 'ADMIT', 'PARTIAL'].map((s) => + option({ value: s }, i18n[`courtsStance${s}`]) + ) + ), + br(), + br(), + button({ type: 'submit', class: 'create-button' }, i18n.courtsAnswerSubmit) + ) + ); + +const VerdictForm = (caseId) => + div( + { class: 'div-center' }, + h2(i18n.courtsVerdictTitle), + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(caseId)}/decide` + }, + label(i18n.courtsVerdictResult), + br(), + input({ type: 'text', name: 'outcome', required: true }), + br(), + br(), + label(i18n.courtsVerdictOrders), + br(), + textarea({ + name: 'orders', + rows: 4, + maxlength: 2000, + placeholder: i18n.courtsVerdictOrdersPh + }), + br(), + br(), + button({ type: 'submit', class: 'create-button' }, i18n.courtsIssueVerdict) + ) + ); + +const SettlementForm = (caseId) => + div( + { class: 'div-center' }, + h2(i18n.courtsMediationPropose), + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(caseId)}/settlements/propose` + }, + label(i18n.courtsSettlementText), + br(), + textarea({ name: 'terms', rows: 3, maxlength: 2000 }), + br(), + br(), + button( + { type: 'submit', class: 'create-button' }, + i18n.courtsSettlementProposeBtn + ) + ) + ); + +const RespondentMediatorsForm = (c) => { + if ( + !c.isRespondent || + c.status === 'SOLVED' || + c.status === 'UNSOLVED' || + c.status === 'DISCARDED' + ) + return null; + return div( + { class: 'div-center' }, + h2(i18n.courtsCaseMediatorsRespondentTitle), + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(c.id)}/mediators/respondent` + }, + label(i18n.courtsCaseMediatorsRespondent), + br(), + input({ + type: 'text', + name: 'mediators', + placeholder: i18n.courtsCaseMediatorsPh + }), + br(), + br(), + button({ type: 'submit', class: 'create-button' }, i18n.courtsMediatorsSubmit) + ) + ); +}; + +const JudgeAssignForm = (caseId) => + div( + { class: 'div-center' }, + h2(i18n.courtsAssignJudgeTitle), + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(caseId)}/judge` + }, + label(i18n.courtsJudgeId), + br(), + input({ + type: 'text', + name: 'judgeId', + placeholder: i18n.courtsJudgeIdPh, + required: true + }), + br(), + br(), + button( + { type: 'submit', class: 'create-button' }, + i18n.courtsAssignJudgeBtn + ) + ) + ); + +const SupportCaseForm = (caseId) => + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(caseId)}/support` + }, + button({ type: 'submit', class: 'vote-btn' }, i18n.courtsSupportCase) + ); + +const OpenVoteForm = (caseId) => + form( + { method: 'POST', action: `/courts/cases/${encodeURIComponent(caseId)}/openVote` }, + button({ type: 'submit', class: 'create-button' }, i18n.courtsOpenVote) + ); + +const AcceptSettlementForm = (caseId) => + form( + { method: 'POST', action: `/courts/cases/${encodeURIComponent(caseId)}/settlements/accept` }, + button({ type: 'submit', class: 'create-button' }, i18n.courtsSettlementAcceptBtn) + ); + +const VerdictVoteForm = (caseId) => + div( + { class: 'div-center' }, + h2(i18n.courtsVerdictVoteTitle), + form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(caseId)}/verdict/vote` + }, + label(i18n.courtsVerdictVoteLabel), + br(), + select( + { name: 'decision' }, + option({ value: 'ACCEPT' }, i18n.courtsVerdictVoteAccept), + option({ value: 'REJECT' }, i18n.courtsVerdictVoteReject) + ), + br(), + br(), + button( + { type: 'submit', class: 'create-button' }, + i18n.courtsVerdictVoteSubmit + ) + ) + ); + +const shortId = (id) => { + const s = String(id || ''); + if (!s) return ''; + if (s.length <= 16) return s; + return `${s.slice(0, 6)}…${s.slice(-4)}`; +}; + +const UserLinkCompact = (id) => id ? userLink(id) : span(''); +const UserLinkFull = (id) => id ? userLink(id) : span(''); + +const renderRichTextNodes = (raw) => { + const text = String(raw || ''); + if (!text) return []; + const nodes = []; + let remaining = text; + const imgRegex = /!\[[^\]]*]\(([^)]+)\)/; + const linkRegex = /\[([^\]]+)]\((https?:\/\/[^)]+)\)/; + while (remaining.length) { + const imgMatch = imgRegex.exec(remaining); + const linkMatch = linkRegex.exec(remaining); + let next = null; + let type = null; + if (imgMatch && (!linkMatch || imgMatch.index < linkMatch.index)) { + next = imgMatch; + type = 'img'; + } else if (linkMatch) { + next = linkMatch; + type = 'link'; + } + if (!next) { + if (remaining.trim()) nodes.push(p(remaining)); + break; + } + const idx = next.index; + if (idx > 0) { + const before = remaining.slice(0, idx); + if (before.trim()) nodes.push(p(before)); + } + if (type === 'img') { + const ref = next[1]; + nodes.push( + img({ + class: 'evidence-image', + src: `/blob/${encodeURIComponent(ref)}`, + alt: 'evidence' + }) + ); + } else { + const labelText = next[1]; + const url = next[2]; + nodes.push( + a( + { + class: 'evidence-link', + href: url, + target: '_blank', + rel: 'noopener noreferrer' + }, + labelText + ) + ); + } + remaining = remaining.slice(idx + next[0].length); + } + return nodes; +}; + +const RichTextBlock = (raw) => { + const nodes = renderRichTextNodes(raw); + if (!nodes.length) return null; + return nodes; +}; + +const CaseCard = (c) => { + const mid = methodKey(c.method); + const yes = Number(c.yes || 0); + const total = Number(c.total || 0); + const needed = Number(c.needed || 0); + const showMetricsFlag = showVoteMetrics(mid) && (total > 0 || needed > 0); + const accLink = UserLinkCompact(c.accuser); + const resLink = UserLinkCompact(c.respondent); + const mediatorsAccuser = Array.isArray(c.mediatorsAccuser) + ? c.mediatorsAccuser + : []; + const mediatorsRespondent = Array.isArray(c.mediatorsRespondent) + ? c.mediatorsRespondent + : []; + const canShowDetails = c.mine || c.publicDetails; + const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => + span( + { class: 'mediator' }, + userLink(mId), + idx < mediatorsAccuser.length - 1 ? span(', ') : null + ) + ); + const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => + span( + { class: 'mediator' }, + userLink(mId), + idx < mediatorsRespondent.length - 1 ? span(', ') : null + ) + ); + const showPublicPrefForm = + (c.status === 'DECIDED' || + c.status === 'CLOSED' || + c.status === 'SOLVED' || + c.status === 'UNSOLVED' || + c.status === 'DISCARDED') && + (c.isAccuser || c.isRespondent); + const publicPreferenceForm = showPublicPrefForm + ? form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(c.id)}/public` + }, + label(i18n.courtsPublicPrefLabel), + br(), + select( + { name: 'preference' }, + option( + { value: 'YES', selected: c.myPublicPreference === true }, + i18n.courtsPublicPrefYes + ), + option( + { value: 'NO', selected: c.myPublicPreference === false }, + i18n.courtsPublicPrefNo + ) + ), + br(), + br(), + button( + { type: 'submit', class: 'create-button' }, + i18n.courtsPublicPrefSubmit + ) + ) + : null; + const respondentMediatorsForm = RespondentMediatorsForm(c); + const canAddEvidence = + c.isAccuser || c.isRespondent || c.isMediator || c.isJudge || c.isDictator; + const canAnswer = c.isRespondent; + const canIssueVerdict = + ((methodKey(c.method) === 'JUDGE' && c.isJudge) || + (methodKey(c.method) === 'MEDIATION' && c.isMediator) || + (methodKey(c.method) === 'DICTATOR' && c.isDictator)) && + (c.status === 'OPEN' || c.status === 'IN_PROGRESS') && + !c.hasVerdict; + const canProposeSettlement = + (c.isAccuser || c.isRespondent || c.isMediator) && + methodKey(c.method) === 'MEDIATION' && + c.status === 'OPEN'; + const canOpenVote = + (c.isAccuser || c.isRespondent) && + (methodKey(c.method) === 'POPULAR' || methodKey(c.method) === 'KARMATOCRACY') && + (c.status === 'OPEN' || c.status === 'IN_PROGRESS') && + !c.voteId; + const canAcceptSettlement = + (c.isAccuser || c.isRespondent) && + methodKey(c.method) === 'MEDIATION' && + (c.status === 'OPEN' || c.status === 'IN_PROGRESS') && + !!c.hasSettlement; + const isNormalUser = + !c.isAccuser && + !c.isRespondent && + !c.isMediator && + !c.isJudge && + !c.isDictator; + const canSupport = + isNormalUser && + (c.status === 'IN_PROGRESS' || c.status === 'OPEN'); + const canAssignJudge = + methodKey(c.method) === 'JUDGE' && + !c.judgeId && + (c.isAccuser || c.isRespondent); + return div( + { class: 'card' }, + div( + { class: 'card-header' }, + div( + { class: 'card-header__meta' }, + h2(c.title || ''), + div({ class: 'card-chips-row' }, CourtsE2EChip()), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsCaseMethod + ': '), + span({ class: 'card-value' }, methodLabel(c.method)) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThStatus + ': '), + span({ class: 'card-value' }, String(c.status || '')) + ), + c.answerBy + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThAnswerBy + ': '), + span({ class: 'card-value' }, fmt(c.answerBy)) + ) + : null, + c.evidenceBy + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThEvidenceBy + ': '), + span({ class: 'card-value' }, fmt(c.evidenceBy)) + ) + : null + ) + ), + div( + { class: 'table-wrap mt-2' }, + applyEl(table, { class: 'table table--centered' }, [ + thead(tr(th(i18n.courtsAccuser), th(i18n.courtsRespondent))), + tbody(tr(td(accLink), td(resLink))) + ]) + ), + mediatorsAccuser.length + ? div( + { class: 'card-field' }, + span( + { class: 'card-label' }, + i18n.courtsMediatorsAccuserLabel + ': ' + ), + span({ class: 'card-value' }, ...mediatorLinksAccuser) + ) + : null, + mediatorsRespondent.length + ? div( + { class: 'card-field' }, + span( + { class: 'card-label' }, + i18n.courtsMediatorsRespondentLabel + ': ' + ), + span({ class: 'card-value' }, ...mediatorLinksRespondent) + ) + : null, + c.supportCount + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsSupportCount + ': '), + span({ class: 'card-value' }, String(c.supportCount)) + ) + : null, + canShowDetails ? RichTextBlock(c.description || '') : null, + showMetricsFlag + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsVotesNeeded + ': '), + span({ class: 'card-value' }, String(needed)) + ) + : null, + showMetricsFlag + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsVotesSlashTotal + ': '), + span({ class: 'card-value' }, `${yes}/${total}`) + ) + : null, + c.voteId + ? form( + { method: 'GET', action: `/votes/${encodeURIComponent(c.voteId)}` }, + button({ type: 'submit', class: 'vote-btn' }, i18n.courtsOpenVote) + ) + : null, + canSupport ? SupportCaseForm(c.id || '') : null, + publicPreferenceForm, + respondentMediatorsForm, + canAssignJudge ? JudgeAssignForm(c.id || '') : null, + canAddEvidence ? EvidenceForm(c.id || '') : null, + canAnswer ? AnswerForm(c.id || '') : null, + canIssueVerdict ? VerdictForm(c.id || '') : null, + canProposeSettlement ? SettlementForm(c.id || '') : null, + canOpenVote ? OpenVoteForm(c.id || '') : null, + canAcceptSettlement ? AcceptSettlementForm(c.id || '') : null + ); +}; + +const MyCaseCard = (c) => { + const mid = methodKey(c.method); + const yes = Number(c.yes || 0); + const total = Number(c.total || 0); + const needed = Number(c.needed || 0); + const showMetricsFlag = showVoteMetrics(mid) && (total > 0 || needed > 0); + + const accLinkFull = UserLinkFull(c.accuser); + const respondentId = c.respondentId || c.respondent; + const resLinkFull = UserLinkFull(respondentId); + + const mediatorsAccuser = Array.isArray(c.mediatorsAccuser) + ? c.mediatorsAccuser + : []; + const mediatorsRespondent = Array.isArray(c.mediatorsRespondent) + ? c.mediatorsRespondent + : []; + + const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => + span( + {}, + userLink(mId), + idx < mediatorsAccuser.length - 1 ? span(', ') : null + ) + ); + + const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => + span( + {}, + userLink(mId), + idx < mediatorsRespondent.length - 1 ? span(', ') : null + ) + ); + + const judgeLinkFull = c.judgeId ? UserLinkFull(c.judgeId) : span(''); + const showMediatorsTable = + !!c.method && + (mediatorsAccuser.length > 0 || + mediatorsRespondent.length > 0 || + !!c.judgeId); + + const showPublicPrefForm = + (c.status === 'DECIDED' || + c.status === 'CLOSED' || + c.status === 'SOLVED' || + c.status === 'UNSOLVED' || + c.status === 'DISCARDED') && + (c.isAccuser || c.isRespondent); + + const publicPreferenceForm = showPublicPrefForm + ? form( + { + method: 'POST', + action: `/courts/cases/${encodeURIComponent(c.id)}/public` + }, + label(i18n.courtsPublicPrefLabel), + br(), + select( + { name: 'preference' }, + option( + { value: 'YES', selected: c.myPublicPreference === true }, + i18n.courtsPublicPrefYes + ), + option( + { value: 'NO', selected: c.myPublicPreference === false }, + i18n.courtsPublicPrefNo + ) + ), + br(), + br(), + button( + { type: 'submit', class: 'create-button' }, + i18n.courtsPublicPrefSubmit + ) + ) + : null; + + const respondentMediatorsForm = RespondentMediatorsForm(c); + + const canAddEvidence = + c.isAccuser || c.isRespondent || c.isMediator || c.isJudge || c.isDictator; + const canAnswer = c.isRespondent; + const canIssueVerdict = + ((methodKey(c.method) === 'JUDGE' && c.isJudge) || + (methodKey(c.method) === 'MEDIATION' && c.isMediator) || + (methodKey(c.method) === 'DICTATOR' && c.isDictator)) && + (c.status === 'OPEN' || c.status === 'IN_PROGRESS') && + !c.hasVerdict; + const canProposeSettlement = + (c.isAccuser || c.isRespondent || c.isMediator) && + methodKey(c.method) === 'MEDIATION' && + c.status === 'OPEN'; + const canOpenVote = + (c.isAccuser || c.isRespondent) && + (methodKey(c.method) === 'POPULAR' || methodKey(c.method) === 'KARMATOCRACY') && + (c.status === 'OPEN' || c.status === 'IN_PROGRESS') && + !c.voteId; + const canAcceptSettlement = + (c.isAccuser || c.isRespondent) && + methodKey(c.method) === 'MEDIATION' && + (c.status === 'OPEN' || c.status === 'IN_PROGRESS') && + !!c.hasSettlement; + const isNormalUser = + !c.isAccuser && + !c.isRespondent && + !c.isMediator && + !c.isJudge && + !c.isDictator; + const canSupport = + isNormalUser && + (c.status === 'IN_PROGRESS' || c.status === 'OPEN'); + const canAssignJudge = + methodKey(c.method) === 'JUDGE' && + !c.judgeId && + (c.isAccuser || c.isRespondent); + + return div( + { class: 'card' }, + br(), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThCase + ': '), + span({ class: 'card-value' }, c.title || '') + ), + div({ class: 'card-chips-row' }, CourtsE2EChip()), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThStatus + ': '), + span({ class: 'card-value' }, String(c.status || '')) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsMethod + ': '), + span({ class: 'card-value' }, methodLabel(c.method)) + ), + c.answerBy + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThAnswerBy + ': '), + span({ class: 'card-value' }, fmt(c.answerBy)) + ) + : null, + c.evidenceBy + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThEvidenceBy + ': '), + span({ class: 'card-value' }, fmt(c.evidenceBy)) + ) + : null, + c.decisionBy + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThDecisionBy + ': '), + span({ class: 'card-value' }, fmt(c.decisionBy)) + ) + : null, + div( + { class: 'table-wrap mt-2' }, + applyEl(table, { class: 'table table--centered' }, [ + thead( + tr( + th(i18n.courtsAccuser), + th(i18n.courtsRespondent) + ) + ), + tbody( + tr( + td(accLinkFull), + td(resLinkFull) + ) + ) + ]) + ), + showMediatorsTable + ? div( + { class: 'table-wrap mt-2 mediators-table' }, + applyEl(table, { class: 'table table--centered' }, [ + thead( + tr( + th(i18n.courtsThJudge), + th( + i18n.courtsMediatorsAccuserLabel || + i18n.courtsMediatorsLabel || + 'Accuser mediators' + ), + th( + i18n.courtsMediatorsRespondentLabel || + i18n.courtsMediatorsLabel || + 'Respondent mediators' + ) + ) + ), + tbody( + tr( + td(judgeLinkFull), + td( + mediatorLinksAccuser.length + ? mediatorLinksAccuser + : span('') + ), + td( + mediatorLinksRespondent.length + ? mediatorLinksRespondent + : span('') + ) + ) + ) + ]) + ) + : null, + + c.supportCount + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsSupportCount + ': '), + span({ class: 'card-value' }, String(c.supportCount)) + ) + : null, + showMetricsFlag + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsVotesNeeded + ': '), + span({ class: 'card-value' }, String(needed)) + ) + : null, + showMetricsFlag + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsVotesSlashTotal + ': '), + span({ class: 'card-value' }, `${yes}/${total}`) + ) + : null, + c.voteId + ? form( + { method: 'GET', action: `/votes/${encodeURIComponent(c.voteId)}` }, + button({ type: 'submit', class: 'vote-btn' }, i18n.courtsOpenVote) + ) + : null, + canSupport ? SupportCaseForm(c.id || '') : null, + publicPreferenceForm, + respondentMediatorsForm, + canAssignJudge ? JudgeAssignForm(c.id || '') : null, + canAddEvidence ? EvidenceForm(c.id || '') : null, + canAnswer ? AnswerForm(c.id || '') : null, + canIssueVerdict ? VerdictForm(c.id || '') : null, + canProposeSettlement ? SettlementForm(c.id || '') : null, + canOpenVote ? OpenVoteForm(c.id || '') : null, + canAcceptSettlement ? AcceptSettlementForm(c.id || '') : null + ); +}; + +const CasesList = (rows = []) => { + const cards = rows.map(CaseCard); + return div({ class: 'cards' }, ...cards); +}; + +const MyCasesList = (rows = []) => { + const cards = rows.map(MyCaseCard); + return div({ class: 'cards' }, ...cards); +}; + +const roleTextForCase = (c) => { + if (c.isAccuser) return i18n.courtsRoleAccuser || 'Accuser'; + if (c.isRespondent) return i18n.courtsRoleDefence || 'Defence'; + if (c.isMediator) return i18n.courtsRoleMediator || 'Mediator'; + if (c.isJudge) return i18n.courtsRoleJudge || 'Judge'; + if (c.isDictator) return i18n.courtsRoleDictator || 'Dictator'; + return ''; +}; + +const CasesTable = (rows = [], opts = {}) => { + if (!rows.length) return div({ class: 'empty' }, p(i18n.courtsNoCases)); + const showRole = !!opts.showRole; + const bodyRows = rows.map((c) => { + const role = showRole ? roleTextForCase(c) : ''; + return tr( + td(c.title || ''), + td( + c.accuser + ? a( + { + class: 'user-link', + href: `/author/${encodeURIComponent(c.accuser)}` + }, + c.accuser + ) + : '' + ), + td(methodLabel(c.method)), + td(c.createdAt ? fmt(c.createdAt) : ''), + showRole ? td(role) : null, + td( + c.id + ? form( + { + method: 'GET', + action: `/courts/cases/${encodeURIComponent(c.id)}` + }, + button( + { type: 'submit', class: 'link-button' }, + i18n.courtsViewDetailsShort || + i18n.courtsViewDetails || + 'View' + ) + ) + : '' + ) + ); + }); + return div( + { class: 'table-wrap' }, + applyEl(table, { class: 'table table--centered' }, [ + thead( + tr( + th(i18n.courtsThCase), + th(i18n.courtsAccuser), + th(i18n.courtsCaseMethod), + th(i18n.courtsThCreatedAt), + showRole ? th(i18n.courtsThRole || 'Role') : null, + th(i18n.courtsThDetails || '') + ) + ), + applyEl(tbody, null, bodyRows) + ]) + ); +}; + +const NominationsTable = (nominations = [], currentUserId = '') => { + if (!nominations || !nominations.length) + return div({ class: 'empty' }, p(i18n.courtsNoNominations)); + const rows = nominations.map((n) => { + const isSelf = + currentUserId && + String(n.judgeId || '') === String(currentUserId || ''); + return tr( + td(userLink(n.judgeId)), + td(String(n.supports || 0)), + td(fmt(n.createdAt)), + td( + isSelf + ? span('') + : form( + { + method: 'POST', + action: `/courts/judges/${encodeURIComponent(n.id)}/vote` + }, + button({ class: 'vote-btn' }, i18n.courtsThVote) + ) + ) + ); + }); + return div( + { class: 'table-wrap' }, + h2(i18n.courtsNominationsTitle), + applyEl(table, { class: 'table table--centered' }, [ + thead( + tr( + th(i18n.courtsThJudge), + th(i18n.courtsThSupports), + th(i18n.courtsThDate), + th(i18n.courtsThVote) + ) + ), + applyEl(tbody, null, rows) + ]) + ); +}; + +const JudgesSection = (nominations = [], currentUserId = '') => { + const nomBlock = NominationsTable(nominations, currentUserId); + return div(NominateJudgeForm(), nomBlock); +}; + +const HistoryList = (rows = []) => { + if (!rows.length) return div({ class: 'empty' }, p(i18n.courtsNoHistory)); + const cards = rows.map((hh) => { + const canShowDescription = hh.mine || hh.publicDetails; + return div( + { class: 'card' }, + br(), + hh.method + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsCaseMethod + ': '), + span({ class: 'card-value' }, methodLabel(hh.method)) + ) + : null, + hh.decidedAt + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThDecisionBy + ': '), + span({ class: 'card-value' }, fmt(hh.decidedAt)) + ) + : null, + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThStatus + ': '), + span({ class: 'card-value' }, String(hh.status || '')) + ), + h2(hh.title || ''), + canShowDescription ? RichTextBlock(hh.description || '') : null, + form( + { + method: 'GET', + action: `/courts/cases/${encodeURIComponent(hh.id)}` + }, + button({ type: 'submit', class: 'create-button' }, i18n.courtsViewDetails) + ) + ); + }); + return div({ class: 'cards' }, ...cards); +}; + +const RulesContent = () => { + const points = [ + i18n.courtsRulesIntro, + i18n.courtsRulesLifecycle, + i18n.courtsRulesRoles, + i18n.courtsRulesEvidence, + i18n.courtsRulesDeliberation, + i18n.courtsRulesVerdict, + i18n.courtsRulesAppeals, + i18n.courtsRulesPrivacy, + i18n.courtsRulesMisconduct, + i18n.courtsRulesGlossary + ]; + return div( + { class: 'card' }, + h2(i18n.courtsRulesTitle), + div({ class: 'rules-points' }, + points.filter(Boolean).map((t, i) => + div({ class: 'rules-point' }, + span({ class: 'rules-point-num' }, String(i + 1)), + span({ class: 'rules-point-text' }, t) + ) + ) + ) + ); +}; + +const CaseSearch = (filter, search = '') => + div( + { class: 'filters' }, + form( + { method: 'GET', action: '/courts' }, + input({ type: 'hidden', name: 'filter', value: filter }), + input({ + type: 'text', + name: 'search', + placeholder: i18n.searchCasesPlaceholder, + value: search || '' + }), + br(), + button({ type: 'submit' }, i18n.applyFilters), + br() + ) + ); + +const CaseDetailsBlock = (c) => { + if (!c) return div({ class: 'empty' }, p(i18n.courtsNoCases)); + const canShowFull = c.mine || c.publicDetails; + const accLink = UserLinkFull(c.accuser); + const resLink = UserLinkFull(c.respondent || c.respondentId); + const judgeValue = c.judgeId + ? UserLinkFull(c.judgeId) + : span(i18n.courtsJudgeNotAssigned || '-'); + const mediatorsAccuser = Array.isArray(c.mediatorsAccuser) + ? c.mediatorsAccuser + : []; + const mediatorsRespondent = Array.isArray(c.mediatorsRespondent) + ? c.mediatorsRespondent + : []; + const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => + span( + { class: 'mediator' }, + userLink(mId), + idx < mediatorsAccuser.length - 1 ? span(', ') : null + ) + ); + const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => + span( + { class: 'mediator' }, + userLink(mId), + idx < mediatorsRespondent.length - 1 ? span(', ') : null + ) + ); + const showMediatorsTable = + c.method && (mediatorsAccuser.length || mediatorsRespondent.length); + const evidences = Array.isArray(c.evidences) ? c.evidences : []; + const answers = Array.isArray(c.answers) ? c.answers : []; + const settlements = Array.isArray(c.settlements) ? c.settlements : []; + + const evidenceSideLabel = (e) => { + const side = String((e && (e.side || e.role)) || '').toUpperCase(); + if (side === 'ACCUSER') { + return i18n.courtsEvidenceSideAccuser || 'Accuser evidence'; + } + if (side === 'RESPONDENT' || side === 'DEFENCE' || side === 'DEFENSE') { + return i18n.courtsEvidenceSideRespondent || 'Defence evidence'; + } + return i18n.courtsEvidenceSideUnknown || ''; + }; + + return div( + { class: 'card case-details-card' }, + br(), + div( + { class: 'card-header' }, + div( + { class: 'card-header__meta' }, + h2(c.title || ''), + div({ class: 'card-chips-row' }, CourtsE2EChip()), + c.method + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsCaseMethod + ': '), + span({ class: 'card-value' }, methodLabel(c.method)) + ) + : null, + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThJudge + ': '), + span({ class: 'card-value' }, judgeValue) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThStatus + ': '), + span({ class: 'card-value' }, String(c.status || '')) + ), + c.createdAt + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThCreatedAt + ': '), + span({ class: 'card-value' }, fmt(c.createdAt)) + ) + : null, + c.decidedAt + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThDecisionBy + ': '), + span({ class: 'card-value' }, fmt(c.decidedAt)) + ) + : null + ) + ), + div( + { class: 'table-wrap mt-2' }, + applyEl(table, { class: 'table table--centered' }, [ + thead( + tr( + th(i18n.courtsAccuser), + th(i18n.courtsRespondent) + ) + ), + tbody( + tr( + td(accLink), + td(resLink) + ) + ) + ]) + ), + showMediatorsTable + ? div( + { class: 'table-wrap mt-2 mediators-table' }, + applyEl(table, { class: 'table table--centered' }, [ + thead( + tr( + th( + i18n.courtsMediatorsAccuserLabel || + i18n.courtsMediatorsLabel || + 'Accuser mediators' + ), + th( + i18n.courtsMediatorsRespondentLabel || + i18n.courtsMediatorsLabel || + 'Respondent mediators' + ) + ) + ), + tbody( + tr( + td( + mediatorLinksAccuser.length + ? mediatorLinksAccuser + : span('') + ), + td( + mediatorLinksRespondent.length + ? mediatorLinksRespondent + : span('') + ) + ) + ) + ]) + ) + : null, + canShowFull ? RichTextBlock(c.description || '') : null, + !canShowFull + ? div( + { class: 'card-field' }, + p(i18n.courtsDetailsNotPublic) + ) + : null, + canShowFull && evidences.length + ? div( + { class: 'card-section evidences-section' }, + h2(i18n.courtsDetailsEvidenceTitle), + ...evidences.map((e) => { + const bodyChildren = []; + if (e.text && String(e.text).trim()) { + bodyChildren.push(RichTextBlock(e.text)); + } + if (e.link && String(e.link).trim()) { + bodyChildren.push( + a( + { + class: 'evidence-link', + href: e.link, + target: '_blank', + rel: 'noopener noreferrer' + }, + e.link + ) + ); + } + if (e.imageUrl && String(e.imageUrl).trim()) { + bodyChildren.push( + br(),br(), + img({ + class: 'evidence-image', + src: `/blob/${encodeURIComponent(e.imageUrl)}`, + alt: 'evidence' + }) + ); + } + if (!bodyChildren.length) { + bodyChildren.push(span('')); + } + const sideLabel = evidenceSideLabel(e); + const dateText = fmt(e.createdAt); + const dateWithSide = sideLabel + ? dateText + ' · ' + sideLabel + : dateText; + return div( + { class: 'evidence-item' }, + div( + { class: 'evidence-date' }, + dateWithSide + ), + div( + { class: 'evidence-body' }, + ...bodyChildren + ) + ); + }) + ) + : null, + canShowFull && answers.length + ? div( + { class: 'card-section answers-section' }, + h2(i18n.courtsDetailsAnswersTitle), + ...answers.map((aItem) => { + const stanceKey = methodKey(aItem.stance); + const stanceLabel = + stanceKey && i18n[`courtsStance${stanceKey}`] + ? i18n[`courtsStance${stanceKey}`] + : aItem.stance || ''; + const dateText = fmt(aItem.createdAt); + const metaText = stanceLabel + ? dateText + ' · ' + stanceLabel + : dateText; + return div( + { class: 'answer-item' }, + div( + { class: 'answer-meta' }, + metaText + ), + RichTextBlock(aItem.text || '') + ); + }) + ) + : null, + canShowFull && c.verdict + ? div( + { class: 'card-section verdict-section' }, + h2(i18n.courtsDetailsVerdictTitle), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsVerdictResult + ': '), + span({ class: 'card-value' }, c.verdict.result || '') + ), + c.verdict.orders + ? div( + { class: 'card-field' }, + span( + { class: 'card-label' }, + i18n.courtsVerdictOrders + ': ' + ), + RichTextBlock(c.verdict.orders || '') + ) + : null + ) + : null, + canShowFull && settlements.length + ? div( + { class: 'card-section settlements-section' }, + h2(i18n.courtsDetailsSettlementsTitle), + ...settlements.map((sItem) => + div( + { class: 'settlement-item' }, + div( + { class: 'settlement-date' }, + fmt(sItem.createdAt) + ), + RichTextBlock(sItem.terms || '') + ) + ) + ) + : null + ); +}; + +const courtsView = async (state) => { + const { + filter = 'cases', + cases = [], + myCases = [], + history = [], + nominations = [], + search = '', + userId = '' + } = state; + return template( + i18n.courtsTitle, + section( + div( + { class: 'tags-header' }, + h2(i18n.courtsTitle), + p(i18n.courtsDescription) + ), + Tabs(filter), + filter === 'cases' ? CaseSearch(filter, search) : null + ), + section( + filter === 'cases' ? CasesTable(cases) : null, + filter === 'mycases' + ? myCases.length + ? CasesTable(myCases, { showRole: true }) + : div({ class: 'empty' }, p(i18n.courtsNoMyCases)) + : null, + filter === 'actions' + ? myCases.length + ? MyCasesList(myCases) + : div({ class: 'empty' }, p(i18n.courtsNoMyCases)) + : null, + filter === 'judges' ? JudgesSection(nominations, userId) : null, + filter === 'history' ? HistoryList(history) : null, + filter === 'rules' ? RulesContent() : null, + filter === 'open' ? CaseForm() : null + ) + ); +}; + +const courtsCaseView = async (state) => { + const { caseData } = state; + return template( + i18n.courtsTitle, + section( + div( + { class: 'tags-header' }, + h2(i18n.courtsTitle), + p(i18n.courtsDescription) + ), + Tabs('cases') + ), + section(CaseDetailsBlock(caseData)) + ); +}; + +module.exports = { courtsView, courtsCaseView }; + diff --git a/src/views/cv_view.js b/src/views/cv_view.js new file mode 100644 index 0000000..9725175 --- /dev/null +++ b/src/views/cv_view.js @@ -0,0 +1,257 @@ +const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option, span } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, renderVisibilityChip } = require('./main_views'); +const { renderUrl } = require('../backend/renderUrl'); + +const generateCVBox = (label, content, className) => { + return div({ class: `cv-box ${className}` }, + h2(label), + content + ); +}; + +const generateTags = (tags) => { + return tags && tags.length + ? div( + tags.map(tag => + a({ + href: `/search?query=%23${encodeURIComponent(tag)}`, + class: "tag-link", + style: "margin-right:0.8em;margin-bottom:0.5em;" + }, `#${tag}`) + ) + ) + : null; +}; + +exports.createCVView = async (cv = {}, editMode = false) => { + const title = editMode ? i18n.cvEditSectionTitle : i18n.cvCreateSectionTitle; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.cvDescription) + ), + div({ class: "cv-form" }, + form({ + method: "POST", + action: editMode ? `/cv/update/${encodeURIComponent(cv.id)}` : "/cv/upload", + enctype: "multipart/form-data" + }, + + generateCVBox(i18n.cvPersonal, [ + label(i18n.cvNameLabel), br(), + input({ type: "text", name: "name", required: true, value: cv.name || "" }), br(), + label(i18n.cvDescriptionLabel), br(), + textarea({ name: "description", required: true, rows: 4 }, cv.description || ""), br(), + label(i18n.cvLanguagesLabel), br(), + input({ type: "text", name: "languages", value: cv.languages || "" }), br(), + label(i18n.cvPhotoLabel), br(), + input({ type: "file", name: "image" }), br(), br(), + label(i18n.cvPersonalExperiencesLabel), br(), + textarea({ name: "personalExperiences", rows: 4 }, cv.personalExperiences || ""), br(), + label(i18n.cvPersonalSkillsLabel), br(), + input({ type: "text", name: "personalSkills", required: true, value: (cv.personalSkills || []).join(", ") }), br() + ], "personal"), + + generateCVBox(i18n.cvOasis, [ + label(i18n.cvOasisExperiencesLabel), br(), + textarea({ name: "oasisExperiences", rows: 4 }, cv.oasisExperiences || ""), br(), + label(i18n.cvOasisSkillsLabel), br(), + input({ type: "text", name: "oasisSkills", value: (cv.oasisSkills || []).join(", ") }), br() + ], "oasis"), + + generateCVBox(i18n.cvEducational, [ + label(i18n.cvEducationExperiencesLabel), br(), + textarea({ name: "educationExperiences", rows: 4 }, cv.educationExperiences || ""), br(), + label(i18n.cvEducationalSkillsLabel), br(), + input({ type: "text", name: "educationalSkills", value: (cv.educationalSkills || []).join(", ") }), br() + ], "education"), + + generateCVBox(i18n.cvProfessional, [ + label(i18n.cvProfessionalExperiencesLabel), br(), + textarea({ name: "professionalExperiences", rows: 4 }, cv.professionalExperiences || ""), br(), + label(i18n.cvProfessionalSkillsLabel), br(), + input({ type: "text", name: "professionalSkills", value: (cv.professionalSkills || []).join(", ") }), br() + ], "professional"), + + generateCVBox(i18n.cvAvailability, [ + label(i18n.cvLocationLabel), br(), + input({ type: "text", name: "location", required: true, value: cv.location || "UNKNOWN" }), br(), + label(i18n.cvStatusLabel), br(), + select({ name: "status", required: true }, + option({ value: "AVAILABLE", selected: cv.status === "AVAILABLE FOR COLLABORATION" }, "AVAILABLE FOR COLLABORATION"), + option({ value: "UNAVAILABLE", selected: cv.status === "NOT CURRENTLY AVAILABLE" }, "NOT CURRENTLY AVAILABLE"), + option({ value: "LOOKING FOR WORK", selected: !cv.status || cv.status === "LOOKING FOR WORK" }, "LOOKING FOR WORK") + ), br(), br(), + label(i18n.cvPreferencesLabel), br(), + select({ name: "preferences", required: true }, + option({ value: "IN PERSON", selected: cv.preferences === "IN-PERSON ONLY" }, "IN-PERSON ONLY"), + option({ value: "REMOTE WORKING", selected: !cv.preferences || cv.preferences === "REMOTE WORKING" }, "REMOTE-WORKING") + ), br(), br(), + label(i18n.visibilityLabel || "Visibility"), br(), + select({ name: "visibility" }, + option({ value: "PUBLIC", selected: (cv.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"), + option({ value: "HIDDEN", selected: cv.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden") + ), br() + ], "availability"), + + button({ type: "submit" }, editMode ? i18n.cvUpdateButton : i18n.cvCreateButton) + ) + ) + ) + ) +}; + +exports.cvView = async (cv) => { + const title = i18n.cvTitle; + + if (!cv) { + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.cvDescription) + ), + div({ class: "no-cv" }, + p(i18n.cvNoCV), + form({ method: "GET", action: "/cv/create" }, + button({ type: "submit" }, i18n.cvCreateButton) + ) + ) + ) + ) + } + + const hasPersonal = cv.contact || cv.name || cv.description || cv.photo || typeof cv.oasisContributor === "boolean" || (cv.personalSkills && cv.personalSkills.length); + const hasPersonalExp = cv.personalExperiences; + const hasOasis = cv.oasisExperiences || (cv.oasisSkills && cv.oasisSkills.length); + const hasEducational = cv.educationExperiences || cv.languages || (cv.educationalSkills && cv.educationalSkills.length); + const hasProfessional = cv.professionalExperiences || (cv.professionalSkills && cv.professionalSkills.length); + const hasAvailability = cv.location || cv.status || cv.preferences; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.cvDescription) + ), + div({ class: "cv-section" }, + div({ class: "cv-item" }, ...[ + (() => { + const vis = (cv.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC'; + const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC'; + return div({ class: "cv-visibility-row" }, + span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `), + renderVisibilityChip(vis, i18n), + " ", + form({ method: "POST", action: `/cv/visibility/${encodeURIComponent(cv.id)}`, class: "inline-form" }, + input({ type: "hidden", name: "visibility", value: next }), + button({ type: "submit", class: "filter-btn" }, + next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden') + ) + ) + ); + })(), + div({ class: "cv-actions" }, + form({ method: "GET", action: `/cv/edit/${encodeURIComponent(cv.id)}` }, + button({ type: "submit" }, i18n.cvEditButton) + ), + form({ method: "POST", action: `/cv/delete/${encodeURIComponent(cv.id)}` }, + button({ type: "submit", class: "danger-btn" }, i18n.cvDeleteButton) + ) + ), + div({ class: "cv-meta" }, + p(`${i18n.cvCreatedAt}: ${new Date(cv.createdAt).toLocaleString()}`), + cv.updatedAt ? p(`${i18n.cvUpdatedAt}: ${new Date(cv.updatedAt).toLocaleString()}`) : null + ), + hasPersonal ? div({ class: "cv-box personal" }, ...[ + cv.photo + ? img({ + src: `/blob/${encodeURIComponent(cv.photo)}`, + class: "cv-photo" + }) + : null, + cv.name ? h2(`${cv.name}`) : null, + (cv.contact || cv.author) + ? a({ href: `/author/${encodeURIComponent(cv.contact || cv.author)}`, class: 'inhabitant-qr-link' }, + img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(cv.contact || cv.author)}?size=120`, alt: 'QR' })) + : null, + cv.contact ? p(userLink(cv.contact)) : null, + cv.description ? p(...renderUrl(`${cv.description}`)) : null, + (cv.personalSkills && cv.personalSkills.length) + ? div( + cv.personalSkills.map(tag => + a({ + href: `/search?query=%23${encodeURIComponent(tag)}`, + class: "tag-link", + style: "margin-right:0.8em;margin-bottom:0.5em;" + }, `#${tag}`) + ) + ) + : null + ]) : null, + hasPersonal ? div({ class: "cv-box personal" }, ...[ + h2(i18n.cvLanguagesLabel), + cv.languages ? p(`${cv.languages.toUpperCase()}`) : null + ]) : null, + hasEducational ? div({ class: "cv-box education" }, ...[ + h2(i18n.cvEducationalView), + cv.educationExperiences ? p(...renderUrl(`${cv.educationExperiences}`)) : null, + (cv.educationalSkills && cv.educationalSkills.length) + ? div( + cv.educationalSkills.map(tag => + a({ + href: `/search?query=%23${encodeURIComponent(tag)}`, + class: "tag-link", + style: "margin-right:0.8em;margin-bottom:0.5em;" + }, `#${tag}`) + ) + ) + : null + ]) : null, + hasProfessional ? div({ class: "cv-box professional" }, ...[ + h2(i18n.cvProfessionalView), + cv.professionalExperiences ? p(...renderUrl(`${cv.professionalExperiences}`)) : null, + (cv.professionalSkills && cv.professionalSkills.length) + ? div( + cv.professionalSkills.map(tag => + a({ + href: `/search?query=%23${encodeURIComponent(tag)}`, + class: "tag-link", + style: "margin-right:0.8em;margin-bottom:0.5em;" + }, `#${tag}`) + ) + ) + : null + ]) : null, + hasOasis ? div({ class: "cv-box oasis" }, ...[ + h2(i18n.cvOasisContributorView), + cv.oasisExperiences ? p(...renderUrl(`${cv.oasisExperiences}`)) : null, + (cv.oasisSkills && cv.oasisSkills.length) + ? div( + cv.oasisSkills.map(tag => + a({ + href: `/search?query=%23${encodeURIComponent(tag)}`, + class: "tag-link", + style: "margin-right:0.8em;margin-bottom:0.5em;" + }, `#${tag}`) + ) + ) + : null + ]) : null, + hasAvailability ? div({ class: "cv-box availability" }, ...[ + h2(i18n.cvAvailabilityView), + cv.location ? p(`${i18n.cvLocationLabel}: ${cv.location}`) : null, + cv.status ? p(`${i18n.cvStatusLabel}: ${cv.status}`) : null, + cv.preferences ? p(`${i18n.cvPreferencesLabel}: ${cv.preferences}`) : null + ]) : null + ]) + ) + ) + ); +}; + diff --git a/src/views/document_view.js b/src/views/document_view.js new file mode 100644 index 0000000..e1e98f2 --- /dev/null +++ b/src/views/document_view.js @@ -0,0 +1,465 @@ +const { form, button, div, h2, p, section, input, label, br, a, span, textarea, select, option } = + require("../server/node_modules/hyperaxe"); + +const moment = require("../server/node_modules/moment"); +const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl"); + +const userId = config.keys.id; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all"); + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const parts = [`filter=${encodeURIComponent(f)}`]; + if (q) parts.push(`q=${encodeURIComponent(q)}`); + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`); + return `/documents?${parts.join("&")}`; +}; + +const safeDomId = (prefix, key) => `${prefix}${String(key || "").replace(/[^A-Za-z0-9_-]/g, "_")}`; + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div( + { class: "card-tags" }, + list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; +}; + +const renderFavoriteToggle = (doc, returnTo) => + form( + { + method: "POST", + action: doc.isFavorite + ? `/documents/favorites/remove/${encodeURIComponent(doc.key)}` + : `/documents/favorites/add/${encodeURIComponent(doc.key)}` + }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button( + { type: "submit", class: "filter-btn" }, + doc.isFavorite ? i18n.documentRemoveFavoriteButton : i18n.documentAddFavoriteButton + ) + ); + +const renderDocumentActions = (filter, doc, params = {}) => { + const returnTo = buildReturnTo(filter, params); + const isAuthor = String(doc.author) === String(userId); + const hasOpinions = Object.keys(doc.opinions || {}).length > 0; + + return isAuthor + ? div( + { class: "bookmark-actions" }, + !hasOpinions + ? form( + { method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton) + ) + : null, + form( + { method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton) + ) + ) + : null; +}; + +const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => { + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + const commentsCount = list.length; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/documents/${encodeURIComponent(documentKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || ""; + const ts = c?.value?.timestamp || c?.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c?.value?.content || {}; + const text = content.text || ""; + const threadRoot = content.fork || content.root || null; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && threadRoot + ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate) + : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ); +}; + +const renderDocumentList = exports.renderDocumentList = (documents, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params); + + return documents.length + ? documents.map((doc) => { + const commentCount = typeof doc.commentCount === "number" ? doc.commentCount : 0; + const title = safeText(doc.title); + const pdfId = safeDomId("pdf-container-", doc.key); + + const topbarLeft = + doc.author && String(doc.author) !== String(userId) + ? form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: doc.author }), + button({ type: "submit", class: "filter-btn" }, i18n.documentMessageAuthorButton) + ) + : null; + + const isOwn = doc.author && String(doc.author) === String(userId); + return div( + { class: "trending-card document-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(doc.key, `/documents/${encodeURIComponent(doc.key)}`) + ), + div( + { class: "card-section document-card-body" }, + div( + { class: "bookmark-topbar" }, + div( + { class: "bookmark-topbar-left" }, + renderFavoriteToggle(doc, returnTo), + topbarLeft + ), + renderDocumentActions(filter, doc, params) + ), + title ? h2(title) : null, + doc.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(doc.lifetime, i18n)) : null, + doc?.url + ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` }) + : p(i18n.documentNoFile), + div( + { class: "card-comments-summary" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ":"), + span({ class: "card-value" }, String(commentCount)), + br(), + br(), + form( + { method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: filter || "all" }), + params.q ? input({ type: "hidden", name: "q", value: params.q }) : null, + params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null, + button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton) + ) + ), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(Object.values(doc.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) + ), + renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants) + ), + div({ class: "card-spread-left" }, renderSpreadButton(doc.key, (params.spreadMap && params.spreadMap.get(doc.key)) || params.spreads)), + br(), + (() => { + const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN; + const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(doc.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })() + ) + ); + }) + : p(params.q ? i18n.documentNoMatch : i18n.noDocuments); +}; + +const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => { + const returnFilter = filter === "create" ? "all" : params.filter || "all"; + const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params); + const tagsValue = safeArr(docToEdit?.tags).join(", "); + + return div( + { class: "div-center document-form" }, + form( + { + action: filter === "edit" ? `/documents/update/${encodeURIComponent(documentId)}` : "/documents/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + label(i18n.documentFileLabel), + br(), + input({ type: "file", name: "document", accept: "application/pdf", required: filter !== "edit" }), + br(), + br(), + label(i18n.documentTitleLabel), + br(), + input({ type: "text", name: "title", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || "" }), + br(), + label(i18n.documentDescriptionLabel), + br(), + textarea({ name: "description", placeholder: i18n.documentDescriptionPlaceholder, rows: "4" }, docToEdit?.description || ""), + br(), + label(i18n.documentTagsLabel), + br(), + input({ type: "text", name: "tags", placeholder: i18n.documentTagsPlaceholder, value: tagsValue }), + br(), + br(), + button({ type: "submit" }, filter === "edit" ? i18n.documentUpdateButton : i18n.documentCreateButton) + ) + ); +}; + +exports.documentView = async (documents, filter = "all", documentId = null, params = {}) => { + const title = + filter === "mine" + ? i18n.documentMineSectionTitle + : filter === "create" + ? i18n.documentCreateSectionTitle + : filter === "edit" + ? i18n.documentUpdateSectionTitle + : filter === "recent" + ? i18n.documentRecentSectionTitle + : filter === "top" + ? i18n.documentTopSectionTitle + : filter === "favorites" + ? i18n.documentFavoritesSectionTitle + : i18n.documentAllSectionTitle; + + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + + const list = safeArr(documents); + const docToEdit = documentId ? list.find((d) => d.key === documentId) : null; + + const tpl = template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.documentDescription) + ), + (() => { + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetDocuments); + return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n)); + })(), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.documentFilterFavorites + ), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton) + ) + ) + ), + section( + filter === "create" || filter === "edit" + ? renderDocumentForm(filter, documentId, docToEdit || {}, { ...params, filter }) + : section( + div( + { class: "documents-search" }, + form( + { method: "GET", action: "/documents", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", value: q, placeholder: i18n.documentSearchPlaceholder, class: "filter-box__input" }), + div( + { class: "filter-box__controls" }, + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.documentSortRecent), + option({ value: "oldest", selected: sort === "oldest" }, i18n.documentSortOldest), + option({ value: "top", selected: sort === "top" }, i18n.documentSortTop) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.documentSearchButton) + ) + ) + ), + div({ class: "documents-list" }, renderDocumentList(list, filter, { q, sort })) + ) + ) + ); + + return `${tpl}`; +}; + +exports.singleDocumentView = async (doc, filter = "all", comments = [], params = {}) => { + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }); + + const title = safeText(doc.title); + const pdfId = safeDomId("pdf-container-", doc.key); + const isAuthor = String(doc.author) === String(userId); + const hasOpinions = Object.keys(doc.opinions || {}).length > 0; + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetDocuments); + + const chips = [ + renderLifespanChip(doc.lifetime, i18n), + doc.sizeBytes ? renderEcoTax(doc.sizeBytes, doc.key) : null + ].filter(Boolean); + + const sideActions = []; + sideActions.push(renderFavoriteToggle(doc, returnTo)); + if (doc.author && String(doc.author) !== String(userId)) { + sideActions.push(form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: doc.author }), + button({ type: "submit", class: "filter-btn" }, i18n.documentMessageAuthorButton) + )); + } + if (isAuthor && !hasOpinions) { + sideActions.push(form( + { method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.documentUpdateButton) + )); + } + if (isAuthor) { + sideActions.push(form( + { method: "POST", action: `/documents/delete/${encodeURIComponent(doc.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.documentDeleteButton) + )); + } + + const tagsNode = renderTags(doc.tags); + + const docSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + title ? h2({ class: "tribe-card-title" }, title) : null, + renderReachChip(isClearnet, i18n) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + safeText(doc.description) + ? p({ class: "tribe-side-description" }, ...renderUrl(doc.description)) + : null, + tagsNode, + div({ class: "card-spread-centered" }, renderSpreadButton(doc.key, params.spreads)), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const docMain = div({ class: "tribe-main" }, + doc?.url + ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` }) + : p(i18n.documentNoFile), + (() => { + const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN; + const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(doc.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })(), + renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants), + renderDocumentCommentsSection(doc.key, doc.rootId || doc.key, comments, returnTo) + ); + + const tpl = template( + i18n.documentTitle, + section( + div({ class: "tags-header" }, + h2(i18n.documentAllSectionTitle || i18n.documentTitle), + p(i18n.documentDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.documentFilterFavorites + ), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton) + ) + ), + div({ class: "tribe-details" }, docSide, docMain) + ) + ); + + return `${tpl}`; +}; + diff --git a/src/views/event_view.js b/src/views/event_view.js new file mode 100644 index 0000000..c3b29fa --- /dev/null +++ b/src/views/event_view.js @@ -0,0 +1,578 @@ +const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe"); +const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderOpenClosedChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl"); +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view"); + +const userId = config.keys.id; + +exports.renderEventInvitePage = (code) => { + const pageContent = div({ class: "invite-page" }, + h2(i18n.tribeInviteCodeText, code), + form({ method: "GET", action: "/events" }, + button({ type: "submit", class: "filter-btn" }, i18n.walletBack) + ) + ); + return template(i18n.invitesEventsTitle || "Events", section(pageContent)); +}; + +const opt = (value, isSelected, text) => + option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text); + +const safeArray = (v) => (Array.isArray(v) ? v : []); + +const normalizePrivacy = (v) => { + const s = String(v || "public").toLowerCase(); + return s === "private" ? "private" : "public"; +}; + +const privacyLabel = (v) => (normalizePrivacy(v) === "private" ? i18n.eventPrivate : i18n.eventPublic); + +const safeExternalHref = (url) => { + const s = String(url || "").trim(); + const lower = s.toLowerCase(); + if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) return s; + return ""; +}; + +const normalizeEventStatus = (v) => { + const up = String(v || "").toUpperCase(); + if (up === "OPEN" || up === "CLOSED") return up; + return up || "OPEN"; +}; + +const eventStatusLabel = (v) => { + const st = normalizeEventStatus(v); + if (st === "OPEN") return i18n.eventStatusOpen; + if (st === "CLOSED") return i18n.eventStatusClosed; + return st; +}; + +const attendanceLabel = (isAttending) => (isAttending ? i18n.eventAttended : i18n.eventUnattended); + +const renderEventOwnerActions = (e, returnTo) => { + const st = normalizeEventStatus(e.status); + if (e.organizer !== userId || st !== "OPEN") return []; + const actions = [ + form( + { method: "GET", action: `/events/edit/${encodeURIComponent(e.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "update-btn" }, i18n.eventUpdateButton) + ), + form( + { method: "POST", action: `/events/delete/${encodeURIComponent(e.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "delete-btn" }, i18n.eventDeleteButton) + ) + ]; + if (normalizePrivacy(e.isPublic) === "private") { + actions.push(form( + { method: "POST", action: `/events/generate-invite/${encodeURIComponent(e.id)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite) + )); + if (e.openInviteCode) { + actions.push(div({ class: "tribe-open-invite" }, + span({ class: "card-label" }, i18n.tribeInviteCodeText), + span({ class: "tribe-open-invite-code" }, e.openInviteCode) + )); + actions.push(form( + { method: "POST", action: `/events/open-invite/remove/${encodeURIComponent(e.id)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation) + )); + } else { + actions.push(form( + { method: "POST", action: `/events/open-invite/create/${encodeURIComponent(e.id)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation) + )); + } + } + return actions; +}; + +const renderEventAttendAction = (e, isAttending, returnTo) => { + const st = normalizeEventStatus(e.status); + if (st !== "OPEN") return null; + if (e.organizer === userId) return null; + if (normalizePrivacy(e.isPublic) === "private" && !isAttending) { + return a({ class: "tribe-action-btn", href: "/invites#invites-events" }, i18n.tribeEnterInvite); + } + return form( + { method: "POST", action: `/events/attend/${encodeURIComponent(e.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, attendanceLabel(isAttending)) + ); +}; + +const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all") => { + const commentsCount = Array.isArray(comments) ? comments.length : 0; + const returnTo = `/events/${encodeURIComponent(eventId)}?filter=${encodeURIComponent(currentFilter || "all")}`; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/events/${encodeURIComponent(eventId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + return visibleComments.length + ? div( + { class: "comments-list" }, + visibleComments.map((c) => { + const author = c.value && c.value.author ? c.value.author : ""; + const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c.value && c.value.content ? c.value.content : {}; + const root = content.fork || content.root || ""; + const text = content.text || ""; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(String(text))) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); + })() + ); +}; + +const renderEventStatusChip = (status) => { + const localized = eventStatusLabel(status); + return renderOpenClosedChip(status, { statusChipOPEN: localized, statusChipCLOSED: localized }); +}; + +const renderEventItem = exports.renderEventItem = (e, filter, spreadInfo) => { + const currentFilter = filter || "all"; + const attendees = safeArray(e.attendees); + const isPrivate = normalizePrivacy(e.isPublic) === "private"; + const isAttending = attendees.includes(userId); + const price = parseFloat(e.price || 0); + + const chips = [ + renderEventStatusChip(e.status), + renderPrivacyChip(isPrivate, i18n), + e.encrypted ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") : null, + isAttending ? renderStateChip("whole", "★", i18n.eventAttended) : null, + renderLifespanChip(e.lifetime, i18n) + ].filter(Boolean); + + const dateText = e.date ? moment(e.date).format("YYYY/MM/DD HH:mm") : ""; + + const isOwn = e.organizer && String(e.organizer) === String(userId); + return div({ class: "trending-card event-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(e.id, `/events/${encodeURIComponent(e.id)}?filter=${encodeURIComponent(currentFilter)}`) + ), + div({ class: "card-section event-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/events/${encodeURIComponent(e.id)}` }, e.title || i18n.eventsTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + dateText ? p({ class: "card-date-highlight" }, dateText) : null, + e.location && String(e.location).trim() + ? p({ class: "job-meta-line" }, String(e.location)) + : null, + price > 0 + ? div({ class: "job-price-line card-salary" }, `${price.toFixed(6)} ECO`) + : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${attendees.length}`) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(e.id, spreadInfo)) + ) + ); +}; + +exports.eventView = async (events, filter, eventId, returnTo, params = {}) => { + const list = Array.isArray(events) ? events : [events]; + const currentFilter = filter || "all"; + const { renderReachChip: renderReachChipEvents } = require('./clearnet_view'); + const viewerClearnetEvents = !!(params.viewerPrefs && params.viewerPrefs.clearnetEvents); + + const title = + currentFilter === "mine" ? i18n.eventMineSectionTitle : + currentFilter === "create" ? i18n.eventCreateSectionTitle : + currentFilter === "edit" ? i18n.eventUpdateSectionTitle : + i18n.eventAllSectionTitle; + + const eventToEdit = list.find((e) => e.id === eventId) || {}; + const editTags = Array.isArray(eventToEdit.tags) ? eventToEdit.tags.filter(Boolean) : []; + + const canSee = (e) => { + const isPub = normalizePrivacy(e.isPublic) === "public"; + if (isPub) return true; + if (e.organizer === userId) return true; + return safeArray(e.attendees).includes(userId); + }; + + const visible = list.filter(canSee); + + let filtered; + if (currentFilter === "all") { + filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public"); + } else if (currentFilter === "mine") { + filtered = visible.filter((e) => e.organizer === userId); + } else if (currentFilter === "today") { + filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isSame(moment(), "day")); + } else if (currentFilter === "week") { + filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(7, "days"), null, "[]")); + } else if (currentFilter === "month") { + filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "month"), null, "[]")); + } else if (currentFilter === "year") { + filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && moment(e.date).isBetween(moment(), moment().add(1, "year"), null, "[]")); + } else if (currentFilter === "archived") { + filtered = visible.filter((e) => normalizePrivacy(e.isPublic) === "public" && normalizeEventStatus(e.status) === "CLOSED"); + } else { + filtered = []; + } + + filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm"); + + const ret = typeof returnTo === "string" && returnTo.startsWith("/events") ? returnTo : "/events?filter=mine"; + const editPrivacy = normalizePrivacy(eventToEdit.isPublic); + + return template( + title, + section( + div({ class: "tags-header" }, + h2(i18n.eventsTitle), + p(i18n.eventsDescription) + ), + div({ class: "shop-title-row" }, renderReachChipEvents(viewerClearnetEvents, i18n)), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/events" }, + button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine), + button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday), + button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek), + button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth), + button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear), + button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton) + ) + ) + ), + section( + currentFilter === "edit" || currentFilter === "create" + ? div( + { class: "event-form" }, + form( + { + action: currentFilter === "edit" ? `/events/update/${encodeURIComponent(eventId)}` : "/events/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: ret }), + label(i18n.eventTitleLabel), + br(), + input({ + type: "text", + name: "title", + id: "title", + required: true, + value: currentFilter === "edit" ? eventToEdit.title || "" : "" + }), + br(), + label(i18n.eventDescriptionLabel), + br(), + textarea( + { name: "description", id: "description", placeholder: i18n.eventDescriptionPlaceholder, rows: "4" }, + currentFilter === "edit" ? eventToEdit.description || "" : "" + ), + br(), + label(i18n.uploadMedia), + br(), + input({ type: "file", name: "image", accept: "image/*" }), + br(), + br(), + label(i18n.eventDateLabel), + br(), + input({ + type: "datetime-local", + name: "date", + id: "date", + required: true, + min: currentFilter === "create" ? minCreate : undefined, + value: currentFilter === "edit" && eventToEdit.date ? moment(eventToEdit.date).format("YYYY-MM-DDTHH:mm") : "" + }), + br(), + br(), + label(i18n.eventPrivacyLabel), + br(), + select( + { name: "isPublic", id: "isPublic" }, + opt("public", editPrivacy !== "private", i18n.eventPublic), + opt("private", editPrivacy === "private", i18n.eventPrivate) + ), + br(), + br(), + label(i18n.eventLocationLabel), + br(), + input({ + type: "text", + name: "location", + id: "location", + required: true, + value: currentFilter === "edit" ? eventToEdit.location || "" : "" + }), + br(), + label(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: eventToEdit?.mapUrl || "" }), + br(), + br(), + label(i18n.eventUrlLabel), + br(), + input({ type: "url", name: "url", id: "url", value: currentFilter === "edit" ? eventToEdit.url || "" : "" }), + br(), + br(), + label(i18n.eventPriceLabel), + br(), + input({ + type: "number", + name: "price", + id: "price", + min: "0.000000", + step: "0.000001", + value: currentFilter === "edit" ? parseFloat(eventToEdit.price || 0).toFixed(6) : (0).toFixed(6) + }), + br(), + br(), + label(i18n.eventTagsLabel), + br(), + input({ type: "text", name: "tags", id: "tags", value: currentFilter === "edit" ? editTags.join(", ") : "" }), + br(), + br(), + ...(currentFilter === "create" ? [ + input({ type: "hidden", name: "addToCalendar", value: "0" }), + label(i18n.eventAddToCalendar || "Add to Calendar"), + br(), + input({ id: "addToCalendar", type: "checkbox", name: "addToCalendar", value: "1", class: "meme-checkbox" }), + br(), + br() + ] : []), + button({ type: "submit" }, currentFilter === "edit" ? i18n.eventUpdateButton : i18n.eventCreateButton) + ) + ) + : filtered.length > 0 + ? div({ class: "jobs-grid" }, filtered.map((e) => renderEventItem(e, currentFilter, params.spreadMap && params.spreadMap.get(e.id)))) + : p(i18n.noevents) + ) + ); +}; + +exports.singleEventView = async (event, filter, comments = [], params = {}) => { + const currentFilter = filter || "all"; + const commentCount = typeof event.commentCount === "number" ? event.commentCount : 0; + const attendees = safeArray(event.attendees); + const urlHref = safeExternalHref(event.url); + + const isPrivateNoAccess = normalizePrivacy(event.isPublic) === "private" && + String(event.organizer) !== String(userId) && + !attendees.includes(userId); + + const filterBar = div( + { class: "filters" }, + form( + { method: "GET", action: "/events" }, + button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine), + button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday), + button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek), + button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth), + button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear), + button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton) + ) + ); + + if (isPrivateNoAccess) { + return template( + event.title, + section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied)) + ); + } + + const { renderReachChip, renderEncryptedChip } = require('./clearnet_view'); + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetEvents && normalizeEventStatus(event.status) !== 'CLOSED' && normalizePrivacy(event.isPublic) === 'public'); + const isPrivate = normalizePrivacy(event.isPublic) === 'private'; + const isEncrypted = !!event.encrypted || isPrivate; + const isAttending = attendees.includes(userId); + const isOrganizer = String(event.organizer) === String(userId); + const returnToSelf = `/events/${encodeURIComponent(event.id)}?filter=${encodeURIComponent(currentFilter)}`; + + const chips = [ + renderEventStatusChip(event.status), + renderPrivacyChip(isPrivate, i18n), + isEncrypted ? renderEncryptedChip(i18n) : renderReachChip(isClearnet, i18n), + isAttending ? renderStateChip("whole", "★", i18n.eventAttended) : null, + renderLifespanChip(event.lifetime, i18n), + renderEcoTax(event.msgSize, event.id) + ].filter(Boolean); + + const sideActions = []; + if (event.organizer && event.organizer !== userId) { + sideActions.push(form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: event.organizer }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + )); + } + const attendNode = renderEventAttendAction(event, isAttending, returnToSelf); + if (attendNode) sideActions.push(attendNode); + if (params.linkedCalendarId) { + sideActions.push(form({ method: "GET", action: `/calendars/${encodeURIComponent(params.linkedCalendarId)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.eventVisitCalendar || "Visit calendar") + )); + } + if (isOrganizer) sideActions.push(...renderEventOwnerActions(event, returnToSelf)); + + const tagsNode = event.tags && event.tags.filter(Boolean).length + ? div({ class: "card-tags" }, + event.tags.filter(Boolean).map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; + + const infoRows = []; + const pushRow = (labelText, valueNode) => + infoRows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: "tribe-info-value" }, valueNode) + )); + if (event.date) pushRow(i18n.eventDateLabel, moment(event.date).format("YYYY/MM/DD HH:mm")); + pushRow(i18n.eventStatus, eventStatusLabel(event.status)); + pushRow(i18n.eventPrivacyLabel, privacyLabel(event.isPublic)); + if (event.location && String(event.location).trim()) pushRow(i18n.eventLocationLabel, event.location); + if (urlHref) pushRow(i18n.eventUrlLabel, a({ href: urlHref, target: "_blank", rel: "noopener noreferrer" }, urlHref)); + const price = parseFloat(event.price || 0); + if (price > 0) pushRow(i18n.eventPriceLabel, `${price.toFixed(6)} ECO`); + + const attendeesListNode = attendees.length + ? div({ class: "card-assigned-list" }, + ...attendees.filter(Boolean).map((id) => userLink(id)) + ) + : null; + + const eventSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, event.title) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(event.id, params.spreads)), + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + tagsNode, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${attendees.length}`) + ), + attendeesListNode, + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const returnToOpinions = `/events/${encodeURIComponent(event.id)}?filter=${encodeURIComponent(currentFilter)}`; + const opinionsBar = renderOpinionsVoting('/events/opinions', event.id, event.opinions, returnToOpinions, event.opinions_inhabitants); + + const eventMain = div({ class: "tribe-main" }, + event.description + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, i18n.eventDescriptionLabel), + p({ class: "tribe-side-description" }, ...renderUrl(event.description)) + ) + : null, + event.mapUrl ? div({ class: "job-section" }, renderMapEmbed(params.mapData, event.mapUrl)) : null, + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(event.organizer) + ), + opinionsBar, + renderEventCommentsSection(event.id, comments, currentFilter) + ); + + return template( + event.title, + section( + filterBar, + div({ class: "tribe-details" }, eventSide, eventMain) + ) + ); +}; + +exports.clearnetEventView = async (event) => { + const { escapeHtml: esc, renderClearnetPage } = require('./clearnet_view'); + const title = esc(event.title || 'Event'); + const desc = esc(event.description || ''); + const dateStr = event.date ? esc(moment(event.date).format("YYYY-MM-DD HH:mm")) : ''; + const loc = esc(event.location || ''); + const price = parseFloat(event.price || 0); + const priceStr = price > 0 ? `${price.toFixed(2)} ECO` : ''; + const urlHref = safeExternalHref(event.url); + const extraCss = ` +.cn-event-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700} +.cn-event-meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px} +.cn-event-meta-item{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:8px 14px;font-size:14px;color:var(--fg-soft);display:inline-flex;align-items:center;gap:6px} +.cn-event-meta-id{font-family:monospace;font-size:11px;word-break:break-all;max-width:100%} +.cn-event-desc{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px;margin:0 0 20px 0} +.cn-event-link{display:inline-block;margin-top:12px;background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:8px 16px;border-radius:6px;font-weight:600} +`; + const body = ` +

${title}

+
+ ${dateStr ? `📅 ${dateStr}` : ''} + ${loc ? `📍 ${loc}` : ''} + ${priceStr ? `💰 ${priceStr}` : ''} +
+ ${desc ? `

${desc}

` : ''} + ${urlHref ? `More info →` : ''} +`; + return renderClearnetPage({ + title: `${event.title || 'Event'} — Oasis`, + ogTitle: event.title || 'Event', + ogDescription: event.description || '', + extraCss, + body, + hubFeedId: event.organizer || null + }); +}; + diff --git a/src/views/favorites_view.js b/src/views/favorites_view.js new file mode 100644 index 0000000..a5e3106 --- /dev/null +++ b/src/views/favorites_view.js @@ -0,0 +1,182 @@ +const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe"); + +const { template, i18n, userLink, renderContentActions } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl"); + +const userId = config.keys.id; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter) => { + const f = safeText(filter || "all"); + return `/favorites?filter=${encodeURIComponent(f)}`; +}; + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div( + { class: "card-tags" }, + list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; +}; + +const renderBookmarkUrl = (item) => { + if (item.kind !== "bookmarks") return null; + if (!item.url) return null; + return p( + a( + { href: item.url, target: "_blank", rel: "noreferrer noopener", class: "bookmark-url" }, + item.url + ) + ); +}; + +const renderImagePreview = (item) => { + if (item.kind !== "images") return null; + if (!item.url) return null; + + return div( + { class: "image-container" }, + a( + { href: item.viewHref }, + img({ + src: `/image/256/${encodeURIComponent(item.url)}`, + alt: item.title || "", + class: "media-preview", + loading: "lazy" + }) + ) + ); +}; + +const renderFavoriteCard = (item, filter) => { + const returnTo = buildReturnTo(filter); + + const title = safeText(item.title) || safeText(item.name) || safeText(item.category) || safeText(item.url) || ""; + + const ts = item.updatedAt || item.createdAt; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + + const isOwn = item.author && String(item.author) === String(userId); + return div( + { class: "trending-card favorites-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + div({ class: 'card-chips-row' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, String(item.kind || '').toUpperCase()) + ) + ), + renderContentActions(item.favId, item.viewHref) + ), + div( + { class: "card-section favorites-card-body" }, + div( + { class: "bookmark-topbar" }, + div( + { class: "bookmark-topbar-left" }, + form( + { + method: "POST", + action: `/favorites/remove/${encodeURIComponent(item.kind)}/${encodeURIComponent(item.favId)}`, + class: "bookmark-favorite-form" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, i18n.favoritesRemoveButton) + ) + ) + ), + title ? h2(title) : null, + renderImagePreview(item), + renderBookmarkUrl(item), + safeText(item.description) ? p(...renderUrl(item.description)) : null, + renderTags(item.tags), + p( + { class: "card-footer" }, + absDate ? span({ class: "date-link" }, `${absDate} ${i18n.performed} `) : "", + item.author ? userLink(item.author) : "" + ) + ) + ); +}; + +exports.favoritesView = async (items, filter = "all", counts = {}) => { + const c = counts || {}; + const total = typeof c.all === "number" ? c.all : safeArr(items).length; + + return template( + i18n.favoritesTitle, + section( + div({ class: "tags-header" }, h2(i18n.favoritesTitle), p(i18n.favoritesDescription)), + div( + { class: "filters" }, + form( + { method: "GET", action: "/favorites", class: "ui-toolbar ui-toolbar--filters" }, + button( + { type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterAll} (${total})` + ), + button( + { type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterRecent} (${total})` + ), + button( + { type: "submit", name: "filter", value: "audios", class: filter === "audios" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterAudios} (${c.audios || 0})` + ), + button( + { type: "submit", name: "filter", value: "bookmarks", class: filter === "bookmarks" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterBookmarks} (${c.bookmarks || 0})` + ), + button( + { type: "submit", name: "filter", value: "documents", class: filter === "documents" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterDocuments} (${c.documents || 0})` + ), + button( + { type: "submit", name: "filter", value: "images", class: filter === "images" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterImages} (${c.images || 0})` + ), + button( + { type: "submit", name: "filter", value: "maps", class: filter === "maps" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterMaps} (${c.maps || 0})` + ), + button( + { type: "submit", name: "filter", value: "pads", class: filter === "pads" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterPads || "PADS"} (${c.pads || 0})` + ), + button( + { type: "submit", name: "filter", value: "chats", class: filter === "chats" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterChats || "CHATS"} (${c.chats || 0})` + ), + button( + { type: "submit", name: "filter", value: "calendars", class: filter === "calendars" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterCalendars || "CALENDARS"} (${c.calendars || 0})` + ), + button( + { type: "submit", name: "filter", value: "videos", class: filter === "videos" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterVideos} (${c.videos || 0})` + ), + button( + { type: "submit", name: "filter", value: "torrents", class: filter === "torrents" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterTorrents || "TORRENTS"} (${c.torrents || 0})` + ), + button( + { type: "submit", name: "filter", value: "market", class: filter === "market" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterMarket || "MARKET"} (${c.market || 0})` + ), + button( + { type: "submit", name: "filter", value: "shopProducts", class: filter === "shopProducts" ? "filter-btn active" : "filter-btn" }, + `${i18n.favoritesFilterShopProducts || "SHOP ITEMS"} (${c.shopProducts || 0})` + ) + ) + ), + div({ class: "bookmark-list" }, safeArr(items).length ? safeArr(items).map((it) => renderFavoriteCard(it, filter)) : p(i18n.favoritesNoItems)) + ) + ); +}; + diff --git a/src/views/fediverse_view.js b/src/views/fediverse_view.js new file mode 100644 index 0000000..c651941 --- /dev/null +++ b/src/views/fediverse_view.js @@ -0,0 +1,229 @@ +const { div, h2, h3, p, section, button, form, a, span, strong, input, label, img, textarea, br, hr, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require("./main_views"); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); +const moment = require("../server/node_modules/moment"); + +const fmtDate = (ts) => { + if (!ts) return ""; + const m = moment(ts); + return m.isValid() ? m.format("YYYY-MM-DD HH:mm") : ""; +}; + +const renderMedia = (m) => { + if (!m || !m.url) return ""; + if (m.type === 'video' || m.type === 'gifv') { + return videoHyperaxe({ class: "post-video", src: m.url, controls: true, preload: "metadata" }); + } + if (m.type === 'audio') { + return audioHyperaxe({ class: "post-audio", src: m.url, controls: true }); + } + return img({ class: "post-image", src: m.url, alt: m.description || "" }); +}; + +const renderComposeForm = (opts = {}) => { + const { action, placeholder, replyToId, submitLabel } = opts; + return div({ class: "publish-form" }, + form( + { method: "POST", action, enctype: "multipart/form-data" }, + replyToId ? input({ type: "hidden", name: "inReplyToId", value: replyToId }) : "", + textarea({ name: "text", id: "text", rows: "6", class: "publish-textarea", placeholder: placeholder || i18n.fediverseComposePlaceholder }), + br(), + label({ for: "fediverse_media" }, i18n.fediverseAttach), + br(), + input({ type: "file", id: "fediverse_media", name: "media", accept: "image/*,video/*", multiple: true }), + br(), br(), + button({ type: "submit", class: "filter-btn" }, submitLabel || i18n.fediversePublish) + ) + ); +}; + +const renderActions = (post, returnTo) => { + const id = encodeURIComponent(post.id); + const rt = input({ type: "hidden", name: "returnTo", value: returnTo }); + const favAction = post.favourited ? `/fediverse/mastodon/unfav/${id}` : `/fediverse/mastodon/fav/${id}`; + const favLabel = post.favourited ? `★ ${post.counts.favourites}` : `☆ ${post.counts.favourites}`; + const boostAction = post.reblogged ? `/fediverse/mastodon/unboost/${id}` : `/fediverse/mastodon/boost/${id}`; + const boostLabel = `🔁 ${post.counts.reblogs}`; + return div( + { class: "fediverse-actions" }, + form({ method: "POST", action: favAction }, rt, button({ type: "submit", class: post.favourited ? "filter-btn active" : "filter-btn" }, favLabel)), + form({ method: "POST", action: boostAction }, rt, button({ type: "submit", class: post.reblogged ? "filter-btn active" : "filter-btn" }, boostLabel)), + form({ method: "GET", action: `/fediverse/mastodon/thread/${id}` }, button({ type: "submit", class: "filter-btn" }, `${i18n.fediverseReply} · ${post.counts.replies}`)) + ); +}; + +const renderPost = (post, returnTo, opts = {}) => { + const acc = post.account || {}; + return div( + { class: "feed-card fediverse-card" }, + post.boostedBy ? div({ class: "fediverse-boosted" }, `🔁 ${post.boostedBy} ${i18n.fediverseBoosted}`) : "", + div( + { class: "fediverse-head" }, + acc.avatar ? img({ class: "fediverse-avatar", src: acc.avatar, alt: acc.displayName }) : "", + div( + { class: "fediverse-author" }, + span({ class: "fediverse-name" }, acc.displayName || acc.acct), + span({ class: "fediverse-acct" }, acc.acct ? `@${acc.acct}` : "") + ), + span({ class: "fediverse-date" }, fmtDate(post.createdAt)) + ), + div({ class: "feed-text", innerHTML: sanitizeHtml(post.html) }), + post.media && post.media.length ? div({ class: "fediverse-media" }, post.media.map(renderMedia)) : "", + opts.noActions ? "" : renderActions(post, returnTo) + ); +}; + +const mastodonBox = (account, stats, actions, showLabel) => { + const host = String(account.instance || "").replace(/^https?:\/\//, ""); + const handle = `${account.acct}@${host}`; + const profileUrl = `${account.instance}/@${account.acct}`; + const joined = stats && stats.createdAt && moment(stats.createdAt).isValid() ? moment(stats.createdAt).format("DD MMM YYYY") : ""; + const stat = (lbl, val) => div({ class: "fediverse-stat" }, span({ class: "fediverse-stat-label" }, lbl), strong(String(val))); + return div({ class: "fediverse-network" }, + showLabel ? h3("Mastodon") : "", + div({ class: "fediverse-profile" }, + div({ class: "fediverse-namerow" }, + account.avatar ? img({ class: "fediverse-avatar", src: account.avatar, alt: account.displayName }) : "", + div({ class: "fediverse-profile-id" }, + div({ class: "fediverse-name" }, account.displayName || account.acct), + a({ class: "fediverse-acct", href: profileUrl, target: "_blank", rel: "noopener noreferrer" }, handle) + ), + actions && actions.length ? div({ class: "fediverse-compact-actions" }, actions) : "" + ), + (stats && stats.bio) ? div({ class: "fediverse-bio", innerHTML: sanitizeHtml(stats.bio) }) : "", + stats + ? div({ class: "fediverse-stats" }, + stat(i18n.fediverseFollowers, stats.followers), + stat(i18n.fediverseFollowing, stats.following), + stat(i18n.fediversePosts, stats.posts), + joined ? stat(i18n.fediverseJoined, joined) : "" + ) + : "", + (stats && Array.isArray(stats.fields) && stats.fields.length) + ? div({ class: "fediverse-fields" }, stats.fields.map(f => div({ class: "fediverse-field" }, + span({ class: "fediverse-field-name" }, f.name, f.verified ? span({ class: "fediverse-field-verified", title: "verified" }, " ✓") : ""), + span({ class: "fediverse-field-value", innerHTML: sanitizeHtml(f.value) }) + ))) + : "" + ) + ); +}; + +const disconnectedBlock = () => { + const text = String(i18n.fediverseDisconnected || ""); + const settingsLink = a({ href: "/settings#fediverse" }, i18n.fediverseEmptyLink || "settings"); + const parts = text.split("%LINK%"); + const para = parts.length > 1 + ? p(parts[0], settingsLink, parts.slice(1).join("%LINK%")) + : p(text, " ", settingsLink); + return div({ class: "fediverse-disconnected" }, h2(i18n.fediverseStatus), para); +}; + +exports.fediverseOverviewView = ({ account, stats } = {}) => { + const box = account + ? mastodonBox(account, stats, [ + form({ method: "GET", action: "/fediverse/mastodon" }, button({ type: "submit", class: "filter-btn" }, i18n.fediverseManage)) + ], true) + : null; + + return template(i18n.fediverse, section( + div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription)), + account ? box : disconnectedBlock() + )); +}; + +exports.fediverseView = ({ account, posts, error, stats } = {}) => { + if (!account) return exports.fediverseOverviewView({ account: null }); + + const body = []; + body.push(div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription))); + body.push(mastodonBox(account, stats, [ + a({ href: "/fediverse", class: "filter-btn" }, `← ${i18n.fediverse}`), + a({ href: "/fediverse/mastodon?refresh=1", class: "filter-btn" }, i18n.fediverseRefresh), + a({ href: "/fediverse/mastodon/preview", class: "filter-btn" }, i18n.fediverseWrite) + ], false)); + if (error) body.push(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError)); + + const list = Array.isArray(posts) ? posts : []; + if (!list.length && !error) { + body.push(p({ class: "muted" }, i18n.fediverseNoPosts)); + } else { + body.push(section({ class: "feed-container" }, list.map(pst => renderPost(pst, "/fediverse/mastodon")))); + } + + return template(i18n.fediverse, section(...body)); +}; + +exports.fediverseThreadView = ({ account, stats, thread, error } = {}) => { + const header = section( + div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription)), + account ? mastodonBox(account, stats, [ + a({ href: "/fediverse/mastodon", class: "filter-btn" }, `← ${i18n.fediverse}`) + ], true) : "" + ); + + if (!thread || !thread.status) { + return template(i18n.fediverse, header, section(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError))); + } + + const body = []; + if (error) body.push(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError)); + const rt = `/fediverse/mastodon/thread/${encodeURIComponent(thread.status.id)}`; + (thread.ancestors || []).forEach(pst => body.push(renderPost(pst, rt))); + body.push(section({ class: "feed-container fediverse-focus" }, renderPost(thread.status, rt))); + (thread.descendants || []).forEach(pst => body.push(renderPost(pst, rt))); + body.push(br()); + body.push(hr()); + body.push(br()); + body.push(renderComposeForm({ action: `/fediverse/mastodon/reply/${encodeURIComponent(thread.status.id)}/preview`, placeholder: i18n.fediverseReplyPlaceholder, submitLabel: i18n.fediversePreview })); + + return template(i18n.fediverse, header, section(...body)); +}; + +exports.fediversePreviewView = ({ account, stats, text, media, error, replyToId, parent } = {}) => { + const mediaList = Array.isArray(media) ? media : []; + const hasContent = (typeof text === "string" && text.trim().length > 0) || mediaList.length > 0; + const isReply = !!replyToId; + const sid = isReply ? encodeURIComponent(String(replyToId)) : ""; + const postAction = isReply ? `/fediverse/mastodon/reply/${sid}` : "/fediverse/mastodon/post"; + const previewAction = isReply ? `/fediverse/mastodon/reply/${sid}/preview` : "/fediverse/mastodon/preview"; + const backHref = isReply ? `/fediverse/mastodon/thread/${sid}` : "/fediverse/mastodon"; + + const previewCard = section({ class: "feed-container" }, + div({ class: "feed-card fediverse-card fediverse-preview-card" }, + text ? div({ class: "feed-text fediverse-pre" }, text) : "", + mediaList.length ? div({ class: "fediverse-media" }, mediaList.map(m => renderMedia({ type: m.type, url: `/fediverse/tmp/${encodeURIComponent(m.name)}` }))) : "" + ) + ); + + return template(i18n.fediverse, + section( + div({ class: "tags-header" }, h2(i18n.fediverse), p(i18n.fediverseDescription)), + account ? mastodonBox(account, stats, [ + a({ href: backHref, class: "filter-btn" }, `← ${i18n.fediverse}`) + ], true) : "" + ), + parent ? section({ class: "feed-container fediverse-focus" }, renderPost(parent, backHref, { noActions: true })) : "", + error ? section(div({ class: "fediverse-error" }, i18n[error] || i18n.fediverseError)) : "", + section( + div({ class: "publish-form" }, + form( + { action: postAction, method: "POST", enctype: "multipart/form-data" }, + textarea({ name: "text", id: "text", rows: "6", class: "publish-textarea", placeholder: isReply ? i18n.fediverseReplyPlaceholder : i18n.fediverseComposePlaceholder }, text || ""), + ...mediaList.map(m => input({ type: "hidden", name: "tmp", value: m.name })), + br(), + label({ for: "fediverse_media" }, i18n.fediverseAttach), + br(), + input({ type: "file", id: "fediverse_media", name: "media", accept: "image/*,video/*", multiple: true }), + br(), + br(), + ...(hasContent ? [hr(), br(), previewCard, br(), br()] : []), + div({ class: "fediverse-actions" }, + button({ type: "submit", class: "filter-btn", formaction: previewAction }, i18n.fediversePreview), + ...(hasContent ? [button({ type: "submit", class: "filter-btn" }, i18n.fediversePublish)] : []) + ) + ) + ) + ) + ); +}; diff --git a/src/views/feed_view.js b/src/views/feed_view.js new file mode 100644 index 0000000..8e1cf97 --- /dev/null +++ b/src/views/feed_view.js @@ -0,0 +1,396 @@ +const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe"); +const { template, i18n, renderOpinionsVoting, userLink, renderContentActions } = require("./main_views"); +const { config } = require("../server/SSB_server.js"); +const { renderTextWithStyles } = require("../backend/renderTextWithStyles"); +const moment = require("../server/node_modules/moment"); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); +const { renderUrl } = require("../backend/renderUrl"); + +const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1); +const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280); + +const normalizeOptions = (opts) => { + if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "", msg: "" }; + if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "", msg: "" }; + return { + filter: String(opts.filter || "ALL").toUpperCase(), + q: typeof opts.q === "string" ? opts.q : "", + tag: typeof opts.tag === "string" ? opts.tag : "", + msg: typeof opts.msg === "string" ? opts.msg : "" + }; +}; + +const formatDate = (feed) => { + const ts = feed?.value?.timestamp || Date.parse(feed?.value?.content?.createdAt || "") || 0; + return ts ? new Date(ts).toLocaleString() : ""; +}; + +const extractTags = (text) => { + const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase()); + return Array.from(new Set(list)); +}; + +const rewriteHashtagLinks = (html) => { + return String(html || '').replace( + /href=(["'])\/hashtag\/([^"'?#\s<]+)\1/gi, + (m, q, rawTag) => { + let t = String(rawTag || ''); + try { t = decodeURIComponent(t); } catch {} + t = t.replace(/[^A-Za-z0-9_]/g, ''); + const tag = t.toLowerCase(); + const query = encodeURIComponent(`#${tag}`); + return `href=${q}/search?query=${query}${q}`; + } + ); +}; + +const generateFilterButtons = (filters, currentFilter, action, extra = {}) => { + const cur = String(currentFilter || "").toUpperCase(); + const hiddenInputs = (obj) => + Object.entries(obj) + .filter(([, v]) => v !== undefined && v !== null && String(v).length > 0) + .map(([k, v]) => input({ type: "hidden", name: k, value: String(v) })); + + return filters.map((mode) => + form( + { method: "GET", action }, + input({ type: "hidden", name: "filter", value: mode }), + ...hiddenInputs(extra), + button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, i18n[mode + "Button"] || mode) + ) + ); +}; + +const renderVotesSummary = (opinions = {}) => { + const entries = Object.entries(opinions).filter(([, v]) => Number(v) > 0); + if (!entries.length) return null; + entries.sort((a, b) => Number(b[1]) - Number(a[1]) || String(a[0]).localeCompare(String(b[0]))); + return div( + { class: "votes" }, + entries.map(([category, count]) => span({ class: "vote-category" }, `${category}: ${count}`)) + ); +}; + +const renderCardField = (labelText, value) => + div( + { class: "card-field" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, value) + ); + +const renderFeedCommentsSection = (feedKey, comments = []) => { + const list = Array.isArray(comments) ? comments : []; + const commentsCount = list.length; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel || i18n.feedPostComment || "Post a comment"), + form( + { method: "POST", action: `/feed/${encodeURIComponent(feedKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder || "" + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia || "Upload media"), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton || i18n.feedPostComment || "Send") + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || ""; + const ts = c?.value?.timestamp || c?.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c?.value?.content || {}; + const text = content.text || c?.value?.text || ""; + const threadRoot = content.fork || content.root || null; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && threadRoot + ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate) + : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet || i18n.noComments || "") + ); +}; + +const renderFeedCard = (feed) => { + const content = feed.value.content || {}; + const rawText = typeof content.text === "string" ? content.text : ""; + const safeText = rawText.trim(); + if (!safeText) return null; + + const voteEntries = Object.entries(content.opinions || {}); + const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0); + const createdAt = formatDate(feed); + const me = config?.keys?.id; + + const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false; + + const authorId = feed.value.author || content.author || ""; + const refeedsNum = Number(content.refeeds || 0) || 0; + const commentCount = Number(content.commentCount || 0); + const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText)); + + return div( + { class: "trending-card feed-card" + (authorId && String(authorId) === String(me) ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`) + ), + div( + { class: "card-section feed-card-body" }, + div( + { class: "feed-row" }, + div( + { class: "refeed-column" }, + h1(String(refeedsNum)), + form( + { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` }, + button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton) + ), + alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null + ), + div( + { class: "feed-main" }, + div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }), + h2( + `${i18n.totalOpinions}: ${totalCount}`, + ...(() => { + const entries = voteEntries.filter(([, v]) => Number(v) > 0); + if (!entries.length) return []; + const maxVal = Math.max(...entries.map(([, v]) => Number(v))); + const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k); + return [ + span({ style: 'margin:0 8px;opacity:0.5;' }, '|'), + span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`) + ]; + })() + ), + p( + { class: "card-footer" }, + span({ class: "date-link" }, `${createdAt} ${i18n.performed} `), + userLink(authorId), + content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null + ) + ) + ), + div( + { class: "card-comments-summary" }, + span({ class: "card-label" }, `${i18n.voteCommentsLabel || "Comments"}:`), + span({ class: "card-value" }, String(commentCount)), + br(), + br(), + form( + { method: "GET", action: `/feed/${encodeURIComponent(feed.key)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton || i18n.feedOpenDiscussion || "Open Discussion") + ) + ), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(totalCount)) + ), + renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants) + ) + ) + ); +}; + +exports.feedView = (feeds, opts = "ALL") => { + const { filter, q, tag, msg } = normalizeOptions(opts); + + const title = + filter === "MINE" + ? i18n.MINEButton + : filter === "TODAY" + ? i18n.TODAYButton + : filter === "TOP" + ? i18n.TOPButton + : filter === "CREATE" + ? i18n.createFeedTitle + : tag + ? `${i18n.filteredByTag || i18n.filteredByTagTitle || "Filtered by tag"}: #${tag}` + : q + ? `${i18n.searchTitle || "Search"}: “${q}”` + : i18n.feedTitle; + + const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions)); + const successBanner = msg === 'feedPublished' + ? div({ class: 'feed-success-msg' }, p('✓ ' + (i18n.feedPublishedSuccess || 'Feed published successfully!'))) + : null; + + const extra = { q, tag }; + + return template( + title, + section( + header, + successBanner, + div( + { class: "mode-buttons-row" }, + ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra), + form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button filter-btn" }, i18n.createFeedTitle || "Create Feed")) + ), + div( + { class: "feed-tools-row" }, + form( + { method: "GET", action: "/feed", class: "feed-search-form" }, + input({ type: "hidden", name: "filter", value: filter }), + tag ? input({ type: "hidden", name: "tag", value: tag }) : null, + input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }), + button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search") + ) + ), + section( + filter === "CREATE" + ? form( + { method: "POST", action: "/feed/create" }, + textarea({ + name: "text", + placeholder: i18n.feedPlaceholder, + required: true, + minlength: String(FEED_TEXT_MIN), + maxlength: String(FEED_TEXT_MAX), + rows: 4, + cols: 50 + }), + br(), + button({ type: "submit", class: "create-button" }, i18n.createFeedButton) + ) + : feeds && feeds.length > 0 + ? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)).filter(Boolean)) + : div({ class: "no-results" }, p(i18n.noFeedsFound)) + ) + ) + ); +}; + +exports.feedCreateView = (opts = {}) => { + const { q, tag } = normalizeOptions(opts); + + return template( + i18n.createFeedTitle, + section( + div({ class: "tags-header" }, h2(i18n.createFeedTitle), p(i18n.FeedshareYourOpinions)), + div({ class: "mode-buttons-row" }, ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], "CREATE", "/feed", { q, tag })), + form( + { method: "POST", action: "/feed/create" }, + textarea({ + name: "text", + required: true, + minlength: String(FEED_TEXT_MIN), + maxlength: String(FEED_TEXT_MAX), + rows: 5, + cols: 50, + placeholder: i18n.feedPlaceholder + }), + br(), + button({ type: "submit", class: "create-button" }, i18n.createFeedButton || "Send Feed!") + ) + ) + ); +}; + +exports.singleFeedView = (feed, comments = []) => { + const content = feed.value?.content || {}; + const rawText = typeof content.text === "string" ? content.text : ""; + const safeText = rawText.trim(); + const authorId = feed.value?.author || content.author || ""; + const createdAt = formatDate(feed); + const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText)); + const me = config?.keys?.id; + const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false; + const refeedsNum = Number(content.refeeds || 0) || 0; + const tags = extractTags(safeText); + + return template( + i18n.feedDetailTitle || "Feed", + section( + div( + { class: "filters" }, + form( + { method: "GET", action: "/feed", class: "ui-toolbar ui-toolbar--filters" }, + button({ type: "submit", name: "filter", value: "ALL", class: "filter-btn" }, i18n.ALLButton || "ALL"), + button({ type: "submit", name: "filter", value: "MINE", class: "filter-btn" }, i18n.MINEButton || "MINE"), + button({ type: "submit", name: "filter", value: "TODAY", class: "filter-btn" }, i18n.TODAYButton || "TODAY"), + button({ type: "submit", name: "filter", value: "TOP", class: "filter-btn" }, i18n.TOPButton || "TOP"), + form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button" }, i18n.createFeedTitle || "Create Feed")) + ) + ), + div( + { class: "bookmark-item card feed-detail-card" }, + br, + div( + { class: "feed-row" }, + div( + { class: "refeed-column" }, + h1(String(refeedsNum)), + form( + { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` }, + button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton) + ), + alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null + ), + div( + { class: "feed-main" }, + div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }), + tags.length + ? div( + { class: "card-tags" }, + tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null, + br, + p( + { class: "card-footer" }, + span({ class: "date-link" }, `${createdAt} ${i18n.performed} `), + userLink(authorId), + content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null + ) + ) + ) + ), + renderFeedCommentsSection(feed.key, comments), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(Object.values(content.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) + ), + renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants) + ) + ) + ); +}; + diff --git a/src/views/forum_view.js b/src/views/forum_view.js new file mode 100644 index 0000000..4b201a1 --- /dev/null +++ b/src/views/forum_view.js @@ -0,0 +1,415 @@ +const { + div, a, span, form, button, section, p, + input, label, br, select, option, h2, textarea +} = require("../server/node_modules/hyperaxe"); +const moment = require("../server/node_modules/moment"); +const { template, i18n, userLink, renderSpreadButton, renderPrivacyChip, renderLifespanChip } = require('./main_views'); +const { renderEncryptedChip: renderForumEncryptedChip } = require('./clearnet_view'); +const { config } = require('../server/SSB_server.js'); +const { renderUrl } = require('../backend/renderUrl'); +const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); + +const userId = config.keys.id; + +exports.renderForumInvitePage = (code) => { + const pageContent = div({ class: 'invite-page' }, + h2(i18n.tribeInviteCodeText, code), + form({ method: 'GET', action: '/forum' }, + button({ type: 'submit', class: 'filter-btn' }, i18n.walletBack) + ) + ); + return template(i18n.invitesForumsTitle || 'Forums', section(pageContent)); +}; + +const BASE_FILTERS = ['hot','all','mine','recent','top']; +const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.','POLITICS','TECH']; +const CAT_BLOCK2 = ['SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS']; +const CAT_BLOCK3 = ['PHILOSOPHY','SOCIETY','PRIVACY','CYBERWARFARE','SURVIVALISM']; +const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3]; + +const catKey = (c) => 'forumCat' + String(c || '').replace(/\./g,'').replace(/[\s-]/g,'').toUpperCase(); +const catLabel = (c) => i18n[catKey(c)] || c; + +const Z = 1.96; +function wilsonScore(pos, neg) { + const n = (pos||0)+(neg||0); + if (n === 0) return 0; + const phat = pos / n, z2 = Z * Z; + return (phat + z2/(2*n) - Z*Math.sqrt((phat*(1-phat)+z2/(4*n))/n)) / (1+z2/n); +} + +function getFilteredForums(filter, forums) { + const now = Date.now(); + if (filter === 'mine') return forums.filter(f => f.author === userId); + if (filter === 'recent') return forums.filter(f => new Date(f.createdAt).getTime() >= now - 86400000); + if (filter === 'top') return forums.slice().sort((a,b) => b.score - a.score); + if (filter === 'hot') return forums + .filter(f => new Date(f.createdAt).getTime() >= now - 86400000) + .sort((a,b) => b.score - a.score); + if (ALL_CATS.includes(filter)) + return forums.filter(f => f.category === filter); + return forums; +} + +const generateFilterButtons = (filters, currentFilter, action, i18nMap = {}) => + div({ class: 'filter-group' }, + filters.map(mode => + form({ method: 'GET', action }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' }, + String(i18nMap[mode] || mode).toUpperCase() + ) + ) + ) + ); + +const renderCreateForumButton = () => + div({ class: 'forum-create-col' }, + form({ method: 'GET', action: '/forum' }, + button({ type: 'submit', name: 'filter', value: 'create', class: 'create-button' }, + i18n.forumCreateButton + ) + ) + ); + +const renderVotes = (target, score, forumId) => + div({ class: 'forum-score-box' }, + form({ method: 'POST', action: `/forum/${encodeURIComponent(forumId)}/vote`, class: 'forum-score-form' }, + button({ name: 'value', value: 1, class: 'score-btn' }, '▲'), + div({ class: 'score-total' }, String(score || 0)), + button({ name: 'value', value: -1, class: 'score-btn' }, '▼'), + input({ type: 'hidden', name: 'target', value: target }), + input({ type: 'hidden', name: 'forumId', value: forumId }) + ) + ); + +const renderForumForm = () => + div({ class: 'forum-form' }, + form({ action: '/forum/create', method: 'POST' }, + label(i18n.forumCategoryLabel), br(), + select({ name: 'category', required: true }, + ALL_CATS.map(cat => option({ value: cat }, catLabel(cat))) + ), br(), br(), + label(i18n.eventPrivacyLabel), br(), + select({ name: 'isPublic', id: 'isPublic' }, + option({ value: 'public', selected: true }, i18n.eventPublic), + option({ value: 'private' }, i18n.eventPrivate) + ), br(), br(), + label(i18n.forumTitleLabel), br(), + input({ + type: 'text', + name: 'title', + required: true, + placeholder: i18n.forumTitlePlaceholder + }), br(), br(), + label(i18n.forumMessageLabel), br(), + textarea({ + name: 'text', + required: true, + rows: 4, + placeholder: i18n.forumMessagePlaceholder + }), br(), br(), + button({ type: 'submit' }, i18n.forumCreateButton) + ) + ); + +const renderThread = (nodes, level = 0, forumId) => { + if (!Array.isArray(nodes)) return []; + return [...nodes] + .filter(m => (m && m.text && String(m.text).trim()) || (m && Array.isArray(m.children) && m.children.length)) + .sort((a, b) => + wilsonScore(b.positiveVotes, b.negativeVotes) + - wilsonScore(a.positiveVotes, a.negativeVotes) + ) + .flatMap((m, i) => { + const isTopLevelWinner = level === 0 && i === 0; + const classList = [ + 'forum-comment', + `level-${level}`, + isTopLevelWinner ? 'highlighted-reply' : '' + ].filter(Boolean).join(' '); + + const commentBox = div( + { class: classList }, + div({ class: 'comment-header' }, + span({ class: 'date-link' }, + `${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), + userLink(m.author), + div({ class: 'comment-votes' }, + span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`), + span({ class: 'votes-count', style: 'margin-left:12px;' }, + `▼: ${m.negativeVotes || 0}`) + ) + ), + div({ class: 'comment-body-row' }, + div({ class: 'comment-vote-col' }, + renderVotes(m.key, m.score, forumId) + ), + div({ class: 'comment-text-col' }, + div( + ...(m.text || '').split('\n') + .map(l => l.trim()) + .filter(l => l) + .map(l => p(...renderUrl(l))) + ) + ) + ), + div({ class: 'new-reply' }, + form({ + method: 'POST', + action: `/forum/${encodeURIComponent(forumId)}/message`, + class: 'comment-form' + }, + input({ type: 'hidden', name: 'parentId', value: m.key }), + textarea({ + name: 'message', + rows: 2, + required: true, + placeholder: i18n.forumMessagePlaceholder, + class: 'comment-textarea' + }), + button({ type: 'submit', class: 'forum-send-btn' }, 'Reply') + ) + ) + ); + + return [ commentBox, ...renderThread(m.children || [], level + 1, forumId) ]; + }); +}; + +const renderForumList = (forums, currentFilter, spreadMap = new Map()) => { + const visibleForums = (Array.isArray(forums) ? forums : []).filter(f => + (f && f.title && String(f.title).trim()) || (f && f.text && String(f.text).trim()) + ) + return div({ class: 'forum-list' }, + visibleForums.length + ? visibleForums.map(f => + div({ class: 'forum-card' }, + div({ class: 'forum-score-col' }, + renderVotes(f.key, f.score, f.key) + ), + div({ class: 'forum-main-col' }, + div({ class: 'forum-header-row' }, + a({ + class: 'forum-category', + href: `/forum?filter=${encodeURIComponent(f.category)}` + }, `[${catLabel(f.category)}]`), + a({ + class: 'forum-title', + href: `/forum/${encodeURIComponent(f.key)}` + }, f.title), + f.isPrivate ? renderPrivacyChip(true, i18n) : null, + f.isPrivate ? renderForumEncryptedChip(i18n) : null, + renderLifespanChip(f.lifetime, i18n) + ), + div({ + class: 'forum-body', + innerHTML: sanitizeHtml(renderTextWithStyles(f.text || '')) + }), + div({ class: 'forum-meta' }, + span({ class: 'forum-positive-votes' }, + `▲: ${f.positiveVotes || 0}`), + span({ class: 'forum-negative-votes', style: 'margin-left:12px;' }, + `▼: ${f.negativeVotes || 0}`), + span({ class: 'forum-participants' }, + `${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`), + span({ class: 'forum-messages' }, + `${i18n.forumMessages.toUpperCase()}: ${(f.messagesCount || 1) - 1}`), + form({ method: 'GET', action: `/forum/${encodeURIComponent(f.key)}`, class: 'visit-forum-form' }, + button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton) + ) + ), + (() => { + const btn = renderSpreadButton(f.key, spreadMap.get(f.key)); + return btn ? div({ class: 'card-spread-left' }, btn) : null; + })(), + div({ class: 'forum-footer' }, + span({ class: 'date-link' }, + `${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), + userLink(f.author) + ), + currentFilter === 'mine' && f.author === userId + ? div({ class: 'forum-owner-actions' }, + form({ + method: 'POST', + action: `/forum/delete/${encodeURIComponent(f.key)}`, + class: 'forum-delete-form' + }, + button({ type: 'submit', class: 'delete-btn' }, + i18n.forumDeleteButton) + ) + ) + : null + ) + ) + ) + : p(i18n.noForums) + ); +} + +exports.forumView = async (forums, currentFilter, params = {}) => { + const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{}); + return template(i18n.forumTitle, + section( + div({ class: 'tags-header' }, + h2(currentFilter === 'create' + ? i18n.forumCreateSectionTitle + : i18n.forumTitle), + p(i18n.forumDescription) + ), + div({ class: 'mode-buttons-cols' }, + generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', { + hot: i18n.forumFilterHot, + all: i18n.forumFilterAll, + mine: i18n.forumFilterMine, + recent: i18n.forumFilterRecent, + top: i18n.forumFilterTop + }), + generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP), + generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP), + generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP), + renderCreateForumButton() + ), + currentFilter === 'create' + ? renderForumForm() + : renderForumList( + getFilteredForums(currentFilter || 'all', forums), + currentFilter, + params.spreadMap + ) + ) + ); +}; + +exports.singleForumView = async (forum, messagesData, currentFilter) => { + const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{}); + return template(forum.title, + section( + div({ class: 'tags-header' }, + h2(i18n.forumTitle), + p(i18n.forumDescription) + ), + div({ class: 'mode-buttons-cols' }, + generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', { + hot: i18n.forumFilterHot, + all: i18n.forumFilterAll, + mine: i18n.forumFilterMine, + recent: i18n.forumFilterRecent, + top: i18n.forumFilterTop + }), + generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP), + generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP), + generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP), + renderCreateForumButton() + ) + ), + div({ class: 'forum-thread-container' }, + div({ + class: 'forum-card forum-thread-header', + style: 'display:flex;align-items:flex-start;' + }, + div({ + class: 'root-vote-col', + style: 'width:60px;text-align:center;' + }, renderVotes( + forum.key, + messagesData.totalScore, + forum.key + )), + div({ + class: 'forum-main-col', + style: 'flex:1;padding-left:10px;' + }, + div({ class: 'forum-header-row' }, + a({ + class: 'forum-category', + href: `/forum?filter=${encodeURIComponent(forum.category)}` + }, `[${catLabel(forum.category)}]`), + a({ + class: 'forum-title', + href: `/forum/${encodeURIComponent(forum.key)}` + }, forum.title), + forum.isPrivate ? renderPrivacyChip(true, i18n) : null, + forum.isPrivate ? renderForumEncryptedChip(i18n) : null, + renderLifespanChip(forum.lifetime, i18n), + (forum.isPrivate && forum.author === userId) + ? form({ method: 'POST', action: `/forum/generate-invite/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' }, + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite)) + : null, + (forum.isPrivate && forum.author === userId && !forum.openInviteCode) + ? form({ method: 'POST', action: `/forum/open-invite/create/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' }, + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeOpenInvitation)) + : null, + (forum.isPrivate && forum.author === userId && forum.openInviteCode) + ? span({ class: 'tribe-open-invite' }, + span({ class: 'card-label' }, i18n.tribeInviteCodeText), + span({ class: 'tribe-open-invite-code' }, forum.openInviteCode)) + : null, + (forum.isPrivate && forum.author === userId && forum.openInviteCode) + ? form({ method: 'POST', action: `/forum/open-invite/remove/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeRemoveInvitation)) + : null, + (forum.isPrivate && forum.author !== userId) + ? a({ class: 'tribe-action-btn', href: '/invites#invites-forums' }, i18n.tribeEnterInvite) + : null, + (forum.author === userId) + ? form({ method: 'POST', action: `/forum/delete/${encodeURIComponent(forum.key)}`, class: 'forum-delete-form' }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.forumDeleteButton)) + : null + ), + div({ + class: 'forum-body', + innerHTML: sanitizeHtml(renderTextWithStyles(forum.text || '')) + }), + div({ class: 'forum-meta' }, + span({ class: 'votes-count' }, + `▲: ${messagesData.positiveVotes}`), + span({ + class: 'votes-count', + style: 'margin-left:12px;' + }, `▼: ${messagesData.negativeVotes}`), + span({ class: 'forum-participants' }, + `${i18n.forumParticipants.toUpperCase()}: ${forum.participants?.length || 1}`), + span({ class: 'forum-messages' }, + `${i18n.forumMessages.toUpperCase()}: ${messagesData.total}`) + ), + (() => { + const btn = renderSpreadButton(forum.key); + return btn ? div({ class: 'card-spread-left' }, btn) : null; + })(), + div({ class: 'forum-footer' }, + span({ class: 'date-link' }, + `${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), + userLink(forum.author) + ) + ) + ), + div({ + class: 'new-message-wrapper', + style: 'margin-top:12px;' + }, + form({ + method: 'POST', + action: `/forum/${encodeURIComponent(forum.key)}/message`, + class: 'new-message-form' + }, + textarea({ + name: 'message', + rows: 4, + required: true, + placeholder: i18n.forumMessagePlaceholder, + style: 'width:100%;' + }), br(), + button({ + type: 'submit', + class: 'forum-send-btn', + style: 'margin-top:4px;' + }, i18n.forumSendButton) + ) + ), + ...renderThread(messagesData.messages, 0, forum.key) + ) + ); +}; + diff --git a/src/views/games_view.js b/src/views/games_view.js new file mode 100644 index 0000000..5619f82 --- /dev/null +++ b/src/views/games_view.js @@ -0,0 +1,151 @@ +const { div, h2, h3, p, section, form, input, button, a, img, table, tr, td, th, span, iframe } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink} = require('./main_views'); +const moment = require("../server/node_modules/moment"); + +const getGames = () => [ + { id: 'cocoland', title: () => i18n.gamesCocolandTitle, desc: () => i18n.gamesCocolandDesc }, + { id: 'ecoinflow', title: () => i18n.gamesTheFlowTitle, desc: () => i18n.gamesTheFlowDesc }, + { id: 'neoninfiltrator', title: () => i18n.gamesNeonInfiltratorTitle, desc: () => i18n.gamesNeonInfiltratorDesc }, + { id: 'audiopendulum', title: () => i18n.gamesAudioPendulumTitle, desc: () => i18n.gamesAudioPendulumDesc }, + { id: 'spaceinvaders', title: () => i18n.gamesSpaceInvadersTitle, desc: () => i18n.gamesSpaceInvadersDesc }, + { id: 'arkanoid', title: () => i18n.gamesArkanoidTitle, desc: () => i18n.gamesArkanoidDesc }, + { id: 'pingpong', title: () => i18n.gamesPingPongTitle, desc: () => i18n.gamesPingPongDesc }, + { id: 'asteroids', title: () => i18n.gamesAsteroidsTitle, desc: () => i18n.gamesAsteroidsDesc }, + { id: 'rockpaperscissors', title: () => i18n.gamesRockPaperScissorsTitle, desc: () => i18n.gamesRockPaperScissorsDesc }, + { id: 'tiktaktoe', title: () => i18n.gamesTikTakToeTitle, desc: () => i18n.gamesTikTakToeDesc }, + { id: 'flipflop', title: () => i18n.gamesFlipFlopTitle, desc: () => i18n.gamesFlipFlopDesc }, + { id: '8ball', title: () => i18n.games8BallTitle, desc: () => i18n.games8BallDesc }, + { id: 'artillery', title: () => i18n.gamesArtilleryTitle, desc: () => i18n.gamesArtilleryDesc }, + { id: 'labyrinth', title: () => i18n.gamesLabyrinthTitle, desc: () => i18n.gamesLabyrinthDesc }, + { id: 'cocoman', title: () => i18n.gamesCocomanTitle, desc: () => i18n.gamesCocomanDesc }, + { id: 'tetris', title: () => i18n.gamesTetrisTitle, desc: () => i18n.gamesTetrisDesc } +]; + +const shortId = (feedId) => feedId ? '@' + feedId.slice(1, 9) + '...' : '?'; + +const renderHallOfFame = (hall) => { + const games = getGames(); + const gamesWithScores = games.filter(g => hall[g.id] && hall[g.id].length > 0); + if (gamesWithScores.length === 0) { + return p({ class: 'no-content' }, i18n.gamesNoScores || 'No scores yet.'); + } + return div({ class: 'games-scoring-list' }, + gamesWithScores.map(game => + div({ class: 'game-scoring-section' }, + div({ class: 'game-scoring-header' }, + img({ src: `/game-assets/${game.id}/thumbnail.svg`, alt: game.title(), class: 'game-scoring-thumb', loading: 'lazy' }), + div({ class: 'game-scoring-info' }, + h3({ class: 'game-card-title' }, game.title()), + p({ class: 'game-card-desc game-desc-yellow' }, game.desc()) + ) + ), + table({ class: 'hall-of-fame-table' }, + tr(th('#'), th(i18n.gamesHallPlayer), th(i18n.gamesHallScore), th(i18n.gamesHallDate || 'Date')), + ...hall[game.id].map((entry, idx) => + tr( + td(String(idx + 1)), + td(userLink(entry.author)), + td({ class: idx === 0 ? 'score-first' : '' }, String(entry.score)), + td(entry.ts ? moment(entry.ts).format('YYYY-MM-DD') : '\u2014') + ) + ) + ) + ) + ) + ); +}; + +const VALID_GAME_IDS = new Set(['cocoland','ecoinflow','neoninfiltrator','audiopendulum','spaceinvaders','arkanoid','pingpong','asteroids','rockpaperscissors','tiktaktoe','flipflop','8ball','artillery','labyrinth','cocoman','tetris']); + +exports.gameShellView = (name) => { + if (!VALID_GAME_IDS.has(name)) { + return template(i18n.gamesTitle, section(p(i18n.notFound || 'Not found'))); + } + const game = getGames().find(g => g.id === name); + const filterBar = div({ class: 'filter-group' }, + form({ method: 'GET', action: '/games' }, + input({ type: 'hidden', name: 'filter', value: 'all' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.gamesFilterAll) + ), + form({ method: 'GET', action: '/games' }, + input({ type: 'hidden', name: 'filter', value: 'scoring' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.gamesFilterScoring) + ) + ); + return template( + game ? game.title() : name, + section( + div({ class: 'tags-header' }, + h2(i18n.gamesTitle), + p(i18n.gamesDescription || 'Discover and play some mini-games in your network.') + ), + filterBar + ), + section({ class: 'game-shell-section' }, + iframe({ + src: `/game-assets/${name}/index.html`, + class: `game-iframe game-iframe-${name}`, + scrolling: 'no', + allowfullscreen: true + }) + ) + ); +}; + +exports.gamesView = (filter = 'all', hall = null) => { + const games = getGames(); + + const filterBar = div({ class: 'filter-group' }, + form({ method: 'GET', action: '/games' }, + input({ type: 'hidden', name: 'filter', value: 'all' }), + button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, + i18n.gamesFilterAll + ) + ), + form({ method: 'GET', action: '/games' }, + input({ type: 'hidden', name: 'filter', value: 'scoring' }), + button({ type: 'submit', class: filter === 'scoring' ? 'filter-btn active' : 'filter-btn' }, + i18n.gamesFilterScoring + ) + ) + ); + + const content = filter === 'scoring' && hall + ? renderHallOfFame(hall) + : div({ class: 'games-single-col' }, + games.map(game => { + const topScore = hall && hall[game.id] && hall[game.id].length > 0 ? hall[game.id][0] : null; + return div({ class: 'game-row' }, + div({ class: 'game-row-media' }, + img({ src: `/game-assets/${game.id}/thumbnail.svg`, alt: game.title(), loading: 'lazy' }) + ), + div({ class: 'game-row-body' }, + h2({ class: 'game-card-title' }, game.title()), + p({ class: 'game-card-desc game-desc-yellow' }, game.desc()), + topScore + ? p({ class: 'game-top-score' }, + userLink(topScore.author), + span({ class: 'game-new-record-label' }, ' - ' + (i18n.gamesNewRecord || 'New Record') + ': '), + String(topScore.score) + ) + : null + ), + div({ class: 'game-row-actions' }, + a({ href: `/games/${game.id}`, class: 'filter-btn' }, i18n.gamesPlayButton) + ) + ); + }) + ); + + return template( + i18n.gamesTitle, + section( + div({ class: 'tags-header' }, + h2(i18n.gamesTitle), + p(filter === 'scoring' ? i18n.gamesHallOfFame : (i18n.gamesDescription || 'Discover and play some mini-games in your network.')) + ), + filterBar + ), + section(content) + ); +}; diff --git a/src/views/graphos_view.js b/src/views/graphos_view.js new file mode 100644 index 0000000..ea2332e --- /dev/null +++ b/src/views/graphos_view.js @@ -0,0 +1,153 @@ +const h = require("../server/node_modules/hyperaxe"); +const { div, h2, p, section, button, form, input, span, a } = h; +const { template, i18n } = require('./main_views'); + +const TAU = Math.PI * 2; + +const escAttr = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const escText = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const adaptiveLayout = (n) => { + if (n <= 1) return { rRatio: 0.22, nodeR: 30, labelGap: 18 }; + if (n <= 3) return { rRatio: 0.28, nodeR: 26, labelGap: 16 }; + if (n <= 6) return { rRatio: 0.34, nodeR: 22, labelGap: 14 }; + if (n <= 12) return { rRatio: 0.40, nodeR: 18, labelGap: 14 }; + return { rRatio: 0.44, nodeR: 14, labelGap: 12 }; +}; + +const buildGraphSvg = (me, peers) => { + const W = 900; + const H = 600; + const cx = W / 2; + const cy = H / 2; + const N = Math.max(1, peers.length); + const { rRatio, nodeR, labelGap } = adaptiveLayout(N); + const r = Math.min(W, H) * rRatio; + const meR = nodeR + 6; + + const positions = peers.map((peer, i) => { + const angle = (i / N) * TAU - Math.PI / 2; + return { + peer, + x: cx + r * Math.cos(angle), + y: cy + r * Math.sin(angle) + }; + }); + + const edges = positions.map(({ peer, x, y }) => + `` + ).join(''); + + const nodes = positions.map(({ peer, x, y }) => { + const xs = x.toFixed(2); + const ys = y.toFixed(2); + const labelY = (y + nodeR + labelGap).toFixed(2); + const enc = escAttr(encodeURIComponent(peer.key)); + const centerHref = `/graphos?center=${enc}`; + const profileHref = `/author/${enc}`; + const name = escText(peer.name); + return `` + + `${name} (${peer.kind})` + + `` + + `` + + `` + + `` + + `${name}` + + `` + + ``; + }).join(''); + + const meLabelY = (cy + meR + labelGap + 2).toFixed(2); + const meEnc = escAttr(encodeURIComponent(me.key)); + const meProfileHref = `/author/${meEnc}`; + const meName = escText(me.name); + const center = `` + + `${meName}` + + `` + + `` + + `` + + `` + + `` + + `${meName}` + + `` + + ``; + + return `` + + edges + nodes + center + + ``; +}; + +const kpi = (label, value) => div({ class: 'stats-kpi' }, + div({ class: 'stats-kpi-label' }, label), + div({ class: 'stats-kpi-value' }, String(value)) +); + +const legendItem = (kind, label) => + span({ class: 'graphos-legend-item' }, + span({ class: `graphos-legend-dot graphos-node-circle-${kind}` }), + span(label) + ); + +exports.graphosView = ({ filter, me, peers, kpis, focus = null, focusId = null, shown = null, total = null }) => { + const title = i18n.graphos || 'Graphos'; + const description = i18n.graphosDescription || 'Interactive map of the network around you.'; + const modes = ['ALL', 'MINE']; + const capped = Number.isFinite(shown) && Number.isFinite(total) && total > shown; + + return template( + title, + section( + div({ class: 'tags-header' }, + h2(title), + p(description) + ), + focus + ? [ + form({ method: 'GET', action: '/graphos', class: 'graphos-backbar' }, + button({ type: 'submit', class: 'filter-btn' }, i18n.graphosBackToMine || '← My network') + ), + div({ class: 'graphos-focus-bar' }, + span({ class: 'graphos-focus-label' }, `${i18n.graphosViewingGraphOf || 'Viewing'}: `), + a({ class: 'user-link', href: `/author/${encodeURIComponent(focusId || '')}` }, `@${focus}`) + ) + ] + : div({ class: 'mode-buttons stats-mode-row' }, + modes.map(m => + form({ method: 'GET', action: '/graphos' }, + input({ type: 'hidden', name: 'filter', value: m }), + button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button']) + ) + ) + ), + capped + ? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`) + : null, + div({ class: 'graphos-legend' }, + legendItem('me', i18n.graphosYou || 'You'), + legendItem('online', i18n.online || 'Online'), + filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null, + filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null + ), + div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }), + div({ class: 'stats-block' }, + div({ class: 'stats-grid' }, + kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total), + kpi(i18n.online || 'Online', kpis.online), + filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null, + filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null + ) + ) + ) + ); +}; diff --git a/src/views/image_view.js b/src/views/image_view.js new file mode 100644 index 0000000..36e74e8 --- /dev/null +++ b/src/views/image_view.js @@ -0,0 +1,541 @@ +const { form, button, div, h2, p, section, input, label, br, a, img, span, textarea, select, option } = + require("../server/node_modules/hyperaxe"); + +const moment = require("../server/node_modules/moment"); +const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions } = require("./main_views"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl") +const { renderMapLocationVisitLabel } = require("./maps_view"); + +const userId = config.keys.id; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all"); + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const parts = [`filter=${encodeURIComponent(f)}`]; + if (q) parts.push(`q=${encodeURIComponent(q)}`); + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`); + return `/images?${parts.join("&")}`; +}; + +const renderPMButton = (recipient, className = "filter-btn") => { + const r = safeText(recipient); + if (!r) return null; + if (String(r) === String(userId)) return null; + + return form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: r }), + button({ type: "submit", class: className }, i18n.privateMessage) + ); +}; + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div( + { class: "card-tags" }, + list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; +}; + +const renderImageFavoriteToggle = (imgObj, returnTo = "") => + form( + { + method: "POST", + action: imgObj.isFavorite + ? `/images/favorites/remove/${encodeURIComponent(imgObj.key)}` + : `/images/favorites/add/${encodeURIComponent(imgObj.key)}` + }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button( + { type: "submit", class: "filter-btn" }, + imgObj.isFavorite ? i18n.imageRemoveFavoriteButton : i18n.imageAddFavoriteButton + ) + ); + +const renderImageMedia = (imgObj, filter, params = {}) => { + const src = imgObj?.url ? `/blob/${encodeURIComponent(imgObj.url)}` : ""; + + return imgObj?.url + ? div( + { class: "image-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" }, + a( + { + href: `/images/${encodeURIComponent(imgObj.key)}?filter=${encodeURIComponent(filter || "all")}${ + params.q ? `&q=${encodeURIComponent(params.q)}` : "" + }${params.sort ? `&sort=${encodeURIComponent(params.sort)}` : ""}` + }, + img({ src, alt: imgObj.title || "", class: "media-preview", loading: "lazy" }) + ) + ) + : p(i18n.imageNoFile); +}; + +const renderImageOwnerActions = (filter, imgObj, params = {}) => { + const returnTo = buildReturnTo(filter, params); + const isAuthor = String(imgObj.author) === String(userId); + const hasOpinions = Object.keys(imgObj.opinions || {}).length > 0; + + if (!isAuthor) return []; + + const items = []; + if (!hasOpinions) { + items.push( + form( + { method: "GET", action: `/images/edit/${encodeURIComponent(imgObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.imageUpdateButton) + ) + ); + } + items.push( + form( + { method: "POST", action: `/images/delete/${encodeURIComponent(imgObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.imageDeleteButton) + ) + ); + + return items; +}; + +const renderImageList = exports.renderImageList = (images, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params); + return images.length + ? images.map((imgObj) => { + const commentCount = typeof imgObj.commentCount === "number" ? imgObj.commentCount : 0; + const title = safeText(imgObj.title); + const ownerActions = renderImageOwnerActions(filter, imgObj, params); + + const isOwn = imgObj.author && String(imgObj.author) === String(userId); + return div( + { class: "trending-card image-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(imgObj.key, `/images/${encodeURIComponent(imgObj.key)}`) + ), + div( + { class: "card-section image-card-body" }, + div( + { class: "bookmark-topbar" }, + div( + { class: "bookmark-topbar-left" }, + renderImageFavoriteToggle(imgObj, returnTo), + renderPMButton(imgObj.author) + ), + ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null + ), + title ? h2(title) : null, + (imgObj.meme || imgObj.lifetime) ? div({ class: "card-chips-row" }, + imgObj.meme ? a({ href: "/images?filter=meme", class: "chip-link" }, renderStateChip("mutuals", null, i18n.imageFilterMeme || "MEME")) : null, + imgObj.lifetime ? renderLifespanChip(imgObj.lifetime, i18n) : null + ) : null, + renderImageMedia(imgObj, filter, params), + div( + { class: "card-comments-summary" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ":"), + span({ class: "card-value" }, String(commentCount)), + br(), + br(), + form( + { method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: filter || "all" }), + params.q ? input({ type: "hidden", name: "q", value: params.q }) : null, + params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null, + button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton) + ) + ), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(Object.values(imgObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) + ), + renderOpinionsVoting('/images/opinions', imgObj.key, imgObj.opinions, returnTo, imgObj.opinions_inhabitants) + ), + div({ class: "card-spread-left" }, renderSpreadButton(imgObj.key, (params.spreadMap && params.spreadMap.get(imgObj.key)) || params.spreads)), + renderMapLocationVisitLabel(imgObj.mapUrl), + br(), + (() => { + const createdTs = imgObj.createdAt ? new Date(imgObj.createdAt).getTime() : NaN; + const updatedTs = imgObj.updatedAt ? new Date(imgObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(imgObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.imageUpdatedAt}: ${moment(imgObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })() + ) + ); + }) + : p(params.q ? i18n.imageNoMatch : i18n.noImages); +}; + +const renderImageForm = (filter, imageId, imageToEdit, params = {}) => { + const returnFilter = filter === "create" ? "all" : params.filter || "all"; + const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params); + const tagsValue = safeArr(imageToEdit?.tags).join(", "); + + return div( + { class: "div-center image-form" }, + form( + { + action: filter === "edit" ? `/images/update/${encodeURIComponent(imageId)}` : "/images/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + label(i18n.imageFileLabel), + br(), + input({ type: "file", name: "image", required: filter !== "edit" }), + br(), + br(), + imageToEdit?.url + ? img({ src: `/blob/${encodeURIComponent(imageToEdit.url)}`, class: "media-preview", alt: imageToEdit?.title || "" }) + : null, + label(i18n.imageTitleLabel), + br(), + input({ type: "text", name: "title", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || "" }), + br(), + label(i18n.imageDescriptionLabel), + br(), + textarea({ name: "description", placeholder: i18n.imageDescriptionPlaceholder, rows: "4" }, imageToEdit?.description || ""), + br(), + label(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: imageToEdit?.mapUrl || "" }), + br(), + input({ type: "hidden", name: "meme", value: "0" }), + label(i18n.imageTagsLabel), + br(), + input({ type: "text", name: "tags", placeholder: i18n.imageTagsPlaceholder, value: tagsValue }), + br(), + label(i18n.imageMemeLabel), + input({ + id: "meme-checkbox", + type: "checkbox", + name: "meme", + value: "1", + class: "meme-checkbox", + ...(imageToEdit?.meme ? { checked: true } : {}) + }), + br(), + br(), + button({ type: "submit" }, filter === "edit" ? i18n.imageUpdateButton : i18n.imageCreateButton) + ) + ); +}; + +const renderGallery = (images) => { + if (!images.length) return div(i18n.noImages); + + return div( + { class: "gallery" }, + images.map((imgObj) => { + const src = imgObj.url ? `/image/256/${encodeURIComponent(imgObj.url)}` : ""; + return a( + { href: `#img-${encodeURIComponent(imgObj.key)}`, class: "gallery-item" }, + img({ src, alt: imgObj.title || "", class: "gallery-image", loading: "lazy" }) + ); + }) + ); +}; + +const renderLightbox = (images) => + images.map((imgObj) => { + const src = imgObj.url ? `/blob/${encodeURIComponent(imgObj.url)}` : ""; + return div( + { id: `img-${encodeURIComponent(imgObj.key)}`, class: "lightbox" }, + a({ href: "#", class: "lightbox-close" }, "×"), + img({ src, class: "lightbox-image", alt: imgObj.title || "" }) + ); + }); + +const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => { + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + const commentsCount = list.length; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/images/${encodeURIComponent(imageKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || ""; + const ts = c?.value?.timestamp || c?.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c?.value?.content || {}; + const text = content.text || ""; + const threadRoot = content.fork || content.root || null; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && threadRoot ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ); +}; + +exports.imageView = async (images, filter = "all", imageId = null, params = {}) => { + const title = + filter === "mine" + ? i18n.imageMineSectionTitle + : filter === "create" + ? i18n.imageCreateSectionTitle + : filter === "edit" + ? i18n.imageUpdateSectionTitle + : filter === "gallery" + ? i18n.imageGallerySectionTitle + : filter === "meme" + ? i18n.imageMemeSectionTitle + : filter === "recent" + ? i18n.imageRecentSectionTitle + : filter === "top" + ? i18n.imageTopSectionTitle + : filter === "favorites" + ? i18n.imageFavoritesSectionTitle + : i18n.imageAllSectionTitle; + + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + + const list = safeArr(images); + const imageToEdit = imageId ? list.find((im) => im.key === imageId) : null; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.imageDescription) + ), + (() => { + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetImages); + return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n)); + })(), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.imageFilterFavorites + ), + button( + { type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" }, + i18n.imageFilterGallery + ), + button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton) + ) + ) + ), + section( + filter === "create" || filter === "edit" + ? renderImageForm(filter, imageId, imageToEdit, { ...params, filter }) + : section( + div( + { class: "images-search" }, + form( + { method: "GET", action: "/images", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ + type: "text", + name: "q", + value: q, + placeholder: i18n.imageSearchPlaceholder, + class: "filter-box__input" + }), + div( + { class: "filter-box__controls" }, + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.imageSortRecent), + option({ value: "oldest", selected: sort === "oldest" }, i18n.imageSortOldest), + option({ value: "top", selected: sort === "top" }, i18n.imageSortTop) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.imageSearchButton) + ) + ) + ), + filter === "gallery" ? renderGallery(list) : div({ class: "images-list" }, renderImageList(list, filter, { q, sort })) + ) + ), + ...(filter === "gallery" ? renderLightbox(list) : []) + ); +}; + +exports.singleImageView = async (imageObj, filter = "all", comments = [], params = {}) => { + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }); + + const title = safeText(imageObj.title); + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetImages); + + const memeChip = imageObj.meme + ? a({ href: "/images?filter=meme", class: "chip-link" }, renderStateChip("mutuals", null, i18n.imageFilterMeme || "MEME")) + : null; + const chips = [ + memeChip, + renderLifespanChip(imageObj.lifetime, i18n), + imageObj.sizeBytes ? renderEcoTax(imageObj.sizeBytes, imageObj.key) : null + ].filter(Boolean); + + const ownerActions = renderImageOwnerActions(filter, imageObj, { q, sort }); + const sideActions = []; + sideActions.push(renderImageFavoriteToggle(imageObj, returnTo)); + if (imageObj.author && String(imageObj.author) !== String(userId)) { + sideActions.push(renderPMButton(imageObj.author)); + } + for (const a of ownerActions) sideActions.push(a); + + const tagsNode = renderTags(imageObj.tags); + + const imageSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + title ? h2({ class: "tribe-card-title" }, title) : null, + renderReachChip(isClearnet, i18n) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + safeText(imageObj.description) + ? p({ class: "tribe-side-description" }, ...renderUrl(imageObj.description)) + : null, + tagsNode, + div({ class: "card-spread-centered" }, renderSpreadButton(imageObj.key, params.spreads)), + renderMapLocationVisitLabel(imageObj.mapUrl), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const imageMain = div({ class: "tribe-main" }, + imageObj?.url + ? div( + { class: "image-container" }, + img({ + src: `/blob/${encodeURIComponent(imageObj.url)}`, + alt: imageObj.title || "", + class: "media-preview", + loading: "lazy" + }) + ) + : p(i18n.imageNoFile), + (() => { + const createdTs = imageObj.createdAt ? new Date(imageObj.createdAt).getTime() : NaN; + const updatedTs = imageObj.updatedAt ? new Date(imageObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(imageObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.imageUpdatedAt}: ${moment(imageObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })(), + renderOpinionsVoting('/images/opinions', imageObj.key, imageObj.opinions, returnTo, imageObj.opinions_inhabitants), + renderImageCommentsSection(imageObj.key, comments, returnTo) + ); + + return template( + i18n.imageTitle, + section( + div({ class: "tags-header" }, + h2(i18n.imageAllSectionTitle || i18n.imageTitle), + p(i18n.imageDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.imageFilterFavorites + ), + button( + { type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" }, + i18n.imageFilterGallery + ), + button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton) + ) + ), + div({ class: "tribe-details" }, imageSide, imageMain) + ) + ); +}; + diff --git a/src/views/indexing_view.js b/src/views/indexing_view.js new file mode 100644 index 0000000..c2d758b --- /dev/null +++ b/src/views/indexing_view.js @@ -0,0 +1,39 @@ +const { a, br, div, h2, p, progress, section, span, strong, meta, head, html, link, title, body, main } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require('./main_views'); +const { getConfig } = require('../configs/config-manager.js'); + +const doctypeString = ''; + +exports.indexingView = ({ percent }) => { + let metaRefresh; + try { + const cfg = getConfig() || {}; + const theme = cfg.themes?.current || 'Dark-SNH'; + void theme; + } catch (_) {} + + const pct = Math.max(0, Math.min(100, Number(percent) || 0)); + const headingText = i18n.indexingTitle || 'Synchronizing'; + const message = i18n.indexingMessage || 'Oasis is trying to syncronize a huge network of inhabitants. Just wait!'; + const refreshNote = i18n.indexingRefreshNote || 'This page refreshes every 10 seconds.'; + const editProfileLabel = i18n.indexingEditProfileLink || 'Set up my avatar'; + + return template( + headingText, + section( + div({ class: 'tags-header' }, + h2(`❤ ${headingText}`), + p(message) + ), + div({ class: 'indexing-progress-block' }, + progress({ value: String(pct), max: '100', class: 'indexing-progress' }), + p({ class: 'indexing-percent' }, strong(`${pct.toFixed(1)} %`)), + p({ class: 'indexing-note' }, refreshNote) + ), + div({ class: 'indexing-actions' }, + a({ href: '/profile/edit', class: 'filter-btn welcome-action-primary' }, editProfileLabel), + a({ href: '/modules', class: 'filter-btn' }, i18n.modulesTitle || 'Modules') + ) + ) + ).replace('', ''); +}; diff --git a/src/views/inhabitants_view.js b/src/views/inhabitants_view.js new file mode 100644 index 0000000..58f0ba0 --- /dev/null +++ b/src/views/inhabitants_view.js @@ -0,0 +1,422 @@ +const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, renderUserSensors, renderContentActions } = require('./main_views'); +const { renderContentStats } = require('./clearnet_view'); +const { renderUrl } = require('../backend/renderUrl'); +const { getConfig } = require('../configs/config-manager'); + +const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256"; +const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/; + +const formatCarbonValue = (g) => { + const n = Number(g) || 0; + if (!n) return '0 µg CO₂'; + if (n >= 1) return `${n.toFixed(2)} g CO₂`; + const mg = n * 1000; + if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`; + return `${(mg * 1000).toFixed(2)} µg CO₂`; +}; + +function isDefaultImageId(v){ + if (!v) return true; + if (typeof v === 'string') { + if (v === DEFAULT_HASH_ENC) return true; + if (DEFAULT_HASH_PATH_RE.test(v)) return true; + } + return false; +} + +function toImageUrl(imgId, size=256){ + if (!imgId || isDefaultImageId(imgId)) return '/assets/images/default-avatar.png'; + if (typeof imgId === 'string' && imgId.startsWith('/image/')) { + return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/'); + } + return `/image/${size}/${encodeURIComponent(imgId)}`; +} + +function extractAboutImageId(about){ + if (!about || typeof about !== 'object') return null; + const aimg = about.image; + if (!aimg) return null; + if (typeof aimg === 'string') return aimg; + return aimg.link || aimg.url || null; +} + +function resolvePhoto(photoField, size = 256) { + if (!photoField) return '/assets/images/default-avatar.png'; + if (typeof photoField === 'string') { + if (photoField.startsWith('/assets/')) return photoField; + if (photoField.startsWith('/blob/')) return photoField; + if (photoField.startsWith('/image/')) { + if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png'; + return photoField.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/'); + } + } + return toImageUrl(photoField, size); +} + +const generateFilterButtons = (filters, currentFilter) => + filters.map(mode => + form({ method: 'GET', action: '/inhabitants' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ + type: 'submit', + class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' + }, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode) + ) + ); + +function lastActivityBadge(user, isMe) { + const bucket = user && user.lastActivityBucket; + const dotClass = + bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : bucket === 'red' ? 'red' : null; + if (!dotClass) return []; + const items = [ + span({ class: 'inhabitant-last-activity' }, + `${i18n.inhabitantActivityLevel}: `, + span({ class: `activity-dot ${dotClass}` }, '●')) + ]; + const currentTheme = getConfig().themes.current; + const src = isMe ? (currentTheme === 'OasisKIT' ? 'KIT' : (currentTheme === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP') : (user && user.deviceSource) || null; + if (src) { + const upper = String(src).toUpperCase(); + const deviceClass = upper === 'KIT' ? 'device-kit' : upper === 'MOBILE' ? 'device-mobile' : 'device-desktop'; + items.push(span({ class: 'inhabitant-last-activity' }, + `${i18n.deviceLabel || 'Device'}: `, + span({ class: deviceClass }, src))); + } + return [div({ class: 'inhabitant-activity-group' }, ...items)]; +} + +const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); + +const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured) => { + const isMe = user.id === currentUserId; + const raw = user.visibilityPrefs || {}; + const clearnetSubKeys = ['clearnetShops','clearnetJobs','clearnetEvents','clearnetProjects','clearnetPosts','clearnetAudios','clearnetVideos','clearnetImages','clearnetDocuments','clearnetTorrents','clearnetBookmarks']; + const hasClearnet = raw.clearnet === true || clearnetSubKeys.some(k => raw[k] === true); + const prefs = { + activity: raw.activity === true, + device: raw.device === true, + karma: raw.karma !== false, + ubi: raw.ubi === true, + wallet: raw.wallet === true, + ecoTax: raw.ecoTax !== false, + larpSign: raw.larpSign === true, + gpg: raw.gpg !== false, + clearnet: hasClearnet, + fediverse: raw.fediverse === true, + fediverseHandle: typeof raw.fediverseHandle === 'string' ? raw.fediverseHandle : '' + }; + return div({ class: 'trending-card inhabitants-card' + (isMe ? ' own-content' : '') }, + div( + { class: 'card-header activity-card-header' }, + span(), + renderContentActions(user.id, `/inhabitant/${encodeURIComponent(user.id)}`) + ), + div({ class: 'card-section inhabitants-card-body' }, + div({ class: 'inhabitant-card' }, + div({ class: 'inhabitant-left' }, + a( + { href: `/author/${encodeURIComponent(user.id)}` }, + img({ class: 'inhabitant-photo-details', src: resolvePhoto(user.photo, 256), alt: user.name || 'Anonymous' }) + ), + ...renderUserSensors({ + isMe, fediverseConfigured, prefs, id: user.id, + karmaScore: user.karmaScore, carbonGrams: user.carbonGrams, + deviceSource: user.deviceSource, activityBucket: user.lastActivityBucket, + gpgFingerprint: user.gpgFingerprint, ecoAddress: user.ecoAddress, + estimatedUBI: user.estimatedUBI, lastClaimedDate: user.lastClaimedDate, totalClaimed: user.totalClaimed, + larpHouse: user.larpHouse, stats: user.stats + }, { relationshipNode: isMe ? span({ class: 'status you' }, i18n.relationshipYou) : null, excludeContent: true }), + !isMe + ? div( + { class: 'cv-actions' }, + form( + { method: 'GET', action: '/pm' }, + input({ type: 'hidden', name: 'recipients', value: user.id }), + button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton) + ) + ) + : null + ), + div({ class: 'inhabitant-details' }, + h2(user.name || 'Anonymous'), + user.description ? p(...renderUrl(user.description)) : null, + filter === 'MATCHSKILLS' && user.commonSkills?.length + ? div({ class: 'matchskills' }, + p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`), + p(`${i18n.matchScore}: ${Math.round((user.matchScore || 0) * 100)}%`) + ) + : null, + filter === 'SUGGESTED' && (user.followsYou || user.commonSkills?.length || user.mutualCount) + ? div({ class: 'suggested-meta' }, + user.followsYou ? span({ class: 'suggested-badge' }, i18n.suggestedFollowsYou || 'Follows you') : null, + user.commonSkills?.length + ? p(`${i18n.commonSkills || 'Common skills'}: ${user.commonSkills.join(', ')}`) + : null, + user.mutualCount ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null + ) + : null, + filter === 'blocked' && user.isBlocked + ? p(i18n.blockedLabel) : null, + p(userLink(user.id)), + !isMe ? (() => { + const rel = user.relationship || {} + const blockedBoth = rel.blocking && rel.blockedBy + const mutual = rel.following && rel.followsMe + const supportAction = rel.following ? 'unfollow' : (rel.blocking ? 'unblock' : 'follow') + return div({ class: 'relationship-status inhabitant-relationship' }, + blockedBoth + ? span({ class: 'status blocked' }, i18n.relationshipMutualBlock) + : [ + rel.blocking ? span({ class: 'status blocked' }, i18n.relationshipBlocking) : null, + rel.blockedBy ? span({ class: 'status blocked-by' }, i18n.relationshipBlockedBy) : null, + mutual + ? span({ class: 'status mutual' }, i18n.relationshipMutuals) + : [ + span({ class: 'status supporting' }, rel.following ? i18n.relationshipFollowing : i18n.relationshipNone), + span({ class: 'status supported-by' }, rel.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing) + ] + ], + div({ class: 'relationship-actions' }, + form({ method: 'POST', action: `/${supportAction}/${encodeURIComponent(user.id)}` }, + button({ type: 'submit', class: 'btn' }, i18n[supportAction]) + ) + ) + ) + })() : null, + renderContentStats(user.stats, i18n) + ) + ) + ) + ); +}; + +const renderGalleryInhabitants = inhabitants => + div( + { class: "gallery" }, + inhabitants.length + ? inhabitants.map(u => + a({ href: `#${lightboxId(u.id)}`, class: "gallery-item" }, + img({ src: resolvePhoto(u.photo, 256), alt: u.name || "Anonymous", class: "gallery-image" }) + ) + ) + : p(i18n.noInhabitantsFound) + ); + +const renderLightbox = inhabitants => + inhabitants.map(u => + div( + { id: lightboxId(u.id), class: "lightbox" }, + a({ href: "#", class: "lightbox-close" }, "×"), + img({ src: resolvePhoto(u.photo, 256), class: "lightbox-image", alt: u.name || "Anonymous" }) + ) + ); + +function stripAndCollectImgs(text) { + if (!text || typeof text !== 'string') return { clean: '', imgs: [] }; + const imgs = []; + let clean = text; + const rawImgRe = /]*src="([^"]+)"[^>]*>/gi; + clean = clean.replace(rawImgRe, (_, src) => { imgs.push(src); return ''; }); + const encImgRe = /<img[^&]*src="([^&]*)"[^&]*>/gi; + clean = clean.replace(encImgRe, (_, src) => { imgs.push(src.replace(/&/g, '&')); return ''; }); + return { clean, imgs }; +} + +function msgIdOf(m) { + return m && (m.key || m.value?.key || m.value?.content?.root || m.value?.content?.branch || null); +} + +exports.inhabitantsView = (inhabitants, filter, query, currentUserId, fediverseConfigured) => { + const title = filter === 'contacts' ? i18n.yourContacts + : filter === 'CVs' ? i18n.allCVs + : filter === 'MATCHSKILLS' ? i18n.matchSkills + : filter === 'SUGGESTED' ? i18n.suggestedSectionTitle + : filter === 'blocked' ? i18n.blockedSectionTitle + : filter === 'GALLERY' ? i18n.gallerySectionTitle + : filter === 'TOP KARMA' ? i18n.topkarmaSectionTitle + : filter === 'TOP ECO' ? (i18n.topecoSectionTitle || 'Top Eco') + : filter === 'TOP ACTIVITY' ? i18n.topactivitySectionTitle + : i18n.allInhabitants; + + const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS'; + const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY']; + + return template( + title, + section( + div({ class: 'tags-header' }, + h2(title), + p(i18n.discoverPeople) + ), + div({ class: 'filters' }, + form({ method: 'GET', action: '/inhabitants' }, + input({ type: 'hidden', name: 'filter', value: filter }), + input({ + type: 'text', + name: 'search', + placeholder: i18n.searchInhabitantsPlaceholder, + value: (query && query.search) || '' + }), + showCVFilters + ? [ + input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: (query && query.location) || '' }), + input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: (query && query.language) || '' }), + input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: (query && query.skills) || '' }) + ] + : null, + br(), + button({ type: 'submit' }, i18n.applyFilters) + ) + ), + div({ class: 'inhabitant-action' }, + ...generateFilterButtons(filters, filter) + ), + filter === 'GALLERY' + ? renderGalleryInhabitants(inhabitants) + : div({ class: 'inhabitants-list' }, + inhabitants.length + ? inhabitants.map(user => renderInhabitantCard(user, filter, currentUserId, fediverseConfigured)) + : p({ class: 'no-results' }, i18n.noInhabitantsFound) + ), + ...renderLightbox(inhabitants) + ) + ); +}; + +exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) => { + const safe = payload && typeof payload === 'object' ? payload : {}; + const about = (safe.about && typeof safe.about === 'object') ? safe.about : {}; + const cv = (safe.cv && typeof safe.cv === 'object') ? safe.cv : {}; + const feed = Array.isArray(safe.feed) ? safe.feed : []; + + const viewedId = typeof safe.viewedId === 'string' ? safe.viewedId : ''; + const id = (cv && cv.author) || (about && about.about) || viewedId || ''; + const baseName = ((cv && cv.name) || (about && about.name) || '').trim(); + const name = baseName || (i18n.unnamed || 'Anonymous'); + const description = (cv && cv.description) || (about && about.description) || ''; + + const listPhoto = (typeof safe.photo === 'string' && safe.photo.trim()) ? safe.photo : null; + const rawCandidate = listPhoto || extractAboutImageId(about) || (cv && cv.photo) || null; + const image = ( + typeof rawCandidate === 'string' && + rawCandidate.startsWith('/image/') && + !DEFAULT_HASH_PATH_RE.test(rawCandidate) && + rawCandidate.indexOf(DEFAULT_HASH_ENC) === -1 + ) + ? rawCandidate.replace('/image/512/','/image/256/').replace('/image/1024/','/image/256/') + : resolvePhoto(rawCandidate, 256); + + const location = (cv && cv.location) || ''; + const languages = typeof (cv && cv.languages) === 'string' + ? (cv.languages || '').split(',').map(x => x.trim()).filter(Boolean) + : Array.isArray(cv && cv.languages) ? cv.languages : []; + const skills = [ + ...((cv && cv.personalSkills) || []), + ...((cv && cv.oasisSkills) || []), + ...((cv && cv.educationalSkills) || []), + ...((cv && cv.professionalSkills) || []) + ]; + const status = (cv && cv.status) || ''; + const preferences = (cv && cv.preferences) || ''; + const createdAt = (cv && cv.createdAt) ? new Date(cv.createdAt).toLocaleString() : ''; + const isMe = id && id === currentUserId; + const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails; + const karmaScore = typeof safe.karmaScore === 'number' ? safe.karmaScore : 0; + const estimatedUBI = typeof safe.estimatedUBI === 'number' ? safe.estimatedUBI : 0; + const lastClaimedDate = safe.lastClaimedDate || null; + const totalClaimed = typeof safe.totalClaimed === 'number' ? safe.totalClaimed : 0; + const ecoAddress = typeof safe.ecoAddress === 'string' ? safe.ecoAddress : null; + const rawPrefs = safe.visibilityPrefs || {}; + const clearnetSubKeys = ['clearnetShops','clearnetJobs','clearnetEvents','clearnetProjects','clearnetPosts','clearnetAudios','clearnetVideos','clearnetImages','clearnetDocuments','clearnetTorrents','clearnetBookmarks']; + const prefs = { + activity: rawPrefs.activity === true, + device: rawPrefs.device === true, + karma: rawPrefs.karma !== false, + ubi: rawPrefs.ubi === true, + wallet: rawPrefs.wallet === true, + ecoTax: rawPrefs.ecoTax !== false, + larpSign: rawPrefs.larpSign === true, + gpg: rawPrefs.gpg !== false, + clearnet: rawPrefs.clearnet === true || clearnetSubKeys.some(k => rawPrefs[k] === true), + fediverse: rawPrefs.fediverse === true, + fediverseHandle: typeof rawPrefs.fediverseHandle === 'string' ? rawPrefs.fediverseHandle : '' + }; + const gpgFingerprint = String(safe.gpgFingerprint || ''); + const carbonGrams = typeof safe.carbonGrams === 'number' ? safe.carbonGrams : 0; + const larpHouse = (safe.larpHouse && safe.larpHouse.key) ? safe.larpHouse : null; + + const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null; + const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red'; + + const detailNodes = [ + description ? p(...renderUrl(description)) : null, + location ? p(`${i18n.locationLabel}: ${location}`) : null, + languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null, + skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null, + status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null, + preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null, + createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null + ].filter(Boolean); + + return template( + name, + section( + div({ class: 'tags-header' }, + h2(title), + p(i18n.discoverPeople) + ), + div({ class: 'mode-buttons' }, + ...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all') + ), + div({ class: 'inhabitant-card' }, + div({ class: 'inhabitant-left' }, + img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }), + h2(name || 'Anonymous'), + safe.oasisVersion ? div({ class: 'profile-side-version' }, '🌴 ' + (i18n.oasisVersionLabel || 'Oasis Version') + ': ', strong(safe.oasisVersion)) : null, + ...renderUserSensors({ + isMe, fediverseConfigured, prefs, id: id || viewedId, + karmaScore, carbonGrams, deviceSource: safe.deviceSource, activityBucket: providedBucket, + gpgFingerprint, ecoAddress, estimatedUBI, lastClaimedDate, totalClaimed, + larpHouse, stats: safe.stats + }, { excludeContent: true }), + (!isMe && (id || viewedId)) + ? form( + { method: 'GET', action: '/pm' }, + input({ type: 'hidden', name: 'recipients', value: id || viewedId }), + button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton) + ) + : null + ), + (() => { + const feedNodes = feed.length + ? [ section({ class: 'profile-feed' }, + h2(i18n.latestInteractions), + ...feed.map(m => { + const raw = (m.value?.content?.text || '').replace(//g, ''); + const parts = stripAndCollectImgs(raw); + const tid = msgIdOf(m); + const visitBtn = tid + ? form({ method: 'GET', action: `/thread/${encodeURIComponent(tid)}#${encodeURIComponent(tid)}` }, + button({ type:'submit', class:'filter-btn' }, i18n.visitContent) + ) + : null; + return div({ class: 'post' }, + visitBtn, + parts.clean && parts.clean.trim() ? p(...renderUrl(parts.clean)) : null, + ...(parts.imgs || []).map(src => img({ src, class: 'post-image', alt: 'image' })) + ); + }) + ) ] + : []; + const contentStatsNode = renderContentStats(safe.stats, i18n); + const rightNodes = [...detailNodes, contentStatsNode, ...feedNodes].filter(Boolean); + return rightNodes.length ? div({ class: 'inhabitant-details' }, ...rightNodes) : null; + })() + ) + ) + ); +}; + +exports.lastActivityBadge = lastActivityBadge; diff --git a/src/views/invites_view.js b/src/views/invites_view.js new file mode 100644 index 0000000..f58703a --- /dev/null +++ b/src/views/invites_view.js @@ -0,0 +1,314 @@ +const { form, button, div, h2, h3, p, section, ul, li, a, br, hr, input, label, span, table, tr, td, textarea } = require("../server/node_modules/hyperaxe"); +const path = require("path"); +const fs = require('fs'); +const { renderUrl } = require("../backend/renderUrl"); +const { template, i18n } = require('./main_views'); + +const homedir = require('os').homedir(); +const gossipPath = path.join(homedir, ".ssb", "gossip.json"); +const unfollowedPath = path.join(homedir, ".ssb", "gossip_unfollowed.json"); + +const encodePubLink = (key) => { + let core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '').replace(/-/g, '+').replace(/_/g, '/'); + if (!core.endsWith('=')) core += '='; + return `/author/${encodeURIComponent('@' + core)}.ed25519`; +}; + +const snhInvitePath = path.join(__dirname, '..', 'configs', 'snh-invite-code.json'); + +let snhInvite = null; +try { + snhInvite = JSON.parse(fs.readFileSync(snhInvitePath, 'utf8')); +} catch {} + +const deduplicateByHost = (list) => { + const seen = new Set(); + return list.filter(p => { + const host = (p.host || '').replace(/:\d+$/, ''); + if (!host || seen.has(host)) return false; + seen.add(host); + return true; + }); +}; + +const invitesView = ({ invitesEnabled, flash }) => { + let pubs = []; + let pubsValue = "false"; + let unfollowed = []; + + try { + pubs = fs.readFileSync(gossipPath, "utf8"); + } catch { + pubs = '[]'; + } + + try { + pubs = JSON.parse(pubs); + pubsValue = Array.isArray(pubs) && pubs.length > 0 ? "true" : "false"; + } catch { + pubsValue = "false"; + pubs = []; + } + + try { + unfollowed = JSON.parse(fs.readFileSync(unfollowedPath, "utf8") || "[]"); + } catch { + unfollowed = []; + } + + const filteredPubs = pubsValue === "true" + ? deduplicateByHost(pubs.filter(pubItem => !unfollowed.find(u => u.key === pubItem.key))) + : []; + + const hasError = (pubItem) => pubItem && (pubItem.error || (typeof pubItem.failure === 'number' && pubItem.failure > 0)); + + const sanitizeError = (err) => { + if (!err) return i18n.genericError || 'Unknown error'; + const firstLine = String(err).split('\n')[0].replace(/^Error:\s*/, '').trim(); + return firstLine || (i18n.genericError || 'Unknown error'); + }; + + const unreachableLabel = i18n.currentlyUnreachable || i18n.currentlyUnrecheable || 'ERROR!'; + + const pubTableHeader = () => tr( + td({ class: 'card-label' }, 'PUB'), + td({ class: 'card-label' }, i18n.invitesPort || 'Port'), + td({ class: 'card-label' }, 'Key'), + td({ class: 'card-label' }, '') + ); + + const activePubs = filteredPubs.filter(pubItem => !hasError(pubItem)); + const unreachablePubs = pubs.filter(hasError); + + const renderPubTable = (items, actionFn) => table({ class: 'block-info-table invites-pubs-table' }, + pubTableHeader(), + items.map(pubItem => tr( + td(pubItem.host || '—'), + td(String(pubItem.port || 8008)), + td(a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key)), + td(actionFn(pubItem)) + )) + ); + + const title = i18n.invites; + const description = i18n.invitesDescription; + + return template( + title, + section( + div({ class: 'tags-header' }, + h2(title), + p(description) + ) + ), + flash === 'alreadyFederated' + ? section(div({ class: 'message-banner' }, p(i18n.invitesAlreadyFederated || 'You are already federated with this pub.'))) + : null, + section( + div({ class: 'pubs-section' }, + h2(i18n.invitesPubsTitle), + form( + { action: '/settings/invite/accept', method: 'post' }, + input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }), + br(), + button({ type: 'submit' }, i18n.invitesAcceptInvite) + ), + br(), + snhInvite ? div({ class: 'snh-invite-box' }, + h3({ class: 'snh-invite-name' }, snhInvite.name), + p({ class: 'snh-invite-name' }, snhInvite.description), + p({ class: 'snh-invite-name' }, renderUrl(snhInvite.url)), + snhInvite.createdAt ? p({ class: 'snh-invite-date' }, `${i18n.statsCreatedAt || 'Created'}: ${new Date(snhInvite.createdAt).toLocaleDateString()}`) : null, + form({ action: '/settings/invite/accept', method: 'post' }, + input({ type: 'hidden', name: 'invite', value: snhInvite.code }), + button({ type: 'submit', class: 'filter-btn' }, snhInvite.code) + ) + ) : null + ) + ), + section( + div({ class: 'federations-section' }, + h2(i18n.invitesFederationsTitle || 'Federations'), + div({ class: 'conn-actions invites-pubs-actions' }, + form({ action: '/invites/refresh-pubs', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsRefresh || 'Refresh')), + form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Remove unreachable')), + form({ action: '/invites/export-pubs', method: 'get' }, button({ type: 'submit' }, i18n.invitesPubsExport || 'Export')) + ), + form( + { action: '/invites/import-pubs', method: 'post', enctype: 'multipart/form-data', class: 'peers-import-form' }, + label({ class: 'peers-import-label' }, i18n.invitesPubsImportTitle || 'Import pubs'), + br(), + textarea({ name: 'peerList', rows: '4', placeholder: i18n.invitesPubsImportPlaceholder || 'Paste one multiserver address or invite code per line…' }), + br(), + input({ type: 'file', name: 'peerFile', accept: '.txt,text/plain' }), + br(), + button({ type: 'submit', class: 'filter-btn' }, i18n.invitesPubsImport || 'Import') + ), + h2(`${i18n.invitesAcceptedInvites} (${activePubs.length})`), + activePubs.length + ? renderPubTable(activePubs, pubItem => + form({ action: '/settings/invite/unfollow', method: 'post' }, + input({ type: 'hidden', name: 'key', value: pubItem.key }), + button({ type: 'submit' }, i18n.invitesUnfollow) + ) + ) + : p(i18n.invitesNoFederatedPubs), + hr(), + h2(`${i18n.invitesUnfollowedInvites} (${unfollowed.length})`), + unfollowed.length + ? renderPubTable(unfollowed, pubItem => + form({ action: '/settings/invite/follow', method: 'post' }, + input({ type: 'hidden', name: 'key', value: pubItem.key }), + input({ type: 'hidden', name: 'host', value: pubItem.host || '' }), + input({ type: 'hidden', name: 'port', value: String(pubItem.port || 8008) }), + button({ type: 'submit', disabled: hasError(pubItem) }, i18n.invitesFollow) + ) + ) + : p(i18n.invitesNoUnfollowed), + hr(), + h2(`${i18n.invitesUnreachablePubs} (${unreachablePubs.length})`), + unreachablePubs.length + ? renderPubTable(unreachablePubs, pubItem => + div({ class: 'error-box' }, + p({ class: 'error-title' }, i18n.errorDetails), + p({ class: 'error-pre' }, sanitizeError(pubItem.error)) + ) + ) + : p(i18n.invitesNoUnreachablePubs) + ) + ), + section( + div({ class: 'invites-houses', id: 'invites-houses' }, + h2(i18n.invitesHousesTitle || 'Houses'), + form( + { action: '/larp/invite/redeem', method: 'post' }, + input({ type: 'hidden', name: 'returnTo', value: '/invites' }), + input({ name: 'code', type: 'text', placeholder: i18n.invitesHouseInviteCodePlaceholder || 'House invitation code', required: true, maxlength: 32 }), + br(), + button({ type: 'submit' }, i18n.invitesHouseJoinButton || 'Join House') + ) + ) + ), + section( + div({ class: 'invites-tribes', id: 'invites-tribes' }, + h2(i18n.invitesTribesTitle), + form( + { action: '/tribes/join-code', method: 'post' }, + input({ name: 'inviteCode', type: 'text', placeholder: i18n.invitesTribeInviteCodePlaceholder, required: true }), + br(), + button({ type: 'submit' }, i18n.invitesTribeJoinButton) + ) + ) + ), + section( + div({ class: 'invites-chats', id: 'invites-chats' }, + h2(i18n.invitesChatsTitle || 'Chats'), + form( + { action: '/chats/join-code', method: 'post' }, + input({ name: 'code', type: 'text', placeholder: i18n.invitesChatInviteCodePlaceholder || 'Enter chat invite code', required: true }), + br(), + button({ type: 'submit' }, i18n.invitesChatJoinButton || 'Join Chat') + ) + ) + ), + section( + div({ class: 'invites-pads', id: 'invites-pads' }, + h2(i18n.invitesPadsTitle || 'Pads'), + form( + { action: '/pads/join-code', method: 'post' }, + input({ name: 'code', type: 'text', placeholder: i18n.invitesPadInviteCodePlaceholder || 'Enter pad invite code', required: true }), + br(), + button({ type: 'submit' }, i18n.invitesPadJoinButton || 'Join Pad') + ) + ) + ), + section( + div({ class: 'invites-calendars', id: 'invites-calendars' }, + h2(i18n.invitesCalendarsTitle || 'Calendars'), + form( + { action: '/calendars/join-code', method: 'post' }, + input({ name: 'code', type: 'text', placeholder: i18n.invitesCalendarInviteCodePlaceholder || 'Enter calendar invite code', required: true }), + br(), + button({ type: 'submit' }, i18n.invitesCalendarJoinButton || 'Join Calendar') + ) + ) + ), + section( + div({ class: 'invites-events', id: 'invites-events' }, + h2(i18n.invitesEventsTitle || 'Events'), + form( + { action: '/events/join-code', method: 'post' }, + input({ name: 'code', type: 'text', placeholder: i18n.invitesEventInviteCodePlaceholder || 'Enter event invite code', required: true }), + br(), + button({ type: 'submit' }, i18n.invitesEventJoinButton || 'Join Event') + ) + ) + ), + section( + div({ class: 'invites-forums', id: 'invites-forums' }, + h2(i18n.invitesForumsTitle || 'Forums'), + form( + { action: '/forum/join-code', method: 'post' }, + input({ name: 'code', type: 'text', placeholder: i18n.invitesForumInviteCodePlaceholder || 'Enter forum invite code', required: true }), + br(), + button({ type: 'submit' }, i18n.invitesForumJoinButton || 'Join Forum') + ) + ) + ), + section( + div({ class: 'invites-maps', id: 'invites-maps' }, + h2(i18n.invitesMapsTitle || 'Maps'), + form( + { action: '/maps/join-code', method: 'post' }, + input({ name: 'code', type: 'text', placeholder: i18n.invitesMapInviteCodePlaceholder || 'Enter map invite code', required: true }), + br(), + button({ type: 'submit' }, i18n.invitesMapJoinButton || 'Join Map') + ) + ) + ), + section( + div({ class: 'invites-shops', id: 'invites-shops' }, + h2(i18n.invitesShopsTitle || 'Shops'), + form( + { action: '/shops/join-code', method: 'post' }, + input({ name: 'code', type: 'text', placeholder: i18n.invitesShopInviteCodePlaceholder || 'Enter shop invite code', required: true }), + br(), + button({ type: 'submit' }, i18n.invitesShopJoinButton || 'Join Shop') + ) + ) + ), + section( + div({ class: 'invites-inhabitants', id: 'invites-inhabitants' }, + h2(i18n.invitesInhabitantsTitle || 'Inhabitants'), + form( + { action: '/invites/inhabitant/follow', method: 'post' }, + input({ name: 'feedId', id: 'inh_oasis_id', type: 'text', placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', required: true, maxlength: 56 }), + br(), + button({ type: 'submit' }, i18n.invitesInhabitantsFollow || 'Give Support') + ) + ) + ), + section( + div({ class: 'invites-peers', id: 'invites-peers' }, + h2(i18n.peers || 'Peers'), + p(i18n.directConnectDescription), + form({ action: '/peers/connect', method: 'post' }, + input({ type: 'text', id: 'peer_host', name: 'host', required: true, placeholder: '192.168.1.100', pattern: '(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*)', title: i18n.peerHostValidation || 'Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.example.com)', maxlength: 253 }), + br(), + label({ for: 'peer_port' }, i18n.peerPort), + br(), + input({ type: 'number', id: 'peer_port', name: 'port', placeholder: '8008', value: '8008', min: 1, max: 65535, required: true, title: i18n.peerPortValidation || 'Port 1-65535' }), + br(), br(), + label({ for: 'peer_key' }, i18n.peerPublicKey), + br(), + input({ type: 'text', id: 'peer_key', name: 'key', required: true, placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', title: i18n.peerKeyValidation || 'SSB ed25519 public key (@<44 chars base64>=.ed25519)', maxlength: 56 }), + br(), br(), + button({ type: 'submit' }, i18n.connectAndFollow) + ) + ) + ) + ); +}; + +exports.invitesView = invitesView; + diff --git a/src/views/jobs_view.js b/src/views/jobs_view.js new file mode 100644 index 0000000..8c6694b --- /dev/null +++ b/src/views/jobs_view.js @@ -0,0 +1,781 @@ +const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, renderStateChip, renderOpenClosedChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") +const { renderUrl } = require("../backend/renderUrl") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view") + +const renderMediaBlob = (value, attrs = {}) => { + if (!value) return null + const s = String(value).trim() + if (!s) return null + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }) + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }) + const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' }) + return null +} + +const userId = config.keys.id + +const FILTERS = [ + { key: "ALL", i18n: "jobsFilterAll", title: "jobsAllTitle" }, + { key: "MINE", i18n: "jobsFilterMine", title: "jobsMineTitle" }, + { key: "APPLIED", i18n: "jobsFilterApplied", title: "jobsAppliedTitle" }, + { key: "REMOTE", i18n: "jobsFilterRemote", title: "jobsRemoteTitle" }, + { key: "PRESENCIAL", i18n: "jobsFilterPresencial", title: "jobsPresencialTitle" }, + { key: "FREELANCER", i18n: "jobsFilterFreelancer", title: "jobsFreelancerTitle" }, + { key: "EMPLOYEE", i18n: "jobsFilterEmployee", title: "jobsEmployeeTitle" }, + { key: "EXCHANGE", i18n: "jobsFilterExchange", title: "jobsExchangeTitle" }, + { key: "OPEN", i18n: "jobsFilterOpen", title: "jobsOpenTitle" }, + { key: "CLOSED", i18n: "jobsFilterClosed", title: "jobsClosedTitle" }, + { key: "RECENT", i18n: "jobsFilterRecent", title: "jobsRecentTitle" }, + { key: "TOP", i18n: "jobsFilterTop", title: "jobsTopTitle" }, + { key: "CV", i18n: "jobsCV", title: "jobsCVTitle" } +] + +function resolvePhoto(photoField, size = 256) { + if (typeof photoField === "string" && photoField.startsWith("/image/")) return photoField + if (typeof photoField === "string" && /^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) return `/image/${size}/${encodeURIComponent(photoField)}` + return "/assets/images/default-avatar.png" +} + +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() + +const parseNum = (v) => { + const n = parseFloat(String(v ?? "").replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} + +const fmtSalary = (v) => { + const n = parseNum(v) + return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "") +} + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "ALL") + const q = safeText(params.search || params.q || "") + const minSalary = params.minSalary ?? "" + const maxSalary = params.maxSalary ?? "" + const sort = safeText(params.sort || "") + const parts = [`filter=${encodeURIComponent(f)}`] + if (q) parts.push(`search=${encodeURIComponent(q)}`) + if (String(minSalary) !== "") parts.push(`minSalary=${encodeURIComponent(String(minSalary))}`) + if (String(maxSalary) !== "") parts.push(`maxSalary=${encodeURIComponent(String(maxSalary))}`) + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`) + return `/jobs?${parts.join("&")}` +} + +const renderPmButton = (recipientId) => + recipientId && String(recipientId) !== String(userId) + ? form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: recipientId }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + ) + : null + +const renderTags = (tags = []) => { + const arr = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean) + return arr.length + ? div( + { class: "card-tags" }, + arr.map((tag) => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`)) + ) + : null +} + +const renderApplicantsProgress = (subsCount, vacants) => { + const s = Math.max(0, Number(subsCount || 0)) + const v = Math.max(1, Number(vacants || 1)) + const colorClass = s < v ? "applicants-under" : s === v ? "applicants-at" : "applicants-over" + return div( + { class: "confirmations-block" }, + div( + { class: "card-field" }, + span({ class: "card-label" }, `${i18n.jobsApplicants}: `), + span({ class: `card-value ${colorClass}` }, `${s}/${v}`) + ), + progress({ class: "confirmations-progress", value: s, max: v }) + ) +} + +const renderUpdatedLabel = (createdAt, updatedAt) => { + const createdTs = createdAt ? new Date(createdAt).getTime() : NaN + const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs) + return showUpdated + ? span({ class: "votations-comment-date" }, ` | ${i18n.jobsUpdatedAt}: ${moment(updatedAt).format("YYYY/MM/DD HH:mm:ss")}`) + : null +} + +const renderJobOwnerActions = (job, returnTo) => { + const isAuthor = String(job.author) === String(userId) + if (!isAuthor) return [] + const isOpen = String(job.status || "").toUpperCase() === "OPEN" + return [ + form( + { method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "status-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" }, isOpen ? i18n.jobSetClosed : i18n.jobSetOpen) + ), + form( + { method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton) + ), + form( + { method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton) + ) + ] +} + +const renderJobStatusChip = (status) => { + const s = String(status || "").toUpperCase() + const localized = i18n["jobStatus" + s] + return renderOpenClosedChip(s, { statusChipOPEN: localized || "OPEN", statusChipCLOSED: localized || "CLOSED" }) +} + +const renderJobAppliedChip = () => + renderStateChip("whole", "★", i18n.jobsAppliedBadge) + +const renderJobHiddenChip = () => + renderVisibilityChip("HIDDEN", i18n) + +const renderJobApplyToggle = (job, returnTo) => { + const isAuthor = String(job.author) === String(userId) + const isOpen = String(job.status || "").toUpperCase() === "OPEN" + if (isAuthor || !isOpen) return null + const isSubscribed = safeArr(job.subscribers).includes(userId) + return isSubscribed + ? form( + { method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, i18n.jobUnsubscribeButton) + ) + : form( + { method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, i18n.jobSubscribeButton) + ) +} + +const renderJobInfoTable = (job, opts = {}) => { + const rows = [] + const typeLabel = i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase() + const locLabel = String(job.location || "").toUpperCase() + const timeLabel = i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase() + const langLabel = String(job.languages || "").toUpperCase() + const pushRow = (labelText, valueNode, valueClass) => + rows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: `tribe-info-value${valueClass ? " " + valueClass : ""}` }, valueNode) + )) + pushRow(i18n.jobType, typeLabel || "—") + pushRow(i18n.jobLocation, locLabel || "—") + pushRow(i18n.jobTime, timeLabel || "—") + if (String(job.job_type || "").toLowerCase() !== "exchange") { + pushRow(i18n.jobSalary, `${fmtSalary(job.salary)} ECO`, "card-salary") + } + if (opts.includeVacants) pushRow(i18n.jobVacants, String(job.vacants ?? 1)) + if (job.mapUrl) { + const mapNode = renderMapLocationVisitLabel(job.mapUrl) + if (mapNode) pushRow(i18n.mapLocationTitle || "Map", mapNode) + } + return table({ class: "tribe-info-table jobs-info-table" }, ...rows) +} + +const renderJobExtraDetails = (job) => { + const langLabel = String(job.languages || "").toUpperCase() + const isExchange = String(job.job_type || "").toLowerCase() === "exchange" + const skill = safeText(job.exchangeSkill) + if (!langLabel && !isExchange) return null + const rows = [] + const pushRow = (labelText, valueNode) => + rows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: "tribe-info-value" }, valueNode) + )) + if (langLabel) pushRow(i18n.jobLanguages, langLabel) + if (isExchange) { + pushRow(i18n.jobsHoursOffered || "Hours offered", `${Number(job.hoursOffered) || 0} h`) + pushRow(i18n.jobsHoursRequested || "Hours requested", `${Number(job.hoursRequested) || 0} h`) + if (skill) pushRow(i18n.jobsExchangeSkill || "Skill", skill) + } + return table({ class: "tribe-info-table jobs-info-table" }, ...rows) +} + +const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => { + const list = safeArr(jobs) + + if (!list.length) return p(i18n.noJobsMatch || i18n.noJobsFound) + + return div({ class: "jobs-grid" }, + list.map((job) => { + const subs = safeArr(job.subscribers) + const isSubscribed = subs.includes(userId) + const heroNode = job.image + ? div({ class: "tribe-card-image-wrapper" }, + a({ href: `/jobs/${encodeURIComponent(job.id)}` }, + renderMediaBlob(job.image, { class: "tribe-card-hero-image" }) + ) + ) + : null + const isHidden = String(job.visibility || "PUBLIC").toUpperCase() === "HIDDEN" + const chips = [ + renderJobStatusChip(job.status), + isHidden ? renderJobHiddenChip() : null, + isSubscribed ? renderJobAppliedChip() : null, + renderLifespanChip(job.lifetime, i18n) + ].filter(Boolean) + const isExchange = String(job.job_type || "").toLowerCase() === "exchange" + const compensationText = isExchange + ? `${Number(job.hoursOffered) || 0}h ⇄ ${Number(job.hoursRequested) || 0}h` + : `${fmtSalary(job.salary)} ECO` + + const isOwn = job.author && String(job.author) === String(userId) + return div({ class: "trending-card job-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(job.id, `/jobs/${encodeURIComponent(job.id)}`) + ), + div({ class: "card-section job-card-body" }, + heroNode, + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/jobs/${encodeURIComponent(job.id)}` }, safeText(job.title) || i18n.jobsTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-date-highlight" }, compensationText), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.jobSubscribers}: ${subs.length}`) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(job.id, params.spreadMap && params.spreadMap.get(job.id))) + ) + ) + ) + }) + ) +} + +const renderJobForm = (job = {}, mode = "create") => { + const isEdit = mode === "edit" + return div( + { class: "div-center job-form" }, + form( + { + action: isEdit ? `/jobs/update/${encodeURIComponent(job.id)}` : "/jobs/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: "/jobs?filter=MINE" }), + label(i18n.jobType), + br(), + select( + { name: "job_type", required: true }, + option({ value: "freelancer", selected: job.job_type === "freelancer" ? "selected" : undefined }, i18n.jobTypeFreelance), + option({ value: "employee", selected: job.job_type === "employee" ? "selected" : undefined }, i18n.jobTypeSalary), + option({ value: "exchange", selected: job.job_type === "exchange" ? "selected" : undefined }, i18n.jobTypeExchange || "Hour exchange") + ), + br(), + br(), + label(i18n.jobTitle), + br(), + input({ type: "text", name: "title", required: true, placeholder: i18n.jobTitlePlaceholder, value: job.title || "" }), + br(), + br(), + label(i18n.jobImage), + br(), + input({ type: "file", name: "image" }), + br(), + job.image ? renderMediaBlob(job.image) : null, + br(), + label(i18n.jobDescription), + br(), + textarea({ name: "description", rows: "6", required: true, placeholder: i18n.jobDescriptionPlaceholder }, job.description || ""), + br(), + br(), + label(i18n.jobRequirements), + br(), + textarea({ name: "requirements", rows: "6", placeholder: i18n.jobRequirementsPlaceholder }, job.requirements || ""), + br(), + br(), + label(i18n.jobsTagsLabel), + br(), + input({ type: "text", name: "tags", value: Array.isArray(job.tags) ? job.tags.join(", ") : (job.tags || "") }), + br(), + br(), + label(i18n.jobLanguages), + br(), + input({ type: "text", name: "languages", placeholder: i18n.jobLanguagesPlaceholder, value: job.languages || "" }), + br(), + br(), + label(i18n.jobTime), + br(), + select( + { name: "job_time", required: true }, + option({ value: "partial", selected: job.job_time === "partial" }, i18n.jobTimePartial), + option({ value: "complete", selected: job.job_time === "complete" }, i18n.jobTimeComplete) + ), + br(), + br(), + label(i18n.jobTasks), + br(), + textarea({ name: "tasks", rows: "6", placeholder: i18n.jobTasksPlaceholder }, job.tasks || ""), + br(), + br(), + label(i18n.jobLocation), + br(), + select( + { name: "location", required: true }, + option({ value: "remote", selected: job.location === "remote" }, i18n.jobLocationRemote), + option({ value: "presencial", selected: job.location === "presencial" }, i18n.jobLocationPresencial) + ), + br(), + br(), + label(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: job.mapUrl || "" }), + br(), + br(), + label(i18n.visibilityLabel || "Visibility"), + br(), + select( + { name: "visibility" }, + option({ value: "PUBLIC", selected: (job.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"), + option({ value: "HIDDEN", selected: job.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden") + ), + br(), + br(), + label(i18n.jobVacants), + br(), + input({ type: "number", name: "vacants", min: "1", placeholder: i18n.jobVacantsPlaceholder, value: job.vacants || 1, required: true }), + br(), + br(), + String(job.job_type || "").toLowerCase() !== "exchange" + ? [ + label(i18n.jobSalary), + br(), + input({ type: "number", name: "salary", step: "0.000001", min: "0", value: job.salary || "" }), + br(), + br() + ] + : null, + String(job.job_type || "").toLowerCase() === "exchange" + ? [ + label(i18n.jobsHoursOffered || "Hours offered"), + br(), + input({ type: "number", name: "hoursOffered", step: "0.5", min: "0", value: job.hoursOffered || "" }), + br(), + br(), + label(i18n.jobsHoursRequested || "Hours requested in return"), + br(), + input({ type: "number", name: "hoursRequested", step: "0.5", min: "0", value: job.hoursRequested || "" }), + br(), + br(), + label(i18n.jobsExchangeSkill || "Skill wanted in exchange"), + br(), + input({ type: "text", name: "exchangeSkill", value: job.exchangeSkill || "" }), + br(), + br() + ] + : null, + button({ type: "submit" }, isEdit ? i18n.jobsUpdateButton : i18n.createJobButton) + ) + ) +} + +const renderCVList = (inhabitants) => + div( + { class: "cv-list" }, + safeArr(inhabitants).length + ? safeArr(inhabitants).map((user) => { + const isMe = String(user.id) === String(userId) + return div( + { class: "inhabitant-card" }, + div( + { class: "inhabitant-left" }, + a({ href: `/author/${encodeURIComponent(user.id)}` }, + img({ class: "inhabitant-photo", src: resolvePhoto(user.photo) }) + ), + h2(user.name), + user.id + ? a({ href: `/author/${encodeURIComponent(user.id)}`, class: 'inhabitant-qr-link' }, + img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(user.id)}?size=96`, alt: 'QR' })) + : null + ), + div( + { class: "inhabitant-details" }, + user.description ? p(...renderUrl(user.description)) : null, + p(userLink(user.id)), + div( + { class: "cv-actions" }, + form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)), + !isMe ? renderPmButton(user.id) : null + ) + ) + ) + }) + : p({ class: "no-results" }, i18n.noInhabitantsFound) + ) + +exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => { + const search = safeText(params.search || "") + const minSalary = params.minSalary ?? "" + const maxSalary = params.maxSalary ?? "" + const sort = safeText(params.sort || "recent") + + const filterObj = FILTERS.find((f) => f.key === filter) || FILTERS[0] + const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle + const { renderReachChip: renderReachChipJobs } = require('./clearnet_view'); + const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetJobs) + + return template( + i18n.jobsTitle, + section( + div({ class: "tags-header" }, + h2(sectionTitle), + p(i18n.jobsDescription) + ), + div({ class: "shop-title-row" }, renderReachChipJobs(viewerClearnet, i18n)), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "search", value: search }), + input({ type: "hidden", name: "minSalary", value: String(minSalary ?? "") }), + input({ type: "hidden", name: "maxSalary", value: String(maxSalary ?? "") }), + input({ type: "hidden", name: "sort", value: sort }), + ...FILTERS.map((f) => + button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n]) + ), + button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob) + ) + ) + ), + section( + filter === "CV" + ? section( + form( + { method: "GET", action: "/jobs", class: "cv-filter-form" }, + input({ type: "hidden", name: "filter", value: "CV" }), + input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: params.location || "" }), + input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: params.language || "" }), + input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: params.skills || "" }), + div({ class: "cv-filter-submit" }, + button({ type: "submit", class: "filter-btn" }, i18n.applyFilters) + ) + ), + br(), + renderCVList(jobsOrCVs) + ) + : filter === "CREATE" || filter === "EDIT" + ? (() => { + const jobToEdit = filter === "EDIT" ? (Array.isArray(jobsOrCVs) ? jobsOrCVs[0] : {}) : {} + return renderJobForm(jobToEdit, filter === "EDIT" ? "edit" : "create") + })() + : section( + div( + { class: "jobs-search" }, + form( + { method: "GET", action: "/jobs", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter || "ALL" }), + input({ type: "text", name: "search", value: search, placeholder: i18n.jobsSearchPlaceholder, class: "filter-box__input" }), + div( + { class: "filter-box__controls" }, + div( + { class: "transfer-range" }, + input({ type: "number", name: "minSalary", step: "0.000001", min: "0", value: String(minSalary ?? ""), placeholder: i18n.jobsMinSalaryLabel, class: "filter-box__number transfer-amount-input" }), + input({ type: "number", name: "maxSalary", step: "0.000001", min: "0", value: String(maxSalary ?? ""), placeholder: i18n.jobsMaxSalaryLabel, class: "filter-box__number transfer-amount-input" }) + ), + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.jobsSortRecent), + option({ value: "salary", selected: sort === "salary" }, i18n.jobsSortSalary), + option({ value: "subscribers", selected: sort === "subscribers" }, i18n.jobsSortSubscribers) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.jobsSearchButton) + ) + ) + ), + br(), + div({ class: "jobs-list" }, renderJobList(jobsOrCVs, filter, { ...params, search, minSalary, maxSalary, sort })) + ) + ) + ) +} + +const renderJobCommentsSection = (jobId, returnTo, comments = []) => { + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text + return t && String(t).trim() + }) + const commentsCount = list.length + + return div( + { id: "comments", class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/jobs/${encodeURIComponent(jobId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || "" + const ts = c?.value?.timestamp || c?.timestamp + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "" + const relDate = ts ? moment(ts).fromNow() : "" + const userName = author && author.includes("@") ? author.split("@")[1] : author + const rootId = c?.value?.content ? (c.value.content.fork || c.value.content.root) : null + const text = c?.value?.content?.text || "" + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ) + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ) +} + +const renderCandidates = (candidates, jobId) => { + if (!Array.isArray(candidates) || candidates.length === 0) return null; + return div( + { class: "job-candidates" }, + h2(i18n.jobsCandidatesTitle || "Suggested candidates"), + p(i18n.jobsCandidatesDescription || "Inhabitants with skills matching your job. Send them a private message to invite them."), + div( + { class: "inhabitants-list" }, + candidates.map(c => div( + { class: "inhabitant-card" }, + div( + { class: "inhabitant-left" }, + a({ href: `/author/${encodeURIComponent(c.id)}` }, + img({ class: "inhabitant-photo", src: resolvePhoto(c.photo) }) + ), + h2(c.name || 'Anonymous'), + c.id + ? a({ href: `/author/${encodeURIComponent(c.id)}`, class: 'inhabitant-qr-link' }, + img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(c.id)}?size=96`, alt: 'QR' })) + : null + ), + div( + { class: "inhabitant-details" }, + c.description ? p(...renderUrl(c.description)) : null, + p(userLink(c.id)), + div({ class: "matchskills" }, + p(`${i18n.matchScore || 'Match score'}: ${Math.round(c.matchScore * 100)}%`), + p(`${i18n.commonSkills || 'Common skills'}: ${(c.commonSkills || []).join(', ')}`) + ), + c.location ? p(`${i18n.locationLabel || 'Location'}: ${c.location}`) : null, + c.status ? p(`${i18n.statusLabel || 'Status'}: ${c.status}`) : null, + c.preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${c.preferences}`) : null, + div({ class: "cv-actions" }, + form({ method: 'GET', action: `/inhabitant/${encodeURIComponent(c.id)}` }, + button({ type: 'submit', class: 'filter-btn' }, i18n.inhabitantviewDetails) + ), + form({ method: 'GET', action: '/pm' }, + input({ type: 'hidden', name: 'recipients', value: c.id }), + input({ type: 'hidden', name: 'subject', value: `${i18n.jobsTitle || 'Job'}: ${job.title || ''}`.slice(0, 150) }), + input({ type: 'hidden', name: 'text', value: `${i18n.jobsCandidatesPmBody || 'Hi, I think your profile matches my job opening'}: /jobs/${jobId}` }), + button({ type: 'submit', class: 'filter-btn' }, i18n.pmCreateButton || 'Send PM') + ) + ) + ) + )) + ) + ); +}; + +exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) => { + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, params) + const subs = safeArr(job.subscribers) + const tagsNode = renderTags(job.tags) + const isAuthor = String(job.author) === String(userId) + const isSubscribed = subs.includes(userId) + const { renderReachChip } = require('./clearnet_view') + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetJobs && String(job.status || '').toUpperCase() !== 'CLOSED' && String(job.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN') + const candidatesBlock = isAuthor ? renderCandidates(params.candidates || [], job.id) : null + + const ownerActions = renderJobOwnerActions(job, returnTo) + const applyToggle = renderJobApplyToggle(job, returnTo) + const pmBtn = renderPmButton(job.author) + const visibility = (job.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC' + const chips = [ + renderJobStatusChip(job.status), + visibility === 'HIDDEN' ? renderJobHiddenChip() : null, + isSubscribed ? renderJobAppliedChip() : null, + renderLifespanChip(job.lifetime, i18n), + renderEcoTax(job.msgSize, job.id), + renderReachChip(isClearnet, i18n) + ].filter(Boolean) + + const nextVisibility = visibility === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC' + const visibilityRow = isAuthor + ? div({ class: "tribe-side-actions" }, + span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `), + renderVisibilityChip(visibility, i18n), + form({ method: "POST", action: `/jobs/visibility/${encodeURIComponent(job.id)}`, class: "inline-form" }, + input({ type: "hidden", name: "visibility", value: nextVisibility }), + button({ type: "submit", class: "filter-btn" }, + nextVisibility === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden') + ) + ) + ) + : null + + const sideActions = [] + if (applyToggle) sideActions.push(applyToggle) + if (pmBtn) sideActions.push(pmBtn) + for (const a of ownerActions) sideActions.push(a) + + const jobSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, safeText(job.title) || i18n.jobsTitle) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(job.id, params.spreads)), + job.image + ? renderMediaBlob(job.image, { class: "tribe-detail-image" }) + : null, + renderJobInfoTable(job, { includeVacants: true, includeSubscribers: false }), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.jobSubscribers}: ${subs.length}`) + ), + sideActions.length + ? div({ class: "tribe-side-actions" }, ...sideActions) + : null, + visibilityRow, + tagsNode + ) + + const renderJobSection = (titleText, bodyText) => + safeText(bodyText) + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, titleText), + p({ class: "tribe-side-description" }, ...renderUrl(bodyText)) + ) + : null + + const extraDetailsTable = renderJobExtraDetails(job) + const jobMain = div({ class: "tribe-main" }, + renderJobSection(i18n.jobDescription, job.description), + renderJobSection(i18n.jobRequirements, job.requirements), + extraDetailsTable ? div({ class: "job-section" }, extraDetailsTable) : null, + renderJobSection(i18n.jobTasks, job.tasks), + job.mapUrl ? div({ class: "job-section" }, renderMapEmbed(params.mapData, job.mapUrl)) : null, + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(job.author), + renderUpdatedLabel(job.createdAt, job.updatedAt) + ), + candidatesBlock ? candidatesBlock : null, + renderJobCommentsSection(job.id, returnTo, comments) + ) + + return template( + i18n.jobsTitle, + section( + div( + { class: "filters" }, + form( + { method: "GET", action: "/jobs", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "search", value: safeText(params.search || "") }), + input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }), + input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }), + input({ type: "hidden", name: "sort", value: safeText(params.sort || "recent") }), + ...FILTERS.map((f) => + button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n]) + ), + button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob) + ) + ), + div({ class: "tribe-details" }, jobSide, jobMain) + ) + ) +} + +exports.clearnetJobView = async (job) => { + const { escapeHtml: esc, blobUrl: cnBlob, renderClearnetPage } = require('./clearnet_view'); + const title = esc(job.title || 'Job'); + const desc = esc(job.description || ''); + const req = esc(job.requirements || ''); + const lang = esc(String(job.languages || '').toUpperCase()); + const loc = esc(String(job.location || '').toUpperCase()); + const jobType = String(job.job_type || '').toLowerCase(); + const jobTypeLabel = jobType === 'exchange' ? 'Hour exchange' : jobType === 'employee' ? 'Employee' : 'Freelancer'; + let compensation = ''; + if (jobType === 'exchange') { + compensation = `${Number(job.hoursOffered || 0)}h offered · ${Number(job.hoursRequested || 0)}h requested`; + if (job.exchangeSkill) compensation += ` · ${esc(job.exchangeSkill)}`; + } else { + compensation = `${parseFloat(job.salary || 0).toFixed(2)} ECO`; + } + const jobImg = cnBlob(job.image); + const extraCss = ` +.cn-job-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700} +.cn-job-meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px} +.cn-job-meta-item{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:8px 14px;font-size:14px;color:var(--fg-soft);display:inline-flex;align-items:center;gap:6px} +.cn-job-comp{background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:8px 16px;border-radius:6px;font-weight:600;display:inline-block;margin-bottom:20px} +.cn-job-img{display:block;max-width:100%;border:1px solid var(--border);border-radius:8px;margin-bottom:20px} +.cn-job-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:24px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--border)} +.cn-job-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px} +`; + const body = ` +

${title}

+
+ 💼 ${jobTypeLabel} + ${job.createdAt ? `📅 ${esc(new Date(job.createdAt).toISOString().slice(0,10))}` : ''} + ${loc ? `📍 ${loc}` : ''} + ${lang ? `🗣 ${lang}` : ''} +
+
${compensation}
+ ${jobImg ? `${title}` : ''} + ${desc ? `

Description

${desc}

` : ''} + ${req ? `

Requirements

${req}

` : ''} +`; + return renderClearnetPage({ + title: `${job.title || 'Job'} — Oasis`, + ogTitle: job.title || 'Job', + ogDescription: job.description || '', + ogImage: jobImg, + extraCss, + body, + hubFeedId: job.author || null + }); +}; + diff --git a/src/views/larp_view.js b/src/views/larp_view.js new file mode 100644 index 0000000..b9de0b5 --- /dev/null +++ b/src/views/larp_view.js @@ -0,0 +1,434 @@ +const { div, h1, h2, h3, p, section, button, form, input, span, img, a, br, table, tr, td, textarea, label, strong } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink } = require("./main_views"); +const moment = require("../server/node_modules/moment"); + +const fmtCycle = (c) => c && c.formatted ? c.formatted : ''; + +const houseImageSrc = (house) => (house && house.image) ? house.image : '/assets/larp/images/default.jpg'; + +const renderCycleBanner = (cycle) => div({ class: 'larp-cycle-banner' }, + span({ class: 'larp-cycle-label' }, i18n.larpCycleLabel || 'Cycle'), + span({ class: 'larp-cycle-value' }, fmtCycle(cycle)) +); + +const renderHouseBadges = ({ myHouse, governingHouse }) => div({ class: 'larp-house-badges' }, + myHouse ? div({ class: 'larp-my-house' }, + span({ class: 'larp-my-house-label' }, i18n.larpMyHouse || 'My House'), + a({ href: `/larp/${myHouse.key}`, class: 'larp-my-house-link' }, myHouse.name) + ) : null, + governingHouse ? div({ class: 'larp-my-house' }, + span({ class: 'larp-my-house-label' }, i18n.larpGoverning || 'Governing'), + a({ href: `/larp/${governingHouse.key}`, class: 'larp-my-house-link' }, governingHouse.name) + ) : null +); + +const renderHouseNav = (houses, currentKey) => div({ class: 'larp-house-nav' }, + span({ class: 'larp-house-nav-label' }, i18n.larpAllHouses || 'Houses:'), + houses.map(h => a({ + href: `/larp/${h.key}`, + class: h.key === currentKey ? 'larp-house-nav-chip active' : 'larp-house-nav-chip', + title: h.name + }, h.name)) +); + +const renderHousePanel = (house, members, posts, { canPost, viewerHouseKey, cyclesUntilRuling }) => { + const { renderEncryptedChip } = require('./clearnet_view'); + const isInHouse = viewerHouseKey === house.key; + const isInLarp = viewerHouseKey !== null && viewerHouseKey !== undefined; + const isMemberE2E = isInHouse && house.key !== 'academia'; + const isAcademia = house.key === 'academia'; + const canInvite = isInHouse && !isAcademia; + const canLeaveToAcademia = isInHouse && !isAcademia; + const canLeaveLarp = isInHouse && isAcademia; + const canJoinAcademia = !isInLarp && isAcademia; + const showInviteForm = !isAcademia && !isInHouse && (viewerHouseKey === 'academia' || !isInLarp); + return div({ class: 'larp-detail' }, + div({ class: 'larp-detail-image-col' }, + img({ src: houseImageSrc(house), alt: house.name, class: 'larp-detail-image' }) + ), + div({ class: 'larp-detail-body' }, + div({ class: 'title-with-chip' }, + h2({ class: 'larp-detail-name' }, house.name), + (cyclesUntilRuling === 0 && !isAcademia) + ? span({ class: 'larp-badge larp-badge-ruling' }, i18n.larpBadgeRuling || 'Ruling') + : null, + isMemberE2E ? renderEncryptedChip(i18n) : null + ), + p({ class: 'larp-detail-motto' }, '“' + house.motto + '”'), + table({ class: 'larp-info-table' }, + tr(td({ class: 'card-label' }, i18n.larpRolesLabel || 'Roles'), td({ class: 'card-value' }, house.roles)), + tr(td({ class: 'card-label' }, i18n.larpFunctionLabel || 'Function'), td({ class: 'card-value' }, house.function)), + tr(td({ class: 'card-label' }, i18n.larpMonthLabel || 'Governance cycle'), td({ class: 'card-value' }, String(house.month || ''))), + (typeof cyclesUntilRuling === 'number' && cyclesUntilRuling > 0) + ? tr(td({ class: 'card-label' }, i18n.larpCyclesUntilRuling || 'Next governance cycle'), td({ class: 'card-value' }, String(cyclesUntilRuling))) + : null, + tr(td({ class: 'card-label' }, i18n.larpMembersCount || 'Members'), td({ class: 'card-value' }, String(Array.isArray(members) ? members.length : (house.memberCount || 0)))) + ), + p({ class: 'larp-detail-description' }, house.description), + div({ class: 'larp-actions' }, + isInHouse + ? a({ href: `/larp/tribe/${encodeURIComponent(house.key)}`, class: 'filter-btn' }, i18n.larpVisitTribe || 'Visit Tribe') + : null, + canInvite + ? form({ method: 'POST', action: '/larp/invite/create', class: 'larp-invite-create-form' }, + input({ type: 'hidden', name: 'house', value: house.key }), + button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteCreate || 'Generate invitation code') + ) + : null, + canLeaveToAcademia + ? form({ method: 'POST', action: '/larp/join' }, + input({ type: 'hidden', name: 'house', value: 'academia' }), + button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.larpLeaveToAcademia || 'Leave House') + ) + : null, + canLeaveLarp + ? form({ method: 'POST', action: '/larp/leave' }, + button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.larpLeaveToAcademia || 'Leave House') + ) + : null, + canJoinAcademia + ? form({ method: 'POST', action: '/larp/join' }, + input({ type: 'hidden', name: 'house', value: 'academia' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteRedeem || 'Join House') + ) + : null, + showInviteForm + ? form({ method: 'POST', action: '/larp/invite/redeem', class: 'larp-card-invite-form' }, + input({ type: 'hidden', name: 'returnTo', value: `/larp/${house.key}` }), + input({ type: 'text', name: 'code', placeholder: i18n.larpInvitePlaceholder || 'Invitation code', maxlength: '32', required: 'required' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.larpInviteRedeem || 'Join House') + ) + : null + ) + ) +); +}; + +const formatCooldown = (ms) => { + if (ms <= 0) return ''; + const days = Math.ceil(ms / (24 * 60 * 60 * 1000)); + return String(days); +}; + +const renderAcademiaJoinPanel = (allHouses, testStatus, housesById, questions, myHouseKey) => { + const myKey = myHouseKey || 'academia'; + const ordered = [ + ...allHouses.filter(h => h.key === myKey), + ...allHouses.filter(h => h.key !== myKey) + ]; + const last = testStatus && testStatus.last ? testStatus.last : null; + const lastHouse = last && housesById ? housesById[last.house] : null; + const lastHouseName = lastHouse ? lastHouse.name : (last ? last.house : null); + const remainingMs = testStatus && !testStatus.allowed ? (testStatus.nextAt - Date.now()) : 0; + const canTake = testStatus && testStatus.allowed; + const qs = Array.isArray(questions) ? questions : []; + return div({ class: 'larp-academia-join' }, + h2(i18n.larpAcademiaJoinTitle || 'Houses Current Status'), + last + ? div({ class: 'larp-last-attempt' }, + h2({ class: 'larp-last-attempt-title' }, i18n.larpLastAttemptTitle || 'Your last attempt'), + table({ class: 'larp-info-table' }, + tr( + td({ class: 'card-label' }, i18n.larpLastAttemptHouse || 'House'), + td({ class: 'card-value' }, lastHouse + ? a({ href: `/larp/${lastHouse.key}` }, lastHouseName) + : lastHouseName || '—') + ), + Number.isFinite(last.score) + ? tr( + td({ class: 'card-label' }, i18n.larpTestScore || 'Score'), + td({ class: 'card-value' }, String(last.score)) + ) + : null, + last.ts + ? tr( + td({ class: 'card-label' }, i18n.larpLastAttemptWhen || 'When'), + td({ class: 'card-value' }, moment(last.ts).format('YYYY/MM/DD HH:mm:ss')) + ) + : null, + !testStatus.allowed + ? tr( + td({ class: 'card-label' }, i18n.larpLastAttemptCooldown || 'Next attempt in'), + td({ class: 'card-value' }, formatCooldown(remainingMs) + ' ' + (i18n.larpTestCooldownDays || 'cycles')) + ) + : null + ) + ) + : (testStatus && !testStatus.allowed + ? p({ class: 'larp-test-cooldown-banner' }, + (i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(remainingMs) + ' ' + (i18n.larpTestCooldownDays || 'cycles') + ) + : null), + div({ class: 'larp-academia-grid' }, + ordered.map(h => a({ href: `/larp/${h.key}`, class: h.key === myKey ? 'larp-academia-thumb larp-academia-thumb-mine' : 'larp-academia-thumb' }, + img({ src: houseImageSrc(h), alt: h.name, class: 'larp-academia-thumb-image' }), + span({ class: 'larp-academia-thumb-name' }, h.name) + )) + ), + canTake && qs.length + ? [ + h2(i18n.larpWillTestTitle || 'WILL test'), + p({ class: 'larp-will-test-hint' }, i18n.larpWillTestHint || 'Once completed, you will be assigned the house that best matches your answers. Your individual answers are processed locally and never stored — only the resulting house assignment is published to your feed.'), + form({ method: 'POST', action: '/larp/test', class: 'larp-test-form larp-test-form-compact' }, + qs.map((q, idx) => div({ class: 'larp-test-question' }, + p({ class: 'larp-test-q-text' }, strong(`${idx + 1}. `), i18n[q.key] || q.question), + div({ class: 'larp-test-options' }, + q.options.map((opt, oi) => label({ class: 'larp-test-option' }, + input({ type: 'radio', name: `q${idx}`, value: String(oi), required: 'required' }), + ' ', i18n[opt.key] || opt.text + )) + ) + )), + div({ class: 'larp-actions' }, + button({ type: 'submit', class: 'filter-btn' }, i18n.larpTestSubmit || 'Join House') + ) + ) + ] + : null + ); +}; + +const renderPostsBlock = (posts, house, canPost) => div({ class: 'larp-posts-block' }, + h2(i18n.larpPostsTitle || 'Wall'), + canPost + ? form({ method: 'POST', action: '/larp/post', class: 'larp-post-form' }, + input({ type: 'hidden', name: 'house', value: house.key }), + textarea({ id: 'larp_post_text', name: 'text', rows: '3', maxlength: '4000', placeholder: i18n.larpPostPlaceholder || 'What does this house need to say?' }), + button({ type: 'submit', class: 'filter-btn larp-post-submit' }, i18n.larpPostSubmit || 'Publish') + ) + : null, + posts.length === 0 + ? p({ class: 'empty' }, i18n.larpPostsEmpty || 'No posts yet.') + : div({ class: 'larp-posts-list' }, + posts.map(post => div({ class: 'larp-post' }, + div({ class: 'larp-post-head' }, + userLink(post.author), + span({ class: 'larp-post-time' }, moment(post.createdAt).format('YYYY-MM-DD HH:mm')) + ), + p({ class: 'larp-post-text' }, post.text) + )) + ) +); + +const renderModeButtons = (filter) => div({ class: 'mode-buttons stats-mode-row' }, + ['ruling', 'houses', 'rules'].map(m => + form({ method: 'GET', action: '/larp' }, + input({ type: 'hidden', name: 'filter', value: m }), + button({ + type: 'submit', + class: filter === m ? 'filter-btn active' : 'filter-btn' + }, + m === 'ruling' ? (i18n.larpFilterRuling || 'RULING') + : m === 'houses' ? (i18n.larpFilterHouses || 'HOUSES') + : (i18n.larpFilterRules || 'FAQ') + ) + ) + ) +); + +const renderRules = () => div({ class: 'larp-rules' }, + h2(i18n.larpRulesTitle || 'F.A.Q.'), + div({ class: 'larp-rules-section' }, + h3(i18n.larpRulesEntryTitle || 'Starting house'), + p(i18n.larpRulesEntryText || 'Every inhabitant begins in ACADEMIA by default.') + ), + div({ class: 'larp-rules-section' }, + h3(i18n.larpRulesOneHouseTitle || 'One house at a time'), + p(i18n.larpRulesOneHouseText || 'You can belong to at most one house at any given moment. If you belong to none, you are in ACADEMIA. Every non-ACADEMIA house page shows a "Leave House" button to its members that resets membership back to ACADEMIA. Leaving does NOT waive the test cooldown.') + ), + div({ class: 'larp-rules-section' }, + h3(i18n.larpRulesTestEntry || 'WILL test'), + p(i18n.larpRulesTestEntryText || 'Each option weights one or more houses; when you submit, the system tallies the scores and auto-admits you into the highest-scoring house.') + ), + div({ class: 'larp-rules-section' }, + h3(i18n.larpRulesInviteEntry || 'Invitation code'), + p(i18n.larpRulesInviteEntryText || 'House members can generate invitation codes that grant direct access to the house.') + ), + div({ class: 'larp-rules-section' }, + h3(i18n.larpRulesGovernanceTitle || 'Governance Wall'), + p(i18n.larpRulesGovernanceText || 'During its cycle, the governing house wears the "Ruling" badge and is the only one whose Wall is surfaced publicly under the RULING tab.') + ), + div({ class: 'larp-rules-section' }, + h3(i18n.larpRulesSignTitle || 'L.A.R.P. Emblem'), + p(i18n.larpRulesSignText || 'Inhabitants can choose whether to add a sensor on their avatar with the emblem of their current house.') + ) +); + +const renderHousesGrid = (houses, myHouseKey, governingKey) => { + const inLarp = !!myHouseKey; + const mine = inLarp ? houses.filter(h => h.key === myHouseKey) : []; + const ruling = houses.filter(h => h.key === governingKey && h.key !== myHouseKey); + const rest = houses.filter(h => h.key !== myHouseKey && h.key !== governingKey); + const ordered = [...mine, ...ruling, ...rest]; + return div({ class: 'larp-grid' }, + ordered.map(h => { + const isMine = h.key === myHouseKey; + const isGoverning = h.key === governingKey; + const highlight = isGoverning ? 'larp-card-ruling' : (isMine ? 'larp-card-mine' : ''); + return div({ class: `larp-card larp-card-${h.key}${highlight ? ' ' + highlight : ''}` }, + a({ href: `/larp/${h.key}`, class: 'larp-card-image-link' }, + img({ class: 'larp-card-image', src: houseImageSrc(h), alt: h.name }) + ), + div({ class: 'larp-card-body' }, + div({ class: 'larp-card-title-row' }, + a({ href: `/larp/${h.key}`, class: 'larp-card-title' }, h.name), + isMine ? span({ class: 'larp-badge larp-badge-mine' }, i18n.larpBadgeYou || 'You') : null, + isGoverning ? span({ class: 'larp-badge larp-badge-ruling' }, i18n.larpBadgeRuling || 'Ruling') : null + ), + p({ class: 'larp-card-motto' }, '“' + h.motto + '”'), + p({ class: 'larp-card-roles' }, h.roles), + p({ class: 'larp-card-count' }, `${i18n.larpMembersCount || 'Members'}: ${h.memberCount || 0}`), + h.openInviteCode ? p({ class: 'larp-card-count' }, i18n.tribeInviteCodeText, span({ class: 'tribe-open-invite-code' }, h.openInviteCode)) : null + ) + ); + }) + ); +}; + +exports.larpListView = ({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost }) => { + const title = i18n.larpTitle || 'L.A.R.P.'; + const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.'; + const myHouse = houses.find(h => h.key === myHouseKey) || null; + const mode = filter === 'houses' ? 'houses' : filter === 'rules' ? 'rules' : 'ruling'; + + return template( + title, + section( + div({ class: 'tags-header' }, + h1(title), + p(description) + ), + renderCycleBanner(cycle), + renderHouseBadges({ myHouse, governingHouse }), + renderModeButtons(mode), + mode === 'houses' + ? renderHousesGrid(houses, myHouseKey, governingKey) + : mode === 'rules' + ? renderRules() + : [ + renderHousePanel(governingHouse, governingMembers, governingPosts, { canPost, viewerHouseKey: myHouseKey, cyclesUntilRuling: 0 }), + renderPostsBlock(governingPosts, governingHouse, canPost) + ] + ) + ); +}; + +const computeCyclesUntilRuling = (house, governingKey, now = new Date()) => { + if (!house || house.key === governingKey) return 0; + const houseIdx = (Number(house.month) || 1) - 1; + const currentMonth = now.getMonth(); + for (let i = 1; i <= 12; i += 1) { + const futureMonth = (currentMonth + i) % 12; + if (futureMonth % 9 === houseIdx) return i; + } + return 0; +}; + +exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, houses, posts, canPost, testStatus, inviteCode, questions }) => { + const myHouse = houses.find(h => h.key === myHouseKey) || null; + const governingHouse = houses.find(h => h.key === governingKey) || null; + const isAcademia = house.key === 'academia'; + const viewerInAcademia = myHouseKey === 'academia'; + const viewerIsMember = myHouseKey === house.key && house.key !== 'academia'; + const showWall = viewerIsMember || house.key === governingKey; + const cycles = computeCyclesUntilRuling(house, governingKey); + const housesById = Object.fromEntries(houses.map(h => [h.key, h])); + + return template( + house.name, + section( + div({ class: 'tags-header' }, + h1(house.name), + p({ class: 'larp-detail-motto' }, '“' + house.motto + '”') + ), + renderCycleBanner(cycle), + renderHouseBadges({ myHouse, governingHouse }), + renderHouseNav(houses, house.key), + renderHousePanel(house, members, posts, { canPost, viewerHouseKey: myHouseKey, cyclesUntilRuling: cycles }), + inviteCode + ? div({ class: 'larp-invite-banner' }, + p({ class: 'larp-invite-banner-title' }, strong(i18n.larpInviteBannerTitle || 'New invitation code')), + p({ class: 'larp-invite-banner-hint' }, i18n.larpInviteBannerHint || 'Share this code with someone in ACADEMIA. It expires in 30 cycles or after one use.'), + p({ class: 'larp-invite-banner-code' }, inviteCode) + ) + : null, + showWall + ? renderPostsBlock(posts, house, viewerIsMember) + : null, + isAcademia && viewerInAcademia ? renderAcademiaJoinPanel(houses, testStatus, housesById, questions, myHouseKey) : null + ) + ); +}; + +exports.larpTestView = ({ questions, cycle, houses, myHouseKey, governingKey, testStatus }) => { + const myHouse = houses.find(h => h.key === myHouseKey) || null; + const governingHouse = houses.find(h => h.key === governingKey) || null; + const titleText = i18n.larpTestTitle || 'Profile test'; + + return template( + titleText, + section( + div({ class: 'tags-header' }, + h1(titleText), + p(i18n.larpTestIntro || 'Answer the psychological questions. The system will pick the house that best fits your answers. You can attempt one test every 30 cycles.') + ), + renderCycleBanner(cycle), + renderHouseBadges({ myHouse, governingHouse }), + testStatus && !testStatus.allowed + ? div({ class: 'larp-test-cooldown' }, + p((i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(testStatus.nextAt - Date.now()) + ' ' + (i18n.larpTestCooldownDays || 'cycles')), + p(a({ href: '/larp/academia', class: 'filter-btn' }, i18n.larpBackToAcademia || 'Back to ACADEMIA')) + ) + : form({ method: 'POST', action: '/larp/test', class: 'larp-test-form' }, + questions.map((q, idx) => div({ class: 'larp-test-question' }, + p({ class: 'larp-test-q-text' }, strong(`${idx + 1}. `), i18n[q.key] || q.question), + div({ class: 'larp-test-options' }, + q.options.map((opt, oi) => label({ class: 'larp-test-option' }, + input({ type: 'radio', name: `q${idx}`, value: String(oi), required: 'required' }), + ' ', i18n[opt.key] || opt.text + )) + ) + )), + div({ class: 'larp-actions' }, + button({ type: 'submit', class: 'filter-btn' }, i18n.larpTestSubmit || 'Join House') + ) + ) + ) + ); +}; + +exports.larpTestResultView = ({ house, result, cycle, houses, myHouseKey, governingKey }) => { + const myHouse = houses.find(h => h.key === myHouseKey) || null; + const governingHouse = houses.find(h => h.key === governingKey) || null; + const titleText = (i18n.larpTestResultTitle || 'Test result') + (house && house.name ? ' — ' + house.name : ''); + const ranking = Array.isArray(result && result.ranking) ? result.ranking : []; + const housesById = Object.fromEntries(houses.map(h => [h.key, h])); + + return template( + titleText, + section( + div({ class: 'tags-header' }, h1(titleText)), + renderCycleBanner(cycle), + renderHouseBadges({ myHouse, governingHouse }), + div({ class: 'larp-test-result' }, + ranking.length + ? div({ class: 'larp-test-ranking' }, + h2({ class: 'larp-test-ranking-title' }, i18n.larpTestRankingTitle || 'Score by house'), + table({ class: 'larp-info-table' }, + ranking.map(([key, score]) => tr( + td({ class: 'card-label' }, housesById[key] ? a({ href: `/larp/${key}` }, housesById[key].name) : key), + td({ class: 'card-value' }, String(score)) + )) + ) + ) + : null, + p({ class: 'larp-test-next' }, i18n.larpTestNextAttempt || 'You can attempt another test in 30 cycles.'), + div({ class: 'larp-actions' }, + a({ href: house ? `/larp/${house.key}` : '/larp/academia', class: 'filter-btn' }, + i18n.larpGoToHouse || 'Go to your house' + ) + ) + ) + ) + ); +}; diff --git a/src/views/legacy_view.js b/src/views/legacy_view.js new file mode 100644 index 0000000..2a8964c --- /dev/null +++ b/src/views/legacy_view.js @@ -0,0 +1,65 @@ +const { form, button, div, h2, p, section, input, label, br } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require('./main_views'); + +const crypto = require('crypto'); + +function generateRandomPassword(length = 32) { + return crypto.randomBytes(length).toString('hex').slice(0, length); +} + +const legacyView = async () => { + const randomPassword = generateRandomPassword(); + + const header = div({ class: 'tags-header' }, + h2(i18n.legacyTitle), + p(i18n.legacyDescription) + ); + + return template( + i18n.legacyTitle, + section( + header, + p({ id: "randomPassword" }, h2({ class: "generated-password" }, randomPassword)), + div({ class: "div-center legacy-section" }, + p(i18n.exportDescription), + form( + { + action: "/legacy/export", + method: "POST", + id: "exportForm" + }, + label(i18n.exportPasswordLabel), + input({ + type: "password", + name: "password", + id: "password", + required: true, + placeholder: i18n.exportPasswordPlaceholder, + minlength: 32 + }), + p({ class: "file-info" }, i18n.fileInfo), + button({ type: "submit" }, i18n.legacyExportButton) + ), + br(), + p(i18n.importDescription), + form( + { action: "/legacy/import", method: "POST", enctype: "multipart/form-data" }, + input({ type: "file", name: "uploadedFile", required: true }), + br(), + p(i18n.passwordImport), + input({ + type: "password", + name: "importPassword", + required: true, + placeholder: i18n.importPasswordPlaceholder, + minlength: 32 + }), + br(), + button({ type: "submit" }, i18n.legacyImportButton) + ) + ) + ) + ); +}; + +exports.legacyView = legacyView; diff --git a/src/views/logs_view.js b/src/views/logs_view.js new file mode 100644 index 0000000..60d0149 --- /dev/null +++ b/src/views/logs_view.js @@ -0,0 +1,249 @@ +const { div, h2, p, section, button, form, span, table, thead, tbody, tr, th, td, input, textarea, br, option, select } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { renderUrl } = require("../backend/renderUrl"); + +const safeArr = v => Array.isArray(v) ? v : []; + +const FILTERS = ["today", "week", "month", "year", "always"]; + +const filterLabel = (f) => { + const map = { + today: i18n.logsFilterToday || 'TODAY', + week: i18n.logsFilterWeek || 'LAST WEEK', + month: i18n.logsFilterMonth || 'LAST MONTH', + year: i18n.logsFilterYear || 'LAST YEAR', + always: i18n.logsFilterAlways || 'ALWAYS' + }; + return map[f] || f.toUpperCase(); +}; + +const renderFilterBar = (current, hasItems = false) => + div({ class: "logs-toolbar" }, + form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" }, + FILTERS.map(f => + button({ + type: "submit", name: "filter", value: f, + class: current === f ? "filter-btn active" : "filter-btn" + }, filterLabel(f)) + ) + ), + form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" }, + input({ type: "hidden", name: "view", value: "create" }), + button({ type: "submit", class: "create-button" }, i18n.logsCreate || 'Create Log') + ), + hasItems + ? form({ method: "GET", action: "/logs/export", class: "logs-toolbar-inline" }, + button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs') + ) + : null + ); + +const renderSearchBox = (current, search) => { + const q = search || {}; + return div({ class: "logs-search" }, + form({ method: "GET", action: "/logs", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: current || 'today' }), + input({ + type: "text", name: "q", class: "filter-box__input", + placeholder: i18n.logsSearchText || 'Search in logs...', + value: q.q || '' + }), + div({ class: "filter-box__controls" }, + input({ + type: "date", name: "date", class: "filter-box__select", + value: q.date || '' + }), + select({ name: "type", class: "filter-box__select" }, + option({ value: '', ...(q.type ? {} : { selected: true }) }, i18n.logsSearchAnyType || 'Any type'), + option({ value: 'manual', ...(q.type === 'manual' ? { selected: true } : {}) }, i18n.logsModeManual || 'Manual'), + option({ value: 'ai', ...(q.type === 'ai' ? { selected: true } : {}) }, i18n.logsModeAI || 'AI') + ), + button({ type: "submit", class: "filter-box__button" }, i18n.logsSearchButton || 'Search') + ) + ) + ); +}; + +const renderToolbar = (current, search, hasItems) => + div({ class: "logs-toolbar-wrap" }, + renderFilterBar(current, hasItems), + renderSearchBox(current, search) + ); + +const MAX_PREVIEW = 140; + +const truncate = (str) => { + const s = String(str || ''); + if (s.length <= MAX_PREVIEW) return s; + return s.slice(0, MAX_PREVIEW).replace(/\s+\S*$/, '') + '…'; +}; + +const renderLogPreview = (item) => { + const text = truncate(item.text); + return div({ class: "logs-entry-text" }, ...renderUrl(text)); +}; + +const renderTable = (items) => { + if (!safeArr(items).length) return p({ class: "no-content" }, i18n.logsEmpty || 'No logs yet.'); + return table({ class: "logs-table" }, + thead( + tr( + th(i18n.logsColumnDate || 'Date'), + th(i18n.logsColumnType || 'Type'), + th(i18n.logsColumnLog || 'Log'), + th(''), + th('') + ) + ), + tbody( + items.map(item => + tr( + td({ class: "logs-col-date" }, + span({ class: "logs-date-day" }, moment(item.ts).format("DD/MM/YYYY")), + ' ', + span({ class: "logs-date-time" }, moment(item.ts).format("HH:mm")) + ), + td({ class: "logs-col-type" }, + span({ class: item.mode === 'ai' ? "logs-type-text logs-type-ai" : "logs-type-text logs-type-manual" }, + item.mode === 'ai' ? (i18n.logsModeAI || 'AI') : (i18n.logsModeManual || 'Manual') + ) + ), + td({ class: "logs-col-log" }, + item.label ? div({ class: "logs-entry-label" }, item.label) : null, + renderLogPreview(item) + ), + td({ class: "logs-col-actions" }, + form({ method: "GET", action: `/logs/view/${encodeURIComponent(item.key)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.logsViewDetails || 'View Details') + ) + ), + td({ class: "logs-col-actions" }, + form({ method: "GET", action: `/logs/export/${encodeURIComponent(item.key)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export') + ) + ) + ) + ) + ) + ); +}; + +const renderModeToggle = (mode, aiModOn) => { + const isAi = mode === 'ai'; + const isManual = !mode || mode === 'manual'; + return form({ method: "GET", action: "/logs", class: "logs-mode-form" }, + input({ type: "hidden", name: "view", value: "create" }), + div({ class: "logs-mode-group" }, + button({ + type: "submit", name: "mode", value: "manual", + class: isManual ? "filter-btn active" : "filter-btn" + }, i18n.logsModeManual || 'Manual'), + aiModOn + ? button({ + type: "submit", name: "mode", value: "ai", + class: isAi ? "filter-btn active" : "filter-btn" + }, i18n.logsModeAIWritten || 'AI-Assistant') + : null + ) + ); +}; + +const renderCreateForm = (mode, aiModOn) => { + const isAi = mode === 'ai' && aiModOn; + const inner = isAi + ? div({ class: "div-center audio-form" }, + form({ method: "POST", action: "/logs/create" }, + input({ type: "hidden", name: "mode", value: "ai" }), + button({ type: "submit", class: "create-button" }, i18n.logsGenerateButton || 'Generate Text') + ) + ) + : div({ class: "div-center audio-form" }, + form({ method: "POST", action: "/logs/create" }, + input({ type: "hidden", name: "mode", value: "manual" }), + span(i18n.logsManualPrompt || 'Write your log'), br(), + textarea({ name: "text", rows: "8", required: true, placeholder: i18n.logsTextPlaceholder || 'Describe your experiences...' }), + br(), br(), + button({ type: "submit", class: "create-button" }, i18n.logsWriteButton || 'Write') + ) + ); + return div(renderModeToggle(mode, aiModOn), inner); +}; + +const renderEditForm = (entry) => { + return div({ class: "div-center audio-form" }, + h2(i18n.logsEditTitle || 'Edit Log'), + form({ method: "POST", action: `/logs/edit/${encodeURIComponent(entry.key)}` }, + span(i18n.logsManualPrompt || 'Write your log...'), br(), + textarea({ name: "text", rows: "8", required: true }, entry.text || ''), + input({ type: "hidden", name: "label", value: entry.label || '' }), + br(), br(), + button({ type: "submit", class: "create-button" }, i18n.logsUpdateButton || 'Update') + ) + ); +}; + +const renderDetail = (entry) => { + const headerLine = `[${moment(entry.ts).format("DD/MM/YYYY HH:mm:ss")}]:`; + return div({ class: "div-center audio-form logs-detail" }, + h2(headerLine), + entry.label ? div({ class: "logs-entry-label" }, entry.label) : null, + div({ class: "logs-detail-text" }, ...renderUrl(String(entry.text || ''))), + div({ class: "logs-detail-actions" }, + form({ method: "GET", action: "/logs" }, + button({ type: "submit", class: "filter-btn" }, i18n.walletBack || 'Back') + ), + form({ method: "GET", action: `/logs/export/${encodeURIComponent(entry.key)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export') + ), + form({ method: "GET", action: "/logs" }, + input({ type: "hidden", name: "view", value: "edit" }), + input({ type: "hidden", name: "id", value: entry.key }), + button({ type: "submit", class: "filter-btn" }, i18n.logsEdit || 'Edit') + ), + form({ method: "POST", action: `/logs/delete/${encodeURIComponent(entry.key)}` }, + button({ type: "submit", class: "filter-btn danger-btn" }, i18n.logsDelete || 'Delete') + ) + ) + ); +}; + +exports.logsView = (items, filter, mode, opts = {}) => { + const listTitle = i18n.logsTitle || 'Logs'; + const description = i18n.logsDescription || 'Record your experience in the network.'; + const view = opts.view || 'list'; + const aiModOn = !!opts.aiModOn; + const hasItems = Array.isArray(items) && items.length > 0; + + if (view === 'create') { + const h = i18n.logsCreateTitle || 'Create Log'; + const body = section( + div({ class: "tags-header" }, h2(h), p(description)), + renderFilterBar(filter, hasItems), + renderCreateForm(mode, aiModOn) + ); + return template(h, body); + } + if (view === 'edit' && opts.entry) { + const h = i18n.logsEditTitle || 'Edit Log'; + const body = section( + div({ class: "tags-header" }, h2(h), p(description)), + renderEditForm(opts.entry) + ); + return template(h, body); + } + if (view === 'detail' && opts.entry) { + const h = i18n.logsViewTitle || 'Log'; + const body = section( + div({ class: "tags-header" }, h2(h), p(description)), + renderDetail(opts.entry) + ); + return template(h, body); + } + const body = section( + div({ class: "tags-header" }, h2(listTitle), p(description)), + renderToolbar(filter, opts.search || {}, hasItems), + div({ class: "logs-list" }, renderTable(items)) + ); + return template(listTitle, body); +}; diff --git a/src/views/main_views.js b/src/views/main_views.js new file mode 100644 index 0000000..4e70d14 --- /dev/null +++ b/src/views/main_views.js @@ -0,0 +1,3940 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); + +const envPaths = require("../server/node_modules/env-paths"); +const debug = require("../server/node_modules/debug")("oasis"); +const highlightJs = require("../server/node_modules/highlight.js"); +const prettyMs = require("../server/node_modules/pretty-ms"); +const moment = require('../server/node_modules/moment'); +const QRCode = require('../server/node_modules/qrcode'); +const { renderUrl } = require('../backend/renderUrl'); +const ssbClientGUI = require("../client/gui"); +const config = require("../server/ssb_config"); +const cooler = ssbClientGUI({ offline: config.offline }); +const sharedState = require('../configs/shared-state'); + +let ssb, userId; + +const getUserId = async () => { + if (!ssb) ssb = await cooler.open(); + if (!userId) userId = ssb.id; + return userId; +}; + +const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, table, td, textarea, title, tr, ul, strong, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe"); + +const lodash = require("../server/node_modules/lodash"); +const markdown = require("./markdown"); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); +const nameCache = require('../backend/nameCache'); + +const userLinkLabel = (feedId, knownName) => { + const id = String(feedId || ''); + if (!id) return ''; + let kn = (knownName && String(knownName).trim()) || ''; + if (kn === id || /^@[A-Za-z0-9+/_-]{43}=\.ed25519$/.test(kn)) kn = ''; + const name = kn || nameCache.get(id); + if (name && name.length) return '@' + name; + return id; +}; + +const userLink = (feedId, knownName) => { + if (!feedId) return null; + return a({ class: 'user-link', href: `/author/${encodeURIComponent(feedId)}` }, userLinkLabel(feedId, knownName)); +}; + +exports.userLink = userLink; +exports.userLinkLabel = userLinkLabel; + +const renderInviteQrCard = ({ qrDataUrl }) => + qrDataUrl ? div({ class: 'invite-qr-card' }, img({ src: qrDataUrl, alt: 'QR', class: 'invite-qr-img' })) : null; +exports.renderInviteQrCard = renderInviteQrCard; + +const renderStateChip = (variant, icon, text) => + span({ class: `pm-exposition-chip pm-exposition-${variant}` }, + icon ? span({ class: "pm-exposition-icon" }, icon) : null, + span({ class: "pm-exposition-text" }, String(text || "")) + ); + +const renderOpenClosedChip = (status, i18nObj) => { + const s = String(status || "").toUpperCase(); + const isOpen = s === "OPEN"; + const label = (i18nObj && i18nObj["statusChip" + s]) || s; + return renderStateChip(isOpen ? "mutuals" : "closed", isOpen ? "✓" : "✗", label); +}; + +const renderVisibilityChip = (visibility, i18nObj) => { + const v = String(visibility || "").toUpperCase(); + if (v === "HIDDEN") { + return renderStateChip("hidden", "🙈", (i18nObj && i18nObj.visibilityHidden) || "HIDDEN"); + } + return renderStateChip("mutuals", "👁", (i18nObj && i18nObj.visibilityPublic) || "PUBLIC"); +}; + +const renderPrivacyChip = (isPrivate, i18nObj) => + isPrivate + ? renderStateChip("closed", "🔒", (i18nObj && i18nObj.privacyPrivate) || "PRIVATE") + : renderStateChip("mutuals", "🌐", (i18nObj && i18nObj.privacyPublic) || "PUBLIC"); + +const renderModeChip = (mode, i18nObj) => { + const m = String(mode || "").toLowerCase(); + if (m === "strict") return renderStateChip("closed", null, (i18nObj && i18nObj.tribeStrict) || "STRICT"); + return renderStateChip("mutuals", null, (i18nObj && i18nObj.tribeOpen) || "OPEN"); +}; + +const renderLifespanChip = (lifetime, i18nObj) => { + const lt = lifetime || null; + if (!lt || !lt.bucket) return null; + const range = lt.range || ""; + if (range) { + return span({ class: `pm-exposition-chip pm-exposition-lifespan-${lt.bucket}` }, + span({ class: "pm-exposition-text" }, range) + ); + } + return span({ class: `pm-exposition-chip pm-exposition-lifespan-${lt.bucket}` }, + span({ class: `activity-dot ${lt.bucket}` }, "●"), + span({ class: "pm-exposition-text" }, (i18nObj && i18nObj.lifespanLabel) || "Lifespan") + ); +}; + +const renderContentActions = (msgId, viewHref) => { + const blockId = (typeof msgId === 'string' && msgId.startsWith('%')) ? msgId : null; + const chainBtn = blockId + ? a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: 'btn-singleview', title: i18n.blockchainViewBlockexplorer || 'View blockexplorer' }, '⦿') + : null; + const contentBtn = viewHref + ? a({ href: viewHref, class: 'btn-singleview btn-content', title: i18n.visitContent || 'Visit content' }, '↗') + : null; + if (!chainBtn && !contentBtn) return null; + return div({ class: 'content-actions' }, chainBtn, contentBtn); +}; +exports.renderContentActions = renderContentActions; + +exports.renderStateChip = renderStateChip; +exports.renderOpenClosedChip = renderOpenClosedChip; +exports.renderVisibilityChip = renderVisibilityChip; +exports.renderPrivacyChip = renderPrivacyChip; +exports.renderModeChip = renderModeChip; +exports.renderLifespanChip = renderLifespanChip; + +const formatCarbon = (bytes) => { + const n = Number(bytes) || 0; + if (!n) return '0 µg CO₂'; + const grams = (n / (1024 * 1024)) * 0.095; + if (grams >= 1) return `${grams.toFixed(2)} g CO₂`; + const mg = grams * 1000; + if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`; + const ug = mg * 1000; + return `${ug.toFixed(2)} µg CO₂`; +}; + +const renderEcoTax = (sizeBytes, blockId) => { + const n = Number(sizeBytes); + if (!Number.isFinite(n) || n <= 0) return null; + const label = (i18n && i18n.ecoTaxLabel) ? i18n.ecoTaxLabel : 'ECO Tax'; + const href = blockId + ? `/blockexplorer?inspect=${encodeURIComponent(blockId)}#inspect` + : '/blockexplorer#inspect'; + let band = 'low'; + let reducer = 1; + let rawRatio = 0; + let inhabitants = 1; + try { + const maxObserved = sharedState.getMaxBlockBytes ? Number(sharedState.getMaxBlockBytes()) : 0; + const max = maxObserved > 0 ? maxObserved : n; + rawRatio = max > 0 ? Math.min(1, n / max) : 0; + inhabitants = Math.max(1, (sharedState.getInhabitantCount && sharedState.getInhabitantCount()) || 1); + reducer = 1 + Math.log10(inhabitants); + const adjusted = rawRatio / reducer; + if (adjusted >= 0.66) band = 'high'; + else if (adjusted >= 0.33) band = 'mid'; + else band = 'low'; + } catch (_) {} + const reducerNote = inhabitants > 1 + ? ` · reducer ×${reducer.toFixed(2)} (${inhabitants} inhabitants)` + : ''; + const title = `${label} · ${formatCarbon(sizeBytes)}${reducerNote}`; + return a({ href, class: `eco-tax-chip eco-tax-chip-${band}`, title }, + span({ class: 'eco-tax-chip-label' }, label + ': '), + span({ class: 'eco-tax-chip-value' }, formatCarbon(sizeBytes)) + ); +}; + +exports.formatCarbon = formatCarbon; +exports.renderEcoTax = renderEcoTax; + +const errorView = ({ title, message, backHref }) => { + const heading = title || i18n.errorPageTitle || 'Error'; + return exports.template( + heading, + section( + div({ class: 'tags-header' }, + message ? p({ class: 'error-page-message' }, String(message)) : null, + div({ class: 'error-page-actions' }, + a({ href: backHref || '/', class: 'filter-btn' }, i18n.goBack || 'Go back') + ) + ) + ) + ); +}; +exports.errorView = errorView; + +const renderSpreadButton = (msgKey, opts) => { + if (!msgKey || typeof msgKey !== 'string' || !msgKey.startsWith('%') || !/\.sha256$/.test(msgKey)) return null; + const o = (opts && typeof opts === 'object') ? opts : {}; + const voters = Array.isArray(o.voters) ? o.voters : []; + const count = typeof o.count === 'number' ? o.count : voters.length; + const alreadySpread = o.alreadySpread === true; + const maxNames = 5; + const maxLen = 16; + const lastVoters = voters.slice(-maxNames); + const tooltipNames = lastVoters + .map(v => (v && typeof v === 'object' ? (v.name || v.key || '') : String(v || ''))) + .filter(Boolean) + .map(n => n.slice(0, maxLen)) + .join(', '); + const extra = count > maxNames ? ` +${count - maxNames} ${i18n.spreadMore || 'more'}` : ''; + const tooltip = count > 0 ? `${tooltipNames}${extra}` : (i18n.spreadHint || 'Spread this to your supporters (replicates via your feed).'); + return form( + { method: 'POST', action: `/spread/${encodeURIComponent(msgKey)}`, class: 'spread-form' }, + button( + { type: 'submit', class: alreadySpread ? 'spread-btn spread-btn-on' : 'spread-btn', title: tooltip }, + `🔁 ${count}` + ) + ); +}; +exports.renderSpreadButton = renderSpreadButton; + +const aiNavResultsView = ({ query, results }) => { + const title = i18n.aiNavResultsTitle || 'AI navigation results'; + const safeQuery = String(query || '').trim(); + const safeResults = Array.isArray(results) ? results : []; + const fmtScore = (s) => { + const n = Number(s); + return Number.isFinite(n) ? n.toFixed(2) : '—'; + }; + const splitTerms = (desc) => String(desc || '') + .split(/[,;]/) + .map(s => s.trim()) + .filter(Boolean); + return exports.template( + title, + section( + div({ class: 'tags-header' }, + h2(title), + safeQuery ? p({ class: 'ai-nav-query' }, `${i18n.aiNavQueryLabel || 'Query'}: "${safeQuery}"`) : null + ), + safeResults.length + ? div({ class: 'ai-nav-results' }, + safeResults.map(r => div({ class: 'ai-nav-result-card card-section' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, `${(i18n.aiNavResultMatch || 'Match').toUpperCase()}: ${fmtScore(r.score)}`), + span({ class: 'card-value' }, a({ href: r.path, class: 'filter-btn' }, r.path)) + ) + )) + ) + : p(i18n.aiNavResultsEmpty || 'No matching routes. Try /search instead.') + ) + ); +}; +exports.aiNavResultsView = aiNavResultsView; + +const i18nBase = require("../client/assets/translations/i18n"); +let selectedLanguage = "en"; +let i18n = {}; +Object.assign(i18n, i18nBase[selectedLanguage]); +exports.setLanguage = (language) => { + selectedLanguage = language; + const newLang = i18nBase[selectedLanguage] || i18nBase['en']; + Object.keys(i18n).forEach(k => delete i18n[k]); + Object.assign(i18n, newLang); +}; +exports.i18n = i18n; +Object.defineProperty(exports, 'selectedLanguage', { get: () => selectedLanguage }); + +const opinionCategoriesList = require('../backend/opinion_categories'); +const renderOpinionsVoting = (basePath, id, opinions, returnTo, voters) => { + const ops = opinions || {}; + const total = Object.values(ops).reduce((s, n) => s + (Number(n) || 0), 0); + const myId = (config.keys && config.keys.id) ? config.keys.id : ''; + const alreadyVoted = Array.isArray(voters) && myId ? voters.includes(myId) : false; + return details({ class: 'opinions-voting-collapse' }, + summary({ class: 'opinions-summary' }, + `${i18n.opinionsTitle || 'Opinions'} (${total})`), + div({ class: 'voting-buttons' }, + opinionCategoriesList.map((category) => + form({ method: 'POST', action: `${basePath}/${encodeURIComponent(id)}/${category}` }, + returnTo ? input({ type: 'hidden', name: 'returnTo', value: returnTo }) : null, + button({ class: alreadyVoted ? 'vote-btn disabled' : 'vote-btn', type: 'submit', ...(alreadyVoted ? { disabled: true } : {}) }, + `${i18n['vote' + category.charAt(0).toUpperCase() + category.slice(1)] || category} [${ops[category] || 0}]`) + ) + ) + ), + alreadyVoted ? p({ class: 'muted' }, i18n.alreadyVoted) : null + ); +}; +exports.renderOpinionsVoting = renderOpinionsVoting; + +// markdown +const markdownUrl = "https://commonmark.org/help/"; + +const doctypeString = ""; + +const THREAD_PREVIEW_LENGTH = 3; +const toAttributes = (obj) => + Object.entries(obj) + .map(([key, val]) => `${key}=${val}`) + .join(", "); + +const nbsp = "\xa0"; + +const { getConfig } = require('../configs/config-manager.js'); + +// menu INIT +const readPkg = () => { + const file = path.resolve(__dirname, "..", "server", "package.json"); + try { + const txt = fs.readFileSync(file, "utf8"); + const parsed = JSON.parse(txt || "{}"); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (_) { + return {}; + } +}; + +const renderFooter = () => { + const pkg = readPkg(); + const year = moment().format("YYYY"); + const pkgName = pkg?.name || "@krakenslab/oasis"; + const pkgVersion = pkg?.version || "?"; + + const hcT = sharedState.getCarbonHcT(); + const hcH = sharedState.getCarbonHcH(); + + const peersOnline = sharedState.getOnlinePeerCount ? sharedState.getOnlinePeerCount() : null; + const inboxUnread = sharedState.getInboxUnreadCount ? sharedState.getInboxUnreadCount() : null; + const lastSyncTs = sharedState.getLastSyncTs ? sharedState.getLastSyncTs() : null; + const lastSyncLabel = lastSyncTs ? moment(lastSyncTs).fromNow() : '–'; + const lastActivity = sharedState.getLastActivity ? sharedState.getLastActivity() : null; + + return div( + { class: "oasis-footer" }, + div( + { class: "oasis-footer-center" }, + a( + { href: "/", class: "oasis-footer-logo-link" }, + img({ + class: "oasis-footer-logo", + src: "/assets/images/snh-oasis.jpg", + alt: "Oasis" + }) + ), + (() => { + const myId = (config.keys && config.keys.id) ? config.keys.id : ''; + if (!myId) return null; + if (process.env.OASIS_MOBILE === '1') { + return [ + br(), + input({ class: "oasis-id-copy", type: "text", readonly: true, value: myId, "aria-label": "Oasis ID" }) + ]; + } + return [ + br(), + a({ href: "/profile" }, span(myId)) + ]; + })(), + br(), + span({ class: "oasis-footer-carbon" }, + span("HcT: "), + a({ href: "/stats?filter=ALL" }, hcT != null ? String(hcT) : '–'), + span(" | HcH: "), + a({ href: "/stats?filter=MINE" }, hcH != null ? String(hcH) : '–') + ), + br(), + a( + { href: "https://code.03c8.net/krakenslab/oasis", target: "_blank", rel: "noreferrer noopener" }, + span(pkgName), + ), + span("["), + span({ class: "oasis-footer-version" }, pkgVersion), + span("]"), + br(), + span(`${i18n.footerLicenseLabel || 'License'}: `), + a( + { href: "https://www.gnu.org/licenses/gpl-3.0.html", target: "_blank", rel: "noreferrer noopener" }, + i18n.footerLicense + ), + span({ class: "oasis-footer-sep" }, " - "), + span({ class: "oasis-footer-year" }, year) + ) + ); +}; + +const navLink = ({ href, emoji, text, current, class: extraClass }) => + li( + a( + { + href, + class: [current ? "current" : "", extraClass] + .filter(Boolean) + .join(" ") + }, + span({ class: "emoji" }, emoji), + span({ class: "nav-text" }, text) + ) + ); + +const customCSS = (filename) => { + const customStyleFile = path.join( + envPaths("oasis", { suffix: "" }).config, + filename + ); + try { + if (fs.existsSync(customStyleFile)) { + return link({ rel: "stylesheet", href: filename }); + } + } catch (error) { + return ""; + } +}; + +const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) => { + const active = items.filter(Boolean); + if (!active.length) return null; + return li( + { class: "oasis-nav-group" }, + input({ + type: "checkbox", + id: `oasis-nav-group-${id}`, + class: "oasis-nav-toggle", + ...(defaultOpen ? { checked: true } : {}) + }), + label( + { for: `oasis-nav-group-${id}`, class: "oasis-nav-header" }, + span({ class: "emoji" }, emoji), + span({ class: "nav-text" }, title), + span({ class: "oasis-nav-arrow" }, "▾") + ), + ul({ class: "oasis-nav-list" }, ...active) + ); +}; + +const renderPopularLink = () => { + const popularMod = getConfig().modules.popularMod === "on"; + return popularMod + ? navLink({ + href: "/public/popular/day", + emoji: "⌘", + text: i18n.popular, + class: "popular-link enabled" + }) + : ""; +}; + +const renderTopicsLink = () => { + const topicsMod = getConfig().modules.topicsMod === "on"; + return topicsMod + ? navLink({ + href: "/public/latest/topics", + emoji: "ϟ", + text: i18n.topics, + class: "topics-link enabled" + }) + : ""; +}; + +const renderSummariesLink = () => { + const summariesMod = getConfig().modules.summariesMod === "on"; + if (summariesMod) { + return [ + navLink({ + href: "/public/latest/summaries", + emoji: "※", + text: i18n.summaries, + class: "summaries-link enabled" + }) + ]; + } + return ""; +}; + +const renderLatestLink = () => { + const latestMod = getConfig().modules.latestMod === "on"; + return latestMod + ? navLink({ + href: "/public/latest", + emoji: "☄", + text: i18n.latest, + class: "latest-link enabled" + }) + : ""; +}; + +const renderThreadsLink = () => { + const threadsMod = getConfig().modules.threadsMod === "on"; + if (threadsMod) { + return [ + navLink({ + href: "/public/latest/threads", + emoji: "♺", + text: i18n.threads, + class: "threads-link enabled" + }) + ]; + } + return ""; +}; + +const renderInvitesLink = () => { + const invitesMod = getConfig().modules.invitesMod === "on"; + return invitesMod + ? navLink({ + href: "/invites", + emoji: "ꔹ", + text: i18n.invites, + class: "invites-link enabled" + }) + : ""; +}; + +const renderWalletLink = () => { + const walletMod = getConfig().modules.walletMod === "on"; + if (walletMod) { + return [ + navLink({ + href: "/wallet", + emoji: "❄", + text: i18n.wallet, + class: "wallet-link enabled" + }) + ]; + } + return ""; +}; + +const renderLegacyLink = () => { + const legacyMod = getConfig().modules.legacyMod === "on"; + if (legacyMod) { + return [ + navLink({ + href: "/legacy", + emoji: "ꖤ", + text: i18n.legacy, + class: "legacy-link enabled" + }) + ]; + } + return ""; +}; + +const renderCipherLink = () => { + const cipherMod = getConfig().modules.cipherMod === "on"; + if (cipherMod) { + return [ + navLink({ + href: "/cipher", + emoji: "ꗄ", + text: i18n.cipher, + class: "cipher-link enabled" + }) + ]; + } + return ""; +}; + +const renderGraphosLink = () => { + const graphosMod = getConfig().modules.graphosMod === "on"; + if (graphosMod) { + return [ + navLink({ + href: "/graphos", + emoji: "ꔯ", + text: i18n.graphos, + class: "graphos-link enabled" + }) + ]; + } + return ""; +}; + +const renderBookmarksLink = () => { + const bookmarksMod = getConfig().modules.bookmarksMod === "on"; + return bookmarksMod + ? navLink({ + href: "/bookmarks", + emoji: "ꔪ", + text: i18n.bookmarksLabel, + class: "bookmark-link enabled" + }) + : ""; +}; + +const renderImagesLink = () => { + const imagesMod = getConfig().modules.imagesMod === "on"; + if (imagesMod) { + return [ + navLink({ + href: "/images", + emoji: "ꕥ", + text: i18n.imagesLabel, + class: "images-link enabled" + }) + ]; + } + return ""; +}; + +const renderTorrentsLink = () => { + const torrentsMod = getConfig().modules.torrentsMod === "on"; + if (torrentsMod) { + return [ + navLink({ + href: "/torrents", + emoji: "ꖅ", + text: i18n.torrentsLabel, + class: "torrents-link enabled" + }) + ]; + } + return ""; +}; + +const renderMapsLink = () => { + const mapsMod = getConfig().modules.mapsMod === "on"; + if (mapsMod) { + return [ + navLink({ + href: "/maps", + emoji: "ꔌ", + text: i18n.mapsLabel, + class: "maps-link enabled" + }) + ]; + } + return ""; +}; + +const renderChatsLink = () => { + const chatsMod = getConfig().modules.chatsMod === "on"; + if (chatsMod) { + return [ + navLink({ + href: "/chats", + emoji: "ꖒ", + text: i18n.chatsTitle, + class: "chats-link enabled" + }) + ]; + } + return ""; +}; + +const renderVideosLink = () => { + const videosMod = getConfig().modules.videosMod === "on"; + if (videosMod) { + return [ + navLink({ + href: "/videos", + emoji: "ꗟ", + text: i18n.videosLabel, + class: "videos-link enabled" + }) + ]; + } + return ""; +}; + +const renderAudiosLink = () => { + const audiosMod = getConfig().modules.audiosMod === "on"; + if (audiosMod) { + return [ + navLink({ + href: "/audios", + emoji: "ꔿ", + text: i18n.audiosLabel, + class: "audios-link enabled" + }) + ]; + } + return ""; +}; + +const renderDocsLink = () => { + const docsMod = getConfig().modules.docsMod === "on"; + if (docsMod) { + return [ + navLink({ + href: "/documents", + emoji: "ꕨ", + text: i18n.docsLabel, + class: "docs-link enabled" + }) + ]; + } + return ""; +}; + +const renderTagsLink = () => { + const tagsMod = getConfig().modules.tagsMod === "on"; + return tagsMod + ? [ + navLink({ + href: "/tags", + emoji: "ꖶ", + text: i18n.tagsLabel, + class: "tags-link enabled" + }) + ] + : ""; +}; + +const renderMultiverseLink = () => { + const multiverseMod = getConfig().modules.multiverseMod === "on"; + return multiverseMod + ? navLink({ + href: "/public/latest/extended", + emoji: "∞", + text: i18n.multiverse, + class: "multiverse-link enabled" + }) + : ""; +}; + +const renderMastodonLink = () => { + const fediverseMod = getConfig().modules.fediverseMod === "on"; + return fediverseMod + ? navLink({ + href: "/fediverse", + emoji: "ꗵ", + text: i18n.fediverseTimeline + }) + : ""; +}; + +const renderMarketLink = () => { + const marketMod = getConfig().modules.marketMod === "on"; + return marketMod + ? [ + navLink({ + href: "/market", + emoji: "ꕻ", + text: i18n.marketTitle + }) + ] + : ""; +}; + +const renderJobsLink = () => { + const jobsMod = getConfig().modules.jobsMod === "on"; + return jobsMod + ? [ + navLink({ + href: "/jobs", + emoji: "ꗒ", + text: i18n.jobsTitle + }) + ] + : ""; +}; + +const renderShopsLink = () => { + const shopsMod = getConfig().modules.shopsMod === "on"; + return shopsMod + ? [ + navLink({ + href: "/shops", + emoji: "ꔜ", + text: i18n.shopsTitle + }) + ] + : ""; +}; + +const renderProjectsLink = () => { + const projectsMod = getConfig().modules.projectsMod === "on"; + return projectsMod + ? [ + navLink({ + href: "/projects", + emoji: "ꕧ", + text: i18n.projectsTitle + }) + ] + : ""; +}; + +const renderBankingLink = () => { + const bankingMod = getConfig().modules.bankingMod === "on"; + return bankingMod + ? navLink({ + href: "/banking", + emoji: "ꗴ", + text: i18n.bankingTitle + }) + : ""; +}; + +const renderTribesLink = () => { + const tribesMod = getConfig().modules.tribesMod === "on"; + return tribesMod + ? [ + navLink({ + href: "/tribes", + emoji: "ꖥ", + text: i18n.tribesTitle, + class: "tribes-link enabled" + }) + ] + : ""; +}; + +const renderLarpLink = () => { + const larpMod = getConfig().modules.larpMod === "on"; + return larpMod + ? [ + navLink({ + href: "/larp", + emoji: "✦", + text: i18n.larpTitle || "L.A.R.P.", + class: "larp-link enabled" + }) + ] + : ""; +}; + +const renderParliamentLink = () => { + const parliamentMod = getConfig().modules.parliamentMod === "on"; + return parliamentMod + ? [ + navLink({ + href: "/parliament", + emoji: "ꗞ", + text: i18n.parliamentTitle, + class: "parliament-link enabled" + }) + ] + : ""; +}; + +const renderCourtsLink = () => { + const courtsMod = getConfig().modules.courtsMod === "on"; + return courtsMod + ? navLink({ + href: "/courts", + emoji: "ꖻ", + text: i18n.courtsTitle, + class: "courts-link enabled" + }) + : ""; +}; + +const renderVotationsLink = () => { + const votesMod = getConfig().modules.votesMod === "on"; + return votesMod + ? [ + navLink({ + href: "/votes", + emoji: "ꔰ", + text: i18n.votationsTitle, + class: "votations-link enabled" + }) + ] + : ""; +}; + +const renderTrendingLink = () => { + const trendingMod = getConfig().modules.trendingMod === "on"; + return trendingMod + ? [ + navLink({ + href: "/trending", + emoji: "ꗝ", + text: i18n.trendingLabel, + class: "trending-link enabled" + }) + ] + : ""; +}; + +const renderReportsLink = () => { + const reportsMod = getConfig().modules.reportsMod === "on"; + return reportsMod + ? [ + navLink({ + href: "/reports", + emoji: "ꕥ", + text: i18n.reportsTitle, + class: "reports-link enabled" + }) + ] + : ""; +}; + +const renderOpinionsLink = () => { + const opinionsMod = getConfig().modules.opinionsMod === "on"; + return opinionsMod + ? [ + navLink({ + href: "/opinions", + emoji: "ꔍ", + text: i18n.opinionsTitle, + class: "opinions-link enabled" + }) + ] + : ""; +}; + +const renderPadsLink = () => { + const padsMod = getConfig().modules.padsMod === "on"; + return padsMod + ? [ + navLink({ + href: "/pads", + emoji: "ꔗ", + text: i18n.padsTitle, + class: "pads-link enabled" + }) + ] + : ""; +}; + +const renderTransfersLink = () => { + const transfersMod = getConfig().modules.transfersMod === "on"; + return transfersMod + ? [ + navLink({ + href: "/transfers", + emoji: "ꘉ", + text: i18n.transfersTitle, + class: "transfers-link enabled" + }) + ] + : ""; +}; + +const renderFeedLink = () => { + const feedMod = getConfig().modules.feedMod === "on"; + return feedMod + ? navLink({ + href: "/feed", + emoji: "ꕿ", + text: i18n.feedTitle, + class: "feed-link enabled" + }) + : ""; +}; + +const renderPixeliaLink = () => { + const pixeliaMod = getConfig().modules.pixeliaMod === "on"; + return pixeliaMod + ? [ + navLink({ + href: "/pixelia", + emoji: "ꔘ", + text: i18n.pixeliaTitle, + class: "pixelia-link enabled" + }) + ] + : ""; +}; + +const renderMelodyLink = () => { + const melodyMod = getConfig().modules.melodyMod === "on"; + return melodyMod + ? [ + navLink({ + href: "/melody", + emoji: "♪", + text: i18n.melodyTitle, + class: "melody-link enabled" + }) + ] + : ""; +}; + +const renderGamesLink = () => { + const gamesMod = getConfig().modules.gamesMod === "on"; + return gamesMod + ? [navLink({ href: "/games", emoji: "ꕇ", text: i18n.gamesTitle, class: "games-link enabled" })] + : ""; +}; + +const renderForumLink = () => { + const forumMod = getConfig().modules.forumMod === "on"; + return forumMod + ? [ + navLink({ + href: "/forum", + emoji: "ꕒ", + text: i18n.forumTitle, + class: "forum-link enabled" + }) + ] + : ""; +}; + +const renderAgendaLink = () => { + const agendaMod = getConfig().modules.agendaMod === "on"; + return agendaMod + ? [ + navLink({ + href: "/agenda", + emoji: "ꗤ", + text: i18n.agendaTitle, + class: "agenda-link enabled" + }) + ] + : ""; +}; + +const renderFavoritesLink = () => { + const favoritesMod = getConfig().modules.favoritesMod === "on"; + return favoritesMod + ? [ + navLink({ + href: "/favorites", + emoji: "ꘝ", + text: i18n.favoritesTitle, + class: "favorites-link enabled" + }) + ] + : ""; +}; + +const renderLogsLink = () => { + const logsMod = getConfig().modules.logsMod === "on"; + return logsMod + ? [ + navLink({ + href: "/logs", + emoji: "ꗯ", + text: i18n.logsTitle || "Logs", + class: "logs-link enabled" + }) + ] + : ""; +}; + +const renderAILink = () => { + const aiMod = getConfig().modules.aiMod === "on"; + return aiMod + ? [ + navLink({ + href: "/ai", + emoji: "ꘜ", + text: i18n.ai, + class: "ai-link enabled" + }) + ] + : ""; +}; + +const renderEventsLink = () => { + const eventsMod = getConfig().modules.eventsMod === "on"; + return eventsMod + ? [ + navLink({ + href: "/events", + emoji: "ꕆ", + text: i18n.eventsLabel, + class: "events-link enabled" + }) + ] + : ""; +}; + +const renderCalendarsLink = () => { + const calendarsMod = getConfig().modules.calendarsMod === "on"; + return calendarsMod + ? [ + navLink({ + href: "/calendars", + emoji: "\uA5AF", + text: i18n.calendarsTitle || "Calendars", + class: "calendars-link enabled" + }) + ] + : ""; +}; + +const renderTasksLink = () => { + const tasksMod = getConfig().modules.tasksMod === "on"; + return tasksMod + ? [ + navLink({ + href: "/tasks", + emoji: "ꖧ", + text: i18n.tasksTitle, + class: "tasks-link enabled" + }) + ] + : ""; +}; + +const template = (titlePrefix, ...elements) => { + const currentConfig = getConfig(); + const theme = currentConfig.themes.current || "Dark-SNH"; + const uxMode = currentConfig.ux?.current === "ainav" ? "ainav" : "blocks"; + const themeLink = link({ + rel: "stylesheet", + href: `/assets/themes/${theme}.css` + }); + const nodes = html( + { lang: "en" }, + head( + title(titlePrefix, " | Oasis"), + link({ rel: "stylesheet", href: "/assets/styles/style.css" }), + themeLink, + link({ rel: "stylesheet", href: "/assets/styles/mobile.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) }), + link({ rel: "icon", href: "/assets/images/favicon.svg" }), + meta({ charset: "utf-8" }), + meta({ name: "description", content: i18n.oasisDescription }), + meta({ + name: "viewport", + content: toAttributes({ + width: "device-width", + "initial-scale": 1 + }) + }) + ), + body( + input({ type: "checkbox", id: "nav-left-toggle", class: "oasis-nav-side-toggle" }), + input({ type: "checkbox", id: "nav-right-toggle", class: "oasis-nav-side-toggle" }), + div( + { class: uxMode === "ainav" ? "header ainav-only" : "header" }, + div( + { class: "top-bar-left" }, + a( + { class: "logo-icon", href: "/" }, + img({ + class: "logo-icon", + src: "/assets/images/snh-oasis.jpg", + alt: "Oasis Logo" + }) + ), + uxMode === "ainav" ? nav( + ul( + (() => { + const inboxCount = sharedState.getInboxCount(); + const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : ''; + return li( + a({ href: "/inbox" }, + span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge + ) + ); + })(), + navLink({ href: "/settings", emoji: "⚙", text: i18n.settings }), + navLink({ href: "/invites", emoji: "ꔮ", text: i18n.invites }) + ) + ) : nav( + ul( + (() => { + const inboxCount = sharedState.getInboxCount(); + const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : ''; + return li( + a({ href: "/inbox" }, + span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge + ) + ); + })(), + navLink({ + href: "/pm", + emoji: "ꕕ", + text: i18n.privateMessage + }), + navLink({ href: "/publish", emoji: "❂", text: i18n.publish }) + ) + ) + ), + div( + { class: "oasis-mobile-menus" }, + label({ for: "nav-left-toggle", class: "oasis-menu-btn" }, + span({ class: "oasis-menu-ico" }, "☰"), span(i18n.menuPersonal || "Personal")), + label({ for: "nav-right-toggle", class: "oasis-menu-btn" }, + span({ class: "oasis-menu-ico" }, "☰"), span(i18n.menuCommunity || "Community")) + ), + (() => { + const aiNavOn = getConfig().modules.aiNavMod === 'on'; + if (!aiNavOn && uxMode !== 'ainav') return null; + return div( + { class: uxMode === 'ainav' ? "top-bar-center top-bar-center-ainav" : "top-bar-center" }, + form( + { method: 'POST', action: '/ai/ask', class: 'ai-ask-form' }, + input({ + type: 'text', + name: 'q', + class: 'ai-ask-input', + placeholder: i18n.aiNavPlaceholder || 'Where do you want to go?', + autocomplete: 'off', + maxlength: '300', + autofocus: uxMode === 'ainav' ? 'autofocus' : undefined + }), + button({ type: 'submit', class: 'ai-ask-btn' }, '➤') + ) + ); + })(), + uxMode === "ainav" ? div( + { class: "top-bar-right" }, + nav( + ul( + navLink({ href: "/activity", emoji: "ꔙ", text: i18n.activityTitle }), + navLink({ href: "/graphos", emoji: "ꕢ", text: i18n.graphos }), + navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }) + ) + ) + ) : div( + { class: "top-bar-right" }, + nav( + ul( + navLink({ href: "/search", emoji: "ꔅ", text: i18n.searchTitle }), + renderGraphosLink(), + navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }) + ) + ) + ) + ), + (() => { + const updateFlagPath = path.join(__dirname, '../server/.update_required'); + if (fs.existsSync(updateFlagPath)) { + return div( + { class: "update-banner" }, + span({ class: "update-banner-icon" }, "⟳"), + span({ class: "update-banner-text" }, i18n.updateBannerText), + a({ href: "/settings", class: "update-banner-link" }, i18n.updateBannerAction) + ); + } + return null; + })(), + div( + { class: uxMode === "ainav" ? "main-content ainav-only" : "main-content" }, + uxMode === "ainav" ? null : div( + { class: "sidebar-left" }, + nav( + ul( + navGroup( + { + id: "personal", + emoji: "⚉", + title: i18n.menuPersonal + }, + navLink({ + href: "/profile", + emoji: "⚉", + text: i18n.profile + }), + navLink({ + href: "/cv", + emoji: "ꕛ", + text: i18n.cvTitle + }), + renderAgendaLink(), + renderFavoritesLink(), + renderLogsLink(), + renderWalletLink(), + navLink({ + href: "/modules", + emoji: "ꗣ", + text: i18n.modules + }), + navLink({ + href: "/settings", + emoji: "⚙", + text: i18n.settings + }) + ), + navGroup( + { + id: "governance", + emoji: "⚖", + title: i18n.menuGovernance + }, + navLink({ + href: "/inhabitants", + emoji: "ꖘ", + text: i18n.inhabitantsLabel + }), + renderTribesLink(), + renderLarpLink(), + renderParliamentLink(), + renderCourtsLink() + ), + navGroup( + { + id: "office", + emoji: "⌂", + title: i18n.menuOffice + }, + renderVotationsLink(), + renderEventsLink(), + renderCalendarsLink(), + renderTasksLink(), + renderReportsLink() + ), + navGroup( + { + id: "economy", + emoji: "¤", + title: i18n.menuEconomy + }, + renderBankingLink(), + renderMarketLink(), + renderProjectsLink(), + renderJobsLink(), + renderShopsLink(), + renderTransfersLink() + ), + navGroup( + { + id: "tools", + emoji: "⚒", + title: i18n.menuTools + }, + renderAILink(), + navLink({ + href: "/blockexplorer", + emoji: "ꖸ", + text: i18n.blockchain + }), + renderCipherLink(), + renderInvitesLink(), + renderLegacyLink(), + navLink({ + href: "/stats", + emoji: "ꕷ", + text: i18n.statistics + }) + ) + ) + ) + ), + main({ id: "content", class: "main-column" }, elements), + uxMode === "ainav" ? null : div( + { class: "sidebar-right" }, + nav( + ul( + navGroup( + { + id: "network", + emoji: "☍", + title: i18n.menuNetwork + }, + navLink({ + href: "/activity", + emoji: "ꔙ", + text: i18n.activityTitle + }), + renderTagsLink(), + renderTrendingLink(), + renderOpinionsLink(), + renderPadsLink(), + renderForumLink(), + renderMapsLink(), + renderChatsLink() + ), + navGroup( + { + id: "blogs", + emoji: "✦", + title: i18n.menuBlogs + }, + navLink({ + href: "/mentions", + emoji: "✺", + text: i18n.mentions + }), + renderLatestLink(), + renderThreadsLink(), + renderTopicsLink(), + renderSummariesLink(), + renderPopularLink(), + renderMultiverseLink() + ), + navGroup( + { + id: "creative", + emoji: "✎", + title: i18n.menuCreative + }, + renderFeedLink(), + renderGamesLink(), + renderPixeliaLink(), + renderMelodyLink() + ), + navGroup( + { + id: "media", + emoji: "▤", + title: i18n.menuMedia + }, + renderAudiosLink(), + renderBookmarksLink(), + renderDocsLink(), + renderImagesLink(), + renderTorrentsLink(), + renderVideosLink() + ), + navGroup( + { + id: "fediverse", + emoji: "⌬", + title: i18n.fediverse + }, + renderMastodonLink() + ) + ) + ) + ) + ), + renderFooter() + ) + ); + return doctypeString + nodes.outerHTML; +}; +// menu END + +exports.template = template; + +exports.ainavHomeView = ({ recentTags = [] } = {}) => { + const currentConfig = getConfig(); + const theme = currentConfig.themes.current || "Dark-SNH"; + const placeholder = i18n.aiNavPlaceholder || 'Where do you want to go?'; + const nodes = html( + { lang: "en" }, + head( + title(placeholder, " | Oasis"), + link({ rel: "stylesheet", href: "/assets/styles/style.css" }), + link({ rel: "stylesheet", href: `/assets/themes/${theme}.css` }), + link({ rel: "stylesheet", href: "/assets/styles/mobile.css", ...(process.env.OASIS_MOBILE === '1' ? {} : { media: "(max-width: 768px)" }) }), + link({ rel: "icon", href: "/assets/images/favicon.svg" }), + meta({ charset: "utf-8" }), + meta({ name: "description", content: i18n.oasisDescription }), + meta({ name: "viewport", content: toAttributes({ width: "device-width", "initial-scale": 1 }) }) + ), + body( + div({ class: "ainav-landing" }, + div({ class: "ainav-landing-topbar" }, + div({ class: "top-bar-left" }, + nav( + ul( + (() => { + const inboxCount = sharedState.getInboxCount(); + const badge = inboxCount > 0 ? span({ class: 'inbox-badge' }, String(inboxCount)) : ''; + return li( + a({ href: "/inbox" }, + span({ class: "emoji" }, "☂"), nbsp, i18n.inbox, badge + ) + ); + })(), + navLink({ href: "/settings", emoji: "⚙", text: i18n.settings }), + navLink({ href: "/invites", emoji: "ꔮ", text: i18n.invites }) + ) + ) + ), + div({ class: "top-bar-right" }, + nav( + ul( + navLink({ href: "/activity", emoji: "ꔙ", text: i18n.activityTitle }), + navLink({ href: "/graphos", emoji: "ꕢ", text: i18n.graphos }), + navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }) + ) + ) + ) + ), + div({ class: "ainav-landing-center" }, + a({ href: "/", class: "ainav-landing-logo" }, + img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis Logo" }) + ), + (() => { + const myId = (config.keys && config.keys.id) ? config.keys.id : ''; + return myId ? div({ class: 'ainav-landing-myid oasis-footer-center' }, + a({ href: '/profile' }, span(myId)) + ) : null; + })(), + form( + { method: 'POST', action: '/ai/ask', class: 'ai-ask-form ainav-landing-form' }, + input({ + type: 'text', + name: 'q', + class: 'ai-ask-input ainav-landing-input', + placeholder, + autocomplete: 'off', + maxlength: '300', + autofocus: 'autofocus' + }), + button({ type: 'submit', class: 'ai-ask-btn ainav-landing-btn' }, '➤') + ), + Array.isArray(recentTags) && recentTags.length + ? div({ class: 'ainav-landing-tags' }, + recentTags.map(t => a({ + href: `/search?query=%23${encodeURIComponent(t.name || t)}`, + class: 'tag-link' + }, `#${t.name || t}`)) + ) + : null + ) + ) + ) + ); + return doctypeString + nodes.outerHTML; +}; + +exports.tribeAccessDeniedView = (tribe) => { + const tribeName = tribe && !tribe.isAnonymous ? tribe.title : ""; + return template( + i18n.tribeContentAccessDenied, + div({ class: "div-center" }, + h2(i18n.tribeContentAccessDenied), + p(i18n.tribeContentAccessDeniedMsg), + tribeName ? p({ class: "tribe-access-name" }, tribeName) : null, + div({ class: "visit-btn-centered" }, + a({ href: "/tribes", class: "filter-btn" }, i18n.tribeViewTribes) + ) + ) + ); +}; + +exports.inviteRequiredView = (kind, tribe) => { + const msg = kind === 'pad' ? (i18n.tribePadInviteRequired || 'You do not have access to the pad. Ask for an invitation to access the content.') + : kind === 'chat' ? (i18n.tribeChatInviteRequired || 'You do not have access to the chat. Ask for an invitation to access the content.') + : (i18n.tribeContentAccessDeniedMsg); + const backHref = tribe ? `/tribe/${encodeURIComponent(tribe.id)}?section=${kind === 'chat' ? 'chats' : 'pads'}` : (kind === 'chat' ? '/chats' : '/pads'); + return template( + i18n.tribeContentAccessDenied, + div({ class: "div-center" }, + h2(i18n.tribeContentAccessDenied), + p(msg), + div({ class: "visit-btn-centered" }, + a({ href: backHref, class: "filter-btn" }, i18n.walletBack || "Back") + ) + ) + ); +}; + +const thread = (messages, spreadMap = null) => { + let lookingForTarget = true; + let shallowest = Infinity; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + const depth = lodash.get(msg, "value.meta.thread.depth", 0); + + if (lookingForTarget) { + const isThreadTarget = Boolean( + lodash.get(msg, "value.meta.thread.target", false) + ); + if (isThreadTarget) { + lookingForTarget = false; + } + } else { + if (depth < shallowest) { + lodash.set(msg, "value.meta.thread.ancestorOfTarget", true); + shallowest = depth; + } + } + } + + const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null; + + const msgList = []; + for (let i = 0; i < messages.length; i++) { + const j = i + 1; + const currentMsg = messages[i]; + const nextMsg = messages[j]; + + const depth = (msg) => { + if (msg === undefined) return 0; + return lodash.get(msg, "value.meta.thread.depth", 0); + }; + + msgList.push(post({ msg: currentMsg, spreadInfo: getSpread(currentMsg.key) })); + + if (depth(currentMsg) < depth(nextMsg)) { + const isAncestor = Boolean( + lodash.get(currentMsg, "value.meta.thread.ancestorOfTarget", false) + ); + const isBlocked = Boolean(nextMsg.value.meta.blocking); + const nextAuthor = lodash.get(nextMsg, "value.meta.author.name") || (typeof nextMsg?.value?.author === "string" ? (nextMsg.value.author.startsWith("@") ? nextMsg.value.author.slice(1) : nextMsg.value.author) : "Anonymous"); + const nextSnippet = postSnippet( + lodash.has(nextMsg, "value.content.contentWarning") + ? lodash.get(nextMsg, "value.content.contentWarning") + : lodash.get(nextMsg, "value.content.text") + ); + msgList.push( + details( + isAncestor ? { open: true } : {}, + summary( + isBlocked + ? i18n.relationshipBlockingPost + : `${nextAuthor}: ${nextSnippet}` + ) + ) + ); + } else if (depth(currentMsg) > depth(nextMsg)) { + const diffDepth = depth(currentMsg) - depth(nextMsg); + } + } + + return div({ class: "thread-container" }, ...msgList); +}; + +const postSnippet = (text) => { + const max = 40; + + text = text.trim().split("\n", 3).join("\n"); + text = text.replace(/_|`|\*|#|^\[@.*?]|\[|]|\(\S*?\)/g, "").trim(); + text = text.replace(/:$/, ""); + text = text.trim().split("\n", 1)[0].trim(); + + if (text.length > max) { + text = text.substring(0, max - 1) + "…"; + } + + return text; +}; + +const continueThreadComponent = (thread, isComment) => { + const encoded = { + next: encodeURIComponent(thread[THREAD_PREVIEW_LENGTH + 1].key), + parent: encodeURIComponent(thread[0].key), + }; + const left = thread.length - (THREAD_PREVIEW_LENGTH + 1); + let continueLink; + if (isComment == false) { + continueLink = `/thread/${encoded.parent}#${encoded.next}`; + return a( + { href: continueLink }, + i18n.continueReading, ` ${left} `, i18n.moreComments+`${left === 1 ? "" : "s"}` + ); + } else { + continueLink = `/thread/${encoded.parent}`; + return a({ href: continueLink }, i18n.readThread); + } +}; + +const postAside = ({ key, value }) => { + const thread = value.meta.thread; + if (thread == null) return null; + + const isComment = value.meta.postType === "comment"; + + let postsToShow; + if (isComment) { + const commentPosition = thread.findIndex((msg) => msg.key === key); + postsToShow = thread.slice( + commentPosition + 1, + Math.min(commentPosition + (THREAD_PREVIEW_LENGTH + 1), thread.length) + ); + } else { + postsToShow = thread.slice( + 1, + Math.min(thread.length, THREAD_PREVIEW_LENGTH + 1) + ); + } + + const fragments = postsToShow.map((p) => post({ msg: p })); + + if (thread.length > THREAD_PREVIEW_LENGTH + 1) { + fragments.push(section(continueThreadComponent(thread, isComment))); + } + + return fragments; +}; + +const post = ({ msg, aside = false, preview = false, spreadInfo = null }) => { + const encoded = { + key: encodeURIComponent(msg.key), + author: encodeURIComponent(msg.value?.author), + parent: encodeURIComponent(msg.value?.content?.root), + }; + + const url = { + author: `/author/${encoded.author}`, + spreadForm: `/spread/${encoded.key}`, + link: `/thread/${encoded.key}#${encoded.key}`, + parent: `/thread/${encoded.parent}#${encoded.parent}`, + avatar: msg.value?.meta?.author?.avatar?.url || '/assets/images/default-avatar.png', + json: `/json/${encoded.key}`, + subtopic: `/subtopic/${encoded.key}`, + comment: `/comment/${encoded.key}`, + }; + + const isPrivate = Boolean(msg.value?.meta?.private); + const isBlocked = Boolean(msg.value?.meta?.blocking); + const isRoot = msg.value?.content?.root == null; + const isFork = msg.value?.meta?.postType === "subtopic"; + const hasContentWarning = typeof msg.value?.content?.contentWarning === "string"; + const isThreadTarget = Boolean(lodash.get(msg, "value.meta.thread.target", false)); + + const authorIdForName = msg.value?.author; + const name = + msg.value?.meta?.author?.name || + (typeof authorIdForName === "string" + ? (authorIdForName.startsWith("@") ? authorIdForName.slice(1) : authorIdForName) + : "Anonymous"); + + const content = msg.value?.content || {}; + const contentType = String(content.type || ""); + + const THREAD_ENTITY_TYPES = new Set([ + 'bookmark', + 'image', + 'audio', + 'video', + 'document', + 'votes', + 'event', + 'task', + 'report', + 'market', + 'project', + 'job' + ]); + + const safeUpper = (s) => String(s || '').toUpperCase(); + const safeStr = (v) => (v == null ? '' : String(v)); + const isMsgId = (s) => typeof s === 'string' && (s.startsWith('%') || s.startsWith('&') || s.startsWith('@')); + const fmtDate = (v) => { + if (!v) return ''; + const m = moment(v, moment.ISO_8601, true); + if (m.isValid()) return m.format('YYYY-MM-DD HH:mm:ss'); + const n = typeof v === 'number' ? v : Date.parse(v); + if (!Number.isFinite(n)) return ''; + return moment(n).format('YYYY-MM-DD HH:mm:ss'); + }; + + const renderField = (labelText, valueNode) => { + if (valueNode == null || valueNode === '') return null; + return div( + { class: 'card-field' }, + span({ class: 'card-label' }, labelText), + span({ class: 'card-value' }, valueNode) + ); + }; + + const entityTitle = (c) => { + const t = String(c.type || '').toLowerCase(); + if (t === 'votes') return safeStr(c.question || c.title); + if (t === 'bookmark') return safeStr(c.title || c.name || c.url); + if (t === 'market') return safeStr(c.title); + if (t === 'project') return safeStr(c.title); + if (t === 'job') return safeStr(c.title); + if (t === 'report') return safeStr(c.title); + if (t === 'task') return safeStr(c.title); + if (t === 'event') return safeStr(c.title); + if (t === 'document') return safeStr(c.title || c.name || c.url); + if (t === 'image' || t === 'audio' || t === 'video') return safeStr(c.title || c.name || c.url); + return safeStr(c.title || c.name || c.question || c.url); + }; + + const renderEntityRoot = (c) => { + const t = String(c.type || '').toLowerCase(); + const header = `[${safeUpper(t)}]`; + const titleText = entityTitle(c) || '(sin título)'; + + const nodes = []; + nodes.push( + div( + { class: 'card-field card-field-mb' }, + span({ class: 'card-label card-label-bold' }, header), + span({ class: 'card-value card-value-bold' }, titleText) + ) + ); + + if (t === 'votes') { + const status = safeStr(c.status); + const deadline = fmtDate(c.deadline); + const totalVotes = (typeof c.totalVotes !== 'undefined') ? safeStr(c.totalVotes) : ''; + const tags = Array.isArray(c.tags) ? c.tags.filter(Boolean) : []; + + const f1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : ''); + const f2 = renderField((i18n.deadline || 'Deadline') + ':', deadline); + const f3 = renderField((i18n.voteTotalVotes || 'Total votes') + ':', totalVotes); + if (f1) nodes.push(f1); + if (f2) nodes.push(f2); + if (f3) nodes.push(f3); + + if (tags.length) { + nodes.push( + div( + { class: 'card-tags card-tags-mt' }, + ...tags.map(tag => + a( + { href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, + `#${tag}` + ) + ) + ) + ); + } + } else if (t === 'report') { + const status = safeStr(c.status); + const severity = safeStr(c.severity); + const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : ''); + const r2 = renderField((i18n.severity || 'Severity') + ':', severity ? safeUpper(severity) : ''); + if (r1) nodes.push(r1); + if (r2) nodes.push(r2); + } else if (t === 'task') { + const status = safeStr(c.status); + const priority = safeStr(c.priority); + const startTime = fmtDate(c.startTime); + const endTime = fmtDate(c.endTime); + + const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : ''); + const r2 = renderField((i18n.priority || 'Priority') + ':', priority ? safeUpper(priority) : ''); + const r3 = renderField((i18n.taskStartTimeLabel || 'Start') + ':', startTime); + const r4 = renderField((i18n.taskEndTimeLabel || 'End') + ':', endTime); + + if (r1) nodes.push(r1); + if (r2) nodes.push(r2); + if (r3) nodes.push(r3); + if (r4) nodes.push(r4); + } else if (t === 'event') { + const dateStr = fmtDate(c.date); + const location = safeStr(c.location); + const price = (typeof c.price !== 'undefined') ? safeStr(c.price) : ''; + + const r1 = renderField((i18n.date || 'Date') + ':', dateStr); + const r2 = renderField((i18n.location || 'Location') + ':', location); + const r3 = renderField((i18n.price || 'Price') + ':', price ? `${price} ECO` : ''); + + if (r1) nodes.push(r1); + if (r2) nodes.push(r2); + if (r3) nodes.push(r3); + } else if (t === 'bookmark') { + const u = safeStr(c.url); + if (u) { + nodes.push( + renderField((i18n.url || 'URL') + ':', a({ href: u, target: '_blank', rel: 'noopener noreferrer' }, u)) + ); + } + } else if (t === 'image') { + const u = safeStr(c.url); + if (u && isMsgId(u)) { + nodes.push( + div({ class: 'card-field card-field-mt' }, + img({ src: `/blob/${encodeURIComponent(u)}`, class: 'feed-image img-content' }) + ) + ); + } + } else if (t === 'audio') { + const u = safeStr(c.url); + if (u && isMsgId(u)) { + nodes.push( + div({ class: 'card-field card-field-mt' }, + audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(u)}` }) + ) + ); + } + } else if (t === 'video') { + const u = safeStr(c.url); + if (u && isMsgId(u)) { + nodes.push( + div({ class: 'card-field card-field-mt' }, + videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(u)}` }) + ) + ); + } + } else if (t === 'document') { + const u = safeStr(c.url); + if (u && isMsgId(u)) { + const safeId = String(msg.key || u).replace(/[^a-zA-Z0-9_-]/g, ''); + nodes.push( + div({ class: 'card-field card-field-mt' }, + div({ + id: `pdf-container-${safeId}`, + class: 'pdf-viewer-container', + 'data-pdf-url': `/blob/${encodeURIComponent(u)}` + }) + ) + ); + } + + } else if (t === 'market') { + const status = safeStr(c.status); + const price = (typeof c.price !== 'undefined') ? safeStr(c.price) : ''; + const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : ''); + const r2 = renderField((i18n.price || 'Price') + ':', price ? `${price} ECO` : ''); + if (r1) nodes.push(r1); + if (r2) nodes.push(r2); + } else if (t === 'project') { + const status = safeStr(c.status); + const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : ''); + if (r1) nodes.push(r1); + } else if (t === 'job') { + const status = safeStr(c.status); + const location = safeStr(c.location); + const salary = (typeof c.salary !== 'undefined') ? safeStr(c.salary) : ''; + + const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : ''); + const r2 = renderField((i18n.jobLocation || 'Location') + ':', location ? safeUpper(location) : ''); + const r3 = renderField((i18n.jobSalary || 'Salary') + ':', salary ? `${salary} ECO` : ''); + + if (r1) nodes.push(r1); + if (r2) nodes.push(r2); + if (r3) nodes.push(r3); + } + + return article({ class: 'content' }, ...nodes.filter(Boolean)); + }; + + const rawText = content.text || ""; + const emptyContent = "

undefined

\n"; + + const isProbablyHtml = + typeof rawText === "string" && + /<\/?[a-z][\s\S]*>/i.test(rawText.trim()); + + let articleElement; + + if (contentType !== 'post' && contentType !== 'blog' && THREAD_ENTITY_TYPES.has(contentType)) { + articleElement = renderEntityRoot(content); + } else if (rawText === emptyContent) { + articleElement = article( + { class: "content" }, + div( + { class: "card-field card-field-mb" }, + span({ class: "card-label" }, (i18n.invalidPost || 'Invalid content') + ':'), + span({ class: "card-value" }, (i18n.invalidPostHint || 'This message has invalid/empty text.')) + ), + details( + summary(i18n.viewJson || 'View JSON'), + pre({ + innerHTML: highlightJs.highlight( + JSON.stringify(msg, null, 2), + { language: "json", ignoreIllegals: true } + ).value, + }) + ) + ); + } else if (isProbablyHtml) { + let html = rawText; + if (!/]*>/i.test(html)) { + html = html.replace( + /(https?:\/\/[^\s<]+)/g, + (u) => `${u}` + ); + } + articleElement = article({ class: "content", innerHTML: sanitizeHtml(html) }); + } else { + articleElement = article( + { class: "content" }, + p({ class: "post-text" }, ...renderUrl(rawText)) + ); + } + + if (preview) { + return section( + { id: msg.key, class: "post-preview" }, + hasContentWarning + ? details(summary(msg.value?.content?.contentWarning), articleElement) + : articleElement + ); + } + + const ts_received = msg.value?.meta?.timestamp?.received; + const iso = + (ts_received && ts_received.iso8601) || + (typeof msg.value?.timestamp === 'number' ? new Date(msg.value.timestamp).toISOString() : null) || + (content.createdAt ? new Date(content.createdAt).toISOString() : null); + + if (!iso || !moment(iso, moment.ISO_8601, true).isValid()) { + return null; + } + + const validTimestamp = moment(iso, moment.ISO_8601); + const timeAgo = validTimestamp.fromNow(); + const timeAbsolute = validTimestamp.toISOString().split(".")[0].replace("T", " "); + + const fallbackVoted = !!msg.value?.meta?.voted; + const fallbackVoteCount = msg.value?.meta?.votes?.length || 0; + const fallbackVoteNames = (msg.value?.meta?.votes || []) + .map((person) => person.name) + .filter(Boolean); + + const spreadInfoObj = (spreadInfo && typeof spreadInfo === 'object') ? spreadInfo : null; + const spreadCount = spreadInfoObj && typeof spreadInfoObj.count === 'number' + ? spreadInfoObj.count + : fallbackVoteCount; + const alreadySpread = spreadInfoObj && typeof spreadInfoObj.alreadySpread === 'boolean' + ? spreadInfoObj.alreadySpread + : fallbackVoted; + const spreadVoters = (spreadInfoObj && Array.isArray(spreadInfoObj.voters)) + ? spreadInfoObj.voters + .map(v => (v && typeof v === 'object') ? (v.name || v.key || '') : String(v || '')) + .filter(Boolean) + : fallbackVoteNames; + + const maxSpreadNameLength = 16; + const maxSpreadNames = 16; + const spreadByNames = spreadVoters + .slice(0, maxSpreadNames) + .map((n) => String(n).slice(0, maxSpreadNameLength)) + .join(", "); + const additionalSpreadsMessage = + spreadCount > maxSpreadNames ? `+${spreadCount - maxSpreadNames} more` : ``; + const spreadByMessage = + spreadCount > 0 ? `${spreadByNames} ${additionalSpreadsMessage}`.trim() : (i18n.spreadHint || 'Spread this to your supporters (replicates via your feed).'); + const spreadButtonClass = alreadySpread ? 'liked' : null; + + const messageClasses = ["post"]; + const recps = []; + + const addRecps = (recpsInfo) => { + recpsInfo.forEach((recp) => { + recps.push( + a( + { href: `/author/${encodeURIComponent(recp.feedId)}` }, + img({ class: "avatar", src: recp.avatarUrl, alt: "" }) + ) + ); + }); + }; + + if (isPrivate) { + messageClasses.push("private"); + addRecps(msg.value?.meta?.recpsInfo || []); + } + + if (isThreadTarget) { + messageClasses.push("thread-target"); + } + + if (isBlocked) { + messageClasses.push("blocked"); + return section( + { + id: msg.key, + class: messageClasses.join(" "), + }, + i18n.relationshipBlockingPost + ); + } + + const articleContent = article( + { class: "content" }, + hasContentWarning ? div({ class: "post-subject" }, msg.value?.content?.contentWarning) : null, + articleElement + ); + + const fragment = section( + { + id: msg.key, + class: messageClasses.join(" "), + }, + header( + div( + { class: "header-content" }, + a( + { href: url.author }, + img({ class: "avatar-profile", src: url.avatar, alt: "" }) + ), + span( + { class: "created-at" }, + `${i18n.createdBy} `, + userLink(authorIdForName, msg.value?.meta?.author?.name), + ` | ${timeAbsolute} | ${i18n.sendTime} `, + a({ href: url.link }, timeAgo) + ), + isPrivate ? "🔒" : null, + isPrivate ? recps : null + ) + ), + articleContent, + footer( + div( + form( + { action: url.spreadForm, method: "post" }, + button( + { + type: "submit", + class: spreadButtonClass, + title: spreadByMessage, + }, + `☉ ${spreadCount}` + ) + ), + a({ href: url.comment }, i18n.comment), + isPrivate || isRoot || isFork + ? null + : a({ href: url.subtopic }, nbsp, i18n.subtopic) + ), + br() + ) + ); + + const threadSeparator = [br()]; + + if (aside) { + return [fragment, postAside(msg), isRoot ? threadSeparator : null]; + } else { + return fragment; + } +}; + +exports.editProfileView = ({ name, description, visibilityPrefs = {}, feedId = '', baseUrl = '', gpgFingerprint = '' }) => { + const prefs = { + activity: visibilityPrefs.activity === true, + device: visibilityPrefs.device === true, + karma: visibilityPrefs.karma !== false, + ubi: visibilityPrefs.ubi === true, + wallet: visibilityPrefs.wallet === true, + clearnetShops: visibilityPrefs.clearnetShops === true, + clearnetJobs: visibilityPrefs.clearnetJobs === true, + clearnetEvents: visibilityPrefs.clearnetEvents === true, + clearnetProjects: visibilityPrefs.clearnetProjects === true, + clearnetPosts: visibilityPrefs.clearnetPosts === true, + clearnetAudios: visibilityPrefs.clearnetAudios === true, + clearnetVideos: visibilityPrefs.clearnetVideos === true, + clearnetImages: visibilityPrefs.clearnetImages === true, + clearnetDocuments: visibilityPrefs.clearnetDocuments === true, + clearnetTorrents: visibilityPrefs.clearnetTorrents === true, + clearnetBookmarks: visibilityPrefs.clearnetBookmarks === true, + profileShops: visibilityPrefs.profileShops === true, + profileJobs: visibilityPrefs.profileJobs === true, + profileEvents: visibilityPrefs.profileEvents === true, + profileProjects: visibilityPrefs.profileProjects === true, + profilePosts: visibilityPrefs.profilePosts === true, + profileAudios: visibilityPrefs.profileAudios === true, + profileVideos: visibilityPrefs.profileVideos === true, + profileImages: visibilityPrefs.profileImages === true, + profileDocuments: visibilityPrefs.profileDocuments === true, + profileTorrents: visibilityPrefs.profileTorrents === true, + profileBookmarks: visibilityPrefs.profileBookmarks === true, + ecoTax: visibilityPrefs.ecoTax !== false, + larpSign: visibilityPrefs.larpSign === true, + fediverse: visibilityPrefs.fediverse === true, + gpg: visibilityPrefs.gpg !== false + }; + const fediverseHandleValue = typeof visibilityPrefs.fediverseHandle === 'string' ? visibilityPrefs.fediverseHandle : ''; + prefs.clearnet = prefs.clearnetShops || prefs.clearnetJobs || prefs.clearnetEvents || prefs.clearnetProjects || prefs.clearnetPosts || prefs.clearnetAudios || prefs.clearnetVideos || prefs.clearnetImages || prefs.clearnetDocuments || prefs.clearnetTorrents || prefs.clearnetBookmarks; + const togglePill = (key, labelText, forced = false) => label( + { class: forced ? "pref-pill pref-pill-forced" : "pref-pill", for: forced ? undefined : `vis_${key}`, title: forced ? (i18n.profileDeviceLockedHint || 'On mobile devices this sensor is mandatory and cannot be disabled.') : undefined }, + forced ? input({ type: "hidden", name: `vis_${key}`, value: "1" }) : null, + input({ type: "checkbox", name: forced ? undefined : `vis_${key}`, id: forced ? undefined : `vis_${key}`, value: "1", class: "pref-pill-input", checked: (forced || prefs[key]) ? "checked" : undefined, disabled: forced ? "disabled" : undefined }), + span({ class: "pref-pill-label" }, labelText) + ); + return template( + i18n.editProfile, + section( + h1(i18n.editProfile), + p(i18n.editProfileDescription), + form( + { + action: "/profile/edit", + method: "POST", + enctype: "multipart/form-data", + }, + label( + i18n.profileImage, + br(), + input({ type: "file", name: "image", accept: "image/*" }) + ), + br(),br(), + label(i18n.profileName, + br(), + input({ name: "name", value: name })), + br(),br(), + label( + i18n.profileDescription, + br(), + textarea( + { + autofocus: true, + name: "description", + rows: "6", + }, + description + ) + ), + br(),br(), + label( + i18n.profileGpgKey || 'GPG Public Key (.asc)', + br(), + div({ class: "gpg-edit-row" }, + input({ type: "file", name: "gpgKey", accept: ".asc,.gpg,.pgp,application/pgp-keys,text/plain" }), + gpgFingerprint + ? button({ type: "submit", formaction: "/profile/gpg/remove", formenctype: "application/x-www-form-urlencoded", formnovalidate: true, class: "gpg-remove-btn" }, + (i18n.profileGpgRemove || 'Remove') + ' (' + String(gpgFingerprint).slice(-8).toUpperCase() + ')' + ) + : null + ) + ), + br(), br(), + div({ class: "prefs-card" }, + div({ class: "tags-header" }, + h2(i18n.profileContentSectionTitle || 'Avatar Content'), + p({ class: "prefs-help" }, i18n.profileContentHelp || 'Choose which of your modules will be displayed on your profile.') + ), + div({ class: "pref-pill-row" }, + togglePill('profileShops', i18n.profileClearnetShopsLabel || 'Shops'), + togglePill('profileJobs', i18n.profileClearnetJobsLabel || 'Jobs'), + togglePill('profileEvents', i18n.profileClearnetEventsLabel || 'Events'), + togglePill('profileProjects', i18n.profileClearnetProjectsLabel || 'Projects'), + togglePill('profilePosts', i18n.profileClearnetPostsLabel || 'Blogs'), + togglePill('profileAudios', i18n.profileClearnetAudiosLabel || 'Audios'), + togglePill('profileVideos', i18n.profileClearnetVideosLabel || 'Videos'), + togglePill('profileImages', i18n.profileClearnetImagesLabel || 'Images'), + togglePill('profileDocuments', i18n.profileClearnetDocumentsLabel || 'Documents'), + togglePill('profileTorrents', i18n.profileClearnetTorrentsLabel || 'Torrents'), + togglePill('profileBookmarks', i18n.profileClearnetBookmarksLabel || 'Bookmarks') + ) + ), + br(), + div({ class: "prefs-card" }, + div({ class: "tags-header" }, + h2(i18n.profileSensorsSectionTitle || 'Sensors'), + p({ class: "prefs-help" }, i18n.profileSensorsHelp || 'Optional metrics shown on your profile.') + ), + div({ class: "pref-pill-row" }, + togglePill('karma', i18n.profileVisibilityKarma || 'KARMA Scoring'), + togglePill('activity', i18n.profileVisibilityActivity || 'Activity Level'), + togglePill('fediverse', i18n.profileVisibilityFediverse || 'Fediverse'), + togglePill('device', i18n.profileVisibilityDevice || 'Device', process.env.OASIS_MOBILE === '1'), + togglePill('larpSign', i18n.profileVisibilityLarpSign || 'L.A.R.P. Sign'), + togglePill('gpg', i18n.profileVisibilityGpg || 'GPG Key'), + togglePill('wallet', i18n.profileVisibilityWallet || 'ECOIN Wallet'), + togglePill('ubi', i18n.profileVisibilityUbi || 'UBI'), + togglePill('ecoTax', i18n.profileVisibilityEcoTax || 'ECO Tax') + ), + input({ type: "hidden", name: "fediverseHandle", value: fediverseHandleValue }) + ), + br(), + div({ class: "prefs-card" }, + div({ class: "tags-header" }, + h2(i18n.clearnetSectionTitle || 'Clearnet'), + p({ class: "prefs-help" }, i18n.profileClearnetHelp || 'Modules that can be accessed from outside Oasis.') + ), + div({ class: "pref-pill-row" }, + togglePill('clearnetShops', i18n.profileClearnetShopsLabel || 'Shops'), + togglePill('clearnetJobs', i18n.profileClearnetJobsLabel || 'Jobs'), + togglePill('clearnetEvents', i18n.profileClearnetEventsLabel || 'Events'), + togglePill('clearnetProjects', i18n.profileClearnetProjectsLabel || 'Projects'), + togglePill('clearnetPosts', i18n.profileClearnetPostsLabel || 'Blogs'), + togglePill('clearnetAudios', i18n.profileClearnetAudiosLabel || 'Audios'), + togglePill('clearnetVideos', i18n.profileClearnetVideosLabel || 'Videos'), + togglePill('clearnetImages', i18n.profileClearnetImagesLabel || 'Images'), + togglePill('clearnetDocuments', i18n.profileClearnetDocumentsLabel || 'Documents'), + togglePill('clearnetTorrents', i18n.profileClearnetTorrentsLabel || 'Torrents'), + togglePill('clearnetBookmarks', i18n.profileClearnetBookmarksLabel || 'Bookmarks') + ) + ), + br(), + button( + { + type: "submit", + }, + i18n.submit + ) + ) + ) + ); +}; + +exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWarning, sentAt }) => { + const { escapeHtml: esc, renderClearnetPage } = require('./clearnet_view'); + const rawText = String(text || ''); + const renderedHtml = sanitizeHtml(markdown(rawText)) + .replace(/(["'])\/blob\//g, '$1/c/blob/'); + const plainPreview = rawText.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/<[^>]+>/g, '').slice(0, 200); + const authorEsc = esc(authorName || (author || '').slice(1, 9)); + const dateStr = sentAt ? esc(new Date(sentAt).toISOString().slice(0, 10)) : ''; + const cw = esc(contentWarning || ''); + const firstLine = rawText.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/<[^>]+>/g, '').split('\n').map(s => s.trim()).find(Boolean) || ''; + const titleText = cw || firstLine.slice(0, 100) || 'Post'; + const extraCss = ` +.cn-blog-meta{color:var(--fg-dim);font-size:13px;margin-bottom:16px;display:flex;gap:14px;flex-wrap:wrap} +.cn-blog-cw{background:#663d00;color:#ffd700;border:1px solid #ff7300;padding:8px 14px;border-radius:6px;margin-bottom:16px;font-weight:600} +.cn-blog-body{color:var(--fg-soft);line-height:1.7;font-size:16px;margin:0;word-wrap:break-word} +.cn-blog-body p{margin:0 0 14px 0} +.cn-blog-body img{max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);display:block;margin:10px 0} +.cn-blog-body a{color:var(--fg);text-decoration:underline} +.cn-blog-body a:hover{color:var(--accent)} +.cn-blog-body pre,.cn-blog-body code{background:var(--bg-sub);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-family:monospace;font-size:13px} +.cn-blog-body pre{padding:10px 14px;overflow-x:auto;white-space:pre-wrap;word-break:break-word} +.cn-blog-body blockquote{margin:10px 0;padding:6px 14px;border-left:3px solid var(--fg);color:var(--fg-soft);background:var(--bg-sub);border-radius:0 4px 4px 0} +.cn-blog-body h1,.cn-blog-body h2,.cn-blog-body h3{color:var(--fg);margin:18px 0 10px 0} +.cn-blog-body ul,.cn-blog-body ol{padding-left:24px;margin:8px 0} +.cn-blog-body video,.cn-blog-body audio{max-width:100%;display:block;margin:10px 0} +`; + const body = ` +
+ ${dateStr ? `📅 ${dateStr}` : ''} +
+ ${cw ? `
${cw}
` : ''} +
${renderedHtml}
+`; + return renderClearnetPage({ + title: `${esc(titleText)} — Oasis`, + ogTitle: titleText, + ogDescription: plainPreview, + extraCss, + body, + hubFeedId: author || null + }); +}; + +exports.clearnetInhabitantView = async ({ feedId, name, description, image, prefs, items = {}, query = '', filterType = '' }) => { + const { blobUrl: cnBlob, escapeHtml: esc, renderClearnetPage } = require('./clearnet_view'); + const blobAvatarUrl = cnBlob(image); + const avatarSrc = blobAvatarUrl || '/assets/images/default-avatar.png'; + const qrSrc = feedId ? `/qr/${encodeURIComponent(feedId)}` : null; + const displayName = esc(name || 'Anonymous'); + const desc = esc(description || ''); + const renderHubItem = (modulePath, it) => { + const blob = cnBlob(it.image); + const title = esc(it.title || 'Untitled'); + const snippet = esc((it.snippet || '').slice(0, 160)); + const meta = esc(it.meta || ''); + const kind = esc(it.kind || ''); + return ` + ${blob ? `` : ''} +
+ ${kind ? `
${kind}
` : ''} +
${title}
+ ${snippet ? `
${snippet}${(it.snippet || '').length > 160 ? '…' : ''}
` : ''} + ${meta ? `
${meta}
` : ''} +
+
`; + }; + const moduleDef = [ + { key: 'shops', label: 'Shops', kind: 'Shop', prefKey: 'clearnetShops' }, + { key: 'jobs', label: 'Jobs', kind: 'Job', prefKey: 'clearnetJobs' }, + { key: 'events', label: 'Events', kind: 'Event', prefKey: 'clearnetEvents' }, + { key: 'projects', label: 'Projects', kind: 'Project', prefKey: 'clearnetProjects' }, + { key: 'posts', label: 'Blogs', kind: 'Blog', prefKey: 'clearnetPosts', modulePath: 'blog' }, + { key: 'audios', label: 'Audios', kind: 'Audio', prefKey: 'clearnetAudios' }, + { key: 'videos', label: 'Videos', kind: 'Video', prefKey: 'clearnetVideos' }, + { key: 'images', label: 'Images', kind: 'Image', prefKey: 'clearnetImages' }, + { key: 'documents', label: 'Documents', kind: 'Document', prefKey: 'clearnetDocuments' }, + { key: 'torrents', label: 'Torrents', kind: 'Torrent', prefKey: 'clearnetTorrents' }, + { key: 'bookmarks', label: 'Bookmarks', kind: 'Bookmark', prefKey: 'clearnetBookmarks' } + ]; + const allItems = []; + for (const m of moduleDef) { + for (const it of (items[m.key] || [])) { + allItems.push({ ...it, modulePath: m.modulePath || m.key, kind: m.kind, _moduleKey: m.key }); + } + } + const activeFilter = (filterType || '').toLowerCase(); + const visibleItems = activeFilter + ? allItems.filter(it => it._moduleKey === activeFilter) + : allItems; + const totalCount = visibleItems.length; + const filterBase = `/c/inhabitant/${encodeURIComponent(feedId)}`; + const filterButtons = `
+ All (${allItems.length}) + ${moduleDef.filter(m => prefs && prefs[m.prefKey] && (items[m.key] || []).length).map(m => { + const isActive = activeFilter === m.key; + const count = (items[m.key] || []).length; + return `${esc(m.label)} (${count})`; + }).join('')} +
`; + const sections = totalCount + ? `${filterButtons}

Public Content (${totalCount})

${visibleItems.map(it => renderHubItem(it.modulePath, it)).join('')}
` + : (allItems.length ? `${filterButtons}
No content in this category.
` : ''); + const noResults = '
This inhabitant has not published content to Clearnet yet.
'; + const extraCss = ` +.cn-profile{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;margin-bottom:24px} +.cn-avatar{width:160px;height:160px;border-radius:8px;border:3px solid var(--fg);object-fit:cover;background:#000;flex:0 0 auto} +.cn-profile-body{flex:1 1 280px;min-width:0} +.cn-name{color:var(--fg);margin:0 0 8px 0;font-size:28px;font-weight:700} +.cn-id{color:var(--fg-dim);font-size:12px;word-break:break-all;font-family:monospace;background:var(--bg-sub);border:1px solid var(--border);padding:6px 10px;border-radius:4px;display:inline-block;margin-bottom:14px} +.cn-desc{color:var(--fg-soft);white-space:pre-wrap;margin:0} +.cn-qr-col{flex:0 0 auto;display:flex;align-items:flex-start;justify-content:center} +.cn-qr-img{width:160px;height:160px;background:#fff;padding:8px;border-radius:8px;image-rendering:pixelated} +.cn-filter-row{display:flex;flex-wrap:wrap;gap:8px;margin:24px 0 12px 0} +.cn-filter-btn{display:inline-block;padding:6px 14px;background:var(--bg-elev);color:var(--fg-soft);border:1px solid var(--border);border-radius:14px;font-size:13px;text-decoration:none;transition:border-color .15s ease,color .15s ease,background .15s ease} +.cn-filter-btn:hover{border-color:var(--fg);color:var(--fg);text-decoration:none} +.cn-filter-btn.active{background:var(--bg-sub);border-color:var(--fg);color:var(--fg);font-weight:600} +.cn-hub-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-top:16px} +.cn-hub-kind{color:var(--fg-dim);font-size:10px;text-transform:uppercase;letter-spacing:2px;font-weight:600} +.cn-hub-card{display:flex;flex-direction:column;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;overflow:hidden;transition:border-color .15s ease;color:var(--fg);text-decoration:none} +.cn-hub-card:hover{border-color:var(--fg);text-decoration:none} +.cn-hub-thumb{width:100%;height:140px;object-fit:cover;background:#000;border-bottom:1px solid var(--border)} +.cn-hub-body{padding:12px 14px;display:flex;flex-direction:column;gap:6px;min-width:0} +.cn-hub-title{color:var(--fg);font-weight:600;font-size:15px;word-break:break-word} +.cn-hub-snippet{color:var(--fg-soft);font-size:13px;line-height:1.4;word-break:break-word;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} +.cn-hub-meta{color:var(--fg-dim);font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-top:auto} +.cn-empty-content{background:var(--bg-elev);border:1px dashed var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--fg-dim);font-size:14px} +`; + const body = ` +
+ ${displayName} +
+

${displayName}

+
${esc(feedId)}
+ ${desc ? `

${desc}

` : ''} +
+ ${qrSrc ? `
QR
` : ''} +
+ ${totalCount > 0 ? sections : noResults} +`; + return renderClearnetPage({ + title: `${name || 'Inhabitant'} — Oasis`, + ogTitle: name || 'Oasis', + ogDescription: description || '', + ogImage: blobAvatarUrl, + extraCss, + body, + hubFeedId: feedId || null + }); +}; + +const renderUserSensors = (u, opts = {}) => { + const { renderReachChip, renderFediverseReach, renderContentStats } = require('./clearnet_view'); + const prefs = u.prefs || {}; + const isMe = !!u.isMe; + const show = (k) => prefs[k]; + const fmtCarbon = (g) => { + const n = Number(g) || 0; + if (!n) return '0 µg CO₂'; + if (n >= 1) return `${n.toFixed(2)} g CO₂`; + const mg = n * 1000; + if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`; + return `${(mg * 1000).toFixed(2)} µg CO₂`; + }; + const fediverseNode = (isMe && !u.fediverseConfigured) ? null : renderFediverseReach(prefs, i18n); + const dot = u.activityBucket === 'green' ? 'green' : u.activityBucket === 'orange' ? 'orange' : u.activityBucket === 'red' ? 'red' : null; + const activityChip = (show('activity') && dot) + ? span({ class: 'inhabitant-last-activity' }, `${i18n.inhabitantActivityLevel}: `, span({ class: `activity-dot ${dot}` }, '●')) + : null; + const deviceSrc = isMe + ? (getConfig().themes.current === 'OasisKIT' ? 'KIT' : (getConfig().themes.current === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP') + : (u.deviceSource || null); + const deviceChip = deviceSrc + ? (() => { + const up = String(deviceSrc).toUpperCase(); + const cls = up === 'KIT' ? 'device-kit' : up === 'MOBILE' ? 'device-mobile' : 'device-desktop'; + return span({ class: 'inhabitant-last-activity' }, `${i18n.deviceLabel || 'Device'}: `, span({ class: cls }, deviceSrc)); + })() + : null; + const items = []; + if (show('karma')) items.push(span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(u.karmaScore !== undefined ? u.karmaScore : 0)))); + if (show('ecoTax')) items.push(span({ class: 'karma-line eco-tax-line' }, `${i18n.profileVisibilityEcoTax || 'ECO Tax'}: `, strong(fmtCarbon(u.carbonGrams)))); + if (activityChip) items.push(activityChip); + if (deviceChip) items.push(deviceChip); + if (show('gpg') && (u.gpgFingerprint || isMe)) { + let gpgNode; + if (u.gpgFingerprint) { + const sid = String(u.gpgFingerprint).slice(-8).toUpperCase(); + gpgNode = a({ href: `/profile/${encodeURIComponent(u.id)}/gpg.asc`, title: i18n.profileGpgDownload || 'Download' }, strong(sid)); + } else { + const nc = i18n.statsEcoWalletNotConfigured || 'Not configured!'; + gpgNode = isMe ? a({ href: '/profile/edit' }, strong(nc)) : strong(nc); + } + items.push(span({ class: 'gpg-line' }, `${i18n.profileGpgChip || 'GPG'}: `, gpgNode)); + } + if (show('wallet') && (u.ecoAddress || isMe)) { + const wt = u.ecoAddress || (i18n.statsEcoWalletNotConfigured || 'Not configured!'); + const wn = isMe ? a({ href: '/wallet' }, strong(wt)) : strong(wt); + items.push(span({ class: 'ubi-line' }, `${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `, wn)); + } + if (show('ubi')) { + items.push(span({ class: 'ubi-line' }, `${i18n.bankUbiThisMonth || 'UBI'}: `, strong(`${Number(u.estimatedUBI || 0).toFixed(6)} ECO`))); + items.push(span({ class: 'ubi-line' }, `${i18n.bankUbiLastClaimed || 'Last claimed'}: `, u.lastClaimedDate ? a({ href: '/transfers?filter=ubi', class: 'user-link' }, new Date(u.lastClaimedDate).toLocaleDateString()) : strong(i18n.bankUbiNeverClaimed || 'Never claimed'))); + items.push(span({ class: 'ubi-line' }, `${i18n.bankUbiTotalClaimed || 'Total claimed'}: `, strong(`${Number(u.totalClaimed || 0).toFixed(6)} ECO`))); + } + const sensorsBox = items.length ? div({ class: 'profile-sensors-box' }, ...items) : null; + const larpNode = (show('larpSign') && u.larpHouse && u.larpHouse.key) + ? a({ href: `/larp/${u.larpHouse.key}`, class: 'larp-sign-block', title: u.larpHouse.name }, img({ src: u.larpHouse.image || '/assets/larp/images/default.jpg', alt: u.larpHouse.name, class: 'larp-sign-large' })) + : null; + const reachNode = prefs.clearnet + ? (() => { + const path = `/c/inhabitant/${encodeURIComponent(u.id)}`; + return div({ class: 'profile-reach' }, + renderReachChip(true, i18n, path), + renderInviteQrCard({ qrDataUrl: `/qr-clearnet/${encodeURIComponent(u.id)}` }), + isMe ? form({ method: 'POST', action: '/profile/clearnet-toggle', class: 'profile-reach-toggle' }, button({ type: 'submit', class: 'btn' }, i18n.profileSwitchToOasis || 'Return to Oasis')) : null + ); + })() + : null; + const contentNode = opts.excludeContent ? null : renderContentStats(u.stats, i18n); + return [fediverseNode, opts.relationshipNode || null, sensorsBox, larpNode, reachNode, contentNode].filter(Boolean); +}; +exports.renderUserSensors = renderUserSensors; + +exports.authorView = async ({ + avatarUrl, + description, + feedId, + messages, + firstPost, + lastPost, + name, + oasisVersion, + relationship, + ecoAddress, + karmaScore = 0, + estimatedUBI = 0, + lastClaimedDate = null, + totalClaimed = 0, + carbonGrams = 0, + larpHouse = null, + lastActivityBucket, + visibilityPrefs = null, + deviceSource = null, + stats = {}, + baseUrl = '', + userActions = [], + allActions = [], + profileItems = null, + profileFilterType = '', + gpgFingerprint = '', + spreadMap = null, + fediverseConfigured = false +}) => { + const isOwnProfile = !!(relationship && relationship.me); + const rawPrefs = visibilityPrefs || {}; + const prefs = { + activity: rawPrefs.activity === true, + device: rawPrefs.device !== false, + karma: rawPrefs.karma !== false, + ubi: rawPrefs.ubi === true, + wallet: rawPrefs.wallet === true, + ecoTax: rawPrefs.ecoTax !== false, + larpSign: rawPrefs.larpSign === true, + clearnet: rawPrefs.clearnet === true, + fediverse: rawPrefs.fediverse === true, + fediverseHandle: typeof rawPrefs.fediverseHandle === 'string' ? rawPrefs.fediverseHandle : '', + gpg: rawPrefs.gpg !== false + }; + const clearnetSubKeys = ['clearnetShops','clearnetJobs','clearnetEvents','clearnetProjects','clearnetPosts','clearnetAudios','clearnetVideos','clearnetImages','clearnetDocuments','clearnetTorrents','clearnetBookmarks']; + const anySubClearnet = clearnetSubKeys.some(k => rawPrefs[k] === true); + prefs.clearnet = prefs.clearnet || anySubClearnet; + const showField = (key) => prefs[key]; + const qrSrc = feedId ? `/qr/${encodeURIComponent(feedId)}` : null; + const linkUrl = `/author/${encodeURIComponent(feedId)}`; + + const escHtml = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const markdownMention = `[@${escHtml(name)}](${escHtml(feedId)})`; + + const contactForms = []; + const addForm = ({ action }) => + contactForms.push( + form( + { action: `/${action}/${encodeURIComponent(feedId)}`, method: "post" }, + button({ type: "submit", class: "btn" }, i18n[action]) + ) + ); + + if (relationship.me === false) { + if (relationship.following) addForm({ action: "unfollow" }); + else if (relationship.blocking) addForm({ action: "unblock" }); + else { addForm({ action: "follow" }); addForm({ action: "block" }) } + } + + const relationshipMessage = (() => { + if (relationship.me) return i18n.relationshipYou; + const following = relationship.following === true; + const followsMe = relationship.followsMe === true; + if (following && followsMe) return i18n.relationshipMutuals; + const messagesArr = []; + messagesArr.push(following ? i18n.relationshipFollowing : i18n.relationshipNone); + messagesArr.push(followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing); + return messagesArr.join(". ") + "."; + })(); + + const relationshipBlock = relationship.me + ? span({ class: "status you" }, i18n.relationshipYou) + : div({ class: "relationship-status" }, + relationship.blocking && relationship.blockedBy + ? span({ class: "status blocked" }, i18n.relationshipMutualBlock) + : [ + relationship.blocking ? span({ class: "status blocked" }, i18n.relationshipBlocking) : null, + relationship.blockedBy ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy) : null, + relationship.following && relationship.followsMe + ? span({ class: "status mutual" }, i18n.relationshipMutuals) + : [ + span({ class: "status supporting" }, relationship.following ? i18n.relationshipFollowing : i18n.relationshipNone), + span({ class: "status supported-by" }, relationship.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing) + ] + ], + contactForms.length + ? div({ class: "relationship-actions" }, ...contactForms) + : null + ); + + const userSensors = renderUserSensors({ + isMe: isOwnProfile, fediverseConfigured, prefs, id: feedId, + karmaScore, carbonGrams, deviceSource, activityBucket: lastActivityBucket, + gpgFingerprint, ecoAddress, estimatedUBI, lastClaimedDate, totalClaimed, + larpHouse, stats + }, { relationshipNode: div({ class: "profile-side-relationship" }, relationshipBlock) }); + + const sideColumn = div({ class: "tribe-side profile-side" }, + img({ class: "inhabitant-photo-details", src: avatarUrl, alt: name }), + h2({ class: "profile-side-name" }, name), + div({ class: "profile-side-mention" }, + a({ href: `/author/${encodeURIComponent(feedId)}` }, strong(feedId)) + ), + oasisVersion ? div({ class: "profile-side-version" }, + '🌴 ' + (i18n.oasisVersionLabel || 'Oasis Version') + ': ', + strong(oasisVersion) + ) : null, + feedId ? renderInviteQrCard({ qrDataUrl: `/qr/${encodeURIComponent(feedId)}` }) : null, + description !== "" + ? div({ class: "profile-side-description", innerHTML: sanitizeHtml(markdown(description)) }) + : null, + ...userSensors, + div({ class: "profile-side-actions" }, + isOwnProfile ? a({ href: `/profile/edit`, class: "btn" }, i18n.editProfile) : null, + a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes), + !isOwnProfile ? a({ href: `/pm?recipients=${encodeURIComponent(feedId)}`, class: "btn" }, i18n.pmCreateButton) : null + ) + ); + + let mainColumnContent = []; + + if (Array.isArray(allActions) && allActions.length) { + const keyToTypes = { + shops: new Set(['shop', 'shopProduct']), + jobs: new Set(['job']), + events: new Set(['event']), + projects: new Set(['project']), + posts: new Set(['post']), + audios: new Set(['audio']), + videos: new Set(['video']), + images: new Set(['image']), + documents: new Set(['document']), + torrents: new Set(['torrent']) + }; + const moduleDef = [ + { key: 'shops', label: i18n.profileClearnetShopsLabel || 'Shops' }, + { key: 'jobs', label: i18n.profileClearnetJobsLabel || 'Jobs' }, + { key: 'events', label: i18n.profileClearnetEventsLabel || 'Events' }, + { key: 'projects', label: i18n.profileClearnetProjectsLabel || 'Projects' }, + { key: 'posts', label: i18n.profileClearnetPostsLabel || 'Blogs' }, + { key: 'audios', label: i18n.profileClearnetAudiosLabel || 'Audios' }, + { key: 'videos', label: i18n.profileClearnetVideosLabel || 'Videos' }, + { key: 'images', label: i18n.profileClearnetImagesLabel || 'Images' }, + { key: 'documents', label: i18n.profileClearnetDocumentsLabel || 'Documents' }, + { key: 'torrents', label: i18n.profileClearnetTorrentsLabel || 'Torrents' } + ]; + const enabledKeys = moduleDef.filter(m => rawPrefs[`profile${m.key.charAt(0).toUpperCase() + m.key.slice(1)}`] === true).map(m => m.key); + if (enabledKeys.length > 0) { + const allowedTypes = new Set(); + for (const k of enabledKeys) for (const t of keyToTypes[k]) allowedTypes.add(t); + const authorActions = allActions.filter(a => a && a.author === feedId && allowedTypes.has(a.type)); + const counts = {}; + for (const k of enabledKeys) counts[k] = 0; + for (const a of authorActions) { + for (const k of enabledKeys) { + if (keyToTypes[k].has(a.type)) { counts[k]++; break; } + } + } + const totalCount = authorActions.length; + if (totalCount > 0) { + const activeFilter = (profileFilterType || '').toLowerCase(); + const filterBase = isOwnProfile ? `/profile` : `/author/${encodeURIComponent(feedId)}`; + const filterRow = div({ class: "tribe-section-nav no-border" }, + div({ class: "tribe-section-group no-border" }, + a({ href: filterBase, class: `filter-btn${activeFilter ? '' : ' active'}` }, `${String(i18n.profileHubAll || 'All').toUpperCase()} (${totalCount})`), + ...moduleDef.filter(m => enabledKeys.includes(m.key) && counts[m.key] > 0).map(m => { + const isActive = activeFilter === m.key; + return a({ href: `${filterBase}?type=${encodeURIComponent(m.key)}`, class: `filter-btn${isActive ? ' active' : ''}` }, `${String(m.label).toUpperCase()} (${counts[m.key]})`); + }) + ) + ); + const visible = activeFilter && keyToTypes[activeFilter] + ? authorActions.filter(a => keyToTypes[activeFilter].has(a.type)) + : authorActions; + visible.sort((a, b) => (b.ts || 0) - (a.ts || 0)); + const limited = visible.slice(0, 50); + const { renderActionCards } = require('./activity_view'); + mainColumnContent.push(filterRow); + mainColumnContent.push(div({ class: 'feed-container profile-module-section' }, + renderActionCards(limited, feedId, allActions || limited, spreadMap instanceof Map ? spreadMap : new Map()) + )); + } + } + } + + const hasMainContent = mainColumnContent.length > 0; + const layout = hasMainContent + ? section(div({ class: "tribe-details profile-layout" }, + sideColumn, + div({ class: "tribe-main profile-main" }, ...mainColumnContent) + )) + : section(div({ class: "profile-layout profile-layout-single" }, sideColumn)); + + let html = template(i18n.profile, layout); + const hasDocument = Array.isArray(allActions) && allActions.some(a => a && a.author === feedId && a.type === 'document'); + if (hasDocument) { + html += ` + + + `; + } + return html; +}; + +exports.previewCommentView = async ({ + previewData, + messages, + myFeedId, + parentMessage, + contentWarning, +}) => { + if (!parentMessage || !parentMessage.value) { + throw new Error("Missing parentMessage or value"); + } + + const publishAction = `/comment/${encodeURIComponent(parentMessage.key)}`; + const preview = generatePreview({ + previewData, + contentWarning, + action: publishAction, + }); + return exports.commentView( + { messages, myFeedId, parentMessage }, + preview, + previewData.text, + contentWarning + ); +}; + +exports.commentView = async ( + { messages, myFeedId, parentMessage, spreadMap = null }, + preview, + text, + contentWarning +) => { + if (!parentMessage || !parentMessage.value) { + throw new Error("Missing parentMessage or value"); + } + + const parentKey = parentMessage.key; + const threadRoot = parentMessage.value?.content?.root || parentKey; + + const messagesInput = Array.isArray(messages) ? messages : []; + const merged = [parentMessage, ...messagesInput]; + + const filtered = merged.filter((m) => { + if (!m || !m.value) return false; + return m.key === threadRoot || m.value?.content?.root === threadRoot; + }); + + const seen = new Set(); + const threadMessages = []; + for (const m of filtered) { + if (m && m.key && !seen.has(m.key)) { + seen.add(m.key); + threadMessages.push(m); + } + } + + const tsNum = (m) => { + const n1 = Number(m?.value?.timestamp); + if (Number.isFinite(n1) && n1 > 0) return n1; + const iso = m?.value?.meta?.timestamp?.received?.iso8601; + const raw = m?.value?.meta?.timestamp?.received; + const n2 = iso ? Date.parse(iso) : (raw ? Date.parse(raw) : NaN); + if (Number.isFinite(n2) && n2 > 0) return n2; + const createdAt = m?.value?.content?.createdAt; + const n3 = createdAt ? Date.parse(createdAt) : NaN; + if (Number.isFinite(n3) && n3 > 0) return n3; + return 0; + }; + + threadMessages.sort((a, b) => tsNum(a) - tsNum(b)); + + const authorName = parentMessage.value?.meta?.author?.name || parentMessage.value?.author || "Anonymous"; + + let markdownMention = ""; + const parentAuthorFeedId = parentMessage.value?.author; + const parentAuthorName = + parentMessage.value?.meta?.author?.name || + (typeof parentAuthorFeedId === "string" + ? (parentAuthorFeedId.startsWith("@") ? parentAuthorFeedId.slice(1) : parentAuthorFeedId) + : "Anonymous"); + + if (parentAuthorFeedId && parentAuthorFeedId !== myFeedId) { + markdownMention = `[@${parentAuthorName}](${parentAuthorFeedId})\n\n`; + } + + const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null; + const messageElements = threadMessages.map((m) => post({ msg: m, spreadInfo: getSpread(m.key) })); + + const action = `/comment/preview/${encodeURIComponent(parentKey)}`; + const method = "post"; + const isPrivate = Boolean(parentMessage.value?.meta?.private); + + return template( + i18n.commentTitle({ authorName }), + div({ class: "thread-container" }, ...messageElements), + form( + { action, method, enctype: "multipart/form-data" }, + i18n.blogSubject, + br(), + label( + i18n.contentWarningLabel, + input({ + name: "contentWarning", + type: "text", + class: "contentWarning", + value: contentWarning ? contentWarning : "", + placeholder: i18n.contentWarningPlaceholder + }) + ), + br(), + label({ for: "text" }, i18n.blogMessage), + br(), + textarea( + { + autofocus: true, + required: true, + name: "text", + rows: "6", + cols: "50", + placeholder: i18n.publishWarningPlaceholder + }, + text ? text : null + ), + br(), + label( + { for: "blob" }, + i18n.blogImage || "Upload media (max-size: 50MB)" + ), + input({ type: "file", id: "blob", name: "blob" }), + br(), + br(), + button({ type: "submit" }, i18n.blogPublish) + ), + preview ? div({ class: "comment-preview" }, preview) : "" + ); +}; + +const renderMessage = (msg) => { + const content = lodash.get(msg, "value.content", {}); + const authorId = msg.value.author || "Anonymous"; + const createdAt = new Date(msg.value.timestamp).toLocaleString(); + const mentionsText = content.text || ''; + const isTribe = content.type === 'tribe-content'; + const visitUrl = isTribe + ? `/tribe/${encodeURIComponent(content.tribeId)}` + : content.root + ? `/thread/${encodeURIComponent(content.root)}#${encodeURIComponent(msg.key)}` + : msg.key + ? `/thread/${encodeURIComponent(msg.key)}#${encodeURIComponent(msg.key)}` + : null; + const badge = isTribe && content.tribeName + ? span({ class: 'tribe-badge' }, content.tribeName) + : null; + const typeLabel = isTribe ? 'TRIBE' : 'POST'; + + return div({ class: 'card card-rpg mention-card' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${typeLabel}]`), + visitUrl + ? form({ method: 'GET', action: visitUrl, class: 'inline-form' }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View details') + ) + : null + ), + div({ class: 'card-body' }, + div({ class: 'card-section' }, + badge, + p({ class: 'post-text' }, ...renderUrl(mentionsText || '[No content]')) + ) + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${createdAt} ${i18n.performed || ''} `), + userLink(authorId) + ) + ); +}; + +const hasMention = (msg, feedId) => { + const content = lodash.get(msg, "value.content", {}); + const mentions = content.mentions; + if (mentions) { + if (Array.isArray(mentions)) { + if (mentions.some(m => m.link === feedId || m.feed === feedId)) return true; + } else if (typeof mentions === 'object') { + for (const arr of Object.values(mentions)) { + if (Array.isArray(arr) && arr.some(m => m.link === feedId || m.feed === feedId)) return true; + if (arr && (arr.link === feedId || arr.feed === feedId)) return true; + } + } + } + const text = content.text || ''; + if (text.includes(feedId) || text.includes(feedId.slice(1))) return true; + return false; +}; + +exports.mentionsView = ({ messages, myFeedId }) => { + const title = i18n.mentions; + const description = i18n.mentionsDescription; + if (!Array.isArray(messages) || messages.length === 0) { + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(description) + ) + ), + section( + div({ class: "mentions-list" }, + p({ class: "empty" }, i18n.noMentions) + ) + ) + ); + } + const filteredMessages = messages + .filter(msg => hasMention(msg, myFeedId)) + .sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0)); + if (filteredMessages.length === 0) { + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(description) + ) + ), + section( + div({ class: "mentions-list" }, + p({ class: "empty" }, i18n.noMentions) + ) + ) + ); + } + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(description) + ) + ), + section( + div({ class: "mentions-list" }, + filteredMessages.map(renderMessage) + ) + ) + ); +}; + +exports.privateView = async (messagesInput, filter, decrypted = null) => { + const messagesRaw = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages + const messages = (messagesRaw || []).filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true) + const userId = await getUserId() + + const isSent = m => (m?.value?.author === userId) || (m?.value?.content?.from === userId) + const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId) + + const linkAuthor = (id) => userLink(id) + + const hrefFor = { + job: (id) => `/jobs/${encodeURIComponent(id)}`, + project: (id) => `/projects/${encodeURIComponent(id)}`, + market: (id) => `/market/${encodeURIComponent(id)}` + } + + const clickableCardProps = (href, extraClass = '') => { + const props = { class: `pm-card ${extraClass}` } + if (href) { + props.onclick = `window.location='${href}'` + props.tabindex = 0 + props.onkeypress = `if(event.key==='Enter') window.location='${href}'` + } + return props + } + + const chip = (txt) => span({ class: 'chip' }, txt) + + const e2eChip = () => span({ class: 'pm-exposition-chip pm-exposition-encrypted' }, + span({ class: 'pm-exposition-icon' }, '🔒'), + span({ class: 'pm-exposition-text' }, i18n.encryptedChipLabel || 'E2E') + ); + const doubleEncChip = () => span({ class: 'pm-exposition-chip pm-exposition-encrypted pm-double-enc-chip' }, + span({ class: 'pm-exposition-icon' }, '🔒'), + span({ class: 'pm-exposition-text' }, i18n.pmCrypterChip || '2xE2E') + ); + + function headerLine({ sentAt, from, toLinks, subject, msgKey, msgSize, crypter }) { + const ecoChip = msgSize ? renderEcoTax(msgSize, msgKey) : null; + return table({ class: 'pm-info-table' }, + tr( + td({ class: 'card-label' }, i18n.pmFromLabel || 'From:'), + td({ class: 'card-value' }, linkAuthor(from)) + ), + tr( + td({ class: 'card-label' }, i18n.privateDate || 'Date'), + td({ class: 'card-value' }, moment(sentAt).format('YYYY/MM/DD HH:mm:ss')) + ), + tr( + td({ class: 'card-label' }, i18n.pmToLabel || 'To:'), + td({ class: 'card-value' }, ...toLinks.reduce((acc, lnk, i) => i > 0 ? [...acc, br(), lnk] : [lnk], [])) + ), + tr( + td({ class: 'card-label' }, i18n.pmSubjectLabel || 'Subject:'), + td({ class: 'card-value' }, subject || i18n.pmNoSubject || '(no subject)') + ), + ecoChip + ? tr( + td({ class: 'card-label' }, i18n.ecoTaxLabel || 'ECO Tax'), + td({ class: 'card-value' }, ecoChip) + ) + : null, + tr( + td({ class: 'card-label' }, i18n.pmEncryptionLabel || 'Encryption'), + td({ class: 'card-value pm-encryption-cell' }, crypter ? [e2eChip(), doubleEncChip()] : [e2eChip()]) + ) + ) + } + + function msgSizeBytes(m) { + try { return Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) { return 0; } + } + + function actions({ key, replyId, subjectRaw, text }) { + const stop = { onclick: 'event.stopPropagation()' } + const subjectReply = /^(\s*RE:\s*)/i.test(subjectRaw || '') ? (subjectRaw || '') : `RE: ${subjectRaw || ''}` + const isSelf = replyId === userId + return div({ class: 'pm-actions' }, + isSelf ? null : form({ method: 'GET', action: '/pm', class: 'pm-action-form', ...stop }, + input({ type: 'hidden', name: 'recipients', value: replyId }), + input({ type: 'hidden', name: 'subject', value: subjectReply }), + input({ type: 'hidden', name: 'quote', value: text || '' }), + button({ type: 'submit', class: 'pm-btn reply-btn' }, i18n.pmReply.toUpperCase()) + ), + form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'pm-action-form', ...stop }, + button({ type: 'submit', class: 'pm-btn delete-btn danger-btn' }, i18n.privateDelete.toUpperCase()) + ) + ) + } + + function canonicalSubject(s) { + return (s || '').replace(/^\s*(RE:\s*)+/i, '').trim() + } + + function participantsKey(m) { + const c = m?.value?.content || {} + const set = new Set([m?.value?.author, ...(Array.isArray(c.to) ? c.to : [])]) + return Array.from(set).sort().join('|') + } + + function threadId(m) { + return canonicalSubject(m?.value?.content?.subject || '') + '||' + participantsKey(m) + } + + function threadLevel(s) { + const m = (s || '').match(/RE:/gi) + return m ? Math.min(m.length, 8) : 0 + } + + function quoted(str) { + const m = str.match(/"([^"]+)"/) + return m ? m[1] : '' + } + + function pickLink(str, kind) { + if (kind === 'job') { + const m = str.match(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/) + return m ? m[1] : '' + } + if (kind === 'project') { + const m = str.match(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/) + return m ? m[1] : '' + } + if (kind === 'market') { + const m = str.match(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/) + return m ? m[1] : '' + } + return '' + } + + function clickableLinks(str) { + const lines = str.split('\n') + const parts = [] + let quoteBuffer = [] + const flushQuote = () => { + if (quoteBuffer.length) { + parts.push(`
${quoteBuffer.join('
')}
`) + quoteBuffer = [] + } + } + for (const line of lines) { + if (/^>\s?/.test(line)) { + quoteBuffer.push(line.replace(/^>\s?/, '')) + } else { + flushQuote() + parts.push(line) + } + } + flushQuote() + return parts.join('
') + .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `${userLinkLabel(id)}`) + .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) + .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) + .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) + .replace(/\/calendars\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `${match}`) + .replace(/\/ai\/ask\?[^\s<"]+/g, (match) => `${match}`) + .replace(/(? `${match}`) + .replace(/(https?:\/\/[^\s<"]+)/g, (match) => `${match}`) + } + + const threads = {} + for (const m of messages) { + const tid = threadId(m) + if (!threads[tid]) threads[tid] = [] + threads[tid].push(m) + } + + const inboxSet = new Set() + for (const arr of Object.values(threads)) { + for (const m of arr) { + if (!isSent(m) && isToUser(m)) inboxSet.add(m) + } + } + + const isReminder = m => { + const s = String(m?.value?.content?.subject || '') + return /^(Task Reminder:|Calendar Reminder:)/i.test(s) + } + + const data = + filter === 'sent' ? messages.filter(m => isSent(m) && !isReminder(m)) : + filter === 'reminders' ? messages.filter(isReminder) : + filter === 'inbox' ? Array.from(inboxSet).filter(m => !isReminder(m)) : + messages + + const inboxCount = Array.from(inboxSet).filter(m => !isReminder(m)).length + const sentCount = messages.filter(m => isSent(m) && !isReminder(m)).length + const reminderCount = messages.filter(isReminder).length + + const sorted = [...data].sort((a, b) => { + const ta = threadId(a) + const tb = threadId(b) + if (ta < tb) return -1 + if (ta > tb) return 1 + const sa = new Date(a?.value?.content?.sentAt || a.timestamp || 0).getTime() + const sb = new Date(b?.value?.content?.sentAt || b.timestamp || 0).getTime() + return sa - sb + }) + + function JobCard({ type, sentAt, from, toLinks, text, key, msgSize }) { + const isSub = type === 'JOB_SUBSCRIBED' + const icon = isSub ? '🟡' : '🟠' + const titleH = isSub ? (i18n.inboxJobSubscribedTitle || 'New subscription to your job offer') : (i18n.inboxJobUnsubscribedTitle || 'Unsubscription from your job offer') + const jobTitle = quoted(text) || 'job' + const jobId = pickLink(text, 'job') + const href = jobId ? hrefFor.job(jobId) : null + return div( + clickableCardProps(href, `job-notification thread-level-0`), + headerLine({ sentAt, from, toLinks, subject: type, msgKey: key, msgSize }), + h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotJobs} · ${titleH}`), + p( + i18n.pmInhabitantWithId, ' ', + linkAuthor(from), ' ', + isSub ? i18n.pmHasSubscribedToYourJobOffer : (i18n.pmHasUnsubscribedFromYourJobOffer || 'has unsubscribed from your job offer'), + ' ', + href ? a({ class: 'job-link', href }, `"${jobTitle}"`) : `"${jobTitle}"` + ), + actions({ key, replyId: from, subjectRaw: jobTitle, text }) + ) + } + + function ProjectFollowCard({ type, sentAt, from, toLinks, text, key, msgSize }) { + const isFollow = type === 'PROJECT_FOLLOWED' + const icon = isFollow ? '🔔' : '🔕' + const titleH = isFollow + ? (i18n.inboxProjectFollowedTitle || 'New follower of your project') + : (i18n.inboxProjectUnfollowedTitle || 'Unfollowed your project') + const projectTitle = quoted(text) || 'project' + const projectId = pickLink(text, 'project') + const href = projectId ? hrefFor.project(projectId) : null + return div( + clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification thread-level-0`), + headerLine({ sentAt, from, toLinks, subject: type, msgKey: key, msgSize }), + h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotProjects} · ${titleH}`), + p( + i18n.pmInhabitantWithId, ' ', + userLink(from), + ' ', + isFollow ? (i18n.pmHasFollowedYourProject || 'has followed your project') : (i18n.pmHasUnfollowedYourProject || 'has unfollowed your project'), + ' ', + href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"` + ), + actions({ key, replyId: from, subjectRaw: projectTitle, text }) + ) + } + + function MarketSoldCard({ sentAt, from, toLinks, subject, text, key, msgSize }) { + const itemTitle = quoted(subject) || quoted(text) || 'item' + const buyerId = (text.match(/OASIS ID:\s*([\w=/+.-]+)/) || [])[1] || from + const price = (text.match(/for:\s*\$([\d.]+)/) || [])[1] || '' + const marketId = pickLink(text, 'market') + const href = marketId ? hrefFor.market(marketId) : null + return div( + clickableCardProps(href, 'market-sold-notification thread-level-0'), + headerLine({ sentAt, from, toLinks, subject, msgKey: key, msgSize }), + h2({ class: 'pm-title' }, `💰 ${i18n.pmBotMarket} · ${i18n.inboxMarketItemSoldTitle}`), + p( + i18n.pmYourItem, ' ', + href ? a({ class: 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`, + ' ', + i18n.pmHasBeenSoldTo, ' ', + linkAuthor(buyerId), + price ? ` ${i18n.pmFor} $${price}.` : '.' + ), + actions({ key, replyId: buyerId, subjectRaw: itemTitle, text }) + ) + } + + function ProjectPledgeCard({ sentAt, from, toLinks, content, text, key, msgSize }) { + const amount = content.meta?.amount ?? (text.match(/pledged\s+([\d.]+)/)?.[1] || '0') + const projectTitle = content.meta?.projectTitle ?? (text.match(/project\s+"([^"]+)"/)?.[1] || 'project') + const projectId = content.meta?.projectId ?? pickLink(text, 'project') + const href = projectId ? hrefFor.project(projectId) : null + return div( + clickableCardProps(href, 'project-pledge-notification thread-level-0'), + headerLine({ sentAt, from, toLinks, subject: 'PROJECT_PLEDGE', msgKey: key, msgSize }), + h2({ class: 'pm-title' }, `💚 ${i18n.pmBotProjects} · ${i18n.inboxProjectPledgedTitle}`), + p( + i18n.pmInhabitantWithId, ' ', + linkAuthor(from), ' ', + i18n.pmHasPledged, ' ', + chip(`${amount} ECO`), ' ', + i18n.pmToYourProject, ' ', + href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"` + ), + actions({ key, replyId: from, subjectRaw: projectTitle, text }) + ) + } + + const { renderEncryptedChip } = require('./clearnet_view'); + return template( + i18n.private, + section( + div({ class: 'tags-header' }, + div({ class: 'title-with-chip' }, h2(i18n.private), renderEncryptedChip(i18n)), + p(i18n.privateDescription) + ), + (() => { + const pmVis = getConfig().pmVisibility === 'mutuals' ? 'mutuals' : 'whole' + const pmVisLabel = pmVis === 'mutuals' ? i18n.settingsPmVisibilityMutuals : i18n.settingsPmVisibilityWhole + const pmVisIcon = pmVis === 'mutuals' ? '🤝' : '🌐' + const nextVis = pmVis === 'mutuals' ? 'whole' : 'mutuals' + const nextLabel = nextVis === 'mutuals' + ? (i18n.inboxToggleToMutuals || 'Switch to mutuals') + : (i18n.inboxToggleToWhole || 'Switch to whole') + return div({ class: 'pm-exposition inbox-exposition' }, + span({ class: 'inbox-filters-label' }, i18n.inboxFiltersLabel || 'Filters:'), + span({ class: `pm-exposition-chip pm-exposition-${pmVis}` }, + span({ class: 'pm-exposition-icon' }, pmVisIcon), + span({ class: 'pm-exposition-text' }, pmVisLabel) + ), + form({ method: 'POST', action: '/settings/pm-visibility?returnTo=/inbox', class: 'inbox-vis-toggle' }, + input({ type: 'hidden', name: 'pmVisibility', value: nextVis }), + button({ type: 'submit', class: 'btn' }, nextLabel) + ) + ) + })(), + div({ class: 'filters' }, + form({ method: 'GET', action: '/inbox' }, [ + button({ + type: 'submit', + name: 'filter', + value: 'inbox', + class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn' + }, `${i18n.privateInbox} (${inboxCount})`), + button({ + type: 'submit', + name: 'filter', + value: 'reminders', + class: filter === 'reminders' ? 'filter-btn active' : 'filter-btn' + }, `${i18n.privateReminders || 'Reminders'} (${reminderCount})`), + button({ + type: 'submit', + name: 'filter', + value: 'sent', + class: filter === 'sent' ? 'filter-btn active' : 'filter-btn' + }, `${i18n.privateSent} (${sentCount})`), + button({ + type: 'submit', + name: 'filter', + value: 'create', + class: 'create-button', + formaction: '/pm', + formmethod: 'GET' + }, i18n.pmCreateButton) + ]) + ), + div({ class: 'message-list' }, + (() => { + function renderMsg(msg) { + const content = msg.value.content + const author = msg.value.author + const subjectRaw = content.subject || '' + const subjectU = subjectRaw.toUpperCase() + const text = content.text || '' + const sentAt = new Date(content.sentAt || msg.timestamp) + const fromResolved = content.from || author + const toLinks = Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : [] + const level = threadLevel(subjectRaw) + const msgSize = msgSizeBytes(msg) + + if (subjectU === 'JOB_SUBSCRIBED' || subjectU === 'JOB_UNSUBSCRIBED') { + return JobCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize }) + } + if (subjectU === 'PROJECT_FOLLOWED' || subjectU === 'PROJECT_UNFOLLOWED') { + return ProjectFollowCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key, msgSize }) + } + if (subjectU === 'MARKET_SOLD') { + return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key, msgSize }) + } + if (subjectU === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') { + return ProjectPledgeCard({ sentAt, from: fromResolved, toLinks, content, text, key: msg.key, msgSize }) + } + + if (content.crypter === true) { + const dec = (decrypted && decrypted.key === msg.key) ? decrypted : null + const shownText = dec && typeof dec.text === 'string' ? dec.text : '' + return div( + { class: 'pm-card normal-pm pm-crypter-card' }, + headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, msgKey: msg.key, msgSize, crypter: true }), + dec && typeof dec.text === 'string' + ? div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(dec.text)) }) + : dec && dec.error + ? div({ class: 'pm-form-error-msg' }, p('✗ ' + i18n.pmCrypterBadKey)) + : null, + dec && typeof dec.text === 'string' + ? null + : form({ method: 'POST', action: '/inbox/decrypt', class: 'pm-crypter-decrypt-form' }, + input({ type: 'hidden', name: 'id', value: msg.key }), + input({ type: 'hidden', name: 'returnFilter', value: filter || 'all' }), + input({ type: 'text', name: 'key', placeholder: 'd66d7d32f4d30f34812aee3d01347154', minlength: 32, maxlength: 32, size: 34, required: true, class: 'pm-crypter-key-input' }), + button({ type: 'submit', class: 'pm-btn pm-crypter-decrypt-btn' }, (i18n.pmCrypterDecryptButton || 'Decrypt').toUpperCase()) + ), + actions({ key: msg.key, replyId: fromResolved, subjectRaw, text: shownText }) + ) + } + + return div( + { class: 'pm-card normal-pm' }, + headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, msgKey: msg.key, msgSize }), + div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text)) }), + actions({ key: msg.key, replyId: fromResolved, subjectRaw, text }) + ) + } + + const threadGroups = {} + const threadOrder = [] + for (const msg of sorted) { + const tid = threadId(msg) + if (!threadGroups[tid]) { + threadGroups[tid] = [] + threadOrder.push(tid) + } + threadGroups[tid].push(msg) + } + + if (!threadOrder.length) return p({ class: 'empty' }, i18n.noPrivateMessages) + + return threadOrder.map(tid => { + const msgs = threadGroups[tid] + const original = msgs[0] + const replies = msgs.slice(1) + + if (!replies.length) { + return renderMsg(original) + } + + const replyLabel = `${replies.length} ${replies.length === 1 ? (i18n.pmReply || 'reply') : (i18n.pmReplies || 'replies')}` + + return div({ class: 'pm-thread' }, + renderMsg(original), + details({ class: 'pm-thread-details' }, + summary({ class: 'pm-thread-toggle' }, + span({ class: 'pm-thread-icon' }, '▶'), + span(replyLabel) + ), + div({ class: 'pm-thread-replies' }, + ...replies.map(renderMsg) + ) + ) + ) + }) + })() + ) + ) + ) +} + +exports.publishCustomView = async () => { + const action = "/publish/custom"; + const method = "post"; + + return template( + i18n.publishCustom, + section( + div({ class: "tags-header" }, + h2(i18n.publishCustom), + p(i18n.publishCustomDescription) + ), + form( + { action, method }, + textarea( + { + autofocus: true, + required: true, + name: "text", + rows: 10, + class: "textarea-full" + }, + "{\n", + ' "type": "feed",\n', + ' "hello": "world"\n', + "}" + ), + br(), + br(), + button({ type: "submit" }, i18n.submit) + ) + ), + section( + div({ class: "tags-header" }, + p(i18n.publishBasicInfo({ href: "/publish" })) + ) + ) + ); +}; + +exports.threadView = ({ messages, spreadMap = null }) => { + const rootMessage = messages[0]; + const rootAuthorName = rootMessage.value.meta.author.name; + + const needsPdfViewer = Array.isArray(messages) && messages.some((m) => { + const t = String(m?.value?.content?.type || "").toLowerCase(); + return t === "document"; + }); + + const tpl = template( + [`@${rootAuthorName}`], + div(thread(messages, spreadMap)) + ); + + return `${tpl}${ + needsPdfViewer + ? ` + ` + : "" + }`; +}; + +exports.publishView = (preview, text, contentWarning) => { + return template( + i18n.publish, + section( + div({ class: "tags-header" }, + h2(i18n.publishBlog), + p(i18n.publishLabel({ markdownUrl, linkTarget: "_blank" })) + ) + ), + section( + div({ class: "publish-form" }, + form( + { + action: "/publish/preview", + method: "post", + enctype: "multipart/form-data", + }, + [ + label({ for: "contentWarning" }, i18n.blogSubject), + br(), + input({ + name: "contentWarning", + id: "contentWarning", + type: "text", + class: "contentWarning", + value: contentWarning || "", + placeholder: i18n.contentWarningPlaceholder + }), + br(), + label({ for: "text" }, i18n.blogMessage), + br(), + textarea( + { + required: true, + name: "text", + id: "text", + rows: "6", + cols: "50", + placeholder: i18n.publishWarningPlaceholder, + class: "publish-textarea", + maxlength: "8096" + }, + text || "" + ), + br(), + label({ for: "blob" }, i18n.blogImage || "Upload media (max-size: 50MB)"), + br(), + input({ type: "file", id: "blob", name: "blob" }), + br(), br(), + button({ type: "submit" }, i18n.blogPublish) + ] + ) + ) + ), + preview || "", + section( + div({ class: "tags-header" }, + p(i18n.publishCustomInfo({ href: "/publish/custom" })) + ) + ) + ); +}; + +//generate preview +const ensureAt = (id) => { + const s = String(id || "").trim() + if (!s) return "" + return s.startsWith("@") ? s : `@${s.replace(/^@+/, "")}` +} + +const stripAt = (id) => String(id || "").trim().replace(/^@+/, "") + +const authorHref = (feed) => `/author/${encodeURIComponent(ensureAt(feed))}` + +const escapeRegex = (s) => String(s || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const escapeHtml = (s) => { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +const normalizeMentionLinks = (text) => { + let t = String(text || "") + t = t.replace( + /\[@([^\]]+)\]\s*\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g, + (_m, label, feed) => `[@${String(label || "").replace(/^@+/, "")}](@${String(feed || "").replace(/^@+/, "")})` + ) + return t +} + +const injectResolvedMentions = (text, mentions) => { + let out = String(text || "") + const obj = mentions && typeof mentions === "object" ? mentions : {} + + const entries = Object.entries(obj) + .map(([k, v]) => [String(k || "").trim().replace(/\s+/g, " "), Array.isArray(v) ? v : []]) + .filter(([k, v]) => k && v.length === 1) + + entries.sort((a, b) => b[0].length - a[0].length) + + for (const [token, list] of entries) { + const m = list[0] || {} + const feed = ensureAt(m.feed || m.link || m.id || "") + if (!feed) continue + + const label = String(m.name || token).replace(/^@+/, "") + const parts = token.split(/\s+/).filter(Boolean).map(escapeRegex) + if (!parts.length) continue + + const tokenPattern = parts.join("\\s+") + const re = new RegExp(`(^|\\s)(?!\\[)@${tokenPattern}(?=\\b|$)`, "g") + out = out.replace(re, (match, prefix) => `${prefix}[@${label}](${feed})`) + } + + return out +} + +const markdownMentionsToHtml = (markdownText) => { + const escaped = escapeHtml(String(markdownText || "")) + const withBr = escaped.replace(/\r\n|\r|\n/g, "
") + + const unescapeBlob = (b) => b.replace(/&/g, '&') + + const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''') + + const withImages = withBr.replace( + /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, + (_m, alt, blob) => `${escAttr(alt)}` + ) + + const withVideos = withImages.replace( + /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, + (_m, _name, blob) => `` + ) + + const withAudios = withVideos.replace( + /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, + (_m, _name, blob) => `` + ) + + const withPdfs = withAudios.replace( + /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, + (_m, name, blob) => `${escapeHtml(name || i18n.pdfFallbackLabel || 'PDF')}` + ) + + const withMentions = withPdfs.replace( + /\[@([^\]]+)\]\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g, + (_m, label, feed) => { + const href = authorHref(feed) + const shown = `@${String(label || "").replace(/^@+/, "")}` + return `${escapeHtml(shown)}` + } + ) + + const withLinks = withMentions.replace( + /(https?:\/\/[^\s"'<>]+)/g, + (u) => `${escAttr(u)}` + ) + + return withLinks +} + +const generatePreview = ({ previewData, contentWarning, action }) => { + const mentions = + previewData && previewData.mentions && typeof previewData.mentions === "object" + ? previewData.mentions + : {} + + const rawText = String((previewData && previewData.text) || "") + const normalized = normalizeMentionLinks(rawText) + const injected = injectResolvedMentions(normalized, mentions) + const publishText = normalizeMentionLinks(injected) + + const previewHtml = markdownMentionsToHtml(publishText) + + const mentionCards = Object.entries(mentions) + .map(([_token, matches]) => { + const list = Array.isArray(matches) ? matches : [] + const first = list.find((x) => x && (x.feed || x.link || x.id)) || null + if (!first) return null + + const feed = ensureAt(first.feed || first.link || first.id || "") + if (!feed) return null + + const nameRaw = String(first.name || stripAt(feed) || "") + const nameText = nameRaw.startsWith("@") ? nameRaw : `@${nameRaw}` + + const rel = first.rel || {} + + const relationshipBadge = rel.me + ? span({ class: "status you" }, i18n.relationshipYou) + : rel.blocking + ? span({ class: "status blocked" }, i18n.relationshipBlocking) + : rel.following && rel.followsMe + ? span({ class: "status mutual" }, i18n.relationshipMutuals) + : rel.following + ? span({ class: "status supporting" }, i18n.relationshipFollowing) + : rel.followsMe + ? span({ class: "status supported-by" }, i18n.relationshipTheyFollow) + : span({ class: "status" }, i18n.relationshipNone) + + const avatar = first.img || first.image || "" + const avatarUrl = + typeof avatar === "string" && avatar.startsWith("&") + ? `/blob/${encodeURIComponent(avatar)}` + : (typeof avatar === "string" && avatar ? avatar : "/assets/images/default-avatar.png") + + return div( + { class: "mention-card" }, + a({ href: authorHref(feed) }, img({ src: avatarUrl, class: "avatar-profile" })), + br(), + div( + { class: "mention-name" }, + span({ class: "label" }, `${i18n.mentionsName}: `), + a({ href: authorHref(feed) }, nameText) + ), + div( + { class: "mention-relationship" }, + span({ class: "label" }, `${i18n.mentionsRelationship}: `), + relationshipBadge, + div( + { class: "mention-relationship-details" }, + span( + { class: "mentions-listing" }, + userLink(feed) + ) + ) + ) + ) + }) + .filter(Boolean) + + return div( + section( + { class: "post-preview" }, + div( + { class: "preview-content" }, + h2(i18n.messagePreview), + contentWarning ? div({ class: "content-warning-preview" }, escapeHtml(contentWarning)) : null, + div({ class: "preview-rendered", innerHTML: previewHtml }) + ) + ), + section( + { class: "mention-suggestions" }, + mentionCards.length ? h2(i18n.mentionsMatching) : null, + ...mentionCards + ), + section( + form( + { action, method: "post" }, + input({ type: "hidden", name: "text", value: publishText }), + input({ type: "hidden", name: "contentWarning", value: contentWarning || "" }), + input({ type: "hidden", name: "mentions", value: JSON.stringify(mentions) }), + button({ type: "submit" }, i18n.publish) + ) + ) + ) +} + +exports.previewView = ({ previewData, contentWarning }) => { + const publishAction = "/publish" + const preview = generatePreview({ + previewData, + contentWarning, + action: publishAction, + }) + return exports.publishView(preview, (previewData && previewData.text) || "", contentWarning) +} + +const viewInfoBox = ({ viewTitle = null, viewDescription = null }) => { + if (!viewTitle && !viewDescription) { + return null + } + return section( + { class: "viewInfo" }, + viewTitle ? h1(viewTitle) : null, + viewDescription ? em(viewDescription) : null + ) +} +//generate preview + +exports.likesView = async ({ messages, feed, name, spreadMap = null }) => { + const authorLink = a( + { href: `/author/${encodeURIComponent(feed)}` }, + "@" + name + ); + const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null; + + return template( + ["@", name], + viewInfoBox({ + viewTitle: span(authorLink), + viewDescription: span(i18n.spreadedDescription) + }), + messages.map((msg) => post({ msg, spreadInfo: getSpread(msg.key) })) + ); +}; + +const messageListView = ({ + messages, + viewTitle = null, + viewDescription = null, + viewElements = null, + aside = null, + spreadMap = null, +}) => { + const hasHeader = !!viewElements; + const titleBlock = hasHeader + ? viewElements + : div({ class: "tags-header" }, + h2(viewTitle), + p(viewDescription) + ); + const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null; + return template( + viewTitle, + section(titleBlock), + messages.map((msg) => post({ msg, aside, spreadInfo: getSpread(msg.key) })) + ); +}; + +exports.popularView = ({ messages, prefix, spreadMap = null }) => { + const header = div({ class: "tags-header" }, + h2(i18n.popular), + p(i18n.popularDescription) + ); + return messageListView({ + messages, + viewTitle: i18n.popular, + viewElements: [header, prefix], + spreadMap + }); +}; + +exports.extendedView = ({ messages, spreadMap = null }) => { + const header = div({ class: "tags-header" }, + h2(i18n.extended), + p(i18n.extendedDescription) + ); + return messageListView({ + messages, + viewTitle: i18n.extended, + viewElements: header, + spreadMap + }); +}; + +exports.latestView = ({ messages, spreadMap = null }) => { + const header = div({ class: "tags-header" }, + h2(i18n.latest), + p(i18n.latestDescription) + ); + return messageListView({ + messages, + viewTitle: i18n.latest, + viewElements: header, + spreadMap + }); +}; + +exports.topicsView = ({ messages, prefix, spreadMap = null }) => { + const header = div({ class: "tags-header" }, + h2(i18n.topics), + p(i18n.topicsDescription) + ); + return messageListView({ + messages, + viewTitle: i18n.topics, + viewElements: [header, prefix], + spreadMap + }); +}; + +exports.summaryView = ({ messages, spreadMap = null }) => { + const header = div({ class: "tags-header" }, + h2(i18n.summaries), + p(i18n.summariesDescription) + ); + return messageListView({ + messages, + viewTitle: i18n.summaries, + viewElements: header, + aside: true, + spreadMap + }); +}; + +exports.spreadedView = ({ messages }) => { + const header = div({ class: "tags-header" }, + h2(i18n.spreaded), + p(i18n.spreadedDescription) + ); + return spreadedListView({ + messages, + viewTitle: i18n.spreaded, + viewElements: header + }); +}; + +exports.threadsView = ({ messages, spreadMap = null }) => { + const header = div({ class: "tags-header" }, + h2(i18n.threads), + p(i18n.threadsDescription) + ); + return messageListView({ + messages, + viewTitle: i18n.threads, + viewElements: header, + aside: true, + spreadMap + }); +}; + +exports.previewSubtopicView = async ({ + previewData, + messages, + myFeedId, + contentWarning, +}) => { + const publishAction = `/subtopic/${encodeURIComponent(messages[0].key)}`; + const preview = generatePreview({ + previewData, + contentWarning, + action: publishAction, + }); + return exports.subtopicView( + { messages, myFeedId }, + preview, + previewData.text, + contentWarning + ); +}; + +exports.subtopicView = async ( + { messages, myFeedId, spreadMap = null }, + preview, + text, + contentWarning +) => { + const subtopicForm = `/subtopic/preview/${encodeURIComponent( + messages[messages.length - 1].key + )}`; + + let markdownMention; + const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null; + + const messageElements = await Promise.all( + messages.reverse().map((message) => { + debug("%O", message); + const authorName = message.value.meta.author.name; + const authorFeedId = message.value.author; + if (authorFeedId !== myFeedId) { + if (message.key === messages[0].key) { + const x = `[@${authorName}](${authorFeedId})\n\n`; + markdownMention = x; + } + } + return post({ msg: message, spreadInfo: getSpread(message.key) }); + }) + ); + + const authorName = messages[messages.length - 1].value.meta.author.name; + + return template( + i18n.subtopicTitle({ authorName }), + div({ class: "thread-container" }, messageElements), + form( + { action: subtopicForm, method: "post", enctype: "multipart/form-data" }, + i18n.blogSubject, + br(), + label( + i18n.contentWarningLabel, + input({ + name: "contentWarning", + type: "text", + class: "contentWarning", + value: contentWarning ? contentWarning : "", + placeholder: i18n.contentWarningPlaceholder, + }) + ), + br(), + label({ for: "text" }, i18n.blogMessage), + br(), + textarea( + { + autofocus: true, + required: true, + name: "text", + rows: "6", + cols: "50", + placeholder: i18n.publishWarningPlaceholder, + }, + text ? text : markdownMention + ), + br(), + label( + { for: "blob" }, + i18n.blogImage || "Upload media (max-size: 50MB)" + ), + input({ type: "file", id: "blob", name: "blob" }), + br(), + br(), + button({ type: "submit" }, i18n.blogPublish) + ), + preview ? div({ class: "comment-preview" }, preview) : "" + ); +}; diff --git a/src/views/maps_view.js b/src/views/maps_view.js new file mode 100644 index 0000000..997555f --- /dev/null +++ b/src/views/maps_view.js @@ -0,0 +1,541 @@ +const { form, button, div, h2, h3, p, section, input, label, br, a, span, textarea, select, option, img, strong } = + require("../server/node_modules/hyperaxe"); + +const moment = require("../server/node_modules/moment"); +const { template, i18n, userLink, renderLifespanChip, renderSpreadButton, renderContentActions } = require("./main_views"); +const { renderEncryptedChip } = require("./clearnet_view"); +const { config } = require("../server/SSB_server.js"); +const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer"); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); + +const userId = config.keys.id; +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all"); + const q = safeText(params.q || ""); + const parts = [`filter=${encodeURIComponent(f)}`]; + if (q) parts.push(`q=${encodeURIComponent(q)}`); + return `/maps?${parts.join("&")}`; +}; + +const renderPMButton = (recipient) => { + const r = safeText(recipient); + if (!r || String(r) === String(userId)) return null; + return form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: r }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)); +}; + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div({ class: "card-tags" }, list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) + : null; +}; + +const renderMapFavoriteToggle = (mapObj, returnTo = "") => + form({ + method: "POST", + action: mapObj.isFavorite ? `/maps/favorites/remove/${encodeURIComponent(mapObj.key)}` : `/maps/favorites/add/${encodeURIComponent(mapObj.key)}` + }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button({ type: "submit", class: "filter-btn" }, mapObj.isFavorite ? i18n.mapRemoveFavoriteButton : i18n.mapAddFavoriteButton)); + +let areaCounter = 0; + +const buildAreas = (clickUrl, latParam = "lat", lngParam = "lng", viewport = null) => { + const GRID = 16; + const cellW = MAP_W / GRID; + const cellH = MAP_H / GRID; + const areas = []; + for (let gy = 0; gy < GRID; gy++) { + for (let gx = 0; gx < GRID; gx++) { + let c; + if (viewport) { + const lat = viewport.latMax - (gy + 0.5) / GRID * (viewport.latMax - viewport.latMin); + const lng = viewport.lngMin + (gx + 0.5) / GRID * (viewport.lngMax - viewport.lngMin); + c = { lat: Math.round(lat * 10000) / 10000, lng: Math.round(lng * 10000) / 10000 }; + } else { + const cx = Math.round(gx * cellW + cellW / 2); + const cy = Math.round(gy * cellH + cellH / 2); + c = pxToLatLng(cx, cy); + } + const x1 = Math.round(gx * cellW); + const y1 = Math.round(gy * cellH); + const x2 = Math.round((gx + 1) * cellW); + const y2 = Math.round((gy + 1) * cellH); + areas.push(`${c.lat},${c.lng}`); + } + } + return areas; +}; + +const renderMap = (markers, clickUrl, mainIdx, opts = {}) => { + areaCounter++; + const mapName = `m${areaCounter}`; + const latParam = opts.latParam || "lat"; + const lngParam = opts.lngParam || "lng"; + const pinLabels = opts.pinLabels || []; + const pinImages = opts.pinImages || []; + const pfx = opts.pinPrefix || `pin${areaCounter}`; + const zoom = parseInt(opts.zoom) || 2; + const centerLat = typeof opts.centerLat === "number" ? opts.centerLat : 0; + const centerLng = typeof opts.centerLng === "number" ? opts.centerLng : 0; + + const pinList = safeArr(markers).filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number"); + const useZoom = zoom > 2; + const mapFile = useZoom + ? renderZoomedMapWithPins(centerLat, centerLng, zoom, pinList, mainIdx) + : (pinList.length > 0 ? renderMapWithPins(pinList, mainIdx) : null); + const imgSrc = mapFile ? `/mapcache/${mapFile}` : "/assets/images/worldmap-z2.png"; + const viewport = useZoom && clickUrl ? getViewportBounds(centerLat, centerLng, zoom) : null; + + const useMap = clickUrl || pinLabels.length > 0; + const mapTag = useMap ? mapName : ""; + + let gridAreasHtml = ""; + if (clickUrl) { + const clickUrlWithZoom = zoom > 2 ? `${clickUrl}zoom=${zoom}&` : clickUrl; + gridAreasHtml = buildAreas(clickUrlWithZoom, latParam, lngParam, viewport).join(""); + } + let popupAreasHtml = ""; + let popupsHtml = ""; + if (pinLabels.length > 0) { + const vp = useZoom ? getViewportBounds(centerLat, centerLng, zoom) : null; + pinList.forEach((m, i) => { + const lbl = pinLabels[i] || ""; + let px; + if (vp) { + px = { + x: ((m.lng - vp.lngMin) / (vp.lngMax - vp.lngMin)) * MAP_W, + y: ((vp.latMax - m.lat) / (vp.latMax - vp.latMin)) * MAP_H + }; + } else { + px = latLngToPx(m.lat, m.lng); + } + const escaped = lbl.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + const withLinks = escaped.replace(/https?:\/\/[^\s&"<>]+/g, (url) => { + const clean = url.replace(/&/g, "&"); + return `${url}`; + }).replace(/\n/g, "
"); + const sz = 20 * Math.pow(2, Math.max(0, zoom - getMaxTileZoom())); + const x1 = Math.max(0, px.x - sz); + const y1 = Math.max(0, px.y - sz); + const x2 = Math.min(MAP_W, px.x + sz); + const y2 = Math.min(MAP_H, px.y + sz); + const popupId = `${pfx}_${i}`; + const latStr = typeof m.lat === "number" ? m.lat.toFixed(4) : ""; + const lngStr = typeof m.lng === "number" ? m.lng.toFixed(4) : ""; + const imgBlobId = pinImages[i] && String(pinImages[i]).startsWith("&") ? pinImages[i] : ""; + const imgHtml = imgBlobId ? `` : ""; + popupAreasHtml += `${escaped}`; + popupsHtml += `
${imgHtml}
${sanitizeHtml(withLinks)}
${latStr}, ${lngStr}
`; + }); + } + const mapHtml = useMap ? `${popupAreasHtml}${gridAreasHtml}` : ""; + const useAttr = useMap ? ` usemap="#${mapTag}"` : ""; + + const mapWrapHtml = `
map${mapHtml}
`; + const viewerEl = div({ class: "map-viewer" }, { innerHTML: mapWrapHtml }); + if (!popupsHtml) return viewerEl; + return div({ class: "map-zone" }, viewerEl, div({ class: "map-popup-container", innerHTML: popupsHtml })); +}; + +const renderCoordPreview = (lat, lng) => { + if (!lat && !lng) return null; + return span({ class: "map-coord-inline" }, + span({ class: "map-coord-pin" }, "📍"), + strong(`${lat}, ${lng}`)); +}; + +const renderLocalEmbed = (lat, lng) => { + const la = parseFloat(lat) || 0; + const lo = parseFloat(lng) || 0; + if (!la && !lo) return null; + return renderMap([{ lat: la, lng: lo }], null, 0); +}; + +const renderMapUrl = (mapObj) => + div({ class: "map-url-container" }, + span({ class: "card-label" }, i18n.mapUrlLabel + ": "), + a({ href: `/maps/${encodeURIComponent(mapObj.key)}`, class: "map-url-link" }, + `/maps/${encodeURIComponent(mapObj.key)}`)); + +const renderMapOwnerActions = (filter, mapObj, params = {}) => { + const returnTo = buildReturnTo(filter, params); + if (String(mapObj.author) !== String(userId)) return []; + const actions = [ + form({ method: "GET", action: `/maps/edit/${encodeURIComponent(mapObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.mapUpdateButton)), + form({ method: "POST", action: `/maps/delete/${encodeURIComponent(mapObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.mapDeleteButton)) + ]; + const isPrivateMap = !mapObj.tribeId && mapObj.mapType !== "OPEN"; + if (isPrivateMap) { + actions.push(form({ method: "POST", action: `/maps/generate-invite/${encodeURIComponent(mapObj.key)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite))); + } + const openInvite = Array.isArray(mapObj.invites) ? mapObj.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code) : null; + if (!mapObj.tribeId) { + if (openInvite) { + actions.push(div({ class: 'tribe-open-invite' }, + span({ class: 'card-label' }, i18n.tribeInviteCodeText), + span({ class: 'tribe-open-invite-code' }, openInvite.code) + )); + actions.push(form({ method: "POST", action: `/maps/open-invite/remove/${encodeURIComponent(mapObj.key)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation))); + } else { + actions.push(form({ method: "POST", action: `/maps/open-invite/create/${encodeURIComponent(mapObj.key)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation))); + } + } + return actions; +}; + +const renderFilters = (filter, q) => + div({ class: "filters" }, + form({ method: "GET", action: "/maps", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q || "" }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterRecent), + button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterFavorites), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.mapUploadButton))); + +const renderMapForm = (filter, mapId, mapToEdit, params = {}) => { + const returnFilter = filter === "create" ? "all" : params.filter || "all"; + const returnTo = safeText(params.returnTo) || buildReturnTo(returnFilter, params); + const latVal = params.lat !== undefined ? String(params.lat) : String(mapToEdit?.lat || ""); + const lngVal = params.lng !== undefined ? String(params.lng) : String(mapToEdit?.lng || ""); + const titleVal = params.title || mapToEdit?.title || ""; + const descVal = params.description || mapToEdit?.description || ""; + const markerLabelVal = params.markerLabel !== undefined ? params.markerLabel : (mapToEdit?.markerLabel || ""); + const tagsValue = params.tags !== undefined ? params.tags : safeArr(mapToEdit?.tags).join(", "); + const mapTypeVal = params.mapType || mapToEdit?.mapType || "SINGLE"; + const maxTileZoom = getMaxTileZoom(); + const zoomVal = parseInt(params.zoom) || 2; + const cleanUrl = `/maps?filter=create${params.tribeId ? '&tribeId=' + encodeURIComponent(params.tribeId) : ''}`; + const pickerMarkers = latVal && lngVal ? [{ lat: parseFloat(latVal), lng: parseFloat(lngVal) }] : []; + + return div({ class: "map-create-layout" }, + div({ class: "map-form map-form-full" }, + form({ + action: filter === "edit" ? `/maps/update/${encodeURIComponent(mapId)}` : "/maps/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: "create" }), + params.tribeId ? input({ type: "hidden", name: "tribeId", value: params.tribeId }) : null, + label(i18n.title || "Title"), + input({ type: "text", name: "title", placeholder: i18n.mapTitlePlaceholder || "Map title", value: titleVal }), + label(i18n.mapDescriptionLabel), + textarea({ name: "description", placeholder: i18n.mapDescriptionPlaceholder, rows: "3" }, descVal), + label(i18n.mapTagsLabel), + input({ type: "text", name: "tags", placeholder: i18n.mapTagsPlaceholder, value: tagsValue }), + label(i18n.mapTypeLabel), + select({ name: "mapType" }, + option({ value: "SINGLE", ...(mapTypeVal === "SINGLE" ? { selected: true } : {}) }, "SINGLE"), + option({ value: "OPEN", ...(mapTypeVal === "OPEN" ? { selected: true } : {}) }, "OPEN"), + option({ value: "CLOSED", ...(mapTypeVal === "CLOSED" ? { selected: true } : {}) }, "CLOSED")), + br(),br(), + label(i18n.mapMarkerLabelField), + textarea({ name: "markerLabel", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, markerLabelVal), + label(i18n.markerImageLabel || "Marker Image"), + input({ type: "file", name: "image", accept: "image/*" }), + br(), br(), + label(i18n.mapLatLabel), + input({ type: "text", name: "lat", placeholder: i18n.mapLatPlaceholder, value: latVal }), + label(i18n.mapLngLabel), + input({ type: "text", name: "lng", placeholder: i18n.mapLngPlaceholder, value: lngVal }), + div({ class: "map-form-row" }, + button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"), + a({ href: cleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")), + renderCoordPreview(latVal, lngVal), + label(i18n.mapZoomLabel || "Zoom"), + select({ name: "zoom" }, + [2, 3, 4, 5, 6, 7, 8].map(z => + option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))), + br(),br(), + button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"), + div({ class: "map-form-map-slot" }, + renderMap(pickerMarkers, null, 0, { zoom: zoomVal, centerLat: parseFloat(latVal) || 0, centerLng: parseFloat(lngVal) || 0 })), + button({ type: "submit", class: "create-button" }, filter === "edit" ? i18n.mapUpdateButton : i18n.mapCreateButton)))); +}; + +const renderMarkerForm = (mapObj, returnTo, params = {}, tribeMembers = []) => { + if (mapObj.mapType === "SINGLE") return null; + if (mapObj.mapType === "CLOSED" && String(mapObj.author) !== String(userId)) return null; + if (mapObj.mapType === "OPEN" && mapObj.tribeId && !tribeMembers.includes(userId)) return null; + const mkLat = params.mkLat || ""; + const mkLng = params.mkLng || ""; + const zoomVal = parseInt(params.zoom) || 2; + + const existingMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat( + safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng }))); + if (mkLat && mkLng) existingMarkers.push({ lat: parseFloat(mkLat), lng: parseFloat(mkLng) }); + + const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat( + safeArr(mapObj.markers).map((m) => m.label || "")); + + const mkCleanUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}`; + const clickUrl = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(params.filter || "all")}&zoom=${zoomVal}&`; + return div({ class: "map-marker-form", id: "add-marker" }, + h3(i18n.mapAddMarkerTitle), + form({ method: "POST", action: `/maps/${encodeURIComponent(mapObj.key)}/marker`, class: "map-form", enctype: "multipart/form-data" }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + label(i18n.mapMarkerLabelField), + textarea({ name: "label", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, params.mkMarkerLabel || ""), + label(i18n.markerImageLabel || "Marker Image"), + input({ type: "file", name: "image", accept: "image/*" }), + br(),br(), + label(i18n.mapMarkerLatLabel), + input({ type: "text", name: "mkLat", placeholder: i18n.mapLatPlaceholder, value: String(mkLat) }), + label(i18n.mapMarkerLngLabel), + input({ type: "text", name: "mkLng", placeholder: i18n.mapLngPlaceholder, value: String(mkLng) }), + div({ class: "map-form-row" }, + button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"), + a({ href: mkCleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")), + renderCoordPreview(mkLat, mkLng), + label(i18n.mapZoomLabel || "Zoom"), + select({ name: "zoom" }, + [2, 3, 4, 5, 6, 7, 8].map(z => + option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))), + br(),br(), + button({ type: "submit", attrs: { formmethod: "GET" }, formaction: `/maps/${encodeURIComponent(mapObj.key)}`, class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"), + div({ class: "map-form-map-slot" }, + renderMap(existingMarkers, clickUrl, 0, { latParam: "mkLat", lngParam: "mkLng", pinLabels, pinPrefix: `mk${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mkLat) || parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mkLng) || parseFloat(mapObj.lng) || 0 })), + button({ type: "submit", class: "create-button" }, i18n.mapAddMarkerButton))); +}; + +const renderMarkersList = (markers, mapObj) => { + const allMarkers = []; + if (mapObj) { + allMarkers.push({ + lat: mapObj.lat, + lng: mapObj.lng, + label: mapObj.markerLabel || mapObj.description || mapObj.title || i18n.mapMarkerDefault, + author: mapObj.author, + createdAt: mapObj.createdAt + }); + } + allMarkers.push(...safeArr(markers)); + if (!allMarkers.length) return null; + return div({ class: "map-markers-list" }, + h3(i18n.mapMarkersTitle), + br(), + div(allMarkers.flatMap((mk, i) => [ + ...(i > 0 ? [br()] : []), + div({ class: "map-marker-info" }, + span({ class: "map-marker-dot" }, "ꔌ"), + span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`), + span({ class: "map-marker-meta" }, + userLink(mk.author), + ` · ${moment(mk.createdAt).fromNow()}`)) + ]))); +}; + +const renderMapCard = (mapObj, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params); + const ownerActions = renderMapOwnerActions(filter, mapObj, params); + const markerCount = safeArr(mapObj.markers).length; + + const thumbMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat( + safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng }))); + const thumbFile = renderMapWithPins(thumbMarkers, 0); + const thumbSrc = thumbFile ? `/mapcache/${thumbFile}` : "/assets/images/worldmap-z2.png"; + + const chips = [ + renderEncryptedChip(i18n), + renderLifespanChip(mapObj.lifetime, i18n) + ].filter(Boolean); + + const isOwn = mapObj.author && String(mapObj.author) === String(userId); + return div({ class: "trending-card map-card" + (isOwn ? " own-content" : "") }, + div({ class: "card-header activity-card-header" }, + span({ class: "map-type-badge" }, mapObj.mapType), + renderContentActions(mapObj.key, `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`)), + div({ class: "card-section maps-card-body" }, + a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`, class: "map-card-thumb-link" }, + { innerHTML: `map` }), + div({ class: "map-card-body" }, + div({ class: "shop-title-row" }, + mapObj.title ? h2({ class: "tribe-card-title" }, a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}` }, mapObj.title)) : null + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "map-card-header" }, + div({ class: "map-card-info" }, + span({ class: "map-coords" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`), + markerCount > 0 ? span({ class: "map-marker-count" }, `▾ ${markerCount}`) : null, + mapObj.key ? renderMapUrl(mapObj) : null), + div({ class: "map-card-actions" }, + renderMapFavoriteToggle(mapObj, returnTo), + renderPMButton(mapObj.author), + ...ownerActions)), + safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null, + (() => { + const btn = renderSpreadButton(mapObj.key, params.spreadMap && params.spreadMap.get(mapObj.key)); + return btn ? div({ class: "card-spread-left" }, btn) : null; + })(), + p({ class: "card-footer" }, + span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()), + span(" · "), + userLink(mapObj.author))))); +}; + +const renderMapList = (maps, filter, params = {}) => + maps.length + ? maps.map((mapObj) => renderMapCard(mapObj, filter, params)) + : p(params.q ? i18n.mapNoMatch : i18n.noMaps); + +exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => { + const title = filter === "mine" ? i18n.mapMineSectionTitle + : filter === "create" ? i18n.mapCreateSectionTitle + : filter === "edit" ? i18n.mapUpdateSectionTitle + : filter === "recent" ? i18n.mapRecentSectionTitle + : filter === "favorites" ? i18n.mapFavoritesSectionTitle + : i18n.mapAllSectionTitle; + + const q = safeText(params.q || ""); + const list = safeArr(maps); + const mapToEdit = mapId ? list.find((m) => m.key === mapId) : null; + const allMarkers = list.map((m) => ({ lat: m.lat, lng: m.lng, href: `/maps/${encodeURIComponent(m.key)}` })); + + return template(title, + section( + div({ class: "tags-header" }, h2(title), p(i18n.mapDescription)), + renderFilters(filter, q)), + section( + filter === "create" || filter === "edit" + ? renderMapForm(filter, mapId, mapToEdit, { ...params, filter }) + : section( + div({ class: "maps-search" }, + form({ method: "GET", action: "/maps", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", value: q, placeholder: i18n.mapSearchPlaceholder, class: "filter-box__input" }), + div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.mapSearchButton)))), + div({ class: "maps-list" }, renderMapList(list, filter, { q }))))); +}; + +exports.singleMapView = async (mapObj, filter = "all", params = {}) => { + const q = safeText(params.q || ""); + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q }); + const ownerActions = renderMapOwnerActions(filter, mapObj, { q }); + const tribeMembers = safeArr(params.tribeMembers); + const zoomVal = parseInt(params.zoom) || 2; + + const allMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat( + safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng }))); + + const pinLabels = [mapObj.markerLabel || mapObj.description || mapObj.title || ""].concat( + safeArr(mapObj.markers).map((m) => m.label || "")); + const pinImages = [mapObj.image || ""].concat(safeArr(mapObj.markers).map((m) => m.image || "")); + + return template(mapObj.title || i18n.mapTitle, + section(renderFilters(filter, q)), + section( + div({ class: "map-detail" }, + mapObj.title ? div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, mapObj.title) + ) : null, + mapObj.title ? div({ class: "card-chips-row" }, + renderEncryptedChip(i18n), + renderLifespanChip(mapObj.lifetime, i18n) + ) : null, + safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null, + div({ class: "map-detail-header" }, + div({ class: "map-detail-info" }, + span({ class: "map-type-badge" }, mapObj.mapType), + span({ class: "map-coords-detail" }, `📍 ${mapObj.lat.toFixed(6)}, ${mapObj.lng.toFixed(6)}`)), + div({ class: "map-detail-actions" }, + renderMapFavoriteToggle(mapObj, returnTo), + renderPMButton(mapObj.author), + (String(mapObj.author) !== String(userId) && !mapObj.tribeId && mapObj.mapType !== "OPEN") + ? a({ class: "tribe-action-btn", href: "/invites#invites-maps" }, i18n.tribeEnterInvite) + : null, + ...ownerActions)), + renderMapUrl(mapObj), + br(), + form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` }, + label(i18n.mapZoomLabel || "Zoom"), + br(), + select({ name: "zoom" }, + [2, 3, 4, 5, 6, 7, 8].map(z => + option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))), + br(), br(), + button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")), + br(), + renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }), + renderMarkersList(mapObj.markers, mapObj), + renderTags(mapObj.tags), + (() => { + const btn = renderSpreadButton(mapObj.key); + return btn ? div({ class: "card-spread-centered" }, btn) : null; + })(), + br(), + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(mapObj.author), + mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt + ? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`) + : null), + renderMarkerForm(mapObj, returnTo, params, tribeMembers)))); +}; + +exports.renderMapLocationUrl = (mapUrl) => { + if (!mapUrl) return null; + return span({ class: "map-location-inline" }, + span({ class: "map-location-icon" }, "ꔌ"), + a({ href: mapUrl, class: "map-location-link" }, mapUrl)); +}; + +exports.renderMapLocationVisitLabel = (mapUrl) => { + if (!mapUrl) return null; + return div({ class: "card-field" }, + span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"), + span({ class: "card-value" }, + a({ href: mapUrl, class: "map-location-link" }, i18n.mapVisitLabel || "Visit map"))); +}; + +exports.renderMapEmbed = (mapData, mapUrl) => { + if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0)) + return exports.renderMapLocationVisitLabel(mapUrl); + return div({ class: "map-embed-section" }, + span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"), + span({ class: "card-value map-zoom-info" }, "Zoom: 2"), + renderLocalEmbed(mapData.lat, mapData.lng), + mapUrl ? div({ class: "map-embed-url" }, + a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null); +}; + +exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => { + if (!mapData || (parseFloat(mapData.lat) === 0 && parseFloat(mapData.lng) === 0)) + return exports.renderMapLocationVisitLabel(mapUrl); + const zoomVal = parseInt(zoom) || 2; + const la = parseFloat(mapData.lat) || 0; + const lo = parseFloat(mapData.lng) || 0; + return div({ class: "map-embed-section" }, + span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"), + form({ method: "GET", action: detailUrl }, + label(i18n.mapZoomLabel || "Zoom"), + br(), + select({ name: "zoom" }, + [2, 3, 4, 5, 6, 7, 8].map(z => + option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))), + br(), br(), + button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")), + br(), + renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }), + mapUrl ? div({ class: "map-embed-url" }, + a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null); +}; + +exports.renderMapLocationGrid = (lat, lng) => { + if (lat === undefined || lng === undefined) return null; + return div({ class: "map-location-embed" }, + renderMap([{ lat: parseFloat(lat) || 0, lng: parseFloat(lng) || 0 }], null, 0)); +}; diff --git a/src/views/markdown.js b/src/views/markdown.js new file mode 100644 index 0000000..4e5d040 --- /dev/null +++ b/src/views/markdown.js @@ -0,0 +1,43 @@ +"use strict"; + +const md = require("../server/node_modules/ssb-markdown"); +const ssbMessages = require("../server/node_modules/ssb-msgs"); +const ssbRef = require("../server/node_modules/ssb-ref"); +const { span } = require("../server/node_modules/hyperaxe"); + +const toUrl = (mentions) => { + const mentionNames = []; + + const handleLink = ({ name, link }) => { + if (typeof name === "string") { + const atName = name.charAt(0) === "@" ? name : `@${name}`; + mentionNames.push({ name: atName, link }); + } + }; + + ssbMessages.links(mentions, "feed").forEach(handleLink); + + const urlHandler = (ref) => { + const found = mentionNames.find(({ name }) => name === ref); + if (found) return `/author/${encodeURIComponent(found.link)}`; + + if (ssbRef.isFeedId(ref)) return `/author/${encodeURIComponent(ref)}`; + if (ssbRef.isMsgId(ref)) return `/thread/${encodeURIComponent(ref)}`; + + const splitIndex = ref.indexOf("?"); + const blobRef = splitIndex === -1 ? ref : ref.slice(0, splitIndex); + + if (ssbRef.isBlobId(blobRef)) return `/blob/${encodeURIComponent(blobRef)}`; + if (ref && ref[0] === "#") return `/hashtag/${encodeURIComponent(ref.substr(1))}`; + + return ""; + }; + + return urlHandler; +}; + +module.exports = (input, mentions = []) => + md.block(input, { + toUrl: toUrl(mentions), + emoji: (character) => span({ class: "emoji" }, character).outerHTML, + }); diff --git a/src/views/market_view.js b/src/views/market_view.js new file mode 100644 index 0000000..019d4f3 --- /dev/null +++ b/src/views/market_view.js @@ -0,0 +1,737 @@ +const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") +const { renderUrl } = require("../backend/renderUrl") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel, renderMapEmbedWithZoom } = require("./maps_view") + +const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => { + if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + const s = String(value).trim() + if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }) + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }) + const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' }) + return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null +} + +const userId = config.keys.id + +const parseBidEntry = (raw) => { + const s = String(raw || "").trim() + if (!s) return null + + if (s.includes("|")) { + const parts = s.split("|") + if (parts.length < 3) return null + const bidder = parts[0] || "" + const amount = parseFloat(String(parts[1] || "").replace(",", ".")) + const time = parts.slice(2).join("|") + if (!bidder || !Number.isFinite(amount) || !time) return null + return { bidder, amount, time } + } + + const first = s.indexOf(":") + const second = s.indexOf(":", first + 1) + if (first === -1 || second === -1) return null + + const bidder = s.slice(0, first) + const amountStr = s.slice(first + 1, second) + const time = s.slice(second + 1) + const amount = parseFloat(String(amountStr || "").replace(",", ".")) + if (!bidder || !Number.isFinite(amount) || !time) return null + return { bidder, amount, time } +} + +const toNum = (v) => { + if (v === null || v === undefined) return NaN + const n = parseFloat(String(v).replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} + +const normalizeQ = (q) => String(q || "").trim().toLowerCase() + +const matchesQuery = (item, q) => { + const qq = normalizeQ(q) + if (!qq) return true + const title = String(item.title || "").toLowerCase() + const tags = Array.isArray(item.tags) ? item.tags : [] + const tagStr = tags.map((t) => String(t || "").toLowerCase()).join(" ") + return title.includes(qq) || tagStr.includes(qq) +} + +const withinPrice = (item, minP, maxP) => { + const p = toNum(item.price) + if (!Number.isFinite(p)) return false + if (Number.isFinite(minP) && p < minP) return false + if (Number.isFinite(maxP) && p > maxP) return false + return true +} + +const sortItems = (items, sort) => { + const s = String(sort || "recent") + if (s === "price") return items.slice().sort((a, b) => toNum(a.price) - toNum(b.price)) + if (s === "deadline") { + return items.slice().sort((a, b) => { + const ad = a.deadline ? new Date(a.deadline).getTime() : Number.POSITIVE_INFINITY + const bd = b.deadline ? new Date(b.deadline).getTime() : Number.POSITIVE_INFINITY + return ad - bd + }) + } + return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) +} + +const buildReturnTo = (filter, q, minPrice, maxPrice, sort) => { + const params = [] + if (filter) params.push(`filter=${encodeURIComponent(filter)}`) + if (q) params.push(`q=${encodeURIComponent(q)}`) + if (minPrice !== undefined && minPrice !== null && String(minPrice) !== "") params.push(`minPrice=${encodeURIComponent(String(minPrice))}`) + if (maxPrice !== undefined && maxPrice !== null && String(maxPrice) !== "") params.push(`maxPrice=${encodeURIComponent(String(maxPrice))}`) + if (sort) params.push(`sort=${encodeURIComponent(sort)}`) + return `/market${params.length ? `?${params.join("&")}` : ""}` +} + +const renderCardField = (labelText, value = "") => + div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...renderUrl(String(value)))) + +const renderCardFieldRich = (labelText, parts) => + div({ class: "card-field" }, span({ class: "card-label" }, labelText), span({ class: "card-value" }, ...(Array.isArray(parts) ? parts : [parts]))) + +const renderPmButton = (recipientId) => + recipientId && String(recipientId) !== String(userId) + ? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: recipientId }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)) + : null + +const renderStockBar = (stockValue, maxValue) => { + const s = Math.max(0, Number(stockValue || 0)) + const m = Math.max(1, Number(maxValue || s || 1)) + return div( + { class: "confirmations-block stock-block" }, + div( + { class: "card-field" }, + span({ class: "card-label" }, `${i18n.marketItemStock}: `), + span({ class: "card-value" }, s > 0 ? `${s}/${m}` : i18n.marketOutOfStock) + ), + progress({ class: "confirmations-progress stock-progress", value: Math.min(s, m), max: m }) + ) +} + +const renderMarketCommentsSection = (itemId, returnTo, comments = []) => { + const commentsCount = Array.isArray(comments) ? comments.length : 0 + + return div( + { class: "vote-comments-section market-comments" }, + div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(commentsCount))), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/market/${encodeURIComponent(itemId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text + return t && String(t).trim() + }) + return visibleComments.length + ? div( + { class: "comments-list" }, + visibleComments.map((c) => { + const author = c.value && c.value.author ? c.value.author : "" + const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "" + const relDate = ts ? moment(ts).fromNow() : "" + const userName = author && author.includes("@") ? author.split("@")[1] : author + const rootId = c.value && c.value.content ? c.value.content.fork || c.value.content.root : null + const text = c.value && c.value.content && c.value.content.text ? c.value.content.text : "" + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ) + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + })() + ) +} + +const isMyBidItem = (item) => { + const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : [] + for (const x of polls) { + const b = parseBidEntry(x) + if (b && b.bidder === userId) return true + } + return false +} + +const auctionCountdownParts = (deadline) => { + if (!deadline) return null + const dl = moment(deadline) + if (!dl.isValid()) return null + const now = moment() + const rel = dl.fromNow() + if (dl.isAfter(now)) return { label: i18n.marketAuctionEndsIn, rel } + return { label: i18n.marketAuctionEnded, rel } +} + +const renderCountdownField = (item) => { + const cd = item && (item.item_type === "auction" || item.item_type === "exchange") ? auctionCountdownParts(item.deadline) : null + if (!cd) return null + return renderCardFieldRich(`${cd.label}:`, [span({ class: "countdown-strong" }, cd.rel)]) +} + +const normStatus = (s) => String(s || "").toUpperCase().replace(/_/g, " ").replace(/\s+/g, " ").trim() + +const renderMarketOwnerActions = (item, returnTo) => { + const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : [] + const canUpdate = item.status !== "SOLD" && item.status !== "DISCARDED" && polls.length === 0 + const cur = normStatus(item.status || "FOR SALE") + const canChange = cur !== "SOLD" && cur !== "DISCARDED" + const out = [] + if (canUpdate) { + out.push( + form( + { method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate) + ) + ) + } + out.push( + form( + { method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete) + ) + ) + if (canChange) { + out.push( + form( + { method: "POST", action: `/market/status/${encodeURIComponent(item.id)}`, class: "project-control-form project-control-form--status" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + select( + { name: "status", class: "project-control-select" }, + option({ value: "FOR SALE", selected: cur === "FOR SALE" }, i18n.marketFilterForSale), + option({ value: "SOLD", selected: cur === "SOLD" }, i18n.marketFilterSold), + option({ value: "DISCARDED", selected: cur === "DISCARDED" }, i18n.marketFilterDiscarded) + ), + button({ class: "status-btn project-control-btn", type: "submit" }, i18n.marketActionsChangeStatus) + ) + ) + } + return out +} + +const renderMarketTopbar = (item, returnTo) => { + const left = [renderPmButton(item && item.seller)].filter(Boolean) + const right = item && String(item.seller) === String(userId) ? renderMarketOwnerActions(item, returnTo) : [] + const leftNode = left.length ? div({ class: "bookmark-topbar-left transfer-topbar-left" }, ...left) : null + const rightNode = right.length ? div({ class: "bookmark-actions transfer-actions" }, ...right) : null + const children = [] + if (leftNode) children.push(leftNode) + if (rightNode) children.push(rightNode) + return children.length ? div({ class: "bookmark-topbar transfer-topbar-single" }, ...children) : null +} + +exports.marketView = async (items, filter, itemToEdit = null, params = {}) => { + const list = Array.isArray(items) ? items : [] + const q = params.q || "" + const minPrice = params.minPrice + const maxPrice = params.maxPrice + const sort = params.sort || "recent" + + let title = i18n.marketAllSectionTitle + switch (filter) { + case "mine": + title = i18n.marketMineSectionTitle + break + case "create": + title = i18n.marketCreateSectionTitle + break + case "edit": + title = i18n.marketUpdateSectionTitle + break + case "mybids": + title = i18n.marketFilterMyBids + break + } + + let filtered = [] + switch (filter) { + case "all": + filtered = list + break + case "mine": + filtered = list.filter((e) => e.seller === userId) + break + case "exchange": + filtered = list.filter((e) => e.item_type === "exchange" && e.status === "FOR SALE") + break + case "auctions": + filtered = list.filter((e) => e.item_type === "auction" && e.status === "FOR SALE") + break + case "new": + filtered = list.filter((e) => e.item_status === "NEW" && e.status === "FOR SALE") + break + case "used": + filtered = list.filter((e) => e.item_status === "USED" && e.status === "FOR SALE") + break + case "broken": + filtered = list.filter((e) => e.item_status === "BROKEN" && e.status === "FOR SALE") + break + case "for sale": + filtered = list.filter((e) => e.status === "FOR SALE") + break + case "sold": + filtered = list.filter((e) => e.status === "SOLD") + break + case "discarded": + filtered = list.filter((e) => e.status === "DISCARDED") + break + case "recent": { + const oneDayAgo = moment().subtract(1, "days").toISOString() + filtered = list.filter((e) => e.status === "FOR SALE" && String(e.createdAt || "") >= oneDayAgo) + break + } + case "mybids": + filtered = list.filter((e) => String(e.item_type || "").toLowerCase() === "auction").filter(isMyBidItem) + break + default: + filtered = list + break + } + + const minP = toNum(minPrice) + const maxP = toNum(maxPrice) + filtered = filtered.filter((it) => matchesQuery(it, q)).filter((it) => withinPrice(it, minP, maxP)) + filtered = sortItems(filtered, sort) + + const returnTo = buildReturnTo(filter, q, minPrice, maxPrice, sort) + const itemEdit = itemToEdit || {} + + const hiddenCtx = [ + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }), + input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }), + input({ type: "hidden", name: "sort", value: sort }) + ] + + const isFormMode = filter === "create" || filter === "edit" + + return template( + title, + section( + div({ class: "tags-header" }, h2(i18n.marketTitle), p(i18n.marketDescription)), + div( + { class: "filters" }, + form( + { method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" }, + ...hiddenCtx, + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine), + button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems), + button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions), + button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids), + button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew), + button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed), + button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken), + button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale), + button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold), + button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton) + ) + ), + !isFormMode + ? div( + { class: "market-search" }, + form( + { method: "GET", action: "/market", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter || "all" }), + input({ type: "text", name: "q", value: q, placeholder: i18n.marketSearchPlaceholder, class: "filter-box__input" }), + div( + { class: "filter-box__controls" }, + div( + { class: "transfer-range" }, + input({ + type: "number", + name: "minPrice", + step: "0.000001", + min: "0", + value: String(minPrice ?? ""), + placeholder: i18n.marketMinPriceLabel, + class: "filter-box__number transfer-amount-input" + }), + input({ + type: "number", + name: "maxPrice", + step: "0.000001", + min: "0", + value: String(maxPrice ?? ""), + placeholder: i18n.marketMaxPriceLabel, + class: "filter-box__number transfer-amount-input" + }) + ), + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.marketSortRecent), + option({ value: "price", selected: sort === "price" }, i18n.marketSortPrice), + option({ value: "deadline", selected: sort === "deadline" }, i18n.marketSortDeadline) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.marketSearchButton) + ) + ) + ) + : null + ), + section( + isFormMode + ? div( + { class: "market-form" }, + form( + { action: filter === "edit" ? `/market/update/${encodeURIComponent(itemEdit.id)}` : "/market/create", method: "POST", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: "/market?filter=mine" }), + label(i18n.marketItemType), + br(), + select( + { name: "item_type", id: "item_type", required: true }, + option({ value: "auction", selected: itemEdit && itemEdit.item_type === "auction" }, "Auction"), + option({ value: "exchange", selected: itemEdit && itemEdit.item_type === "exchange" }, "Exchange") + ), + br(), + br(), + label(i18n.marketItemTitle), + br(), + input({ type: "text", name: "title", id: "title", value: (itemEdit && itemEdit.title) || "", required: true }), + br(), + br(), + label(i18n.marketItemDescription), + br(), + textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", required: true }, (itemEdit && itemEdit.description) || ""), + br(), + br(), + label(i18n.marketCreateFormImageLabel), + br(), + input({ type: "file", name: "image", id: "image" }), + br(), + br(), + label(i18n.marketItemStatus), + br(), + select( + { name: "item_status", id: "item_status" }, + option({ value: "BROKEN", selected: itemEdit && itemEdit.item_status === "BROKEN" }, "BROKEN"), + option({ value: "USED", selected: itemEdit && itemEdit.item_status === "USED" }, "USED"), + option({ value: "NEW", selected: itemEdit && itemEdit.item_status === "NEW" }, "NEW") + ), + br(), + br(), + label(i18n.marketItemStock), + br(), + input({ type: "number", name: "stock", id: "stock", value: (itemEdit && itemEdit.stock) || 1, required: true, min: "1", step: "1" }), + br(), + br(), + label(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: itemEdit?.mapUrl || "" }), + br(), + br(), + label(i18n.visibilityLabel || "Visibility"), + br(), + select( + { name: "visibility" }, + option({ value: "PUBLIC", selected: (itemEdit?.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"), + option({ value: "HIDDEN", selected: itemEdit?.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden") + ), + br(), + br(), + label(i18n.marketItemPrice), + br(), + input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || "", required: true, step: "0.000001", min: "0.000001" }), + br(), + br(), + label(i18n.marketItemTags), + br(), + input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: (itemEdit && itemEdit.tags && itemEdit.tags.join(", ")) || "" }), + br(), + br(), + label(i18n.marketItemDeadline), + br(), + input({ + type: "datetime-local", + name: "deadline", + id: "deadline", + required: true, + min: moment().format("YYYY-MM-DDTHH:mm"), + value: itemEdit && itemEdit.deadline ? moment(itemEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" + }), + br(), + br(), + input({ type: "hidden", name: "includesShipping", value: "0" }), + label(i18n.marketItemIncludesShipping), + br(), + input({ + id: "includesShipping-checkbox", + type: "checkbox", + name: "includesShipping", + value: "1", + class: "meme-checkbox", + ...(itemEdit && itemEdit.includesShipping ? { checked: true } : {}) + }), + br(),br(), + button({ type: "submit" }, filter === "edit" ? i18n.marketUpdateButton : i18n.marketCreateButton) + ) + ) + : div( + { class: "market-grid" }, + filtered.length > 0 + ? filtered.map((item) => { + const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : [] + const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time)) + const myBid = item.item_type === "auction" ? parsedBids.some((b) => b.bidder === userId) : false + const maxStock = item.initialStock || item.stockMax || item.stock || 1 + const stockLeft = Number(item.stock || 0) + const isOwner = String(item.seller) === String(userId) + + const actionNodesRaw = isOwner + ? renderMarketOwnerActions(item, "/market?filter=mine") + : [ + item.status !== "SOLD" && item.status !== "DISCARDED" && item.item_type === "auction" + ? form( + { method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }), + br(), + button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton) + ) + : null, + item.status === "FOR SALE" && item.item_type !== "auction" && !isOwner && stockLeft > 0 + ? form( + { method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }), + input({ type: "hidden", name: "buyerId", value: userId }), + button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy) + ) + : null + ].filter(Boolean) + + return div({ class: "tribe-card market-tribe-card" }, + div({ class: "tribe-card-image-wrapper" }, + a({ href: `/market/${encodeURIComponent(item.id)}` }, + renderMediaBlob(item.image, '/assets/images/default-market.png', { class: 'tribe-card-hero-image' }) + ) + ), + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, a({ href: `/market/${encodeURIComponent(item.id)}` }, item.title)) + ), + div({ class: "card-chips-row" }, + renderStateChip("encrypted", "", String(item.item_type || "").toUpperCase()), + item.item_status ? renderStateChip("whole", "", String(item.item_status).toUpperCase()) : null, + String(item.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null, + item.includesShipping ? renderStateChip("mutuals", "📦", String(i18n.marketItemIncludesShipping || "Shipping").replace(/\?$/, "").toUpperCase()) : null, + renderLifespanChip(item.lifetime, i18n) + ), + div({ class: "market-card-price card-date-highlight" }, `${item.price} ECO`), + div({ class: "card-visit-btn-centered" }, + form({ method: 'GET', action: `/market/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: filter || "all" }), + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }), + input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: 'submit', class: 'filter-btn' }, i18n.viewItem || i18n.marketVisitItem || 'View Item') + ) + ) + ) + ) + }) + : p(i18n.marketNoItemsMatch || i18n.marketNoItems) + ) + ) + ) +} + +exports.singleMarketView = async (item, filter, comments = [], params = {}) => { + const polls = Array.isArray(item.auctions_poll) ? item.auctions_poll : [] + const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time)) + const q = params.q || "" + const minPrice = params.minPrice + const maxPrice = params.maxPrice + const sort = params.sort || "recent" + const returnTo = params.returnTo || buildReturnTo(filter, q, minPrice, maxPrice, sort) + const topbar = renderMarketTopbar(item, returnTo) + const stockLeft = Number(item.stock || 0) + const showBuy = item.status === "FOR SALE" && item.item_type !== "auction" && String(item.seller) !== String(userId) && stockLeft > 0 + const maxStock = item.initialStock || item.stockMax || item.stock || 1 + + return template( + item.title, + section( + div( + { class: "filters" }, + form( + { method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }), + input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMine), + button({ type: "submit", name: "filter", value: "exchange", class: filter === "exchange" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterItems), + button({ type: "submit", name: "filter", value: "auctions", class: filter === "auctions" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterAuctions), + button({ type: "submit", name: "filter", value: "mybids", class: filter === "mybids" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterMyBids), + button({ type: "submit", name: "filter", value: "new", class: filter === "new" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterNew), + button({ type: "submit", name: "filter", value: "used", class: filter === "used" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterUsed), + button({ type: "submit", name: "filter", value: "broken", class: filter === "broken" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterBroken), + button({ type: "submit", name: "filter", value: "for sale", class: filter === "for sale" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterForSale), + button({ type: "submit", name: "filter", value: "sold", class: filter === "sold" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterSold), + button({ type: "submit", name: "filter", value: "discarded", class: filter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterDiscarded), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.marketFilterRecent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton) + ) + ), + (() => { + const isHidden = String(item.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' + const chips = [ + renderStateChip("encrypted", "", String(item.item_type || "").toUpperCase()), + item.item_status ? renderStateChip("whole", "", String(item.item_status).toUpperCase()) : null, + item.includesShipping ? renderStateChip("mutuals", "📦", String(i18n.marketItemIncludesShipping || "Shipping").replace(/\?$/, "").toUpperCase()) : null, + isHidden ? renderVisibilityChip("HIDDEN", i18n) : null, + renderLifespanChip(item.lifetime, i18n), + renderEcoTax(item.msgSize, item.id) + ].filter(Boolean) + + const tagsNode = item.tags && item.tags.length + ? div({ class: "card-tags" }, item.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) + : null + + const infoRows = [] + const pushRow = (labelText, valueNode) => + infoRows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: "tribe-info-value" }, valueNode) + )) + pushRow(i18n.marketItemSeller, userLink(item.seller)) + if (item.shopId && item.shopTitle) pushRow(i18n.marketShopLabel || "Shop", a({ href: `/shops/${encodeURIComponent(item.shopId)}`, class: "user-link" }, item.shopTitle)) + if (item.deadline) pushRow(i18n.marketItemAvailable, moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")) + + const itemSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, item.title) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(item.id, params.spreads)), + renderMediaBlob(item.image, "/assets/images/default-market.png"), + div({ class: "card-date-highlight" }, `${item.price} ECO`), + renderStockBar(item.stock, maxStock), + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + tagsNode + ) + + const visibilityRow = String(item.seller) === String(userId) + ? (() => { + const vis = isHidden ? 'HIDDEN' : 'PUBLIC' + const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC' + return div({ class: "tribe-side-actions" }, + span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `), + renderVisibilityChip(vis, i18n), + form({ method: "POST", action: `/market/visibility/${encodeURIComponent(item.id)}`, class: "inline-form" }, + input({ type: "hidden", name: "visibility", value: next }), + button({ type: "submit", class: "filter-btn" }, + next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden') + ) + ) + ) + })() + : null + + const marketActions = [] + if (item.seller && String(item.seller) !== String(userId)) { + marketActions.push(form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: item.seller }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + )) + } + if (String(item.seller) === String(userId)) { + marketActions.push(form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` }, + button({ type: "submit", class: "update-btn" }, i18n.marketUpdateButton || "Update") + )) + marketActions.push(form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` }, + button({ type: "submit", class: "delete-btn" }, i18n.marketDeleteButton || "Delete") + )) + } + const itemMain = div({ class: "tribe-main" }, + marketActions.length ? div({ class: "tribe-side-actions" }, ...marketActions) : null, + visibilityRow, + item.description + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, i18n.marketItemDescription), + p({ class: "tribe-side-description" }, ...renderUrl(item.description)) + ) + : null, + renderCountdownField(item), + item.mapUrl ? div({ class: "job-section" }, renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom)) : null, + item.item_type === "auction" + ? div( + { class: "auction-info job-section" }, + h2({ class: "job-section-title" }, i18n.marketAuctionBids), + parsedBids.length + ? table( + { class: "auction-bid-table" }, + tr(th(i18n.marketAuctionBidTime), th(i18n.marketAuctionUser), th(i18n.marketAuctionBidAmount)), + parsedBids.map((bid) => + tr(td(moment(bid.time).format("YYYY-MM-DD HH:mm:ss")), td(a({ href: `/author/${encodeURIComponent(bid.bidder)}` }, bid.bidder)), td(`${parseFloat(bid.amount).toFixed(6)} ECO`)) + ) + ) + : p({ class: "tribe-side-description" }, i18n.marketNoBids || "No bids yet"), + item.status !== "SOLD" && item.status !== "DISCARDED" && String(item.seller) !== String(userId) + ? form( + { method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }), + br(), + button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton) + ) + : null + ) + : null, + showBuy + ? div( + { class: "market-item actions" }, + form( + { method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }), + button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy) + ) + ) + : null, + renderMarketCommentsSection(item.id, returnTo, comments) + ) + + return div({ class: "tribe-details" }, itemSide, itemMain) + })() + ) + ) +} + diff --git a/src/views/melody_view.js b/src/views/melody_view.js new file mode 100644 index 0000000..8129272 --- /dev/null +++ b/src/views/melody_view.js @@ -0,0 +1,144 @@ +"use strict"; + +const { div, h2, p, section, audio, source, span, a, form, input, button, br, textarea, label } = require("../server/node_modules/hyperaxe"); +const moment = require("../server/node_modules/moment"); +const { template, i18n, userLink, renderEcoTax } = require("./main_views"); + +const renderSequenceList = (sequence, availableIds = null) => { + if (!sequence || sequence.length === 0) { + return p({ class: "empty" }, i18n.melodyEmpty || "No notes yet — your blockchain has not produced any block."); + } + const checkAvailability = availableIds && typeof availableIds.has === 'function'; + return div({ class: "melody-composition" }, + h2({ class: "melody-section-title" }, i18n.melodyCompositionTitle || "Blockchain Composition Map"), + div({ class: "melody-notes-grid" }, + sequence.slice(0, 256).map(n => { + const noteName = n.name || n.n; + const noteType = n.type || n.t; + const noteDur = n.durMs || n.d || 0; + const blockId = typeof n.id === 'string' ? n.id : null; + const reachable = blockId && (!checkAvailability || availableIds.has(blockId)); + const unavailableNote = checkAvailability && blockId && !reachable + ? ' · ' + (i18n.melodyNoteUnavailable || 'block not in your local feed') + : ''; + const titleAttr = `${noteType} · ${noteName} · ${noteDur}ms` + (blockId ? ` · ${blockId}` : '') + unavailableNote; + const inner = [ + span({ class: "melody-note-name" }, noteName), + span({ class: "melody-note-type" }, noteType) + ]; + if (reachable) { + return a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: "melody-note-chip melody-note-chip-link", title: titleAttr }, ...inner); + } + const cls = checkAvailability && blockId && !reachable + ? "melody-note-chip melody-note-chip-unavailable" + : "melody-note-chip"; + return div({ class: cls, title: titleAttr }, ...inner); + }) + ) + ); +}; + +exports.renderCompositionSequence = renderSequenceList; + +const renderFilters = (activeFilter) => div({ class: "filters" }, + form({ method: "GET", action: "/melody", class: "ui-toolbar ui-toolbar--filters" }, + button({ type: "submit", name: "filter", value: "mine", class: activeFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.melodyFilterMine || "MINE"), + button({ type: "submit", name: "filter", value: "all", class: activeFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.melodyFilterAll || "ALL") + ) +); + +const renderOwnPanel = ({ feedId, total, sequence }) => { + const audioHref = `/melody/audio.wav?_=${Date.now()}`; + return div( + div({ class: "melody-player-card" }, + div({ class: "melody-meta" }, + userLink(feedId), + span({ class: "melody-meta-sep" }, " · "), + span({ class: "card-label" }, `${i18n.melodyTotalBlocks || "Notes"}: `), + span({ class: "card-value" }, String(total)) + ), + sequence && sequence.length > 0 + ? div( + audio({ controls: true, preload: "none", class: "melody-audio" }, + source({ src: audioHref, type: "audio/wav" }) + ), + div({ class: "melody-regen-form" }, + form({ method: "GET", action: "/melody", class: "inline-form" }, + input({ type: "hidden", name: "filter", value: "mine" }), + input({ type: "hidden", name: "r", value: String(Date.now()) }), + button({ type: "submit", class: "filter-btn" }, i18n.melodyRegenerate || "Regenerate") + ), + a({ href: "/melody/audio.wav?download=1", download: `${feedId || 'oasis'}.wav`, class: "filter-btn melody-download-btn" }, i18n.melodyDownload || "Download Melody") + ), + div({ class: "melody-upload-block" }, + h2({ class: "melody-section-title" }, i18n.melodyUploadBcsTitle || "Publish"), + p({ class: "melody-upload-name-note" }, (i18n.melodyUploadBcsNameNote || "The audio will be published with the auto-generated name") + ' ', span({ class: "melody-upload-name-value" }, `BCS-${feedId || 'OASISID'}`), '.'), + form({ method: "POST", action: "/melody/upload", class: "melody-upload-form" }, + textarea({ + id: "melody_upload_stego", + name: "stegoMessage", + rows: "3", + maxlength: "280", + placeholder: `${i18n.melodyUploadStegoPlaceholder || "Optional."} ${i18n.melodyUploadStegoLabel || "Hidden message (steganography)"} — ${i18n.melodyUploadStegoMaxLabel || "max 280 characters"}` + }), + br(), + button({ type: "submit", class: "filter-btn melody-upload-submit" }, i18n.melodyUploadBcsButton || "Publish") + ) + ) + ) + : p({ class: "empty" }, i18n.melodyEmpty || "No notes yet") + ), + renderSequenceList(sequence) + ); +}; + +const renderBcsList = (bcsAudios) => { + if (!bcsAudios || bcsAudios.length === 0) { + return p({ class: "empty" }, i18n.melodyAllEmpty || "No BCS compositions from other inhabitants yet."); + } + return div({ class: "melody-bcs-list" }, + bcsAudios.map(a => div({ class: "melody-bcs-card" }, + div({ class: "melody-bcs-head" }, + a.title ? h2({ class: "melody-bcs-title" }, a.title) : null, + div({ class: "melody-bcs-meta" }, + span({ class: "card-label" }, `${i18n.melodyByLabel || "By"}: `), + userLink(a.author), + span({ class: "melody-meta-sep" }, " · "), + span({ class: "card-value" }, moment(a.createdAt).format("YYYY/MM/DD HH:mm:ss")) + ) + ), + a.url + ? div({ class: "audio-container melody-bcs-player" }, + audio({ controls: true, preload: "metadata", src: `/blob/${encodeURIComponent(a.url)}` }) + ) + : null, + a.description ? p({ class: "melody-bcs-desc" }, a.description) : null, + div({ class: "melody-bcs-actions" }, + form({ method: "GET", action: `/melody/transcode/${encodeURIComponent(a.key)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE") + ) + ) + )) + ); +}; + +exports.melodyView = ({ feedId, total, sequence, filter, bcsAudios }) => { + const title = i18n.melodyTitle || "Melody"; + const description = i18n.melodyDescription || "Play the melody of your blockchain — each block becomes a note."; + const activeFilter = filter === "all" ? "all" : "mine"; + const body = activeFilter === "all" + ? renderBcsList(bcsAudios) + : renderOwnPanel({ feedId, total, sequence }); + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(description) + ), + renderFilters(activeFilter), + body + ) + ); +}; diff --git a/src/views/modules_view.js b/src/views/modules_view.js new file mode 100644 index 0000000..03be08b --- /dev/null +++ b/src/views/modules_view.js @@ -0,0 +1,150 @@ +const { form, button, div, h2, p, section, table, tr, td, input, a, br, ul, li, span, strong } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require('./main_views'); +const { getConfig } = require('../configs/config-manager.js'); + +const modulesView = () => { + const config = getConfig().modules; + const modules = [ + { name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription }, + { name: 'ai', label: i18n.modulesAILabel, description: i18n.modulesAIDescription }, + { name: 'aiNav', label: i18n.modulesAINavLabel, description: i18n.modulesAINavDescription }, + { name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription }, + { name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription }, + { name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription }, + { name: 'calendars', label: i18n.modulesCalendarsLabel, description: i18n.modulesCalendarsDescription }, + { name: 'chats', label: i18n.modulesChatsLabel, description: i18n.modulesChatsDescription }, + { name: 'cipher', label: i18n.modulesCipherLabel, description: i18n.modulesCipherDescription }, + { name: 'courts', label: i18n.modulesCourtsLabel, description: i18n.modulesCourtsDescription }, + { name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription }, + { name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription }, + { name: 'favorites', label: i18n.modulesFavoritesLabel, description: i18n.modulesFavoritesDescription }, + { name: 'fediverse', label: i18n.modulesFediverseLabel, description: i18n.modulesFediverseDescription }, + { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription }, + { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription }, + { name: 'games', label: i18n.modulesGamesLabel, description: i18n.modulesGamesDescription }, + { name: 'graphos', label: i18n.modulesGraphosLabel, description: i18n.modulesGraphosDescription }, + { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription }, + { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription }, + { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription }, + { name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription }, + { name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription }, + { name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription }, + { name: 'maps', label: i18n.modulesMapLabel, description: i18n.modulesMapDescription }, + { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription }, + { name: 'melody', label: i18n.modulesMelodyLabel, description: i18n.modulesMelodyDescription }, + { name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription }, + { name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription }, + { name: 'pads', label: i18n.modulesPadsLabel, description: i18n.modulesPadsDescription }, + { name: 'parliament', label: i18n.modulesParliamentLabel, description: i18n.modulesParliamentDescription }, + { name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription }, + { name: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription }, + { name: 'popular', label: i18n.modulesPopularLabel, description: i18n.modulesPopularDescription }, + { name: 'reports', label: i18n.modulesReportsLabel, description: i18n.modulesReportsDescription }, + { name: 'shops', label: i18n.modulesShopsLabel, description: i18n.modulesShopsDescription }, + { name: 'summaries', label: i18n.modulesSummariesLabel, description: i18n.modulesSummariesDescription }, + { name: 'tags', label: i18n.modulesTagsLabel, description: i18n.modulesTagsDescription }, + { name: 'tasks', label: i18n.modulesTasksLabel, description: i18n.modulesTasksDescription }, + { name: 'threads', label: i18n.modulesThreadsLabel, description: i18n.modulesThreadsDescription }, + { name: 'torrents', label: i18n.modulesTorrentsLabel, description: i18n.modulesTorrentsDescription }, + { name: 'transfers', label: i18n.modulesTransfersLabel, description: i18n.modulesTransfersDescription }, + { name: 'trending', label: i18n.modulesTrendingLabel, description: i18n.modulesTrendingDescription }, + { name: 'tribes', label: i18n.modulesTribesLabel, description: i18n.modulesTribesDescription }, + { name: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription }, + { name: 'videos', label: i18n.modulesVideosLabel, description: i18n.modulesVideosDescription }, + { name: 'votes', label: i18n.modulesVotationsLabel, description: i18n.modulesVotationsDescription }, + { name: 'wallet', label: i18n.modulesWalletLabel, description: i18n.modulesWalletDescription }, + { name: 'topics', label: i18n.modulesTopicsLabel, description: i18n.modulesTopicsDescription } + ]; + + const moduleStates = modules.reduce((acc, mod) => { + acc[`${mod.name}Mod`] = config[`${mod.name}Mod`] === 'on' ? 'on' : 'off'; + return acc; + }, {}); + + const activeModulesCount = modules.filter(mod => moduleStates[`${mod.name}Mod`] === 'on').length; + const totalModulesCount = modules.length; + const disabledModulesCount = totalModulesCount - activeModulesCount; + + const header = div({ class: 'tags-header' }, + h2(i18n.modulesViewTitle), + p(i18n.modulesViewDescription), + div({ style: 'margin-bottom:16px;' }, + `${i18n.modulesTotalModulesLabel}: `, + strong({ style: 'color:#888;' }, totalModulesCount), + ul({ style: 'list-style-type:none; padding:0; margin:0;' }, + li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, + `${i18n.modulesEnabledModulesLabel}: `, + span({ style: 'color:#888;' }, activeModulesCount) + ), + li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, + `${i18n.modulesDisabledModulesLabel}: `, + span({ style: 'color:#888;' }, disabledModulesCount) + ) + ) + ) + ); + + const PRESETS = { + minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'], + social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'], + economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'], + full: modules.map(m => m.name) + }; + + const presetButtons = div({ class: 'preset-group', style: 'display:flex;gap:8px;flex-wrap:nowrap;margin-bottom:16px;' }, + Object.entries(PRESETS).map(([key, mods]) => { + const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase(); + const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on')); + return form({ action: "/modules/preset", method: "post", style: "display:inline;margin:0;" }, + input({ type: "hidden", name: "preset", value: key }), + button({ + type: 'submit', + class: isActive ? 'filter-btn active' : 'filter-btn', + }, presetLabel) + ); + }) + ); + + return template( + i18n.modules, + section(header), + section( + h2(i18n.modulesPresetTitle || "Common Configurations"), + presetButtons, + form( + { action: "/save-modules", method: "post" }, + table( + { class: "module-table" }, + tr( + td(i18n.modulesModuleName), + td(i18n.modulesModuleDescription), + td({ style: 'text-align: center;' }, i18n.modulesModuleStatus) + ), + modules.map(mod => + tr( + td(a({ href: `/${mod.name}` }, mod.label)), + td(p(mod.description)), + td( + input({ + type: "checkbox", + id: `${mod.name}Mod`, + name: `${mod.name}Form`, + class: "input-checkbox", + value: 'on', + checked: moduleStates[`${mod.name}Mod`] === 'on' ? true : undefined + }) + ) + ) + ) + ), + div( + { class: "save-button-container", style: "margin-top: 20px; text-align: center;" }, + button({ type: "submit", class: "submit-button" }, i18n.saveSettings) + ) + ) + ) + ); +}; + +exports.modulesView = modulesView; + diff --git a/src/views/opinions_view.js b/src/views/opinions_view.js new file mode 100644 index 0000000..c742fd0 --- /dev/null +++ b/src/views/opinions_view.js @@ -0,0 +1,397 @@ +const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span, details, summary } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, renderSpreadButton, renderContentActions } = require('./main_views'); +const { config } = require('../server/SSB_server.js'); +const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); +const { renderUrl } = require('../backend/renderUrl'); +const opinionCategories = require('../backend/opinion_categories'); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); + +const seenDocumentTitles = new Set(); + +const detailHref = (type, key) => { + switch (type) { + case 'bookmark': return `/bookmarks/${encodeURIComponent(key)}`; + case 'image': return `/images/${encodeURIComponent(key)}`; + case 'video': return `/videos/${encodeURIComponent(key)}`; + case 'audio': return `/audios/${encodeURIComponent(key)}`; + case 'torrent': return `/torrents/${encodeURIComponent(key)}`; + case 'document': return `/documents/${encodeURIComponent(key)}`; + case 'feed': return `/feed/${encodeURIComponent(key)}`; + case 'votes': return `/votes/${encodeURIComponent(key)}`; + case 'transfer': return `/transfers/${encodeURIComponent(key)}`; + default: return null; + } +}; + +const renderContentHtml = (content, key) => { + switch (content.type) { + case 'bookmark': + return div({ class: 'opinion-bookmark' }, + div({ class: 'card-section bookmark' }, + h2(content.url ? div({ class: 'card-field' }, + span({ class: 'card-label' }, p(a({ href: content.url, target: '_blank', class: "bookmark-url" }, content.url))) + ) : ""), + content.lastVisit ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bookmarkLastVisitLabel + ':'), + span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString()) + ) : "", + content.description + ? [ + span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"), + p(...renderUrl(content.description)) + ] + : null + ) + ); + case 'image': + return div({ class: 'opinion-image' }, + div({ class: 'card-section image' }, + content.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), + span({ class: 'card-value' }, content.title) + ) : "", + content.description + ? [ + span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), + p(...renderUrl(content.description)) + ] + : null, + content.meme ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.trendingCategory + ':'), + span({ class: 'card-value' }, i18n.meme) + ) : "", + br(), + div({ class: 'card-field' }, + img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' }) + ) + ) + ); + case 'video': + return div({ class: 'opinion-video' }, + div({ class: 'card-section video' }, + content.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), + span({ class: 'card-value' }, content.title) + ) : "", + content.description + ? [ + span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), + p(...renderUrl(content.description)) + ] + : null, + div({ class: 'card-field' }, + videoHyperaxe({ + controls: true, + src: `/blob/${encodeURIComponent(content.url)}`, + type: content.mimeType || 'video/mp4', + width: '640', + height: '360' + }) + ) + ) + ); + case 'audio': + return div({ class: 'opinion-audio' }, + div({ class: 'card-section audio' }, + content.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), + span({ class: 'card-value' }, content.title) + ) : "", + content.description + ? [ + span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), + p(...renderUrl(content.description)) + ] + : null, + div({ class: 'card-field' }, + audioHyperaxe({ + controls: true, + src: `/blob/${encodeURIComponent(content.url)}`, + type: content.mimeType, + preload: 'metadata' + }) + ) + ) + ); + case 'torrent': + return div({ class: 'opinion-torrent' }, + div({ class: 'card-section' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : "" + ) + ); + case 'document': { + const t = content.title?.trim(); + if (t && seenDocumentTitles.has(t)) return null; + if (t) seenDocumentTitles.add(t); + return div({ class: 'opinion-document' }, + div({ class: 'card-section document' }, + t ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), + span({ class: 'card-value' }, t) + ) : "", + content.description + ? [ + span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), + p(...renderUrl(content.description)) + ] + : null, + div({ class: 'card-field' }, + div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` }) + ) + ) + ); + } + case 'feed': + return div({ class: 'opinion-feed' }, + div({ class: 'card-section feed' }, + div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text)) }), + content.refeeds + ? h2({ class: 'card-field' }, span({ class: 'card-label' }, `${i18n.tribeFeedRefeeds}: `), span({ class: 'card-value' }, content.refeeds)) + : "" + ) + ); + case 'votes': { + const votesList = content.votes && typeof content.votes === 'object' + ? Object.entries(content.votes).map(([option, count]) => ({ option, count })) + : []; + return div({ class: 'opinion-votes' }, + div({ class: 'card-section votes' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'), + span({ class: 'card-value' }, content.question) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteDeadline + ':'), + span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '') + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), + span({ class: 'card-value' }, content.totalVotes) + ), + table( + tr(...votesList.map(({ option }) => th(i18n[option] || option))), + tr(...votesList.map(({ count }) => td(count))) + ) + ) + ); + } + case 'transfer': + return div({ class: 'opinion-transfer' }, + div({ class: 'card-section transfer' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.concept + ':'), + span({ class: 'card-value' }, content.concept) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.deadline + ':'), + span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '') + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.status + ':'), + span({ class: 'card-value' }, content.status) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.amount + ':'), + span({ class: 'card-value' }, content.amount) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.from + ':'), + span({ class: 'card-value' }, userLink(content.from)) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.to + ':'), + span({ class: 'card-value' }, userLink(content.to)) + ), + h2({ class: 'card-field' }, + span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `), + span({ class: 'card-value' }, `${content.confirmedBy.length}/2`) + ) + ) + ); + default: + return div({ class: 'styled-text' }, + div({ class: 'card-section styled-text-content' }, + div({ class: 'card-field' }, + span({ class: 'card-value', innerHTML: sanitizeHtml(content.text || content.description || content.title || '[no content]') }) + ) + ) + ); + } +}; + +exports.opinionsView = (items, filter, spreadMap = new Map()) => { + seenDocumentTitles.clear(); + items = items + .filter(item => { + const c = item.value?.content || item.content; + return c && typeof c === 'object' && c.type !== 'tombstone'; + }) + .sort((a, b) => { + if (filter === 'TOP') { + const aVotes = (a.value.content.opinions_inhabitants || []).length; + const bVotes = (b.value.content.opinions_inhabitants || []).length; + return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp; + } + return b.value.timestamp - a.value.timestamp; + }); + + const title = i18n.opinionsTitle; + const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT']; + + const cards = items + .map(item => { + const c = item.value.content; + const key = item.key; + const contentHtml = renderContentHtml(c, key); + if (!contentHtml) return null; + const voteEntries = Object.entries(c.opinions || {}); + const total = voteEntries.reduce((sum, [, v]) => sum + v, 0); + const voted = c.opinions_inhabitants?.includes(config.keys.id); + const created = new Date(item.value.timestamp).toLocaleString(); + const allCats = opinionCategories; + + const isOwn = item.value.author && String(item.value.author) === String(config.keys.id); + return div( + { class: 'trending-card opinions-card' + (isOwn ? ' own-content' : '') }, + div({ class: 'card-header activity-card-header' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase()) + ), + renderContentActions(key, detailHref(c.type, key)) + ), + div( + { class: 'card-section opinions-card-body' }, + contentHtml, + div({ class: 'card-spread-left' }, renderSpreadButton(key, spreadMap.get(key))), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${created} ${i18n.performed} `), + userLink(item.value.author) + ), + (() => { + const entries = voteEntries.filter(([, v]) => v > 0); + const dominantPart = (() => { + if (!entries.length) return null; + const maxVal = Math.max(...entries.map(([, v]) => v)); + const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k); + return [ + span({ class: 'trending-dominant-sep' }, '|'), + span({ class: 'trending-dominant-text' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`) + ]; + })(); + return h2( + `${i18n.totalOpinions || i18n.opinionsTotalCount}: `, + span({ class: 'trending-total-count' }, String(total)), + ...(dominantPart || []) + ); + })(), + details({ class: 'opinions-voting-collapse' }, + summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`), + div({ class: 'voting-buttons' }, + allCats.map(cat => { + const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`; + if (voted) { + return button({ class: 'vote-btn', type: 'button' }, label); + } + return form({ method: 'POST', action: `/opinions/${encodeURIComponent(key)}/${cat}` }, + button({ class: 'vote-btn' }, label) + ); + }) + ) + ) + ) + ); + }) + .filter(Boolean); + + const hasDocuments = items.some(item => item.value.content?.type === 'document'); + const header = div({ class: 'tags-header' }, + h2(title), + p(i18n.shareYourOpinions) + ); + + const html = template( + title, + section( + header, + div({ class: 'mode-buttons' }, + div({ class: 'column' }, + baseFilters.map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ), + div({ class: 'column' }, + opinionCategories.positive.slice(0, 5).map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ), + div({ class: 'column' }, + opinionCategories.positive.slice(5, 10).map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ), + div({ class: 'column' }, + opinionCategories.positive.slice(10, 15).map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ) + ), + div({ class: 'mode-buttons' }, + div({ class: 'column' }, + opinionCategories.constructive.slice(0, 5).map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ), + div({ class: 'column' }, + opinionCategories.constructive.slice(5, 11).map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ), + div({ class: 'column' }, + opinionCategories.moderation.slice(0, 5).map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ), + div({ class: 'column' }, + opinionCategories.moderation.slice(5, 10).map(mode => + form({ method: 'GET', action: '/opinions' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode) + ) + ) + ) + ), + section( + cards.length + ? div({ class: 'opinions-container' }, ...cards) + : div({ class: 'no-results' }, p(i18n.noOpinionsFound)) + ) + ) + ); + + return `${html}${hasDocuments + ? ` + ` + : ''}`; +}; + diff --git a/src/views/pads_view.js b/src/views/pads_view.js new file mode 100644 index 0000000..25c9ec6 --- /dev/null +++ b/src/views/pads_view.js @@ -0,0 +1,392 @@ +const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views") +const { renderEncryptedChip } = require("./clearnet_view") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") + +const userId = config.keys.id + +const PAD_COLOR_CLASSES = ["pad-author-color-0","pad-author-color-1","pad-author-color-2","pad-author-color-3","pad-author-color-4","pad-author-color-5","pad-author-color-6","pad-author-color-7","pad-author-color-8","pad-author-color-9"] +const memberColorClass = (members, feedId) => { + const idx = members.indexOf(feedId) + return idx >= 0 ? PAD_COLOR_CLASSES[idx % PAD_COLOR_CLASSES.length] : "pad-author-color-none" +} + +const sliceChunksByOffset = (chunks, from, to) => { + const out = [] + let pos = 0 + for (const c of chunks) { + const cStart = pos + const cEnd = pos + c.text.length + if (cEnd <= from) { pos = cEnd; continue } + if (cStart >= to) break + const sliceStart = Math.max(0, from - cStart) + const sliceEnd = Math.min(c.text.length, to - cStart) + if (sliceEnd > sliceStart) out.push({ text: c.text.slice(sliceStart, sliceEnd), author: c.author }) + pos = cEnd + } + return out +} + +const mergeAdjacent = (chunks) => { + const out = [] + for (const c of chunks) { + if (!c.text) continue + if (out.length > 0 && out[out.length - 1].author === c.author) { + out[out.length - 1].text += c.text + } else { + out.push({ ...c }) + } + } + return out +} + +const computeAttributedChunks = (entries) => { + if (!entries || entries.length === 0) return [] + let chunks = [{ text: entries[0].text || "", author: entries[0].author }] + for (let i = 1; i < entries.length; i++) { + const prev = entries[i - 1].text || "" + const curr = entries[i].text || "" + const author = entries[i].author + let start = 0 + const maxStart = Math.min(prev.length, curr.length) + while (start < maxStart && prev.charCodeAt(start) === curr.charCodeAt(start)) start++ + let endPrev = prev.length + let endCurr = curr.length + while (endPrev > start && endCurr > start && prev.charCodeAt(endPrev - 1) === curr.charCodeAt(endCurr - 1)) { + endPrev-- + endCurr-- + } + const inserted = curr.slice(start, endCurr) + const headChunks = sliceChunksByOffset(chunks, 0, start) + const tailChunks = sliceChunksByOffset(chunks, endPrev, prev.length) + const middle = inserted ? [{ text: inserted, author }] : [] + chunks = mergeAdjacent([...headChunks, ...middle, ...tailChunks]) + } + return chunks +} + +const renderStatus = (status, isClosed) => { + if (isClosed) return span({ class: "pad-status-closed" }, i18n.padStatusClosed || "CLOSED") + if (status === "INVITE-ONLY") return span({ class: "pad-status-invite" }, i18n.padStatusInviteOnly || "INVITE-ONLY") + return span({ class: "pad-status-open" }, i18n.padStatusOpen || "OPEN") +} + +const renderPadStatusChip = (status, isClosed) => { + const s = isClosed ? "CLOSED" : String(status || "OPEN").toUpperCase() + const variant = s === "CLOSED" ? "closed" : s === "INVITE-ONLY" ? "whole" : "mutuals" + const icon = s === "CLOSED" ? "✗" : s === "INVITE-ONLY" ? "🔑" : "✓" + const label = s === "CLOSED" ? (i18n.padStatusClosed || "CLOSED") + : s === "INVITE-ONLY" ? (i18n.padStatusInviteOnly || "INVITE-ONLY") + : (i18n.padStatusOpen || "OPEN") + return renderStateChip(variant, icon, label) +} + +const renderModeButtons = (currentFilter) => + div({ class: "tribe-mode-buttons" }, + ["all", "mine", "recent", "open", "closed"].map(f => + form({ method: "GET", action: "/pads" }, + input({ type: "hidden", name: "filter", value: f }), + button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, + i18n[`padFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase()) + ) + ), + form({ method: "GET", action: "/pads" }, + input({ type: "hidden", name: "filter", value: "create" }), + button({ type: "submit", class: "create-button" }, i18n.padCreate || "Create Pad") + ) + ) + + +const renderPadCard = (pad, filter, spreadInfo) => { + const chips = [ + renderPadStatusChip(pad.status, pad.isClosed), + renderEncryptedChip(i18n), + renderLifespanChip(pad.lifetime, i18n) + ].filter(Boolean) + return div({ class: "tribe-card" }, + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/pads/${encodeURIComponent(pad.rootId)}` }, pad.title || "\u2014") + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + pad.deadline + ? p({ class: "job-meta-line" }, `${i18n.padDeadlineLabel || "Deadline"}: ${moment(pad.deadline).format("YYYY-MM-DD HH:mm")}`) + : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`) + ), + (() => { + const btn = renderSpreadButton(pad.rootId, spreadInfo); + return btn ? div({ class: "card-spread-left" }, btn) : null; + })(), + div({ class: "visit-btn-centered" }, + a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" }, i18n.padVisitPad || "Visit Pad") + ) + ) + ) +} + +const renderCreateForm = (padToEdit, params) => { + const tribeId = (params && params.tribeId) || "" + return div({ class: "div-center audio-form" }, + h2(padToEdit ? (i18n.padUpdateSectionTitle || "Update Pad") : (i18n.padCreateSectionTitle || "Create New Pad")), + form({ + method: "POST", + action: padToEdit ? `/pads/update/${encodeURIComponent(padToEdit.rootId)}` : "/pads/create" + }, + tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null, + span(i18n.padTitleLabel || "Title"), require("../server/node_modules/hyperaxe").br(), + input({ type: "text", name: "title", value: padToEdit ? padToEdit.title : "", placeholder: i18n.padTitlePlaceholder || "Enter pad title...", required: true }), + require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(), + span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(), + select({ name: "status" }, + ["OPEN", "INVITE-ONLY"].map(s => + option({ value: s, ...(padToEdit && padToEdit.status === s ? { selected: true } : {}) }, s) + ) + ), + require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(), + span(i18n.padDeadlineLabel || "Deadline"), require("../server/node_modules/hyperaxe").br(), + input({ + type: "datetime-local", + name: "deadline", + value: padToEdit && padToEdit.deadline ? moment(padToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "", + min: moment().format("YYYY-MM-DDTHH:mm") + }), + require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(), + span(i18n.padTagsLabel || "Tags"), require("../server/node_modules/hyperaxe").br(), + input({ type: "text", name: "tags", value: padToEdit ? padToEdit.tags.join(", ") : "", placeholder: i18n.padTagsPlaceholder || "tag1, tag2, ..." }), + require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(), + button({ type: "submit", class: "create-button" }, padToEdit ? (i18n.padUpdate || "Update Pad") : (i18n.padCreate || "Create Pad")) + ) + ) +} + +exports.renderPadInvitePage = (code) => { + const pageContent = div({ class: "invite-page" }, + h2(i18n.tribeInviteCodeText, code), + form({ method: "GET", action: "/pads" }, + input({ type: "hidden", name: "filter", value: "all" }), + button({ type: "submit", class: "filter-btn" }, i18n.walletBack) + ) + ) + return template(i18n.padInviteMode || "Invite", section(pageContent)) +} + +exports.padsView = async (pads, filter, padToEdit, params) => { + const q = String((params && params.q) || "").trim() + const isForm = filter === "create" || filter === "edit" + const headerMap = { + all: i18n.padAllSectionTitle || "Pads", + mine: i18n.padMineSectionTitle || "Your Pads", + recent: i18n.padRecentSectionTitle || "Recent Pads", + open: i18n.padOpenSectionTitle || "Open Pads", + closed: i18n.padClosedSectionTitle || "Closed Pads" + } + const headerText = headerMap[filter] || headerMap.all + + const filteredPads = q + ? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase())) + : pads + + const body = div({ class: "main-column" }, + div({ class: "tags-header" }, + h2(headerText), + p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.") + ), + renderModeButtons(filter), + !isForm + ? div({ class: "filters" }, + form({ method: "GET", action: "/pads" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q }), + br(), + button({ type: "submit" }, i18n.search), + br() + ) + ) + : null, + isForm + ? renderCreateForm(padToEdit, params) + : div( + filteredPads.length === 0 + ? p({ class: "no-content" }, i18n.padsNoItems || "No pads found.") + : div({ class: "tribe-grid" }, ...filteredPads.map(pd => renderPadCard(pd, filter, params && params.spreadMap && params.spreadMap.get(pd.rootId)))) + ) + ) + + return template(i18n.padsTitle || "Pads", body) +} + +exports.singlePadView = async (pad, entries, params) => { + const isAuthor = String(pad.author) === String(userId) + const isMember = pad.members.includes(userId) || (!!pad.tribeId && !!pad.isTribeMember) + const padClosed = pad.isClosed + const returnTo = `/pads/${encodeURIComponent(pad.rootId)}` + const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}` + const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY" + + const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0 + ? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`))) + : null + + const detailChips = [ + renderPadStatusChip(pad.status, padClosed), + renderEncryptedChip(i18n), + renderLifespanChip(pad.lifetime, i18n) + ].filter(Boolean) + const padSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, pad.title || "\u2014") + ), + detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null, + div({ class: "shop-share" }, + span({ class: "tribe-info-label" }, i18n.padShareUrl || "Share URL"), + input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" }) + ), + (() => { + const btn = renderSpreadButton(pad.rootId); + return btn ? div({ class: "card-spread-centered" }, btn) : null; + })(), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`) + ), + table({ class: "tribe-info-table" }, + tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))), + isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value pad-author-cell", colspan: "4" }, userLink(pad.author))), + isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014")) + ), + isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" }, + isAuthor && pad.status === "INVITE-ONLY" + ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite) + ) + : null, + (() => { + if (!(isAuthor && pad.status === "INVITE-ONLY")) return null + const invs = Array.isArray(pad.invites) ? pad.invites : [] + const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code) + if (openInvite) return [ + div({ class: "tribe-open-invite" }, + span({ class: "card-label" }, i18n.tribeInviteCodeText), + span({ class: "tribe-open-invite-code" }, openInvite.code) + ), + form({ method: "POST", action: `/pads/open-invite/remove/${encodeURIComponent(pad.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation) + ) + ] + return form({ method: "POST", action: `/pads/open-invite/create/${encodeURIComponent(pad.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation) + ) + })(), + form( + { method: "POST", action: pad.isFavorite ? `/pads/favorites/remove/${encodeURIComponent(pad.key)}` : `/pads/favorites/add/${encodeURIComponent(pad.key)}` }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button({ type: "submit", class: "tribe-action-btn" }, pad.isFavorite ? (i18n.padRemoveFavorite || "Remove Favorite") : (i18n.padAddFavorite || "Add Favorite")) + ), + !isAuthor + ? a({ href: `/pm?to=${encodeURIComponent(pad.author)}`, class: "tribe-action-btn" }, "PM") + : null, + isAuthor + ? form({ method: "GET", action: "/pads" }, + input({ type: "hidden", name: "filter", value: "edit" }), + input({ type: "hidden", name: "id", value: pad.rootId }), + button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update") + ) + : null, + isAuthor && pad.status !== "CLOSED" && !padClosed + ? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.padClose || "Close Pad") + ) + : null, + isAuthor + ? form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.padDelete || "Delete") + ) + : null + ), + !isAuthor && pad.status === "INVITE-ONLY" && !isMember + ? div({ class: "pad-invite-section" }, + a({ class: "tribe-action-btn", href: "/invites#invites-pads" }, i18n.tribeEnterInvite) + ) + : null, + !isRestrictedInviteOnly && !isAuthor && !isMember && pad.status === "OPEN" && !padClosed + ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` }, + button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!") + ) + : null, + tags + ) + + let canonicalEntries = entries + if (params.selectedVersion) { + const idx = entries.findIndex(e => e.key === params.selectedVersion.key) + if (idx >= 0) canonicalEntries = entries.slice(0, idx + 1) + } + const chunks = computeAttributedChunks(canonicalEntries) + const lastEntry = canonicalEntries.length > 0 ? canonicalEntries[canonicalEntries.length - 1] : null + const currentText = lastEntry ? lastEntry.text : "" + + const coloredView = chunks.length > 0 + ? div({ class: "pad-readonly-colored" }, + ...chunks.map(c => + span({ class: "pad-author-span " + memberColorClass(pad.members, c.author) }, c.text) + ) + ) + : p(i18n.padNoEntries || "No entries yet.") + + const visibleEntries = entries.filter(e => e.text && String(e.text).trim()) + const versionList = visibleEntries.length > 0 + ? div({ class: "pad-version-list" }, + h4(i18n.padVersionHistory || "Version History"), + ...visibleEntries.slice().reverse().map((e, idx) => + div({ class: "pad-version-item" }, + span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")), + span({ class: "pad-version-author" }, + span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }), + userLink(e.author) + ), + a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View") + ) + ) + ) + : null + + const editorArea = isMember && !padClosed && !params.selectedVersion + ? div({ class: "pad-editor-area" }, + coloredView, + form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` }, + textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText), + button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit") + ), + versionList ? div({ class: "pad-version-section" }, versionList) : null + ) + : div({ class: "pad-editor-area" }, + params.selectedVersion + ? div({ class: "pad-viewer-back" }, + a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" }, + "\u2190 " + (i18n.padBackToEditor || "Back to editor")) + ) + : null, + coloredView, + versionList ? div({ class: "pad-version-section" }, versionList) : null + ) + + const padMain = isRestrictedInviteOnly + ? div({ class: "tribe-main" }, p({ class: "access-denied-msg" }, i18n.padAccessDenied)) + : div({ class: "tribe-main" }, editorArea) + + return template( + pad.title || i18n.padsTitle || "Pad", + section( + div({ class: "tags-header" }, + h2(i18n.padsTitle || "Pads"), + p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.") + ), + renderModeButtons("all") + ), + section(div({ class: "tribe-details" }, padSide, padMain)) + ) +} diff --git a/src/views/parliament_view.js b/src/views/parliament_view.js new file mode 100644 index 0000000..7a9a9b1 --- /dev/null +++ b/src/views/parliament_view.js @@ -0,0 +1,986 @@ +const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); +const moment = require("../server/node_modules/moment"); +const { template, i18n, userLink} = require('./main_views'); + +const TERM_DAYS = 60; + +const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss'); +const timeLeft = (end) => { + const diff = moment(end).diff(moment()); + if (diff <= 0) return '0d 00:00:00'; + const dur = moment.duration(diff); + const d = Math.floor(dur.asDays()); + const h = String(dur.hours()).padStart(2,'0'); + const m = String(dur.minutes()).padStart(2,'0'); + const s = String(dur.seconds()).padStart(2,'0'); + return `${d}d ${h}:${m}:${s}`; +}; +const reqVotes = (method, total) => { + const m = String(method || '').toUpperCase(); + if (m === 'DEMOCRACY' || m === 'ANARCHY') return Math.floor(Number(total || 0) / 2) + 1; + if (m === 'MAJORITY') return Math.ceil(Number(total || 0) * 0.8); + if (m === 'MINORITY') return Math.ceil(Number(total || 0) * 0.2); + return 0; +}; +const showVoteMetrics = (method) => { + const m = String(method || '').toUpperCase(); + return !(m === 'DICTATORSHIP' || m === 'KARMATOCRACY'); +}; +const applyEl = (fn, attrs, kids) => fn.apply(null, [attrs || {}].concat(kids || [])); +const methodImageSrc = (method) => `assets/images/${String(method || '').toUpperCase().toLowerCase()}.png`; +const MethodBadge = (method) => { + const m = String(method || '').toUpperCase(); + const labelTxt = String(i18n[`parliamentMethod${m}`] || m).toUpperCase(); + return span( + { class: 'method-badge' }, + labelTxt, + br(),br(), + img({ src: methodImageSrc(m), alt: labelTxt, class: 'method-badge__icon' }) + ); +}; +const MethodHero = (method) => { + const m = String(method || '').toUpperCase(); + const labelTxt = String(i18n[`parliamentMethod${m}`] || m).toUpperCase(); + return span( + { class: 'method-hero' }, + labelTxt, + br(),br(), + img({ src: methodImageSrc(m), alt: labelTxt, class: 'method-hero__icon' }) + ); +}; +const KPI = (labelTxt, value) => + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, labelTxt), + span({ class: 'kpi__value' }, value) + ); +const CycleInfo = (start, end, labels = { + since: i18n.parliamentLegSince, + end: i18n.parliamentLegEnd, + remaining: i18n.parliamentTimeRemaining +}) => + div({ class: 'cycle-info' }, + KPI((labels.since + ': ').toUpperCase(), fmt(start)), + KPI((labels.end + ': ').toUpperCase(), fmt(end)), + KPI((labels.remaining + ': ').toUpperCase(), timeLeft(end)) + ); +const Tabs = (active) => + div( + { class: 'filters' }, + form( + { method: 'GET', action: '/parliament' }, + ['government', 'candidatures', 'proposals', 'laws', 'revocations', 'historical', 'leaders', 'rules'].map(f => + button({ type: 'submit', name: 'filter', value: f, class: active === f ? 'filter-btn active' : 'filter-btn' }, i18n[`parliamentFilter${f.charAt(0).toUpperCase()+f.slice(1)}`]) + ) + ) + ); + +const GovHeader = (g) => { + const termStart = g && g.since ? g.since : moment().toISOString(); + const termEnd = g && g.end ? g.end : moment(termStart).add(TERM_DAYS, 'days').toISOString(); + const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY'; + const methodKey = methodKeyRaw.toUpperCase(); + const i18nMeth = i18n[`parliamentMethod${methodKey}`]; + const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : methodKey).toUpperCase(); + const isAnarchy = methodKey === 'ANARCHY'; + const population = String(Number(g.inhabitantsTotal || 0)); + const votesReceivedNum = Number.isFinite(Number(g.votesReceived)) ? Number(g.votesReceived) : 0; + const totalVotesNum = Number.isFinite(Number(g.totalVotes)) ? Number(g.totalVotes) : 0; + const votesDisplay = `${votesReceivedNum} (${totalVotesNum})`; + return div( + { class: 'cycle-info' }, + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, (i18n.parliamentLegSince + ': ').toUpperCase()), + span({ class: 'kpi__value' }, fmt(termStart)) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, (i18n.parliamentLegEnd + ': ').toUpperCase()), + span({ class: 'kpi__value' }, fmt(termEnd)) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, (i18n.parliamentTimeRemaining + ': ').toUpperCase()), + span({ class: 'kpi__value' }, timeLeft(termEnd)) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, (i18n.parliamentPopulation + ': ').toUpperCase()), + span({ class: 'kpi__value' }, population) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), + span({ class: 'kpi__value' }, methodLabel) + ), + !isAnarchy + ? div({ class: 'kpi' }, + span({ class: 'kpi__label' }, (i18n.parliamentVotesReceived + ': ').toUpperCase()), + span({ class: 'kpi__value' }, votesDisplay) + ) + : null + ); +}; + +const GovernmentCard = (g, meta) => { + const termStart = g && g.since ? g.since : moment().toISOString(); + const termEnd = g && g.end ? g.end : moment(termStart).add(TERM_DAYS, 'days').toISOString(); + const actorLabel = + g.powerType === 'tribe' + ? (i18n.parliamentActorInPowerTribe || i18n.parliamentActorInPower || 'TRIBE RULING') + : (i18n.parliamentActorInPowerInhabitant || i18n.parliamentActorInPower || 'INHABITANT RULING'); + const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY'; + const methodKey = methodKeyRaw.toUpperCase(); + const i18nMeth = i18n[`parliamentMethod${methodKey}`]; + const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : methodKey).toUpperCase(); + const actorLink = + g.powerType === 'tribe' + ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) + : userLink(g.powerId, g.powerTitle); + const actorBio = meta && meta.bio ? meta.bio : ''; + const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []); + const membersRow = + g.powerType === 'tribe' + ? tr( + { class: 'parliament-members-row' }, + td( + { colspan: 2 }, + div( + span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()), + memberIds && memberIds.length + ? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(userLink(id)))) + : span({ class: 'card-value' }, String(g.members || 0)) + ) + ) + ) + : null; + return div( + { class: 'card' }, + h2(i18n.parliamentGovernmentCard), + GovHeader(g), + div({ class: 'method-image-centered' }, img({ src: methodImageSrc(methodKey), alt: methodLabel })), + div( + { class: 'table-wrap' }, + applyEl(table, { class: 'table table--centered gov-overview' }, [ + thead(tr( + th(i18n.parliamentGovMethod), + th(i18n.parliamentThPresented || 'CANDIDATURES'), + th(i18n.parliamentPoliciesProposal || 'LAWS PROPOSAL'), + th(i18n.parliamentPoliciesApproved || 'LAWS APPROVED'), + th(i18n.parliamentPoliciesDeclined || 'LAWS DECLINED'), + th(i18n.parliamentPoliciesDiscarded || 'LAWS DISCARDED'), + th(i18n.parliamentPoliciesRevocated || 'LAWS REVOCATED'), + th(i18n.parliamentEfficiency || '% EFFICIENCY') + )), + tbody(tr( + td({ 'data-label': i18n.parliamentGovMethod }, methodLabel), + td({ 'data-label': i18n.parliamentThPresented || 'CANDIDATURES' }, String(g.presented || 0)), + td({ 'data-label': i18n.parliamentPoliciesProposal || 'LAWS PROPOSAL' }, String(g.proposed || 0)), + td({ 'data-label': i18n.parliamentPoliciesApproved || 'LAWS APPROVED' }, String(g.approved || 0)), + td({ 'data-label': i18n.parliamentPoliciesDeclined || 'LAWS DECLINED' }, String(g.declined || 0)), + td({ 'data-label': i18n.parliamentPoliciesDiscarded || 'LAWS DISCARDED' }, String(g.discarded || 0)), + td({ 'data-label': i18n.parliamentPoliciesRevocated || 'LAWS REVOCATED' }, String(g.revocated || 0)), + td({ 'data-label': i18n.parliamentEfficiency || '% EFFICIENCY' }, `${String(g.efficiency || 0)} %`) + )) + ]) + ), + (g.powerType === 'tribe' || g.powerType === 'inhabitant') + ? div( + { class: 'table-wrap mt-2' }, + applyEl(table, { class: 'table parliament-actor-table' }, [ + thead(tr( + th({ class: 'parliament-actor-col' }, String(actorLabel).toUpperCase()), + th({ class: 'parliament-description-col' }, i18n.description.toUpperCase()) + )), + tbody( + tr( + td({ class: 'parliament-actor-col' }, div({ class: 'leader-cell' }, actorLink)), + td({ class: 'parliament-description-col' }, p(actorBio || '-')) + ), + membersRow + ) + ]) + ) + : null + ); +}; + +const NoGovernment = () => div({ class: 'empty' }, p(i18n.parliamentNoStableGov)); +const NoProposals = () => div({ class: 'empty' }, p(i18n.parliamentNoProposals)); +const NoLaws = () => div({ class: 'empty' }, p(i18n.parliamentNoLaws)); +const NoGovernments = () => div({ class: 'empty' }, p(i18n.parliamentNoGovernments)); +const NoRevocations = () => null; + +const CandidatureForm = () => + div( + { class: 'div-center' }, + h2(i18n.parliamentCandidatureFormTitle), + form( + { method: 'POST', action: '/parliament/candidatures/propose' }, + label(i18n.parliamentCandidatureId), br(), + input({ type: 'text', name: 'candidateId', placeholder: i18n.parliamentCandidatureIdPh, required: true }), br(), br(), + label(i18n.parliamentCandidatureMethod), br(), + select({ name: 'method' }, + ['DEMOCRACY','MAJORITY','MINORITY','DICTATORSHIP','KARMATOCRACY'].map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m)) + ), br(), br(), + button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn) + ) + ); + +const pickLeader = (arr) => { + if (!arr || !arr.length) return null; + const sorted = [...arr].sort((a, b) => { + const va = Number(a.votes || 0), vb = Number(b.votes || 0); + if (vb !== va) return vb - va; + const ka = Number(a.karma || 0), kb = Number(b.karma || 0); + if (kb !== ka) return kb - ka; + const sa = Number(a.profileSince || 0), sb = Number(b.profileSince || 0); + if (sa !== sb) return sa - sb; + const ca = new Date(a.createdAt).getTime(), cb = new Date(b.createdAt).getTime(); + if (ca !== cb) return ca - cb; + return String(a.targetId).localeCompare(String(b.targetId)); + }); + return sorted[0]; +}; + +const CandidatureStats = (cands, govCard, leaderMeta, electionQuorum = 2) => { + if (!cands || !cands.length) return null; + const leader = pickLeader(cands || []); + if (!leader) return null; + const methodKey = String(leader.method || '').toUpperCase(); + const methodLabel = String(i18n[`parliamentMethod${methodKey}`] || methodKey).toUpperCase(); + const votes = String(leader.votes || 0); + const hasQuorum = Number(leader.votes || 0) >= electionQuorum; + const avatarSrc = (leaderMeta && leaderMeta.avatarUrl) ? leaderMeta.avatarUrl : '/assets/images/default-avatar.png'; + const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase(); + const idLink = leader + ? (leader.targetType === 'inhabitant' + ? userLink(leader.targetId) + : a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId)) + : null; + const avatarHref = leader + ? (leader.targetType === 'inhabitant' + ? `/author/${encodeURIComponent(leader.targetId)}` + : `/tribe/${encodeURIComponent(leader.targetId)}?`) + : null; + return div( + { class: 'card' }, + h2(i18n.parliamentElectionsStatusTitle), + div({ class: 'card-field card-field--spaced' }, + span({ class: 'card-label' }, winLbl + ': '), + span({ class: 'card-value' }, hasQuorum ? idLink : span({ style: 'color:#ffcc00;font-weight:bold;' }, i18n.voteNoQuorum || 'NO QUORUM')) + ), + hasQuorum + ? div({ class: 'card-field card-field--spaced' }, + span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), + span({ class: 'card-value' }, methodLabel) + ) + : null, + hasQuorum + ? div( + { class: 'table-wrap mt-2' }, + applyEl(table, { class: 'parliament-gov-table' }, [ + thead(tr( + th(i18n.parliamentThLeader), + th({ class: 'parliament-method-col' }, i18n.parliamentGovMethod), + th({ class: 'parliament-votes-col' }, i18n.parliamentVotesReceived) + )), + tbody(tr( + td( + div({ class: 'parliament-avatar-cell' }, + avatarHref + ? a({ href: avatarHref }, img({ src: avatarSrc, class: 'parliament-leader-avatar' })) + : img({ src: avatarSrc, class: 'parliament-leader-avatar' }), + idLink ? div({ class: 'parliament-avatar-name' }, idLink) : null + ) + ), + td({ class: 'parliament-method-col' }, + img({ src: methodImageSrc(methodKey), alt: methodLabel, class: 'parliament-method-img' }) + ), + td({ class: 'parliament-votes-col' }, span({ class: 'votes-value' }, votes)) + )) + ]) + ) + : null + ); +}; + +const CandidaturesTable = (candidatures) => { + const rows = (candidatures || []).map(c => { + const idLink = + c.targetType === 'inhabitant' + ? p(userLink(c.targetId)) + : p(a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(c.targetId)}?` }, c.targetTitle || c.targetId)); + const voters = Array.isArray(c.voters) ? c.voters : []; + return tr( + td(idLink), + td(fmt(c.createdAt)), + td({ class: 'nowrap' }, c.method), + td(c.targetType === 'inhabitant' ? String(c.karma || 0) : '-'), + td( + span({ class: 'candidature-votes-count' }, String(c.votes || 0)), + voters.length ? div({ class: 'candidature-voters' }, ...voters.map(v => div({ class: 'candidature-voter' }, userLink(v)))) : null + ), + td(form({ method: 'POST', action: `/parliament/candidatures/${encodeURIComponent(c.id)}/vote` }, button({ class: 'vote-btn' }, i18n.parliamentVoteBtn))) + ); + }); + return div( + { class: 'table-wrap' }, + h2(i18n.parliamentCandidaturesListTitle), + applyEl(table, { class: 'table table--centered' }, [ + thead(tr( + th(i18n.parliamentThId), + th(i18n.parliamentThProposalDate), + th(i18n.parliamentThMethod), + th(i18n.parliamentThKarma), + th(i18n.parliamentThSupports), + th(i18n.parliamentThVote) + )), + applyEl(tbody, null, rows) + ]) + ); +}; + +const ProposalForm = () => + div( + { class: 'div-center' }, + h2(i18n.parliamentProposalFormTitle), + form( + { method: 'POST', action: '/parliament/proposals/create' }, + label(i18n.parliamentProposalTitle), br(), + input({ type: 'text', name: 'title', required: true }), br(), br(), + label(i18n.parliamentProposalDescription), br(), + textarea({ name: 'description', rows: 5, maxlength: 1000 }), br(), br(), + button({ type: 'submit', class: 'create-button' }, i18n.parliamentProposalPublish) + ) + ); + +const ProposalsList = (proposals) => { + if (!proposals || !proposals.length) return null; + const cards = proposals.map(pItem => { + const titleNode = pItem && pItem.voteId + ? a({ class: 'proposal-title-link', href: `/votes/${encodeURIComponent(pItem.voteId)}` }, pItem.title || '') + : (pItem.title || ''); + const onTrackLabel = pItem && pItem.onTrack + ? (i18n.parliamentProposalOnTrackYes || 'THRESHOLD REACHED') + : (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD'); + return div( + { class: 'card' }, + br(), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), + span({ class: 'card-value' }, fmt(pItem.createdAt)) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), + span({ class: 'card-value' }, userLink(pItem.proposer)) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), + span({ class: 'card-value' }, pItem.method) + ), + br(), + div( + h2(titleNode), + p(pItem.description || '') + ), + pItem.deadline + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '), + span({ class: 'card-value' }, fmt(pItem.deadline)) + ) + : null, + pItem.deadline + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '), + span({ class: 'card-value' }, timeLeft(pItem.deadline)) + ) + : null, + showVoteMetrics(pItem.method) + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '), + span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total))) + ) + : null, + showVoteMetrics(pItem.method) + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '), + span({ class: 'card-value' }, `${Number(pItem.total || 0)}/${Number(pItem.needed || 0)}`) + ) + : null, + showVoteMetrics(pItem.method) + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentProposalVoteStatusLabel.toUpperCase() + ': '), + span({ class: 'card-value' }, onTrackLabel) + ) + : null, + pItem && pItem.voteId + ? form( + { method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` }, + button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction) + ) + : null + ); + }); + return div( + { class: 'cards' }, + h2(i18n.parliamentCurrentProposalsTitle), + applyEl(div, null, cards) + ); +}; + +const FutureLawsList = (rows) => { + if (!rows || !rows.length) return null; + const cards = rows.map(pItem => + div( + { class: 'card' }, + br(), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))), + h2(pItem.title || ''), + p(pItem.description || '') + ) + ); + return div( + { class: 'cards' }, + h2(i18n.parliamentFutureLawsTitle), + applyEl(div, null, cards) + ); +}; + +const RevocationForm = (laws = []) => + div( + { class: 'div-center' }, + h2(i18n.parliamentRevocationFormTitle), + form( + { + method: 'POST', + action: '/parliament/revocations/create' + }, + label(i18n.parliamentRevocationLaw), br(), + select( + { name: 'lawId', required: true }, + ...(laws || []).map(l => + option( + { value: l.id }, + `${l.question || l.title || l.id}` + ) + ) + ), + br(), br(), + label(i18n.parliamentRevocationReasons), br(), + textarea({ name: 'reasons', rows: 4, maxlength: 1000 }), + br(), br(), + button({ type: 'submit', class: 'create-button' }, i18n.parliamentRevocationPublish || 'Publish Revocation') + ) + ); + +const RevocationsList = (revocations) => { + if (!revocations || !revocations.length) return null; + const cards = revocations.map(pItem => { + const titleNode = pItem && pItem.voteId + ? a({ class: 'revocation-title-link', href: `/votes/${encodeURIComponent(pItem.voteId)}` }, pItem.title || pItem.lawTitle || '') + : (pItem.title || pItem.lawTitle || ''); + const onTrackLabel = pItem && pItem.onTrack + ? (i18n.parliamentProposalOnTrackYes || 'THRESHOLD REACHED') + : (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD'); + return div( + { class: 'card' }, + br(), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), + span({ class: 'card-value' }, fmt(pItem.createdAt)) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), + span( + { class: 'card-value' }, + userLink(pItem.proposer) + ) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentGovMethod + ': '), + span({ class: 'card-value' }, pItem.method) + ), + br(), + div( + h2(titleNode), + p(pItem.reasons || '') + ), + pItem.deadline + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentProposalDeadlineLabel.toUpperCase() + ': '), + span({ class: 'card-value' }, fmt(pItem.deadline)) + ) + : null, + pItem.deadline + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentProposalTimeLeft.toUpperCase() + ': '), + span({ class: 'card-value' }, timeLeft(pItem.deadline)) + ) + : null, + showVoteMetrics(pItem.method) + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentVotesNeeded.toUpperCase() + ': '), + span({ class: 'card-value' }, String(pItem.needed || reqVotes(pItem.method, pItem.total))) + ) + : null, + showVoteMetrics(pItem.method) + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentVotesSlashTotal.toUpperCase() + ': '), + span({ class: 'card-value' }, `${Number(pItem.total || 0)}/${Number(pItem.needed || 0)}`) + ) + : null, + showVoteMetrics(pItem.method) + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentProposalVoteStatusLabel.toUpperCase() + ': '), + span({ class: 'card-value' }, onTrackLabel) + ) + : null, + pItem && pItem.voteId + ? form( + { method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` }, + button({ type: 'submit', class: 'vote-btn' }, i18n.parliamentVoteAction) + ) + : null + ); + }); + return div( + { class: 'cards' }, + h2(i18n.parliamentCurrentRevocationsTitle), + applyEl(div, null, cards) + ); +}; + +const FutureRevocationsList = (rows) => { + if (!rows || !rows.length) return null; + const cards = rows.map(pItem => + div( + { class: 'card' }, + br(), + pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))), + h2(pItem.title || pItem.lawTitle || ''), + p(pItem.reasons || '') + ) + ); + return div( + { class: 'cards' }, + h2(i18n.parliamentFutureRevocationsTitle), + applyEl(div, null, cards) + ); +}; + +const LawsStats = (laws = [], revocatedCount = 0) => { + const proposed = laws.length; + const approved = laws.length; + const declined = 0; + const discarded = 0; + const revocated = Number(revocatedCount || 0); + return div( + { class: 'table-wrap' }, + h2(i18n.parliamentPoliciesTitle || 'POLICIES'), + applyEl(table, { class: 'table table--centered' }, [ + thead(tr( + th(i18n.parliamentThProposed), + th(i18n.parliamentThApproved), + th(i18n.parliamentThDeclined), + th(i18n.parliamentThDiscarded), + th(i18n.parliamentPoliciesRevocated) + )), + tbody( + tr( + td(String(proposed)), + td(String(approved)), + td(String(declined)), + td(String(discarded)), + td(String(revocated)) + ) + ) + ]) + ); +}; + +const LawsList = (laws) => { + if (!laws || !laws.length) return NoLaws(); + const cards = laws.map(l => { + const total = Number((l.votes && (l.votes.total || l.votes.TOTAL)) || 0); + const yes = Number((l.votes && (l.votes.YES || l.votes.Yes || l.votes.yes)) || 0); + const needed = reqVotes(l.method, total); + const showMetricsFlag = showVoteMetrics(l.method); + return div( + { class: 'card' }, + br(), + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)), + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(l.proposer))), + h2(l.question || ''), + p(l.description || ''), + showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null, + showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesSlashTotal + ': ').toUpperCase()), span({ class: 'card-value' }, `${yes}/${total}`)) : null + ); + }); + return div( + { class: 'cards' }, + h2(i18n.parliamentLawsTitle || 'LAWS'), + applyEl(div, null, cards) + ); +}; + +const HistoricalGovsSummary = (rows = []) => { + const byMethod = new Map(); + for (const g of rows) { + const k = String(g.method || 'ANARCHY').toUpperCase(); + if (!byMethod.has(k)) byMethod.set(k, { cycles: 0, proposed: 0, approved: 0, declined: 0, discarded: 0, revocated: 0 }); + const rec = byMethod.get(k); + rec.cycles += 1; + rec.proposed += Number(g.proposed || 0); + rec.approved += Number(g.approved || 0); + rec.declined += Number(g.declined || 0); + rec.discarded += Number(g.discarded || 0); + rec.revocated += Number(g.revocated || 0); + } + const entries = Array.from(byMethod.entries()).sort((a,b) => String(a[0]).localeCompare(String(b[0]))); + const lines = entries.map(([method, rec]) => + tr( + td(method), + td(String(rec.proposed)), + td(String(rec.approved)), + td(String(rec.declined)), + td(String(rec.discarded)), + td(String(rec.revocated)), + td(String(rec.cycles)) + ) + ); + return div( + { class: 'table-wrap' }, + h2(i18n.parliamentHistoricalGovernmentsTitle || 'Governments'), + applyEl(table, { class: 'table table--centered' }, [ + thead(tr( + th(String(i18n.parliamentGovMethod).toUpperCase()), + th(i18n.parliamentPoliciesProposal), + th(i18n.parliamentPoliciesApproved), + th(i18n.parliamentPoliciesDeclined), + th(i18n.parliamentPoliciesDiscarded), + th(i18n.parliamentPoliciesRevocated), + th(i18n.parliamentThCycles) + )), + applyEl(tbody, null, lines) + ]) + ); +}; + +const HistoricalList = (rows, metasByKey = {}) => { + if (!rows || !rows.length) return NoGovernments(); + const cards = rows.map(g => { + const key = `${g.powerType}:${g.powerId}`; + const meta = metasByKey[key]; + const showActor = g.powerType === 'tribe' || g.powerType === 'inhabitant'; + const showMembers = g.powerType === 'tribe'; + const actorLabel = + g.powerType === 'tribe' + ? (i18n.parliamentActorInPowerTribe || 'TRIBE RULING') + : (i18n.parliamentActorInPowerInhabitant || 'INHABITANT RULING'); + return div( + { class: 'card' }, + h2(g.method), + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.parliamentLegSince + ': ').toUpperCase()), + span({ class: 'card-value' }, fmt(g.since)) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.parliamentLegEnd + ': ').toUpperCase()), + span({ class: 'card-value' }, fmt(g.end)) + ), + showActor ? div({ class: 'card-field' }, + span({ class: 'card-label' }, String(actorLabel).toUpperCase() + ': '), + span({ class: 'card-value' }, + g.powerType === 'tribe' + ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) + : userLink(g.powerId, g.powerTitle) + ) + ) : null, + (g.method !== 'ANARCHY') + ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentVotesReceived + ': '), + span({ class: 'card-value' }, `${g.votesReceived} (${g.totalVotes})`) + ) + : null, + br(), + showActor && meta && (meta.avatarUrl || meta.bio) + ? div( + { class: 'actor-meta' }, + meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'avatar--lg' }) : null, + meta.bio ? p({ class: 'bio' }, meta.bio) : null + ) + : null, + showMembers + ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentMembers + ': '), + span({ class: 'card-value' }, String(g.members || 0)) + ) + : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentPoliciesProposal + ': '), + span({ class: 'card-value' }, String(g.proposed || 0)) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentPoliciesApproved + ': '), + span({ class: 'card-value' }, String(g.approved || 0)) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentPoliciesDeclined + ': '), + span({ class: 'card-value' }, String(g.declined || 0)) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentPoliciesDiscarded + ': '), + span({ class: 'card-value' }, String(g.discarded || 0)) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentPoliciesRevocated + ': '), + span({ class: 'card-value' }, String(g.revocated || 0)) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentEfficiency + ': '), + span({ class: 'card-value' }, `${g.efficiency || 0} %`) + ) + ); + }); + return div( + { class: 'cards' }, + h2(i18n.parliamentHistoricalElectionsTitle || 'ELECTION CYCLES'), + applyEl(div, null, cards) + ); +}; + +const LeadersSummary = (leaders = [], candidatures = [], allGovCards = [], totalCandidatures = null) => { + const timesInPower = leaders.reduce((s, l) => s + Number(l.inPower || 0), 0); + const totals = (allGovCards || []).reduce((acc, g) => { + acc.proposed += Number(g.proposed || 0); + acc.approved += Number(g.approved || 0); + acc.declined += Number(g.declined || 0); + acc.discarded += Number(g.discarded || 0); + acc.revocated += Number(g.revocated || 0); + return acc; + }, { proposed:0, approved:0, declined:0, discarded:0, revocated:0 }); + const presented = totalCandidatures != null ? Number(totalCandidatures) : (candidatures || []).length; + const efficiencyPct = totals.proposed > 0 ? Math.round((totals.approved / totals.proposed) * 100) : 0; + return div( + { class: 'table-wrap' }, + h2(i18n.parliamentHistoricalLawsTitle || 'Actions'), + applyEl(table, { class: 'table table--centered' }, [ + thead(tr( + th(i18n.parliamentThTotalCandidatures), + th(i18n.parliamentThTimesInPower), + th(i18n.parliamentThProposed), + th(i18n.parliamentThApproved), + th(i18n.parliamentThDeclined), + th(i18n.parliamentThDiscarded), + th(i18n.parliamentPoliciesRevocated), + th(i18n.parliamentEfficiency) + )), + tbody( + tr( + td(String(presented)), + td(String(timesInPower)), + td(String(totals.proposed)), + td(String(totals.approved)), + td(String(totals.declined)), + td(String(totals.discarded)), + td(String(totals.revocated)), + td(`${efficiencyPct} %`) + ) + ) + ]) + ); +}; + +const LeadersList = (leaders, metas = {}, candidatures = []) => { + if (!leaders || !leaders.length) return div({ class: 'empty' }, p(i18n.parliamentNoLeaders)); + const rows = leaders.map(l => { + const key = `${l.powerType}:${l.powerId}`; + const meta = metas[key] || {}; + const avatarHref = l.powerType === 'tribe' + ? `/tribe/${encodeURIComponent(l.powerId)}` + : `/author/${encodeURIComponent(l.powerId)}`; + const avatar = meta.avatarUrl + ? a({ href: avatarHref }, img({ src: meta.avatarUrl, alt: '', class: 'leader-table__avatar' })) + : null; + const link = l.powerType === 'tribe' + ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId) + : userLink(l.powerId, l.powerTitle); + const leaderCell = div({ class: 'leader-cell parliament-avatar-cell' }, avatar, div({ class: 'parliament-avatar-name' }, link)); + return tr( + td(leaderCell), + td(String(l.proposed || 0)), + td(String(l.approved || 0)), + td(String(l.declined || 0)), + td(String(l.discarded || 0)), + td(String(l.revocated || 0)), + td(`${(l.efficiency != null ? Math.round(l.efficiency * 100) : (l.proposed > 0 ? Math.round((l.approved / l.proposed) * 100) : 0))} %`) + ); + }); + return div( + { class: 'table-wrap' }, + h2(i18n.parliamentHistoricalLeadersTitle), + applyEl(table, { class: 'table table--centered gov-overview' }, [ + thead(tr( + th(i18n.parliamentThLeader), + th(i18n.parliamentPoliciesProposal), + th(i18n.parliamentPoliciesApproved), + th(i18n.parliamentPoliciesDeclined), + th(i18n.parliamentPoliciesDiscarded), + th(i18n.parliamentPoliciesRevocated), + th(i18n.parliamentEfficiency) + )), + applyEl(tbody, null, rows) + ]) + ); +}; + +const RulesContent = () => { + const points = [ + i18n.parliamentRulesIntro, + i18n.parliamentRulesTerm, + i18n.parliamentRulesMethods, + i18n.parliamentRulesAnarchy, + i18n.parliamentRulesCandidates, + i18n.parliamentRulesElection, + i18n.parliamentRulesTies, + i18n.parliamentRulesProposals, + i18n.parliamentRulesLimit, + i18n.parliamentRulesLaws, + i18n.parliamentRulesRevocations, + i18n.parliamentRulesHistorical, + i18n.parliamentRulesLeaders + ]; + return div( + { class: 'card' }, + h2(i18n.parliamentRulesTitle), + div({ class: 'rules-points' }, + points.filter(Boolean).map((t, i) => + div({ class: 'rules-point' }, + span({ class: 'rules-point-num' }, String(i + 1)), + span({ class: 'rules-point-text' }, t) + ) + ) + ) + ); +}; + +const CandidaturesSection = (governmentCard, candidatures, leaderMeta, electionQuorum = 2) => { + return div( + h2(i18n.parliamentGovernmentCard), + GovHeader(governmentCard || {}), + CandidatureStats(candidatures || [], governmentCard || null, leaderMeta || null, electionQuorum), + CandidatureForm(), + candidatures && candidatures.length ? CandidaturesTable(candidatures) : null + ); +}; + +const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) => { + const has = proposals && proposals.length > 0; + const fl = FutureLawsList(futureLaws || []); + if (!has && canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), fl); + if (!has && !canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), NoProposals(), fl); + return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), ProposalsList(proposals), fl); +}; + +const RevocationsSection = (governmentCard, laws, revocations, futureRevocations) => + div( + h2(i18n.parliamentGovernmentCard), + GovHeader(governmentCard || {}), + RevocationForm(laws || []), + RevocationsList(revocations || []) || '', + FutureRevocationsList(futureRevocations || []) || '' + ); + +const normalizeGovCard = (governmentCard, inhabitantsTotal) => { + const pop = Number(inhabitantsTotal ?? governmentCard?.inhabitantsTotal ?? 0) || 0; + if (governmentCard && (governmentCard.method || governmentCard.since || governmentCard.end || governmentCard.powerType)) { + return { ...governmentCard, inhabitantsTotal: pop }; + } + return null; +}; + +const parliamentView = async (state) => { + const { + filter, + governmentCard, + candidatures, + proposals, + futureLaws, + canPropose, + laws, + historical, + leaders, + leaderMeta, + powerMeta, + revocations, + futureRevocations, + revocationsEnactedCount, + historicalMetas = {}, + leadersMetas = {}, + inhabitantsTotal + } = state; + + const fallbackGov = { + method: 'ANARCHY', + votesReceived: 0, + totalVotes: 0, + proposed: 0, + approved: 0, + declined: 0, + discarded: 0, + revocated: 0, + efficiency: 0, + powerType: 'none', + powerId: null, + powerTitle: 'ANARCHY', + since: moment().toISOString(), + end: moment().add(TERM_DAYS, 'days').toISOString(), + inhabitantsTotal: Number(inhabitantsTotal ?? 0) || 0 + }; + + const gov = normalizeGovCard(governmentCard, inhabitantsTotal) || fallbackGov; + const electionQuorum = Math.max(2, Math.ceil((Number(inhabitantsTotal) || 0) * 0.25)); + const leadersGovCards = [...(historical || []), ...(governmentCard ? [governmentCard] : [])]; + const leadersTotalCandidatures = governmentCard && governmentCard.presented != null ? governmentCard.presented : null; + + const LawsSectionWrap = () => + div( + LawsStats(laws || [], revocationsEnactedCount || 0), + LawsList(laws || []) + ); + + return template( + i18n.parliamentTitle, + section(div({ class: 'tags-header' }, h2(i18n.parliamentTitle), p(i18n.parliamentDescription)), Tabs(filter)), + section( + filter === 'government' ? GovernmentCard(gov, powerMeta) : null, + filter === 'candidatures' ? CandidaturesSection(gov, candidatures, leaderMeta, electionQuorum) : null, + filter === 'proposals' ? ProposalsSection(gov, proposals, futureLaws, canPropose) : null, + filter === 'laws' ? LawsSectionWrap() : null, + filter === 'revocations' ? RevocationsSection(gov, laws, revocations, futureRevocations) : null, + filter === 'historical' ? div(HistoricalGovsSummary(historical || []), HistoricalList(historical || [], historicalMetas)) : null, + filter === 'leaders' ? div(LeadersSummary(leaders || [], candidatures || [], leadersGovCards, leadersTotalCandidatures), LeadersList(leaders || [], leadersMetas, candidatures || [])) : null, + filter === 'rules' ? RulesContent() : null + ) + ); +}; + +module.exports = { parliamentView, pickLeader }; + diff --git a/src/views/peers_view.js b/src/views/peers_view.js new file mode 100644 index 0000000..4f9e793 --- /dev/null +++ b/src/views/peers_view.js @@ -0,0 +1,144 @@ +const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadcastActive = false, technicalPeers = [] }) => { + const { form, button, div, h2, p, section, a, hr, input, label, br, span, table, tr, td, textarea } = require("../server/node_modules/hyperaxe"); + const { template, i18n } = require('./main_views'); + + const startButton = form({ action: "/settings/conn/start", method: "post" }, button({ type: "submit" }, i18n.startNetworking)); + const restartButton = form({ action: "/settings/conn/restart", method: "post" }, button({ type: "submit" }, i18n.restartNetworking)); + const stopButton = form({ action: "/settings/conn/stop", method: "post" }, button({ type: "submit" }, i18n.stopNetworking)); + const syncButton = form({ action: "/settings/conn/sync", method: "post" }, button({ type: "submit" }, i18n.sync)); + const connButtons = [startButton, restartButton, stopButton, syncButton]; + + const deduplicatePeers = (peers) => { + const seen = new Set(); + return peers.filter(p => { + const key = p[1]?.key; + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + + const sourceLabel = (src) => { + const s = String(src || '').toLowerCase(); + if (s === 'rpc') return i18n.peerSourceRpc || 'RPC'; + if (s === 'gossip') return i18n.peerSourceGossip || 'Gossip'; + if (s === 'ebt') return i18n.peerSourceEbt || 'EBT'; + if (s === 'recent') return i18n.peerSourceRecent || 'Recent'; + if (s === 'lan') return i18n.peerSourceLan || 'LAN'; + return null; + }; + + const renderPeerRow = (peerData) => { + const peer = peerData[1]; + const { name, users, key } = peer; + const peerUrl = `/author/${encodeURIComponent(key)}`; + const filteredUsers = (users || []).filter(u => u.id !== key); + const userCount = filteredUsers.length; + return tr( + td({ 'data-label': i18n.peerHost || 'Pub' }, a({ href: peerUrl, class: "user-link" }, name || key.slice(0, 20) + '…')), + td({ 'data-label': 'Key' }, a({ href: peerUrl, class: 'user-link peer-key' }, key)), + td({ 'data-label': i18n.peersReplicatedFeeds || 'Feeds' }, String(userCount)) + ); + }; + + const dedupOnline = deduplicatePeers(onlinePeers); + const dedupDiscovered = deduplicatePeers(discoveredPeers); + const dedupUnknown = deduplicatePeers(unknownPeers); + + const onlineCount = dedupOnline.length; + const discoveredCount = dedupDiscovered.length; + const unknownCount = dedupUnknown.length; + + const renderPeerTable = (peers, emptyKey) => { + if (peers.length === 0) return p(i18n[emptyKey] || i18n.noConnections); + return table({ class: 'block-info-table' }, + tr( + td({ class: 'card-label' }, i18n.peerHost || 'Pub'), + td({ class: 'card-label' }, 'Key'), + td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated feeds') + ), + ...peers.map(renderPeerRow) + ); + }; + + const technicalRows = (technicalPeers || []).map(tp => { + const k = tp.key || ''; + const connected = tp.state === 'connected'; + const action = connected ? 'disconnect' : 'connect'; + const btnLabel = connected ? (i18n.peerDisconnect || 'Disconnect') : (i18n.peerConnect || 'Connect'); + return tr( + td({ 'data-label': 'Key' }, a({ href: `/author/${encodeURIComponent(k)}`, class: 'user-link peer-key' }, k ? k.slice(0, 20) + '…' : '—')), + td({ 'data-label': 'Host' }, String(tp.host || '—')), + td({ 'data-label': i18n.peerPort || 'Port' }, String(tp.port || '—')), + td({ 'data-label': 'State' }, String(tp.state || tp.source || '—')), + td({ 'data-label': i18n.peerLastChange || 'Last change' }, String(tp.stateChange ? new Date(tp.stateChange).toISOString().slice(0, 16).replace('T', ' ') : '—')), + td({ 'data-label': '' }, + form({ method: "POST", action: `/peers/${action}`, class: "inline-form" }, + input({ type: "hidden", name: "key", value: k }), + input({ type: "hidden", name: "host", value: String(tp.host || '') }), + input({ type: "hidden", name: "port", value: String(tp.port || 8008) }), + button({ type: "submit", class: "filter-btn" }, btnLabel) + ) + ) + ); + }); + const refreshButton = form({ action: "/peers/refresh", method: "post" }, button({ type: "submit" }, i18n.peerRefresh || 'Refresh')); + const pruneButton = form({ action: "/peers/prune", method: "post" }, button({ type: "submit" }, i18n.peerPruneIdle || 'Remove idle')); + const exportButton = form({ action: "/peers/export", method: "get" }, button({ type: "submit" }, i18n.peerExport || 'Export')); + const importForm = form( + { action: "/peers/import", method: "post", enctype: "multipart/form-data", class: "peers-import-form" }, + label({ class: 'peers-import-label' }, i18n.peerImportTitle || 'Import peer list'), + br(), + textarea({ name: "peerList", rows: "4", placeholder: i18n.peerImportPlaceholder || 'Paste one multiserver address per line…' }), + br(), + input({ type: "file", name: "peerFile", accept: ".txt,text/plain" }), + br(), + button({ type: "submit", class: "filter-btn" }, i18n.peerImport || 'Import') + ); + + const peersTechnicalBlock = div({ class: 'tags-header peers-technical-block' }, + h2(i18n.peerConnectionsTitle || 'Connections'), + div({ class: "conn-actions peers-conn-actions" }, refreshButton, pruneButton, exportButton), + technicalPeers.length + ? table({ class: 'block-info-table' }, + tr( + td({ class: 'card-label' }, 'Key'), + td({ class: 'card-label' }, 'Host'), + td({ class: 'card-label' }, i18n.peerPort || 'Port'), + td({ class: 'card-label' }, 'State'), + td({ class: 'card-label' }, 'Last change'), + td({ class: 'card-label' }, '') + ), + ...technicalRows + ) + : p(i18n.peersTechnicalEmpty || 'No peers registered yet.'), + importForm + ); + + return template( + i18n.peers, + section( + div({ class: 'tags-header' }, + h2(i18n.peers), + p(i18n.peerConnectionsIntro) + ), + div({ class: "conn-actions" }, ...connButtons), + (onlineCount + discoveredCount + unknownCount) > 0 + ? div({ class: "peers-list" }, + div({ class: "tags-header" }, h2(`${i18n.online} (${onlineCount})`)), + renderPeerTable(dedupOnline, 'noConnections'), + hr(), + div({ class: "tags-header" }, h2(`${i18n.discovered} (${discoveredCount})`)), + renderPeerTable(dedupDiscovered, 'noDiscovered'), + hr(), + div({ class: "tags-header" }, h2(`${i18n.unknown} (${unknownCount})`)), + renderPeerTable(dedupUnknown, 'noUnknownPeers') + ) + : null, + peersTechnicalBlock, + p(i18n.connectionActionIntro) + ) + ); +}; + +exports.peersView = peersView; diff --git a/src/views/pixelia_view.js b/src/views/pixelia_view.js new file mode 100644 index 0000000..8cc81ae --- /dev/null +++ b/src/views/pixelia_view.js @@ -0,0 +1,92 @@ +const { div, h2, p, section, form, input, label, select, option, button, table, tr, td, hr, ul, li, a, br } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink} = require('./main_views'); + +exports.pixeliaView = (pixelArt, errorMessage) => { + const title = i18n.pixeliaTitle; + const description = i18n.pixeliaDescription; + const gridWidth = 50; + const gridHeight = 200; + + const grid = table( + { class: "pixelia-grid" }, + ...Array.from({ length: gridHeight }, (_, rowIndex) => + tr( + ...Array.from({ length: gridWidth }, (_, colIndex) => { + const pixel = pixelArt.find(p => p.x === colIndex + 1 && p.y === rowIndex + 1); + const colorClass = pixel ? `pixel-color-${pixel.color.slice(1)}` : 'pixel-empty'; + const cellId = `cell-${rowIndex + 1}-${colIndex + 1}`; + return td( + { + id: cellId, + title: pixel ? `By: ${pixel.author}` : "", + class: `pixel-cell ${colorClass}` + }, + "" + ); + }) + ) + ) + ); + + const contributors = pixelArt.length > 0 ? [...new Set(pixelArt.flatMap(p => p.contributors_inhabitants || []))] : []; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(description) + ) + ), + section( + div({ class: "pixelia-form-wrap" }, + form({ method: "POST", action: "/pixelia/paint", class: "pixelia-paint-form" }, + label({ for: "x" }, "X (1-50):"), + input({ type: "number", id: "x", name: "x", min: 1, max: gridWidth, required: true }), + label({ for: "y" }, "Y (1-200):"), + input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }), + label({ for: "color" }, i18n.colorLabel), + select({ id: "color", name: "color", required: true }, + option({ value: "#000000", style: "background-color:#000000;" }, "Black"), + option({ value: "#ffffff", style: "background-color:#ffffff;" }, "White"), + option({ value: "#17f018", style: "background-color:#17f018;" }, "Green"), + option({ value: "#ffbb00", style: "background-color:#ffbb00;" }, "Yellow"), + option({ value: "#ff0000", style: "background-color:#ff0000;" }, "Red"), + option({ value: "#0000ff", style: "background-color:#0000ff;" }, "Blue"), + option({ value: "#ffff00", style: "background-color:#ffff00;" }, "Lime"), + option({ value: "#00ff00", style: "background-color:#00ff00;" }, "Spring Green"), + option({ value: "#00ffff", style: "background-color:#00ffff;" }, "Aqua"), + option({ value: "#ff00ff", style: "background-color:#ff00ff;" }, "Fuchsia"), + option({ value: "#a52a2a", style: "background-color:#a52a2a;" }, "Brown"), + option({ value: "#800080", style: "background-color:#800080;" }, "Purple"), + option({ value: "#808000", style: "background-color:#808000;" }, "Olive"), + option({ value: "#00bfff", style: "background-color:#00bfff;" }, "Deep Sky Blue"), + option({ value: "#d3d3d3", style: "background-color:#d3d3d3;" }, "Light Grey"), + option({ value: "#ff6347", style: "background-color:#ff6347;" }, "Tomato") + ), + button({ type: "submit" }, i18n.paintButton) + ) + ), + errorMessage ? div({ class: "error-message" }, errorMessage) : null, + div({ class: "total-pixels" }, + h2(`${i18n.totalPixels}: ${pixelArt.length}`) + ) + ), + hr(), + section( + div({ class: "main_content" }, + div({ class: "pixelia-grid-wrap" }, grid), + pixelArt.length > 0 ? + div({ class: "contributors" }, + h2(i18n.contributorsTitle), + ul( + ...contributors.map(author => + li(userLink(author)) + ) + ) + ) : null + ) + ) + ); +}; + diff --git a/src/views/pm_view.js b/src/views/pm_view.js new file mode 100644 index 0000000..3612627 --- /dev/null +++ b/src/views/pm_view.js @@ -0,0 +1,97 @@ +const { div, h2, p, section, button, form, input, textarea, br, label, pre, span, strong } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require('./main_views'); +const { getConfig } = require('../configs/config-manager.js'); + +exports.pmView = async (initialRecipients = '', initialSubject = '', initialText = '', showPreview = false, sentKey = '', crypterError = false, crypterPreview = null, recipientError = false) => { + const title = i18n.pmSendTitle; + const description = i18n.pmDescription; + const textLen = (initialText || '').length; + + const { renderEncryptedChip, renderDoubleEncryptionChip } = require('./clearnet_view'); + return template( + title, + section( + div({ class: "tags-header" }, + div({ class: "title-with-chip" }, h2(title), renderEncryptedChip(i18n)), + p(description) + ), + crypterError + ? div({ class: "pm-form-error-msg" }, p('✗ ' + i18n.pmCrypterTooLong)) + : null, + recipientError + ? div({ class: "pm-form-error-msg" }, p('✗ ' + i18n.pmInvalidRecipients)) + : null, + sentKey + ? div({ class: "pm-sent-key" }, + input({ type: "text", readonly: true, value: sentKey, class: "pm-sent-key-value" }) + ) + : null, + section( + div({ class: "pm-form" }, + form({ method: "POST", action: "/pm", id: "pm-form" }, + label({ for: "recipients" }, i18n.pmLimitsHint), + br(), + input({ + type: "text", + name: "recipients", + placeholder: i18n.pmRecipientsHint, + required: true, + value: initialRecipients, + maxlength: "511" + }), + br(), + label({ for: "subject" }, i18n.pmSubject), + br(), + input({ type: "text", name: "subject", placeholder: i18n.pmSubjectHint, value: initialSubject, maxlength: "150" }), + br(), + label({ for: "text" }, i18n.pmText), + br(), + textarea({ name: "text", rows: "6", cols: "50", id: "pm-text", maxlength: "8096" }, initialText), + div({ class: "pm-crypter-row" }, + label({ for: "pm-crypter" }, + input({ type: "checkbox", name: "crypter", value: "1", id: "pm-crypter" }), + renderDoubleEncryptionChip(i18n) + ) + ), + div({ class: "pm-actions-block" }, + div({ class: "pm-actions" }, + button({ type: "submit", formaction: "/pm/preview", formmethod: "POST" }, i18n.pmPreview), + button({ type: "submit", class: "btn-compact" }, i18n.pmSend) + ) + ) + ), + showPreview + ? (crypterPreview + ? div({ class: "pm-preview pm-crypter-preview" }, + div({ class: "title-with-chip" }, h2(i18n.pmPreviewTitle), renderEncryptedChip(i18n)), + p({ class: "pm-preview-count" }, `${(initialText || '').length} ${i18n.pmCrypterCharsLabel}`), + div({ class: "pm-preview-content" }, pre({ class: "pm-pre" }, initialText || '')), + div({ class: "pm-sent-key" }, + input({ type: "text", readonly: true, value: crypterPreview.key, class: "pm-sent-key-value" }) + ), + div({ class: "title-with-chip" }, h2(i18n.pmCrypterCipherLabel), renderDoubleEncryptionChip(i18n)), + p({ class: "pm-preview-count" }, `${(crypterPreview.cipher || '').length} ${i18n.pmCrypterCharsLabel}`), + div({ class: "pm-preview-content" }, pre({ class: "pm-pre" }, crypterPreview.cipher || '')), + form({ method: "POST", action: "/pm", class: "pm-crypter-send-form" }, + input({ type: "hidden", name: "recipients", value: initialRecipients }), + input({ type: "hidden", name: "subject", value: initialSubject }), + input({ type: "hidden", name: "text", value: initialText }), + input({ type: "hidden", name: "crypter", value: "1" }), + input({ type: "hidden", name: "crypterKey", value: crypterPreview.key }), + input({ type: "hidden", name: "precomputed", value: crypterPreview.cipher }), + button({ type: "submit", class: "btn-compact" }, i18n.pmSend) + ) + ) + : div({ id: "pm-preview-area", class: "pm-preview" }, + div({ class: "title-with-chip" }, h2(i18n.pmPreviewTitle), renderEncryptedChip(i18n)), + p({ id: "pm-preview-count", class: "pm-preview-count" }, `${textLen}/8096`), + div({ id: "pm-preview-content", class: "pm-preview-content" }, + pre({ class: "pm-pre" }, initialText || '') + ) + )) + : null + ) + ) + ) + ); +}; diff --git a/src/views/projects_view.js b/src/views/projects_view.js new file mode 100644 index 0000000..8c17d80 --- /dev/null +++ b/src/views/projects_view.js @@ -0,0 +1,760 @@ +const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe") +const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") +const { renderUrl } = require("../backend/renderUrl") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel, renderMapEmbedWithZoom } = require("./maps_view") + +const renderMediaBlob = (value, attrs = {}) => { + if (!value) return null + const s = String(value).trim() + if (!s) return null + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }) + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }) + const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' }) + return null +} + +const userId = config.keys.id + +const FILTERS = [ + { key: "ALL", i18n: "projectFilterAll", title: "projectAllTitle" }, + { key: "MINE", i18n: "projectFilterMine", title: "projectMineTitle" }, + { key: "APPLIED", i18n: "projectFilterApplied", title: "projectAppliedTitle" }, + { key: "ACTIVE", i18n: "projectFilterActive", title: "projectActiveTitle" }, + { key: "PAUSED", i18n: "projectFilterPaused", title: "projectPausedTitle" }, + { key: "COMPLETED", i18n: "projectFilterCompleted", title: "projectCompletedTitle" }, + { key: "FOLLOWING", i18n: "projectFilterFollowing", title: "projectFollowingTitle" }, + { key: "RECENT", i18n: "projectFilterRecent", title: "projectRecentTitle" }, + { key: "TOP", i18n: "projectFilterTop", title: "projectTopTitle" }, + { key: "BACKERS", i18n: "projectFilterBackers", title: "projectBackersLeaderboardTitle" } +] + +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() + +const toNum = (v) => { + const s = v === null || v === undefined ? "" : String(v) + const n = parseFloat(s.replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} + +const clamp = (n, a, b) => Math.max(a, Math.min(b, n)) + +const sumAmounts = (list) => safeArr(list).reduce((s, x) => s + (toNum(x && x.amount) || 0), 0) + +const followersCount = (p) => safeArr(p && p.followers).length +const backersCount = (p) => safeArr(p && p.backers).length +const backersTotal = (p) => sumAmounts((p && p.backers) || []) + +const budgetSummary = (project) => { + const goal = Math.max(0, toNum(project && project.goal) || 0) + const assigned = Math.max(0, sumAmounts((project && project.bounties) || [])) + const remaining = Math.max(0, goal - assigned) + const exceeded = assigned > goal + return { goal, assigned, remaining, exceeded } +} + +const buildReturnTo = (filter) => `/projects?filter=${encodeURIComponent(String(filter || "ALL").toUpperCase())}` + +const renderCardField = (labelText, valueNode) => + div( + { class: "card-field" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, valueNode) + ) + +const renderCardFieldRich = (labelText, children) => + div( + { class: "card-field card-field-rich" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, ...children) + ) + +const renderProgressBlock = (labelText, valueText, value, maxValue) => + div( + { class: "confirmations-block progress-block" }, + div( + { class: "card-field" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, valueText) + ), + progress({ class: "confirmations-progress", value: value, max: maxValue }) + ) + +const aggregateTopBackers = (projects) => { + const map = new Map() + for (const pr of safeArr(projects)) { + const backers = safeArr(pr && pr.backers) + for (const b of backers) { + const uid = b && b.userId + const amt = Math.max(0, toNum(b && b.amount) || 0) + if (!uid) continue + if (!map.has(uid)) map.set(uid, { userId: uid, total: 0, pledges: 0, projects: new Set() }) + const rec = map.get(uid) + rec.total += amt + rec.pledges += 1 + rec.projects.add(pr.id) + } + } + return Array.from(map.values()) + .map((r) => ({ userId: r.userId, total: r.total, pledges: r.pledges, projects: r.projects.size })) + .sort((a, b) => b.total - a.total) +} + +const renderBackersLeaderboard = (projects) => { + const rows = aggregateTopBackers(projects) + return rows.length + ? div( + { class: "backers-leaderboard" }, + h2(i18n.projectBackersLeaderboardTitle), + ...rows.slice(0, 50).map((r) => + div( + { class: "backer-row" }, + renderCardField(i18n.projectBackerAuthor + ":", a({ href: `/author/${encodeURIComponent(r.userId)}`, class: "user-link user-pill" }, r.userId)), + renderCardField(i18n.projectBackerAmount + ":", span({ class: "chip chip-amt" }, `${r.total} ECO`)), + renderCardField(i18n.projectBackerPledges + ":", span({ class: "chip chip-pledges" }, String(r.pledges))), + renderCardField(i18n.projectBackerProjects + ":", span({ class: "chip chip-projects" }, String(r.projects))) + ) + ) + ) + : div({ class: "backers-leaderboard empty" }, p(i18n.projectNoBackersFound)) +} + +const renderBudget = (project) => { + const S = budgetSummary(project) + const pct = S.goal > 0 ? clamp(Math.round((S.assigned / S.goal) * 100), 0, 100) : 0 + return div( + { class: `budget-summary${S.exceeded ? " over" : ""}` }, + S.goal > 0 ? renderProgressBlock(i18n.projectBudgetAssigned + ":", `${S.assigned}/${S.goal}`, pct, 100) : null, + S.exceeded ? p({ class: "warning" }, i18n.projectBudgetOver) : null + ) +} + +const renderFollowers = (project) => { + const followers = safeArr(project && project.followers) + if (!followers.length) return div({ class: "followers-block" }, h2(i18n.projectFollowersTitle), p(i18n.projectFollowersNone)) + const show = followers.slice(0, 12) + return div( + { class: "followers-block" }, + h2(i18n.projectFollowersTitle), + ul(show.map((uid) => li(userLink(uid)))), + followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null + ) +} + +const renderBackers = (project, filter) => { + const backers = safeArr(project && project.backers) + const total = sumAmounts(backers) + const mine = sumAmounts(backers.filter((b) => b && b.userId === userId)) + const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}` + + const pending = project && project.author === userId + ? backers.filter((b) => b && b.transferId && b.confirmed === false).slice(0, 10) + : [] + + return div( + { class: "backers-block" }, + h2(i18n.projectBackersTitle), + renderCardField(i18n.projectBackersTotal + ":", String(backers.length)), + renderCardField(i18n.projectBackersTotalPledged + ":", `${total} ECO`), + mine > 0 ? renderCardField(i18n.projectBackersYourPledge + ":", span({ class: "chip chip-you" }, `${mine} ECO`)) : null, + backers.length + ? table( + { class: "backers-table" }, + thead(tr(th(i18n.projectBackerDate), th(i18n.projectBackerAuthor), th(i18n.projectBackerAmount))), + tbody( + ...backers.slice(0, 8).map((b) => + tr( + td(b.at ? moment(b.at).format("YYYY/MM/DD HH:mm") : ""), + td(userLink(b.userId)), + td(`${b.amount} ECO`) + ) + ) + ) + ) + : p(i18n.projectBackersNone), + pending.length + ? div( + { class: "pending-transfers" }, + h2(i18n.projectPendingTransfersTitle), + ...pending.map((b) => + div( + { class: "card-field" }, + span({ class: "card-label" }, b.userId), + span( + { class: "card-value" }, + form( + { method: "POST", action: `/projects/confirm-transfer/${encodeURIComponent(b.transferId)}` }, + input({ type: "hidden", name: "returnTo", value: rt }), + button({ type: "submit", class: "btn" }, i18n.projectConfirmTransferButton) + ) + ) + ) + ) + ) + : null + ) +} + +const renderPledgeBox = (project, filter, isAuthor) => { + const statusUpper = String((project && project.status) || "ACTIVE").toUpperCase() + const isActive = statusUpper === "ACTIVE" + if (!isActive || isAuthor) return null + const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}` + + return div( + { class: "pledge-box" }, + h2(i18n.projectPledgeTitle), + form( + { method: "POST", action: `/projects/pledge/${encodeURIComponent(project.id)}` }, + input({ type: "hidden", name: "returnTo", value: rt }), + input({ type: "number", name: "amount", min: "0.01", step: "0.01", required: true, placeholder: i18n.projectPledgePlaceholder }), + select( + { name: "milestoneOrBounty" }, + option({ value: "" }, i18n.projectSelectMilestoneOrBounty), + ...safeArr(project && project.milestones).map((m, idx) => option({ value: `milestone:${idx}` }, m.title)), + ...safeArr(project && project.bounties).map((b, idx) => option({ value: `bounty:${idx}` }, b.title)) + ), + button({ class: "btn", type: "submit" }, i18n.projectPledgeButton) + ) + ) +} + +const bountyTotalsForMilestone = (project, mIndex) => { + const bounties = safeArr(project && project.bounties) + const list = bounties.filter((b) => { + const mi = b && b.milestoneIndex + return mi === mIndex + }) + const total = sumAmounts(list) + const done = list.filter((b) => !!(b && b.done)).length + return { total, count: list.length, done } +} + +const renderMilestonesAndBounties = (project, filter, editable) => { + const milestones = safeArr(project && project.milestones) + const bounties = safeArr(project && project.bounties) + const rt = `/projects/${encodeURIComponent(project.id)}?filter=${encodeURIComponent(filter || "ALL")}` + const remain = budgetSummary(project).remaining + + const blocks = milestones.map((m, idx) => { + const totals = bountyTotalsForMilestone(project, idx) + const items = bounties.filter((b) => b && b.milestoneIndex === idx) + const maxCount = Math.max(1, totals.count) + const pctDone = clamp(Math.round((totals.done / maxCount) * 100), 0, 100) + + return div( + { class: "milestone-with-bounties" }, + div( + { class: "milestone-stats" }, + renderCardField(i18n.projectMilestoneStatus + ":", m.done ? i18n.projectMilestoneDone.toUpperCase() : i18n.projectMilestoneOpen.toUpperCase()), + renderProgressBlock(i18n.projectBounties + ":", `${totals.done}/${totals.count} · ${totals.total} ECO`, pctDone, 100) + ), + div( + { class: "milestone-head" }, + span({ class: "milestone-title" }, m.title), + m.dueDate ? span({ class: "chip chip-due" }, `${i18n.projectMilestoneDue}: ${moment(m.dueDate).format("YYYY/MM/DD HH:mm")}`) : null, + safeText(m.description) ? p(...renderUrl(m.description)) : null, + editable && !m.done + ? form( + { method: "POST", action: `/projects/milestones/complete/${encodeURIComponent(project.id)}/${idx}` }, + input({ type: "hidden", name: "returnTo", value: rt }), + button({ class: "btn", type: "submit" }, i18n.projectMilestoneMarkDone) + ) + : null + ), + items.length + ? ul( + items.map((b) => { + const globalIndex = bounties.indexOf(b) + const statusText = b.done + ? i18n.projectBountyDone.toUpperCase() + : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase()) + + return li( + { class: "bounty-item" }, + div( + { class: "bounty-main" }, + span({ class: "bounty-title" }, b.title), + span({ class: "bounty-amount" }, `${b.amount} ECO`) + ), + safeText(b.description) ? p(...renderUrl(b.description)) : null, + renderCardField(i18n.projectBountyStatus + ":", statusText), + b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null, + !editable && !b.done && !b.claimedBy && project.author !== userId + ? form( + { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` }, + input({ type: "hidden", name: "returnTo", value: rt }), + button({ type: "submit", class: "btn" }, i18n.projectBountyClaimButton) + ) + : null, + editable && !b.done + ? form( + { method: "POST", action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` }, + input({ type: "hidden", name: "returnTo", value: rt }), + button({ type: "submit", class: "btn" }, i18n.projectBountyCompleteButton) + ) + : null, + editable + ? form( + { method: "POST", action: `/projects/bounties/update/${encodeURIComponent(project.id)}/${globalIndex}`, class: "bounty-update-form" }, + input({ type: "hidden", name: "returnTo", value: rt }), + label(i18n.projectMilestoneSelect), + br(), + select( + { name: "milestoneIndex" }, + option({ value: "", selected: b.milestoneIndex == null }, "-"), + ...milestones.map((m2, idx2) => option({ value: String(idx2), selected: b.milestoneIndex === idx2 }, m2.title)) + ), + br(), + br(), + button({ class: "btn", type: "submit", disabled: remain <= 0 }, i18n.projectBountyCreateButton) + ) + : null + ) + }) + ) + : p(i18n.projectNoBounties) + ) + }) + + const unassigned = bounties.filter((b) => b && (b.milestoneIndex === null || b.milestoneIndex === undefined)) + const unassignedBlock = unassigned.length + ? div( + { class: "bounty-milestone-block" }, + h2(i18n.projectBounties), + ul( + unassigned.map((b) => { + const globalIndex = bounties.indexOf(b) + const statusText = b.done + ? i18n.projectBountyDone.toUpperCase() + : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase()) + + return li( + { class: "bounty-item" }, + div( + { class: "bounty-main" }, + span({ class: "bounty-title" }, b.title), + span({ class: "bounty-amount" }, `${b.amount} ECO`) + ), + safeText(b.description) ? p(...renderUrl(b.description)) : null, + renderCardField(i18n.projectBountyStatus + ":", statusText), + b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null, + !editable && !b.done && !b.claimedBy && project.author !== userId + ? form( + { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` }, + input({ type: "hidden", name: "returnTo", value: rt }), + button({ type: "submit", class: "btn" }, i18n.projectBountyClaimButton) + ) + : null, + editable && !b.done + ? form( + { method: "POST", action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` }, + input({ type: "hidden", name: "returnTo", value: rt }), + button({ type: "submit", class: "btn" }, i18n.projectBountyCompleteButton) + ) + : null + ) + }) + ) + ) + : null + + if (blocks.length === 0 && !unassignedBlock) return null + return div({ class: "milestones-bounties" }, ...blocks, unassignedBlock) +} + +const renderProjectStatusChip = (status) => { + const s = String(status || "ACTIVE").toUpperCase() + const variant = + s === "ACTIVE" ? "mutuals" : + s === "PAUSED" ? "whole" : + s === "COMPLETED" ? "encrypted" : + s === "CANCELLED" ? "closed" : + "whole" + const localized = i18n["projectStatus" + s] || s + return renderStateChip(variant, "", localized) +} + +const renderProjectList = exports.renderProjectList = (projects, filter, spreadMap = new Map()) => { + const list = safeArr(projects) + + if (!list.length) return p(i18n.projectNoProjectsFound) + + return div({ class: "jobs-grid" }, + list.map((pr) => { + const goal = Math.max(0, toNum(pr && pr.goal) || 0) + const pledged = Math.max(0, toNum(pr && pr.pledged) || 0) + const fundingPct = goal > 0 ? clamp(Math.round((pledged / goal) * 100), 0, 100) : 0 + const heroNode = pr.image + ? div({ class: "tribe-card-image-wrapper" }, + a({ href: `/projects/${encodeURIComponent(pr.id)}` }, + renderMediaBlob(pr.image, { class: "tribe-card-hero-image" }) + ) + ) + : null + const chips = [ + renderProjectStatusChip(pr.status), + renderLifespanChip(pr.lifetime, i18n) + ].filter(Boolean) + + const isOwn = pr.author && String(pr.author) === String(userId) + return div({ class: "trending-card project-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`) + ), + div({ class: "card-section project-card-body" }, + heroNode, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/projects/${encodeURIComponent(pr.id)}` }, pr.title || i18n.projectsTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + goal > 0 + ? div({ class: "card-date-highlight" }, `${pr.goal} ECO`) + : null, + goal > 0 + ? renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100) + : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.projectFollowers}: ${followersCount(pr)}`) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(pr.id || pr.key, spreadMap.get(pr.id || pr.key))) + ) + ) + }) + ) +} + +const renderProjectForm = (project, mode) => { + const pr = project || {} + const isEdit = mode === "edit" + const nowLocal = moment().format("YYYY-MM-DDTHH:mm") + const deadlineValue = pr.deadline ? moment(pr.deadline).format("YYYY-MM-DDTHH:mm") : "" + const milestoneMax = deadlineValue || undefined + const returnTo = "/projects?filter=MINE" + + return div( + { class: "div-center project-form" }, + form( + { + action: isEdit ? `/projects/update/${encodeURIComponent(pr.id)}` : "/projects/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + label(i18n.projectTitle), + br(), + input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: pr.title || "" }), + br(), + label(i18n.projectDescription), + br(), + textarea({ name: "description", rows: "6", required: true, placeholder: i18n.projectDescriptionPlaceholder }, pr.description || ""), + br(), + label(i18n.projectImage), + br(), + input({ type: "file", name: "image" }), + br(), + pr.image ? renderMediaBlob(pr.image) : null, + br(), + label(i18n.projectGoal), + br(), + input({ type: "number", step: "0.01", min: "0.01", name: "goal", required: true, placeholder: i18n.projectGoalPlaceholder, value: pr.goal || "" }), + br(), + br(), + label(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: pr.mapUrl || "" }), + br(), + br(), + label(i18n.projectDeadline), + br(), + input({ type: "datetime-local", name: "deadline", id: "deadline", required: true, min: nowLocal, value: deadlineValue }), + br(), + br(), + h2(i18n.projectAddMilestoneTitle), + label(i18n.projectMilestoneTitle), + br(), + input({ type: "text", name: "milestoneTitle", required: true, placeholder: i18n.projectMilestoneTitlePlaceholder }), + br(), + label(i18n.projectMilestoneDescription), + br(), + textarea({ name: "milestoneDescription", rows: "3", placeholder: i18n.projectMilestoneDescriptionPlaceholder }), + br(), + label(i18n.projectMilestoneTargetPercent), + br(), + input({ type: "number", name: "milestoneTargetPercent", min: "0", max: "100", step: "1", value: "0" }), + br(), + br(), + label(i18n.projectMilestoneDueDate), + br(), + input({ type: "datetime-local", name: "milestoneDueDate", min: nowLocal, max: milestoneMax }), + br(), + br(), + button({ type: "submit" }, isEdit ? i18n.projectUpdateButton : i18n.projectCreateButton) + ) + ) +} + +exports.projectsView = async (projectsOrForm, filter, _unused, params = {}) => { + const f = String(filter || "ALL").toUpperCase() + const filterObj = FILTERS.find((x) => x.key === f) || FILTERS[0] + const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle + const { renderReachChip: renderReachChipProjects } = require('./clearnet_view'); + const viewerClearnetProjects = !!(params.viewerPrefs && params.viewerPrefs.clearnetProjects); + + return template( + i18n.projectsTitle, + section( + div({ class: "tags-header" }, + h2(sectionTitle), + p(i18n.projectsDescription) + ), + div({ class: "shop-title-row" }, renderReachChipProjects(viewerClearnetProjects, i18n)), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" }, + FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n])) + .concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject)) + ) + ), + f === "CREATE" || f === "EDIT" + ? (() => { + const prToEdit = f === "EDIT" ? (safeArr(projectsOrForm)[0] || {}) : {} + return renderProjectForm(prToEdit, f === "EDIT" ? "edit" : "create") + })() + : (f === "BACKERS" + ? renderBackersLeaderboard(projectsOrForm) + : div({ class: "projects-list" }, renderProjectList(projectsOrForm, f, params.spreadMap)) + ) + ) + ) +} + +exports.singleProjectView = async (project, filter, comments, params = {}) => { + const pr = project || {} + const f = String(filter || "ALL").toUpperCase() + const isAuthor = pr.author === userId + const isFollower = safeArr(pr.followers).includes(userId) + + const statusUpper = String(pr.status || "ACTIVE").toUpperCase() + const pctRaw = toNum(pr.progress) + const pct = clamp(Math.round(Number.isFinite(pctRaw) ? pctRaw : 0), 0, 100) + const goal = Math.max(0, toNum(pr.goal) || 0) + const pledged = Math.max(0, toNum(pr.pledged) || 0) + const fundingPct = goal > 0 ? clamp(Math.round((pledged / goal) * 100), 0, 100) : 0 + + const returnTo = `/projects/${encodeURIComponent(pr.id)}?filter=${encodeURIComponent(f)}` + + const chips = [ + renderProjectStatusChip(pr.status), + isFollower ? renderStateChip("whole", "★", i18n.projectFollowing || "FOLLOWING") : null, + renderLifespanChip(pr.lifetime, i18n), + renderEcoTax(pr.msgSize, pr.id || pr.key) + ].filter(Boolean) + + const sideActions = [] + if (!isAuthor && pr.author) { + sideActions.push(form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: pr.author }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + )) + } + if (!isAuthor && statusUpper === "ACTIVE") { + sideActions.push(isFollower + ? form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(pr.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "unsubscribe-btn" }, i18n.projectUnfollowButton) + ) + : form({ method: "POST", action: `/projects/follow/${encodeURIComponent(pr.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "subscribe-btn" }, i18n.projectFollowButton) + ) + ) + } + if (isAuthor) { + sideActions.push(form({ method: "GET", action: `/projects/edit/${encodeURIComponent(pr.id)}` }, + button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton) + )) + sideActions.push(form({ method: "POST", action: `/projects/delete/${encodeURIComponent(pr.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton) + )) + sideActions.push(form( + { method: "POST", action: `/projects/status/${encodeURIComponent(pr.id)}`, class: "project-control-form project-control-form--status" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + select( + { name: "status", class: "project-control-select" }, + option({ value: "ACTIVE", selected: statusUpper === "ACTIVE" }, i18n.projectStatusACTIVE), + option({ value: "PAUSED", selected: statusUpper === "PAUSED" }, i18n.projectStatusPAUSED), + option({ value: "COMPLETED", selected: statusUpper === "COMPLETED" }, i18n.projectStatusCOMPLETED), + option({ value: "CANCELLED", selected: statusUpper === "CANCELLED" }, i18n.projectStatusCANCELLED) + ), + button({ class: "status-btn project-control-btn", type: "submit" }, i18n.projectSetStatus) + )) + sideActions.push(form( + { method: "POST", action: `/projects/progress/${encodeURIComponent(pr.id)}`, class: "project-control-form project-control-form--progress" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "number", name: "progress", min: "0", max: "100", value: pct, class: "project-control-input project-progress-input" }), + button({ class: "status-btn project-control-btn", type: "submit" }, i18n.projectSetProgress) + )) + } + + const projectSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, pr.title) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(pr.id || pr.key, params.spreads)), + pr.image ? renderMediaBlob(pr.image, { class: "tribe-detail-image" }) : null, + div({ class: "job-price-line card-salary" }, `${pr.goal || 0} ECO`), + div({ class: "job-price-line card-salary" }, `${i18n.projectFollowers}: ${followersCount(pr)}`), + renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100), + goal > 0 ? renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100) : null, + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ) + + const projectMain = div({ class: "tribe-main" }, + !isAuthor && isFollower ? p({ class: "hint" }, i18n.projectYouFollowHint) : null, + safeText(pr.description) + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, i18n.projectDescription), + p({ class: "tribe-side-description" }, ...renderUrl(pr.description)) + ) + : null, + pr.mapUrl ? div({ class: "job-section" }, renderMapEmbedWithZoom(params.mapData, pr.mapUrl, `/projects/${encodeURIComponent(pr.id || pr.key)}`, params.zoom)) : null, + renderBudget(pr), + renderBackers(pr, f), + renderMilestonesAndBounties(pr, f, isAuthor), + renderFollowers(pr), + renderPledgeBox(pr, f, isAuthor), + div({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(pr.author) + ), + renderOpinionsVoting('/projects/opinions', pr.id || pr.key, pr.opinions, null, pr.opinions_inhabitants), + div( + { id: "comments", class: "vote-comments-section" }, + div({ class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/projects/${encodeURIComponent(pr.id || pr.key)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), br(), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text + return t && String(t).trim() + }) + return visibleComments.length + ? div({ class: "comments-list" }, + visibleComments.map((c) => { + const author = c?.value?.author || "" + const ts = c?.value?.timestamp || c?.timestamp + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "" + const relDate = ts ? moment(ts).fromNow() : "" + return div({ class: "comment-card" }, + div({ class: "comment-header" }, userLink(author)), + div({ class: "comment-date" }, span({ title: absDate }, relDate)), + div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || "")) + ) + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + })() + ) + ) + + return template( + i18n.projectsTitle, + section( + div({ class: "tags-header" }, h2(i18n.projectsTitle), p(i18n.projectsDescription)), + div( + { class: "filters" }, + form( + { method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" }, + FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n])) + .concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject)) + ) + ), + div({ class: "tribe-details" }, projectSide, projectMain) + ) + ) +} + +exports.clearnetProjectView = async (project) => { + const { escapeHtml: esc, blobUrl: cnBlob, renderClearnetPage } = require('./clearnet_view'); + const pr = project || {}; + const title = esc(pr.title || 'Project'); + const desc = esc(pr.description || ''); + const goal = Math.max(0, toNum(pr.goal) || 0); + const pledged = Math.max(0, toNum(pr.pledged) || 0); + const fundingPct = goal > 0 ? Math.min(100, Math.round((pledged / goal) * 100)) : 0; + const status = String(pr.status || 'ACTIVE').toUpperCase(); + const projectImg = cnBlob(pr.image); + const deadline = pr.deadline ? new Date(pr.deadline).toISOString().slice(0, 10) : ''; + const milestones = Array.isArray(pr.milestones) ? pr.milestones.slice(0, 10) : []; + const milestonesBlock = milestones.length + ? `

Milestones

    ${milestones.map(m => `
  1. ${esc(m.title || '')}${m.targetPercent ? ` — ${m.targetPercent}%` : ''}
  2. `).join('')}
` + : ''; + const extraCss = ` +.cn-prj-title{color:var(--fg);margin:0 0 12px 0;font-size:32px;font-weight:700} +.cn-prj-status{display:inline-block;background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:6px 12px;border-radius:6px;font-weight:600;text-transform:uppercase;letter-spacing:1px;font-size:12px;margin-bottom:16px} +.cn-prj-img{display:block;max-width:100%;border:1px solid var(--border);border-radius:8px;margin-bottom:20px} +.cn-prj-funding{background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:20px} +.cn-prj-funding-label{color:var(--fg-dim);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px} +.cn-prj-funding-amount{color:var(--fg);font-size:18px;font-weight:700;margin-bottom:8px} +.cn-prj-bar{height:8px;background:#000;border-radius:4px;overflow:hidden} +.cn-prj-bar-fill{height:100%;background:var(--fg);border-radius:4px} +.cn-prj-section{margin-top:24px} +.cn-prj-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border)} +.cn-prj-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px;margin:0} +.cn-prj-ms{padding-left:22px;margin:0;color:var(--fg-soft);line-height:1.7} +.cn-prj-pct{color:var(--fg-dim);font-size:13px} +.cn-prj-deadline{color:var(--fg-dim);font-size:13px;margin-top:8px} +.cn-prj-meta{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:16px} +.cn-prj-date,.cn-prj-idmeta{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:13px;color:var(--fg-soft)} +.cn-prj-idmeta{font-family:monospace;font-size:11px;word-break:break-all} +`; + const body = ` +

${title}

+
+ ${esc(status)} + ${pr.createdAt ? `📅 ${esc(new Date(pr.createdAt).toISOString().slice(0,10))}` : ''} +
+ ${projectImg ? `${title}` : ''} + ${goal > 0 ? ` +
+
Funding
+
${pledged.toFixed(2)} / ${goal.toFixed(2)} ECO · ${fundingPct}%
+
+ ${deadline ? `
Deadline: ${deadline}
` : ''} +
` : ''} + ${desc ? `

Description

${desc}

` : ''} + ${milestonesBlock} +`; + return renderClearnetPage({ + title: `${pr.title || 'Project'} — Oasis`, + ogTitle: pr.title || 'Project', + ogDescription: pr.description || '', + ogImage: projectImg, + extraCss, + body, + hubFeedId: pr.author || null + }); +}; + diff --git a/src/views/report_view.js b/src/views/report_view.js new file mode 100644 index 0000000..c3f261c --- /dev/null +++ b/src/views/report_view.js @@ -0,0 +1,696 @@ +const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe"); +const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views"); +const { config } = require("../server/SSB_server.js"); +const moment = require("../server/node_modules/moment"); +const { renderUrl } = require("../backend/renderUrl"); + +const renderMediaBlob = (value, attrs = {}) => { + if (!value) return null; + const s = String(value).trim(); + if (!s) return null; + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }); + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); + if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }); + const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); + if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` }); + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' }); + return null; +}; + +const userId = config.keys.id; + +const normU = (v) => String(v || "").trim().toUpperCase(); +const normalizeStatus = (v) => normU(v).replace(/\s+/g, "_").replace(/-+/g, "_"); + +const CATEGORY_BY_FILTER = { + features: "FEATURES", + bugs: "BUGS", + abuse: "ABUSE", + content: "CONTENT" +}; + +const STATUS_BY_FILTER = { + open: "OPEN", + under_review: "UNDER_REVIEW", + resolved: "RESOLVED", + invalid: "INVALID", + closed: "CLOSED" +}; + +const opt = (value, isSelected, text) => + option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text); + +const hasAnyTemplateValue = (t) => { + if (!t || typeof t !== "object") return false; + return Object.values(t).some((v) => String(v || "").trim()); +}; + +const renderCardField = (labelText, value = "") => + div( + { class: "card-field" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, ...renderUrl(String(value ?? ""))) + ); + +const renderStackedTextField = (lbl, val) => + String(val || "").trim() + ? div( + { class: "card-field card-field-stacked" }, + span({ class: "card-label" }, lbl), + span({ class: "card-value" }, ...renderUrl(String(val))) + ) + : null; + +const renderPmButton = (recipientId) => + recipientId && String(recipientId) !== String(userId) + ? form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: recipientId }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + ) + : null; + +const renderReportStatusSetter = (report) => { + const st = normalizeStatus(report && report.status ? report.status : "OPEN"); + return form( + { method: "POST", action: `/reports/status/${encodeURIComponent(report.id)}`, class: "project-control-form project-control-form--status" }, + select( + { name: "status", class: "project-control-select" }, + opt("OPEN", st === "OPEN", i18n.reportsStatusOpen), + opt("UNDER_REVIEW", st === "UNDER_REVIEW", i18n.reportsStatusUnderReview), + opt("RESOLVED", st === "RESOLVED", i18n.reportsStatusResolved), + opt("INVALID", st === "INVALID", i18n.reportsStatusInvalid), + opt("CLOSED", st === "CLOSED", i18n.reportsStatusClosed || "CLOSED") + ), + button({ class: "status-btn project-control-btn", type: "submit" }, i18n.reportsSetStatus || i18n.projectSetStatus || "Set status") + ); +}; + +const renderTemplateDetails = (report) => { + const category = normU(report.category); + const t = report.template && typeof report.template === "object" ? report.template : {}; + if (!hasAnyTemplateValue(t)) return null; + + const renderValueField = (lbl, val) => + String(val || "").trim() + ? renderCardField(lbl, String(val)) + : null; + + if (category === "BUGS") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle), + renderStackedTextField(i18n.reportsStepsToReproduceLabel + ":", t.stepsToReproduce), + renderStackedTextField(i18n.reportsExpectedBehaviorLabel + ":", t.expectedBehavior), + renderStackedTextField(i18n.reportsActualBehaviorLabel + ":", t.actualBehavior), + renderStackedTextField(i18n.reportsEnvironmentLabel + ":", t.environment), + renderValueField(i18n.reportsReproduceRateLabel + ":", t.reproduceRate) + ); + } + + if (category === "FEATURES") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle), + renderStackedTextField(i18n.reportsProblemStatementLabel + ":", t.problemStatement), + renderStackedTextField(i18n.reportsUserStoryLabel + ":", t.userStory), + renderStackedTextField(i18n.reportsAcceptanceCriteriaLabel + ":", t.acceptanceCriteria) + ); + } + + if (category === "ABUSE") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle), + renderStackedTextField(i18n.reportsWhatHappenedLabel + ":", t.whatHappened), + renderStackedTextField(i18n.reportsReportedUserLabel + ":", t.reportedUser), + renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks) + ); + } + + if (category === "CONTENT") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle), + renderStackedTextField(i18n.reportsContentLocationLabel + ":", t.contentLocation), + renderStackedTextField(i18n.reportsWhyInappropriateLabel + ":", t.whyInappropriate), + renderStackedTextField(i18n.reportsRequestedActionLabel + ":", t.requestedAction), + renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks) + ); + } + + return null; +}; + +const renderReportCommentsSection = (reportId, comments = []) => { + const commentsCount = Array.isArray(comments) ? comments.length : 0; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { + method: "POST", + action: `/reports/${encodeURIComponent(reportId)}/comments`, + class: "comment-form", + enctype: "multipart/form-data" + }, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + return visibleComments.length + ? div( + { class: "comments-list" }, + visibleComments.map((c) => { + const author = c.value && c.value.author ? c.value.author : ""; + const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c.value && c.value.content ? c.value.content : {}; + const root = content.fork || content.root || ""; + const text = content.text || ""; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); + })() + ); +}; + +const renderTemplateForCategory = (category, templateData = {}) => { + const cat = normU(category || "FEATURES"); + const t = templateData && typeof templateData === "object" ? templateData : {}; + const tval = (k) => String(t[k] || ""); + const reproduceRateVal = normU(t.reproduceRate || ""); + + if (cat === "BUGS") { + return div( + { class: "report-template-block" }, + h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle), + label(i18n.reportsStepsToReproduceLabel), + br(), + textarea({ name: "stepsToReproduce", rows: "4", placeholder: i18n.reportsStepsToReproducePlaceholder }, tval("stepsToReproduce")), + br(), + br(), + label(i18n.reportsExpectedBehaviorLabel), + br(), + textarea({ name: "expectedBehavior", rows: "3", placeholder: i18n.reportsExpectedBehaviorPlaceholder }, tval("expectedBehavior")), + br(), + br(), + label(i18n.reportsActualBehaviorLabel), + br(), + textarea({ name: "actualBehavior", rows: "3", placeholder: i18n.reportsActualBehaviorPlaceholder }, tval("actualBehavior")), + br(), + br(), + label(i18n.reportsEnvironmentLabel), + br(), + textarea({ name: "environment", rows: "3", placeholder: i18n.reportsEnvironmentPlaceholder }, tval("environment")), + br(), + br(), + label(i18n.reportsReproduceRateLabel), + br(), + select( + { name: "reproduceRate" }, + opt("", !reproduceRateVal, i18n.reportsReproduceRateUnknown), + opt("ALWAYS", reproduceRateVal === "ALWAYS", i18n.reportsReproduceRateAlways), + opt("OFTEN", reproduceRateVal === "OFTEN", i18n.reportsReproduceRateOften), + opt("SOMETIMES", reproduceRateVal === "SOMETIMES", i18n.reportsReproduceRateSometimes), + opt("RARELY", reproduceRateVal === "RARELY", i18n.reportsReproduceRateRarely), + opt("UNABLE", reproduceRateVal === "UNABLE", i18n.reportsReproduceRateUnable) + ) + ); + } + + if (cat === "ABUSE") { + return div( + { class: "report-template-block" }, + h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle), + label(i18n.reportsWhatHappenedLabel), + br(), + textarea({ name: "whatHappened", rows: "4", placeholder: i18n.reportsWhatHappenedPlaceholder }, tval("whatHappened")), + br(), + br(), + label(i18n.reportsReportedUserLabel), + br(), + textarea({ name: "reportedUser", rows: "2", placeholder: i18n.reportsReportedUserPlaceholder }, tval("reportedUser")), + br(), + br(), + label(i18n.reportsEvidenceLinksLabel), + br(), + textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks")) + ); + } + + if (cat === "CONTENT") { + return div( + { class: "report-template-block" }, + h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle), + label(i18n.reportsContentLocationLabel), + br(), + textarea({ name: "contentLocation", rows: "3", placeholder: i18n.reportsContentLocationPlaceholder }, tval("contentLocation")), + br(), + br(), + label(i18n.reportsWhyInappropriateLabel), + br(), + textarea({ name: "whyInappropriate", rows: "4", placeholder: i18n.reportsWhyInappropriatePlaceholder }, tval("whyInappropriate")), + br(), + br(), + label(i18n.reportsRequestedActionLabel), + br(), + textarea({ name: "requestedAction", rows: "3", placeholder: i18n.reportsRequestedActionPlaceholder }, tval("requestedAction")), + br(), + br(), + label(i18n.reportsEvidenceLinksLabel), + br(), + textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks")) + ); + } + + return div( + { class: "report-template-block" }, + h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle), + label(i18n.reportsProblemStatementLabel), + br(), + textarea({ name: "problemStatement", rows: "4", placeholder: i18n.reportsProblemStatementPlaceholder }, tval("problemStatement")), + br(), + br(), + label(i18n.reportsUserStoryLabel), + br(), + textarea({ name: "userStory", rows: "3", placeholder: i18n.reportsUserStoryPlaceholder }, tval("userStory")), + br(), + br(), + label(i18n.reportsAcceptanceCriteriaLabel), + br(), + textarea({ name: "acceptanceCriteria", rows: "4", placeholder: i18n.reportsAcceptanceCriteriaPlaceholder }, tval("acceptanceCriteria")) + ); +}; + +const renderReportStatusChip = (status) => { + const s = normalizeStatus(status); + const variant = + s === "OPEN" ? "whole" : + s === "UNDER_REVIEW" ? "lifespan-orange" : + s === "RESOLVED" ? "mutuals" : + s === "INVALID" ? "closed" : + s === "CLOSED" ? "closed" : + "whole"; + const localized = + s === "OPEN" ? i18n.reportsStatusOpen : + s === "UNDER_REVIEW" ? i18n.reportsStatusUnderReview : + s === "RESOLVED" ? i18n.reportsStatusResolved : + s === "INVALID" ? i18n.reportsStatusInvalid : + s === "CLOSED" ? (i18n.reportsStatusClosed || "CLOSED") : + s; + return renderStateChip(variant, "", localized); +}; + +const renderReportSeverityChip = (severity) => { + const s = String(severity || "low").toLowerCase(); + const variant = + s === "critical" ? "closed" : + s === "high" ? "lifespan-orange" : + s === "medium" ? "whole" : + "mutuals"; + const localized = + s === "critical" ? i18n.reportsSeverityCritical : + s === "high" ? i18n.reportsSeverityHigh : + s === "medium" ? i18n.reportsSeverityMedium : + i18n.reportsSeverityLow; + return renderStateChip(variant, "⚑", localized); +}; + +const renderReportCategoryChip = (category) => { + const c = normU(category); + const localized = + c === "FEATURES" ? i18n.reportsCategoryFeatures : + c === "BUGS" ? i18n.reportsCategoryBugs : + c === "ABUSE" ? i18n.reportsCategoryAbuse : + c === "CONTENT" ? i18n.reportsCategoryContent : + c; + return renderStateChip("encrypted", "", localized); +}; + +const renderReportCard = (report, userId, currentFilter = "all", spreadInfo) => { + const confirmations = Array.isArray(report.confirmations) ? report.confirmations : []; + + const chips = [ + renderReportStatusChip(report.status), + renderReportSeverityChip(report.severity), + renderReportCategoryChip(report.category), + renderLifespanChip(report.lifetime, i18n) + ].filter(Boolean); + + return div({ class: "tribe-card report-card" }, + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/reports/${encodeURIComponent(report.id)}` }, report.title || i18n.reportsTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(report.id, spreadInfo)), + div({ class: "card-visit-btn-centered" }, + form({ method: "GET", action: `/reports/${encodeURIComponent(report.id)}` }, + input({ type: "hidden", name: "filter", value: currentFilter }), + button({ type: "submit", class: "filter-btn" }, i18n.viewReport || "View Report") + ) + ) + ) + ); +}; + +exports.reportView = async (reports, filter, reportId, createCategory, params = {}) => { + const title = + filter === "create" ? i18n.reportsCreateButton : + filter === "edit" ? i18n.reportsUpdateButton : + filter === "mine" ? i18n.reportsMineSectionTitle : + filter === "features" ? i18n.reportsFeaturesSectionTitle : + filter === "bugs" ? i18n.reportsBugsSectionTitle : + filter === "abuse" ? i18n.reportsAbuseSectionTitle : + filter === "content" ? i18n.reportsContentSectionTitle : + filter === "confirmed" ? i18n.reportsConfirmedSectionTitle : + filter === "open" ? i18n.reportsOpenSectionTitle : + filter === "under_review" ? i18n.reportsUnderReviewSectionTitle : + filter === "resolved" ? i18n.reportsResolvedSectionTitle : + filter === "invalid" ? i18n.reportsInvalidSectionTitle : + i18n.reportsAllSectionTitle; + + let filtered = Array.isArray(reports) ? reports : []; + + if (filter === "mine") { + filtered = filtered.filter((r) => r.author === userId); + } else if (filter === "confirmed") { + filtered = filtered.filter((r) => Array.isArray(r.confirmations) && r.confirmations.includes(userId)); + } else if (CATEGORY_BY_FILTER[filter]) { + const wanted = CATEGORY_BY_FILTER[filter]; + filtered = filtered.filter((r) => normU(r.category) === wanted); + } else if (STATUS_BY_FILTER[filter]) { + const wanted = STATUS_BY_FILTER[filter]; + filtered = filtered.filter((r) => normalizeStatus(r.status) === wanted); + } + + filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + const reportToEdit = filter === "edit" + ? (Array.isArray(reports) ? reports.find((r) => r.id === reportId) : null) + : null; + + const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn"); + + const selectedCategory = normU( + filter === "create" + ? (createCategory || "FEATURES") + : (reportToEdit?.category || "FEATURES") + ); + + const selectedTemplate = reportToEdit?.template && typeof reportToEdit.template === "object" ? reportToEdit.template : {}; + const applyLabel = i18n.apply || "Apply"; + const sev = String(reportToEdit?.severity || "low"); + const hiddenDescription = String(reportToEdit?.description || ""); + + return template( + title, + section( + div( + { class: "tags-header" }, + h2(i18n.reportsTitle), + p(i18n.reportsDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/reports" }, + button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine), + button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures), + button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs), + button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse), + button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent), + button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed), + button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen), + button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview), + button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved), + button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton) + ) + ) + ), + section( + filter === "edit" || filter === "create" + ? div( + { class: "report-form" }, + filter === "create" + ? div( + label(i18n.reportsTitleLabel), + br(), + input({ type: "text", name: "title", required: true, value: "", form: "report-create-form" }), + br(), + br(), + form( + { id: "report-category-form", method: "GET", action: "/reports" }, + input({ type: "hidden", name: "filter", value: "create" }), + label(i18n.reportsCategory), + br(), + select( + { name: "category" }, + opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures), + opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs), + opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse), + opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent) + ), + br(), + br(), + button({ type: "submit", class: "create-button" }, applyLabel) + ), + br(), + h2({ class: "report-category-fixed" }, selectedCategory), + br(), + form( + { id: "report-create-form", action: "/reports/create", method: "POST", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "category", value: selectedCategory }), + input({ type: "hidden", name: "description", value: "" }), + label(i18n.reportsSeverity), + br(), + select( + { name: "severity" }, + opt("critical", sev === "critical", i18n.reportsSeverityCritical), + opt("high", sev === "high", i18n.reportsSeverityHigh), + opt("medium", sev === "medium", i18n.reportsSeverityMedium), + opt("low", sev === "low", i18n.reportsSeverityLow) + ), + br(), + br(), + h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle), + renderTemplateForCategory(selectedCategory, {}), + label(i18n.reportsUploadFile), + br(), + input({ type: "file", name: "image" }), + br(), + br(), + label("Tags"), + br(), + input({ type: "text", name: "tags", value: "" }), + br(), + br(), + button({ type: "submit", class: "create-button" }, i18n.reportsCreateButton) + ) + ) + : div( + form( + { id: "report-edit-form", action: `/reports/update/${encodeURIComponent(reportId)}`, method: "POST", enctype: "multipart/form-data" }, + label(i18n.reportsTitleLabel), + br(), + input({ type: "text", name: "title", required: true, value: reportToEdit?.title || "" }), + br(), + br(), + input({ type: "hidden", name: "description", value: hiddenDescription }), + label(i18n.reportsCategory), + br(), + select( + { name: "category", required: true }, + opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures), + opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs), + opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse), + opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent) + ), + br(), + br(), + label(i18n.reportsSeverity), + br(), + select( + { name: "severity" }, + opt("critical", sev === "critical", i18n.reportsSeverityCritical), + opt("high", sev === "high", i18n.reportsSeverityHigh), + opt("medium", sev === "medium", i18n.reportsSeverityMedium), + opt("low", sev === "low", i18n.reportsSeverityLow) + ), + br(), + br(), + h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle), + renderTemplateForCategory(selectedCategory, selectedTemplate), + br(), + br(), + label(i18n.reportsUploadFile), + br(), + input({ type: "file", name: "image" }), + br(), + br(), + label("Tags"), + br(), + input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }), + br(), + br(), + button({ type: "submit" }, i18n.reportsUpdateButton) + ) + ) + ) + : filtered.length > 0 + ? div({ class: "jobs-grid" }, filtered.map((r) => renderReportCard(r, userId, filter, params.spreadMap && params.spreadMap.get(r.id)))) + : p(i18n.reportsNoItems) + ) + ); +}; + +exports.singleReportView = async (report, filter, comments = [], params = {}) => { + const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn"); + const confirmations = Array.isArray(report.confirmations) ? report.confirmations : []; + const isAuthor = String(report.author) === String(userId); + const details = renderTemplateDetails(report); + + const chips = [ + renderReportStatusChip(report.status), + renderReportSeverityChip(report.severity), + renderReportCategoryChip(report.category), + renderLifespanChip(report.lifetime, i18n), + renderEcoTax(report.msgSize, report.id) + ].filter(Boolean); + + const sideActions = []; + const pm = renderPmButton(report.author); + if (pm) sideActions.push(pm); + sideActions.push(form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, + button({ type: "submit", class: "filter-btn" }, i18n.reportsConfirmButton) + )); + sideActions.push(a({ href: "/tasks?filter=create", target: "_blank" }, + button({ type: "button", class: "filter-btn" }, i18n.reportsCreateTaskButton) + )); + if (isAuthor) { + sideActions.push(renderReportStatusSetter(report)); + sideActions.push(form({ method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` }, + button({ type: "submit", class: "update-btn" }, i18n.reportsUpdateButton) + )); + sideActions.push(form({ method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` }, + button({ type: "submit", class: "delete-btn" }, i18n.reportsDeleteButton) + )); + } + + const tagsNode = report.tags && report.tags.length + ? div({ class: "card-tags" }, + report.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; + + const infoRows = []; + const pushRow = (labelText, valueNode) => + infoRows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: "tribe-info-value" }, valueNode) + )); + pushRow(i18n.reportsStatus, report.status); + pushRow(i18n.reportsSeverity, normU(report.severity || "low")); + pushRow(i18n.reportsCategory, report.category); + + const reportSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, report.title) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(report.id, params.spreads)), + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + tagsNode, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`) + ), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const opinionsBar = renderOpinionsVoting('/reports/opinions', report.id, report.opinions, null, report.opinions_inhabitants); + + const reportMain = div({ class: "tribe-main" }, + (details || report.image) ? div({ class: "job-section" }, + details || null, + report.image ? renderMediaBlob(report.image, { class: "report-detail-image" }) : null + ) : null, + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(report.author) + ), + opinionsBar, + renderReportCommentsSection(report.id, comments) + ); + + return template( + report.title, + section( + div( + { class: "filters" }, + form( + { method: "GET", action: "/reports" }, + button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine), + button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures), + button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs), + button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse), + button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent), + button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed), + button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen), + button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview), + button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved), + button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton) + ) + ), + div({ class: "tribe-details" }, reportSide, reportMain) + ) + ); +}; + diff --git a/src/views/search_view.js b/src/views/search_view.js new file mode 100644 index 0000000..055519a --- /dev/null +++ b/src/views/search_view.js @@ -0,0 +1,660 @@ +const { form, button, div, h2, p, section, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, renderContentActions } = require('./main_views'); +const moment = require("../server/node_modules/moment"); +const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); +const { renderUrl } = require('../backend/renderUrl'); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); +const { config } = require("../server/SSB_server.js"); +const userId = config.keys.id; + +const decodeMaybe = (s) => { + try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); } +}; + +const rewriteHashtagLinks = (html) => { + const s = String(html || ''); + return s.replace(/href=(["'])(?:https?:\/\/[^"']+)?\/hashtag\/([^"'?#]+)([^"']*)\1/g, (m, q, tag, rest) => { + const t = decodeMaybe(tag).replace(/^#/, '').trim().toLowerCase(); + const href = `/search?query=%23${encodeURIComponent(t)}`; + return `href=${q}${href}${q}`; + }); +}; + +const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, resultCount = "10" }) => { + const searchInput = input({ + id: "search_query", + name: "query", + required: false, + type: "search", + value: query, + placeholder: i18n.searchPlaceholder + }); + searchInput.setAttribute("minlength", 3); + + const contentTypes = [ + "post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes", + "report", "task", "event", "bookmark", "image", "audio", "video", "document", "torrent", + "bankWallet", "bankClaim", "project", "job", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "chat", "pad", "all" + ]; + + const filterSelect = select( + { + id: "search-type", + name: "type", + class: "input-select", + style: "position:relative; z-index:10;" + }, + contentTypes.map(type => + option({ + value: type === 'all' ? "" : type, + selected: (types.length === 0 && type === 'all') || types.includes(type) + }, i18n[type + "Label"] || type.toUpperCase()) + ) + ); + + const resultsPerPageSelect = select( + { + id: "results-per-page", + name: "resultsPerPage", + class: "input-select", + style: "position:relative; z-index:10;margin-left:10px;" + }, + option({ value: "100", selected: resultCount === "100" ? "selected" : undefined }, "100"), + option({ value: "50", selected: resultCount === "50" ? "selected" : undefined }, "50"), + option({ value: "10", selected: resultCount === "10" ? "selected" : undefined }, "10"), + option({ value: "all", selected: resultCount === "all" ? "selected" : undefined }, i18n.allTypesLabel) + ); + + const getViewDetailsActionForSearch = (type, contentId, content) => { + switch (type) { + case 'votes': return `/votes/${encodeURIComponent(contentId)}`; + case 'transfer': return `/transfers/${encodeURIComponent(contentId)}`; + case 'tribe': return `/tribe/${encodeURIComponent(contentId)}`; + case 'about': return content && (content.about || content.author) ? `/inhabitant/${encodeURIComponent(content.about || content.author)}` : '#'; + case 'curriculum': return content && content.author ? `/inhabitant/${encodeURIComponent(content.author)}` : '#'; + case 'image': return `/images/${encodeURIComponent(contentId)}`; + case 'audio': return `/audios/${encodeURIComponent(contentId)}`; + case 'video': return `/videos/${encodeURIComponent(contentId)}`; + case 'document': return `/documents/${encodeURIComponent(contentId)}`; + case 'bookmark': return `/bookmarks/${encodeURIComponent(contentId)}`; + case 'event': return `/events/${encodeURIComponent(contentId)}`; + case 'task': return `/tasks/${encodeURIComponent(contentId)}`; + case 'post': return `/thread/${encodeURIComponent(contentId)}#${encodeURIComponent(contentId)}`; + case 'market': return `/market/${encodeURIComponent(contentId)}`; + case 'report': return `/reports/${encodeURIComponent(contentId)}`; + case 'project': return `/projects/${encodeURIComponent(contentId)}`; + case 'job': return `/jobs/${encodeURIComponent(contentId)}`; + case 'forum': return `/forum/${encodeURIComponent(contentId)}`; + case 'vote': return content && content.vote && content.vote.link ? `/thread/${encodeURIComponent(content.vote.link)}#${encodeURIComponent(content.vote.link)}` : '#'; + case 'contact': return content && content.contact ? `/author/${encodeURIComponent(content.contact)}` : '#'; + case 'pub': return '#'; + case 'bankWallet': return `/banking`; + case 'bankClaim': return `/banking`; + case 'map': return `/maps/${encodeURIComponent(contentId)}`; + case 'shop': return `/shops/${encodeURIComponent(contentId)}`; + case 'shopProduct': return `/shops/product/${encodeURIComponent(contentId)}`; + case 'chat': return `/chats/${encodeURIComponent(contentId)}`; + case 'pad': return `/pads/${encodeURIComponent(contentId)}`; + case 'torrent': return `/torrents/${encodeURIComponent(contentId)}`; + case 'gameScore': return content && content.game ? `/games/${encodeURIComponent(content.game)}` : '/games'; + default: return '#'; + } + }; + + let hasDocument = false; + + const blobImg = (value) => { + if (!value) return null; + const s = String(value).trim().replace(/&/g, '&'); + const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); + const src = m ? m[1] : s; + return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'search-result-image' }) : null; + }; + + const renderContentHtml = (content) => { + switch (content.type) { + case 'post': + return div({ class: 'search-post' }, + content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null, + content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(content.text) })) : null + ); + case 'about': + return div({ class: 'search-about' }, + content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null, + content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null, + content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null + ); + case 'feed': { + const rawText = typeof content.text === 'string' ? content.text.trim() : ''; + const htmlText = rawText ? rewriteHashtagLinks(renderTextWithStyles(rawText)) : ''; + const refeedsNum = Number(content.refeeds || 0) || 0; + return div({ class: 'search-feed' }, + rawText ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(htmlText) })) : null, + refeedsNum > 0 + ? h2({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ':'), + span({ class: 'card-value' }, String(refeedsNum)) + ) + : null + ); + } + case 'event': { + const rawEvDesc = content.description || ''; + const blobInEvDesc = rawEvDesc.match(/!\[[^\]]*\]\((&[^)\s]+\.sha256)\)/)?.[1] || null; + const cleanEvDesc = rawEvDesc.replace(/!\[[^\]]*\]\(&[^)]+\.sha256\)/g, '').trim(); + return div({ class: 'search-event' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, + cleanEvDesc ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, cleanEvDesc)) : null, + blobImg(content.image || blobInEvDesc), + content.date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventDate + ':'), span({ class: 'card-value' }, new Date(content.date).toLocaleString())) : null, + content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventLocation + ':'), span({ class: 'card-value' }, content.location)) : null, + content.isPublic ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventPrivacyLabel + ':'), span({ class: 'card-value' }, content.isPublic)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventStatus + ':'), span({ class: 'card-value' }, content.status)) : null, + content.eventUrl ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.eventUrl, target: '_blank' }, content.eventUrl))) : null, + content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventPrice + ':'), span({ class: 'card-value' }, content.price)) : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + } + case 'votes': + const votesList = content.votes && typeof content.votes === 'object' + ? Object.entries(content.votes).map(([option, count]) => ({ option, count })) + : []; + return div({ class: 'search-vote' }, + br(), + content.question ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteQuestionLabel + ':' ), + span({ class: 'card-value' }, content.question) + ) : null, + content.status ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteStatus + ':' ), + span({ class: 'card-value' }, content.status) + ) : null, + content.deadline ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteDeadline + ':' ), + span({ class: 'card-value' }, content.deadline ? new Date(content.deadline).toLocaleString() : '') + ) : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.voteTotalVotes + ':' ), + span({ class: 'card-value' }, content.totalVotes !== undefined ? content.totalVotes : '0') + ), + br(), + votesList.length > 0 ? div({ class: 'card-votes' }, + table( + tr(...votesList.map(({ option }) => th(i18n[option] || option))), + tr(...votesList.map(({ count }) => td(count))) + ) + ) : null + ); + case 'tribe': + return div({ class: 'search-tribe' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + (() => { const s = String(content.image || '').trim().replace(/&/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(), + content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null, + content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)), + content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null, + Array.isArray(content.members) + ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeMembersCount + ':'), span({ class: 'card-value' }, String(content.members.length))) + : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'audio': + return content.url ? div({ class: 'search-audio' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + br(), + audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }), + br(), + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ) : null; + case 'image': + return content.url ? div({ class: 'search-image' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : null, + br(), + img({ src: `/blob/${encodeURIComponent(content.url)}` }), + br(), + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ) : null; + case 'video': + return content.url ? div({ class: 'search-video' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + br(), + videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }), + br(), + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ) : null; + case 'document': + return div({ class: 'search-document' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, + br(), + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, + br(), + div({ + id: `pdf-container-${content.key || content.url}`, + class: 'pdf-viewer-container', + 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` + }), + br(), + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'torrent': + return div({ class: 'search-torrent' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || i18n.title) + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || i18n.searchDescription) + ':'), span({ class: 'card-value' }, content.description)) : null, + content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'market': + return div({ class: 'search-market' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, + content.item_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, content.item_type.toUpperCase())) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemCondition + ':'), span({ class: 'card-value' }, content.status)) : null, + content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemDeadline + ':'), span({ class: 'card-value' }, new Date(content.deadline).toLocaleString())) : null, + br(), + blobImg(content.image), + br(), + content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, userLink(content.seller))) : null, + content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null, + content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null, + content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null, + content.includesShipping ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemIncludesShipping + ':'), span({ class: 'card-value' }, `${content.includesShipping ? i18n.YESLabel : i18n.NOLabel}`)) : null, + content.auctions_poll && content.auctions_poll.length > 0 + ? div({ class: 'auction-info' }, + p(i18n.marketAuctionBids), + table({ class: 'auction-bid-table' }, + tr( + th(i18n.marketAuctionBidTime), + th(i18n.marketAuctionUser), + th(i18n.marketAuctionBidAmount) + ), + content.auctions_poll.map(bid => { + const [userId, bidAmount, bidTime] = bid.split(':'); + return tr( + td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')), + td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)), + td(`${parseFloat(bidAmount).toFixed(6)} ECO`) + ); + }) + ) + ) + : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'bookmark': + return div({ class: 'search-bookmark' }, + content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, + content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null, + content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'task': + return div({ class: 'search-task' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field', style: 'display:flex;flex-direction:column;' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), p({ class: 'card-value', style: 'white-space:pre-wrap;margin-top:4px;' }, content.description)) : null, + content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null, + content.priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriorityLabel + ':'), span({ class: 'card-value' }, content.priority)) : null, + typeof content.isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchIsPublicLabel + ':'), span({ class: 'card-value' }, content.isPublic ? i18n.YESLabel : i18n.NOLabel)) : null, + content.startTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskStartTimeLabel + ':'), span({ class: 'card-value' }, new Date(content.startTime).toLocaleString())) : null, + content.endTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskEndTimeLabel + ':'), span({ class: 'card-value' }, new Date(content.endTime).toLocaleString())) : null, + Array.isArray(content.assignees) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskAssignees + ':'), span({ class: 'card-value' }, content.assignees.length)) : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'report': + return div({ class: 'search-report' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null, + content.severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsSeverity + ':'), span({ class: 'card-value' }, content.severity)) : null, + content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, + br(), + blobImg(content.image), + br(), + typeof content.confirmations === 'number' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsConfirmations + ':'), span({ class: 'card-value' }, content.confirmations)) : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'transfer': + return div({ class: 'search-transfer' }, + content.concept ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConcept + ':'), span({ class: 'card-value' }, content.concept)) : null, + content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersDeadline + ':'), span({ class: 'card-value' }, content.deadline)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null, + content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null, + br(), + content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, userLink(content.from))) : null, + content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, userLink(content.to))) : null, + br(), + content.confirmedBy && content.confirmedBy.length + ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length)) + : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + case 'curriculum': + return div({ class: 'search-curriculum' }, + content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvNameLabel + ':'), span({ class: 'card-value' }, content.name)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + content.photo ? img({ src: `/blob/${encodeURIComponent(content.photo)}`, class: 'curriculum-photo' }) : null, + content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null, + content.preferences ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvPreferencesLabel + ':'), span({ class: 'card-value' }, content.preferences)) : null, + Array.isArray(content.personalSkills) && content.personalSkills.length + ? div({ class: 'card-field' }, content.personalSkills.map(skill => + a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: 'tag-link' }, `#${skill}`) + )) : null, + Array.isArray(content.personalExperiences) && content.personalExperiences.length + ? div({ class: 'card-field' }, content.personalExperiences.map(exp => p(exp))) : null, + Array.isArray(content.oasisExperiences) && content.oasisExperiences.length + ? div({ class: 'card-field' }, content.oasisExperiences.map(exp => p(exp))) : null, + Array.isArray(content.oasisSkills) && content.oasisSkills.length + ? div({ class: 'card-field' }, content.oasisSkills.map(skill => p(skill))) : null, + Array.isArray(content.educationExperiences) && content.educationExperiences.length + ? div({ class: 'card-field' }, content.educationExperiences.map(exp => p(exp))) : null, + Array.isArray(content.educationalSkills) && content.educationalSkills.length + ? div({ class: 'card-field' }, content.educationalSkills.map(skill => + a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: 'tag-link' }, `#${skill}`) + )) : null, + Array.isArray(content.languages) && content.languages.length + ? div({ class: 'card-field' }, content.languages.map(lang => p(lang))) : null, + Array.isArray(content.professionalExperiences) && content.professionalExperiences.length + ? div({ class: 'card-field' }, content.professionalExperiences.map(exp => p(exp))) : null, + Array.isArray(content.professionalSkills) && content.professionalSkills.length + ? div({ class: 'card-field' }, content.professionalSkills.map(skill => p(skill))) : null + ); + case 'bankWallet': + return div({ class: 'search-bank-wallet' }, + content.address ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankWalletConnected + ':' ), + span({ class: 'card-value' }, content.address) + ) : null + ); + case 'bankClaim': + return div({ class: 'search-bank-claim' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankUbiReceived + ':' ), + span({ class: 'card-value' }, `${Number(content.amount || 0).toFixed(6)} ECO`) + ), + content.epochId ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankEpochShort + ':' ), + span({ class: 'card-value' }, content.epochId) + ) : null, + content.allocationId ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankAllocId + ':' ), + span({ class: 'card-value' }, content.allocationId) + ) : null, + content.txid ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.bankTx + ':' ), + a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${content.txid}`, target: '_blank' }, content.txid) + ) : null + ); + case 'job': + return div({ class: 'search-job' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + content.salary ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobSalary + ':'), span({ class: 'card-value' }, `${content.salary} ECO`)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobStatus + ':'), span({ class: 'card-value' }, content.status.toUpperCase())) : null, + content.job_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobType + ':'), span({ class: 'card-value' }, content.job_type.toUpperCase())) : null, + content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobLocation + ':'), span({ class: 'card-value' }, String(content.location).toUpperCase())) : null, + content.vacants ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobVacants + ':'), span({ class: 'card-value' }, content.vacants)) : null, + Array.isArray(content.subscribers) ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.jobSubscribers + ':'), span({ class: 'card-value' }, `${content.subscribers.length}`)) : null + ); + case 'forum': + return div({ class: 'search-forum' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null, + content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.text)) : null + ); + case 'vote': + return div({ class: 'search-vote-link' }, + content.vote && content.vote.link ? p(a({ href: `/thread/${encodeURIComponent(content.vote.link)}#${encodeURIComponent(content.vote.link)}`, class: 'activityVotePost' }, content.vote.link)) : null + ); + case 'contact': + return div({ class: 'search-contact' }, + content.contact ? p(a({ href: `/author/${encodeURIComponent(content.contact)}`, class: 'activitySpreadInhabitant2' }, content.contact)) : null + ); + case 'pub': + return div({ class: 'search-pub' }, + content.address && content.address.key ? p(a({ href: `/author/${encodeURIComponent(content.address.key)}`, class: 'activitySpreadInhabitant2' }, content.address.key)) : null + ); + case 'shop': + return div({ class: 'search-shop' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + blobImg(content.image), + content.shortDescription ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.shortDescription)) : null, + content.visibility ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.visibility)) : null, + content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchLocationLabel || 'LOCATION') + ':'), span({ class: 'card-value' }, content.location)) : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`))) + : null + ); + case 'shopProduct': + return div({ class: 'search-shop-product' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + blobImg(content.image), + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, + content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null, + content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null + ); + case 'chat': { + const chatInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId)); + if (chatInviteOnly) return div({ class: 'search-chat' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null, + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, i18n.chatStatusInviteOnly || 'INVITE-ONLY')) + ); + return div({ class: 'search-chat' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null, + content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null + ); + } + case 'pad': { + const padInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId)); + if (padInviteOnly) return div({ class: 'search-pad' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null, + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, i18n.padStatusInviteOnly || 'INVITE-ONLY')) + ); + return div({ class: 'search-pad' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null, + content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : null, + content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : null + ); + } + case 'gameScore': + return div({ class: 'search-game' }, + content.game ? div({ class: 'game-row' }, + img({ src: `/game-assets/${content.game}/thumbnail.svg`, alt: content.game, class: 'game-scoring-thumb', loading: 'lazy' }), + div({ class: 'game-row-body' }, + h2({ class: 'game-card-title' }, content.game.charAt(0).toUpperCase() + content.game.slice(1)), + content.score !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.gamesHallScore || 'Score') + ':'), span({ class: 'card-value' }, String(content.score))) : null, + a({ href: `/games/${encodeURIComponent(content.game)}`, class: 'filter-btn' }, i18n.gamesPlayButton || 'PLAY!') + ) + ) : null + ); + case 'map': + return div({ class: 'search-map' }, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + content.lat && content.lng ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapLocationTitle + ':'), span({ class: 'card-value' }, `${content.lat}, ${content.lng}`)) : null, + content.mapType ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapTypeLabel + ':'), span({ class: 'card-value' }, content.mapType)) : null, + content.tags && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) + : null + ); + default: + return div({ class: 'styled-text', innerHTML: sanitizeHtml(renderTextWithStyles(content.text || content.description || content.title || '[no content]')) }); + } + }; + + const resultSection = Object.entries(results).length > 0 + ? Object.entries(results).map(([key, msgs]) => + div( + { class: "search-result-group" }, + ...msgs.map((msg) => { + const content = msg.value.content || {}; + const created = new Date(msg.timestamp).toLocaleString(); + if (content.type === 'document') hasDocument = true; + const contentHtml = renderContentHtml(content); + let author; + let authorUrl = '#'; + if (content.type === 'market') { + author = content.seller || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.seller)}`; + } else if (content.type === 'event') { + author = content.organizer || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.organizer)}`; + } else if (content.type === 'transfer') { + author = content.from || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.from)}`; + } else if (content.type === 'post' || content.type === 'about') { + author = msg.value.author || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(msg.value.author)}`; + } else if (content.type === 'report') { + author = content.author || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.author || 'Anonymous')}`; + } else if (content.type === 'votes') { + author = content.createdBy || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.createdBy || 'Anonymous')}`; + } else if (content.type === 'shop' || content.type === 'shopProduct' || content.type === 'chat' || content.type === 'gameScore') { + author = content.author || content.player || msg.value.author || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.author || content.player || msg.value.author || 'Anonymous')}`; + } else { + author = content.author; + authorUrl = `/author/${encodeURIComponent(content.author || 'Anonymous')}`; + } + + const contentId = msg.key; + const isOwn = msg.value.author && String(msg.value.author) === String(userId); + + return div({ class: 'trending-card search-card' + (isOwn ? ' own-content' : '') }, [ + div({ class: 'card-header activity-card-header' }, + div({ class: 'card-chips-row' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, String(content.type || '').toUpperCase()) + ) + ), + renderContentActions(contentId, getViewDetailsActionForSearch(content.type, contentId, content)) + ), + div({ class: 'card-section search-card-body' }, + contentHtml, + author + ? p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${created} ${i18n.performed} `), + (authorUrl && authorUrl !== '#' && authorUrl.startsWith('/author/')) + ? userLink(decodeURIComponent(authorUrl.replace(/^\/author\//, ''))) + : a({ href: authorUrl, class: 'user-link' }, `${author}`) + ): null + ) + ]); + }) + ) + ) + : div({ class: 'no-results' }, p(i18n.noResultsFound)); + + let html = template( + hashtag ? `#${hashtag}` : i18n.searchTitle, + section( + div({ class: "tags-header" }, + h2(hashtag ? `#${hashtag}` : i18n.searchTitle), + p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel) + ), + form( + { action: "/search", method: "POST", class: "search-form" }, + div({ class: "search-filters-row" }, + table({ class: "search-filters-table" }, + tr( + td({ class: 'card-label' }, label({ for: "search_from" }, i18n.searchFromLabel || "From")), + td(input({ id: "search_from", type: "datetime-local", name: "from" })) + ), + tr( + td({ class: 'card-label' }, label({ for: "search_to" }, i18n.searchToLabel || "To")), + td(input({ id: "search_to", type: "datetime-local", name: "to" })) + ), + tr( + td({ class: 'card-label' }, label({ for: "search_inhabitant" }, "Oasis ID")), + td(input({ id: "search_inhabitant", type: "text", name: "inhabitant", placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", maxlength: 56, class: "search-oasis-id" })) + ) + ), + table({ class: "search-filters-table" }, + tr( + td({ class: 'card-label' }, label({ for: "search_query" }, i18n.searchQueryLabel || "Query")), + td(searchInput) + ), + tr( + td({ class: 'card-label' }, label({ for: "results-per-page" }, i18n.searchPerPageLabel || "Results per page")), + td(resultsPerPageSelect) + ) + ) + ), + div({ class: "search-submit-row" }, + button({ type: "submit" }, i18n.searchSubmit) + ) + ) + ), + section(resultSection) + ); + + if (hasDocument) { + html += ` + + + `; + } + + return html; +}; + +exports.searchView = searchView; + diff --git a/src/views/settings_view.js b/src/views/settings_view.js new file mode 100644 index 0000000..0969d08 --- /dev/null +++ b/src/views/settings_view.js @@ -0,0 +1,368 @@ +const { form, button, div, h2, h3, p, section, select, option, input, br, a, label, span } = require("../server/node_modules/hyperaxe"); +const fs = require('fs'); +const path = require('path'); +const { getConfig } = require('../configs/config-manager.js'); +const { template, selectedLanguage, i18n, setLanguage } = require('./main_views'); +const i18nBase = require("../client/assets/translations/i18n"); + +const snhUrl = "https://wiki.solarnethub.com/socialnet/overview"; + +const themeFilePath = path.join(__dirname, '../configs/oasis-config.json'); +const getThemeConfig = () => { + try { + const configData = fs.readFileSync(themeFilePath); + return JSON.parse(configData); + } catch (error) { + console.error('Error reading config file:', error); + return {}; + } +}; + +const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) => { + const currentThemeConfig = getThemeConfig(); + const theme = currentThemeConfig.themes?.current || "Dark-SNH"; + const currentConfig = getConfig(); + let serverConfig = {}; + try { serverConfig = JSON.parse(fs.readFileSync(path.join(__dirname, '../configs/server-config.json'), 'utf8')); } catch (_) {} + const currentHops = (serverConfig.friends && Number.isFinite(serverConfig.friends.hops)) ? serverConfig.friends.hops : 2; + const walletUrl = currentConfig.wallet.url; + const walletUser = currentConfig.wallet.user; + const walletFee = currentConfig.wallet.fee; + const pubId = currentConfig.walletPub?.pubId || ''; + const currentWish = currentConfig.wish === 'mutuals' ? 'mutuals' : 'whole'; + const currentPmVisibility = currentConfig.pmVisibility === 'mutuals' ? 'mutuals' : 'whole'; + + const themeElements = [ + option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"), + option({ value: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-SNH"), + option({ value: "Purple-SNH", selected: theme === "Purple-SNH" ? true : undefined }, "Purple-SNH"), + option({ value: "Matrix-SNH", selected: theme === "Matrix-SNH" ? true : undefined }, "Matrix-SNH"), + option({ value: "OasisMobile", selected: theme === "OasisMobile" ? true : undefined }, "Oasis-Mobile") + ]; + + const languageOption = (longName, shortName) => { + return shortName === selectedLanguage + ? option({ value: shortName, selected: true }, longName) + : option({ value: shortName }, longName); + }; + + const rebuildButton = form( + { action: "/settings/rebuild", method: "post" }, + button({ type: "submit" }, i18n.rebuildName) + ); + + const updateFlagPath = path.join(__dirname, '../server/.update_required'); + let updateButton = null; + if (fs.existsSync(updateFlagPath)) { + updateButton = form( + { action: "/update", method: "post" }, + button({ type: "submit" }, i18n.updateit) + ); + } + + return template( + i18n.settings, + section( + div({ class: "tags-header" }, + h2(i18n.settings), + p(a({ href: snhUrl, target: "_blank" }, i18n.settingsIntro({ version }))), + updateButton + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.theme), + p(i18n.themeIntro), + form( + { action: "/settings/theme", method: "post" }, + select({ name: "theme" }, ...themeElements), + br(), + br(), + button({ type: "submit" }, i18n.setTheme) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.language), + p(i18n.languageDescription), + form( + { action: "/language", method: "post" }, + select({ name: "language" }, [ + languageOption("English", "en"), + languageOption("Español", "es"), + languageOption("Français", "fr"), + languageOption("Euskara", "eu"), + languageOption("Deutsch", "de"), + languageOption("Italiano", "it"), + languageOption("Português", "pt"), + languageOption("中文", "zh"), + languageOption("العربية", "ar"), + languageOption("हिन्दी", "hi"), + languageOption("Русский", "ru") + ]), + br(), + br(), + button({ type: "submit" }, i18n.setLanguage) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.uxModeTitle || "UX"), + p(i18n.uxModeDescription || "Select which UX navigation mode you want for your GUI."), + form( + { action: "/settings/ux", method: "POST" }, + (() => { + const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on'; + const opts = [ + option({ value: "blocks", selected: (currentConfig.ux?.current !== "ainav") ? true : undefined }, i18n.uxModeMenus || "Blocks") + ]; + if (aiNavEnabled) { + opts.push(option({ value: "ainav", selected: currentConfig.ux?.current === "ainav" ? true : undefined }, i18n.uxModeAINav || "AI")); + } + return select({ name: "ux" }, ...opts); + })(), + br(), br(), + button({ type: "submit" }, i18n.saveSettings) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.homePageTitle), + p(i18n.homePageDescription), + form( + { action: "/settings/home-page", method: "POST" }, + select({ name: "homePage" }, + option({ value: "activity", selected: currentConfig.homePage === "activity" ? true : undefined }, i18n.activityTitle), + option({ value: "ai", selected: currentConfig.homePage === "ai" ? true : undefined }, i18n.aiTitle), + option({ value: "trending", selected: currentConfig.homePage === "trending" ? true : undefined }, i18n.trendingTitle), + option({ value: "opinions", selected: currentConfig.homePage === "opinions" ? true : undefined }, i18n.opinionsTitle), + option({ value: "forum", selected: currentConfig.homePage === "forum" ? true : undefined }, i18n.forumTitle), + option({ value: "feed", selected: currentConfig.homePage === "feed" ? true : undefined }, i18n.feedTitle), + option({ value: "mentions", selected: currentConfig.homePage === "mentions" ? true : undefined }, i18n.mentions), + option({ value: "inbox", selected: currentConfig.homePage === "inbox" ? true : undefined }, i18n.inbox), + option({ value: "agenda", selected: currentConfig.homePage === "agenda" ? true : undefined }, i18n.agendaTitle), + option({ value: "favorites", selected: currentConfig.homePage === "favorites" ? true : undefined }, i18n.favoritesTitle), + option({ value: "stats", selected: currentConfig.homePage === "stats" ? true : undefined }, i18n.statsTitle), + option({ value: "blockexplorer", selected: currentConfig.homePage === "blockexplorer" ? true : undefined }, i18n.blockchain) + ), + br(), br(), + button({ type: "submit" }, i18n.saveHomePage) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.ssbLogStream), + p(i18n.ssbLogStreamDescription), + form( + { action: "/settings/ssb-logstream", method: "POST" }, + input({ + type: "number", + id: "ssb_log_limit", + name: "ssb_log_limit", + min: 1, + max: 100000, + value: currentConfig.ssbLogStream?.limit || 1000 + }), br(),br(), + button({ type: "submit" }, i18n.saveSettings) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.settingsReplicationTitle || 'Replication'), + p(i18n.settingsReplicationDesc || 'Configure the number of hops your peer follows out from your own feed when replicating content.'), + form( + { action: "/settings/replication", method: "POST" }, + label({ for: "replication_hops" }, i18n.settingsReplicationHopsLabel || 'Hops'), + br(), + input({ + type: "number", + id: "replication_hops", + name: "hops", + min: 0, + max: 6, + value: currentHops + }), + br(), br(), + button({ type: "submit" }, i18n.saveSettings) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.settingsLanTitle || 'LAN Broadcasting'), + p(i18n.settingsLanDesc || 'Periodically announce this peer to other Oasis instances on the same local network. Disable to stop UDP broadcasts.'), + form( + { action: "/settings/lan-broadcasting", method: "POST" }, + label({ for: "lanBroadcasting", class: "lan-checkbox-label" }, + input({ + type: "checkbox", + id: "lanBroadcasting", + name: "lanBroadcasting", + value: "on", + class: "lan-checkbox-input", + checked: currentConfig.lanBroadcasting !== false ? true : undefined + }), + span({ class: "lan-checkbox-text" }, i18n.settingsLanEnable || 'Enable LAN broadcasting') + ), + br(), + button({ type: "submit" }, i18n.saveSettings) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.settingsWishTitle), + p(i18n.settingsWishDesc), + form( + { action: "/settings/wish", method: "POST" }, + select({ name: "wish" }, + option({ value: "whole", selected: currentWish === "whole" ? true : undefined }, i18n.settingsWishWhole), + option({ value: "mutuals", selected: currentWish === "mutuals" ? true : undefined }, i18n.settingsWishMutuals), + option({ value: "only-lan", selected: currentWish === "only-lan" ? true : undefined }, i18n.settingsWishOnlyLan || "Only LAN") + ), br(), br(), + button({ type: "submit" }, i18n.saveSettings) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.settingsPmVisibilityTitle), + p(i18n.settingsPmVisibilityDesc), + form( + { action: "/settings/pm-visibility", method: "POST" }, + select({ name: "pmVisibility" }, + option({ value: "whole", selected: currentPmVisibility === "whole" ? true : undefined }, i18n.settingsPmVisibilityWhole), + option({ value: "mutuals", selected: currentPmVisibility === "mutuals" ? true : undefined }, i18n.settingsPmVisibilityMutuals) + ), br(), br(), + button({ type: "submit" }, i18n.saveSettings) + ) + ) + ), + section( + { id: "wallet" }, + div({ class: "tags-header" }, + h2(i18n.wallet), + p( + i18n.walletSettingsDescription, " ", + a({ href: "docs/ecoin.md", target: "_blank", rel: "noopener" }, `[${i18n.walletSettingsDocLink}]`) + ), + form( + { action: "/settings/wallet", method: "POST" }, + label({ for: "wallet_url" }, i18n.walletAddress), br(), + input({ type: "text", id: "wallet_url", name: "wallet_url", placeholder: walletUrl, value: walletUrl }), br(), + label({ for: "wallet_user" }, i18n.walletUser), br(), + input({ type: "text", id: "wallet_user", name: "wallet_user", placeholder: walletUser, value: walletUser }), br(), + label({ for: "wallet_pass" }, i18n.walletPass), br(), + input({ type: "password", id: "wallet_pass", name: "wallet_pass" }), br(), + label({ for: "wallet_fee" }, i18n.walletFee), br(), + input({ type: "text", id: "wallet_fee", name: "wallet_fee", placeholder: walletFee, value: walletFee }), br(), + button({ type: "submit" }, i18n.walletConfiguration) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.pubIdTitle || "PUB Wallet"), + p(i18n.pubIdDescription || "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI)."), + form( + { action: "/settings/pub-id", method: "POST" }, + input({ + type: "text", + id: "pub_id", + name: "pub_id", + value: pubId, + placeholder: i18n.pubIdPlaceholder || "@example.ed25519" + }), br(), + button({ type: "submit" }, i18n.pubIdSave || "Save configuration") + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.aiTitle), + p(i18n.aiSettingsDescription), + form( + { action: "/settings/ai", method: "POST" }, + input({ + type: "text", + id: "ai_prompt", + name: "ai_prompt", + placeholder: aiPrompt, + value: aiPrompt, + maxlength: "128", + required: true + }), br(), + button({ type: "submit" }, i18n.aiConfiguration) + ) + ) + ), + section( + { id: "fediverse" }, + div({ class: "tags-header" }, + h2(i18n.fediverseSettingsTitle), + div({ class: "fediverse-network" }, + h3("Mastodon"), + p(i18n.fediverseTokenHelp), + fediverseError ? p({ class: "fediverse-error" }, i18n[fediverseError] || i18n.fediverseError) : "", + fediverseAccount + ? (() => { + const host = String(fediverseAccount.instance || "").replace(/^https?:\/\//, ""); + const profileUrl = `${fediverseAccount.instance}/@${fediverseAccount.acct}`; + const link = (txt) => a({ href: profileUrl, target: "_blank", rel: "noopener noreferrer" }, txt); + return form( + { action: "/settings/fediverse/disconnect", method: "POST" }, + p( + `${i18n.fediverseConnectedAs}: `, + link(`${fediverseAccount.acct}@${host}`) + ), + button({ type: "submit" }, i18n.fediverseDisconnect) + ); + })() + : form( + { action: "/settings/fediverse", method: "POST" }, + label({ for: "fediverse_instance" }, i18n.fediverseInstanceLabel), br(), + input({ type: "text", id: "fediverse_instance", name: "instance", placeholder: "mastodon.social", required: true }), br(), + label({ for: "fediverse_token" }, i18n.fediverseTokenLabel), br(), + input({ type: "password", id: "fediverse_token", name: "token", autocomplete: "off", required: true }), br(), + button({ type: "submit" }, i18n.fediverseConnect) + ) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.indexes), + p(i18n.indexesDescription), + rebuildButton + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.exportDataTitle), + p(i18n.exportDataDescription), + form( + { action: "/export/create", method: "POST", id: "exportForm" }, + button({ type: "submit" }, i18n.exportDataButton) + ) + ) + ), + section( + div({ class: "tags-header" }, + h2(i18n.panicMode), + p(i18n.removeDataDescription), + form( + { action: "/panic/remove", method: "POST", id: "removeForm" }, + button({ type: "submit" }, i18n.removePanicButton) + ) + ) + ) + ); +}; + +exports.settingsView = settingsView; + diff --git a/src/views/shops_view.js b/src/views/shops_view.js new file mode 100644 index 0000000..7f50ab0 --- /dev/null +++ b/src/views/shops_view.js @@ -0,0 +1,711 @@ +const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderOpinionsVoting, renderInviteQrCard } = require("./main_views") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") +const { renderUrl } = require("../backend/renderUrl") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view") +const opinionCategories = require("../backend/opinion_categories") +const { renderReachChip, renderClearnetUrlBlock, renderClearnetPage, renderClearnetSearchForm, renderEncryptedChip, blobUrl: cnBlobUrl, escapeHtml: cnEscapeHtml } = require("./clearnet_view") + +const userId = config.keys.id +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() +const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0) +const sumCats = (opinions = {}, cats = []) => (cats || []).reduce((s, c) => s + (Number((opinions || {})[c]) || 0), 0) +const renderStarRating = (opinions, voterCount) => { + const pos = sumCats(opinions, opinionCategories.positive) + const neg = sumCats(opinions, opinionCategories.constructive) + sumCats(opinions, opinionCategories.moderation) + const totalVotes = pos + neg + const full = totalVotes > 0 ? Math.round((pos / totalVotes) * 5) : 0 + const stars = "\u2605".repeat(full) + "\u2606".repeat(5 - full) + return span({ class: "shop-product-stars" }, `${stars} (${voterCount})`) +} + +const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => { + if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + const s = String(value).trim() + if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }) + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs }) + return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null +} + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all") + const q = safeText(params.q || "") + const sort = safeText(params.sort || "recent") + const parts = [`filter=${encodeURIComponent(f)}`] + if (q) parts.push(`q=${encodeURIComponent(q)}`) + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`) + return `/shops?${parts.join("&")}` +} + +const renderModeButtons = (currentFilter) => + div({ class: "tribe-mode-buttons" }, + ["all", "recent", "mine", "top", "products", "prices", "favorites"].map(f => + form({ method: "GET", action: "/shops" }, + input({ type: "hidden", name: "filter", value: f }), + button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, i18n[`shopFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase()) + ) + ), + form({ method: "GET", action: "/shops/purchases" }, + button({ type: "submit", class: currentFilter === "purchases" ? "filter-btn active" : "filter-btn" }, (i18n.shopPurchasesButton || "Purchases").toUpperCase()) + ), + form({ method: "GET", action: "/shops" }, + input({ type: "hidden", name: "filter", value: "create" }), + button({ type: "submit", class: "create-button" }, i18n.shopUpload) + ) + ) + +const renderFavoriteToggle = (shop, returnTo) => + form( + { method: "POST", action: shop.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(shop.key)}` : `/shops/favorites/add/${encodeURIComponent(shop.key)}` }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button({ type: "submit", class: "filter-btn" }, shop.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite) + ) + +const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params) + const isAuthor = String(shop.author) === String(userId) + + return div({ class: "tribe-card" }, + div({ class: "tribe-card-image-wrapper" }, + a({ href: `/shops/${encodeURIComponent(shop.key)}` }, + renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-card-hero-image' }) + ) + ), + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, a({ href: `/shops/${encodeURIComponent(shop.key)}` }, shop.title || i18n.shopUntitled)) + ), + div({ class: "card-chips-row" }, + shop.visibility === "CLOSED" + ? renderStateChip("closed", "✗", i18n.shopClosed) + : renderStateChip("mutuals", "✓", i18n.shopOpen), + renderLifespanChip(shop.lifetime, i18n) + ), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreadMap && params.spreadMap.get(shop.key))), + div({ class: "card-visit-btn-centered" }, + form({ method: 'GET', action: `/shops/${encodeURIComponent(shop.key)}` }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewShop || i18n.shopVisitShop || 'View Shop') + ) + ) + ) + ) +} + +const renderProductCard = (product, shopId, returnTo) => { + const isAuthor = String(product.author) === String(userId) + const stock = Number(product.stock) || 0 + const voterCount = safeArr(product.opinions_inhabitants).length + const productUrl = `/shops/product/${encodeURIComponent(product.key)}?shopId=${encodeURIComponent(shopId)}` + + return div({ class: "shop-product-card" }, + product.image ? div({ class: "shop-product-media" }, a({ href: productUrl }, renderMediaBlob(product.image))) : null, + div({ class: "shop-product-body" }, + product.shopTitle ? p(a({ href: `/shops/${encodeURIComponent(shopId)}`, class: "user-link" }, product.shopTitle)) : null, + h2(a({ href: productUrl }, product.title || i18n.shopProductUntitled)), + renderStarRating(product.opinions, voterCount), + product.description ? p(...renderUrl(product.description)) : null, + div({ class: "shop-product-price" }, `${Number(product.price || 0).toFixed(6)} ECO`), + div({ class: "confirmations-block stock-block" }, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.shopProductStock}: `), + span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock) + ), + progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 }) + ), + (() => { + const actions = []; + if (!isAuthor && stock > 0) { + actions.push(a({ href: productUrl, class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)); + } + actions.push(form({ method: "POST", action: product.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(product.key)}` : `/shops/favorites/add/${encodeURIComponent(product.key)}` }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button({ type: "submit", class: "filter-btn" }, product.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite))); + return actions.length ? div({ class: "shop-product-actions" }, ...actions) : null; + })() + ) + ) +} + +const renderCommentsSection = (parentId, returnTo, comments = []) => { + const count = Array.isArray(comments) ? comments.length : 0 + return div({ class: "vote-comments-section market-comments" }, + div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(count))), + div({ class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form({ method: "POST", action: `/shops/${encodeURIComponent(parentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + textarea({ name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + count + ? div({ class: "comments-list" }, + comments.map(c => { + const author = c.value?.author || "" + const ts = c.value?.timestamp || c.timestamp + const text = c.value?.content?.text || "" + const rootId = c.value?.content?.fork || c.value?.content?.root || null + return div({ class: "votations-comment-card" }, + span({ class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + ts ? span(" | ", span({ class: "votations-comment-date" }, moment(ts).format("YYYY/MM/DD HH:mm:ss"))) : "", + ts && rootId ? span(" | ", a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, moment(ts).fromNow())) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ) + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ) +} + +const renderShopForm = (filter, shop = {}, params = {}) => { + const isEdit = filter === "edit" + const returnTo = safeText(params.returnTo) || buildReturnTo("all") + return div({ class: "create-tribe-form" }, + h2(isEdit ? i18n.shopUpdateSectionTitle : i18n.shopCreateSectionTitle), + form({ action: isEdit ? `/shops/update/${encodeURIComponent(shop.key || "")}` : "/shops/create", method: "POST", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + label(i18n.title || "Title"), br, + input({ type: "text", name: "title", required: true, placeholder: i18n.shopTitlePlaceholder || "Name of your shop", value: shop.title || "" }), br(), + label(i18n.shopShortDescription), br, + input({ type: "text", name: "shortDescription", required: true, maxlength: 160, placeholder: i18n.shopShortDescriptionPlaceholder || "Brief description of your shop", value: shop.shortDescription || "" }), br(), + label(i18n.description || "Description"), br, + textarea({ name: "description", rows: 4, placeholder: i18n.shopDescriptionPlaceholder || "Detailed description of your shop" }, shop.description || ""), br, + label(i18n.blogImage || "Upload media (max-size: 50MB)"), br, + input({ type: "file", name: "image", accept: "image/*,video/*" }), br(), br(), + label(i18n.shopUrl), br, + input({ type: "text", name: "url", placeholder: "https://", value: shop.url || "" }), br, + label(i18n.shopLocation), br, + input({ type: "text", name: "location", placeholder: i18n.shopLocationPlaceholder || "City, Country", value: shop.location || "" }), br, + label(i18n.mapLocationTitle || "Map Location"), br, + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: shop.mapUrl || "" }), br, + label(i18n.shopTags), br, + input({ type: "text", name: "tags", placeholder: i18n.shopTagsPlaceholder || "tag1, tag2, tag3", value: safeArr(shop.tags).join(", ") }), br, + isEdit ? null : label(i18n.shopVisibility), + isEdit ? null : br, + isEdit ? null : select({ name: "visibility" }, + option({ value: "OPEN", selected: (shop.visibility || "OPEN") === "OPEN" }, i18n.shopOpen), + option({ value: "CLOSED", selected: shop.visibility === "CLOSED" }, i18n.shopClosed) + ), + isEdit ? null : br(), + br(), + button({ type: "submit" }, isEdit ? i18n.shopUpdate : i18n.shopCreate) + ) + ) +} + +const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "") => { + return div({ class: "create-tribe-form" }, + h2(isEdit ? i18n.shopProductUpdate : i18n.shopProductAdd), + form({ action: isEdit ? `/shops/product/update/${encodeURIComponent(product.key || "")}` : "/shops/product/create", method: "POST", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "shopId", value: shopId }), + input({ type: "hidden", name: "returnTo", value: returnTo }), + label(i18n.title || "Title"), br, + input({ type: "text", name: "title", required: true, value: product.title || "" }), br(), + label(i18n.description || "Description"), br, + textarea({ name: "description", rows: 4 }, product.description || ""), br, + label(i18n.shopProductPrice), br, + input({ type: "number", name: "price", step: "0.000001", min: "0.000001", required: true, value: product.price || "" }), br(), br(), + label(i18n.shopProductStock), br, + input({ type: "number", name: "stock", min: "0", value: product.stock !== undefined ? product.stock : 1 }), br(), br(), + label(i18n.blogImage || "Upload media (max-size: 50MB)"), br, + input({ type: "file", name: "image", accept: "image/*,video/*" }), br(), br(), + input({ type: "hidden", name: "featured", value: "0" }), + label(i18n.shopProductFeatured), + input({ id: "featured", type: "checkbox", name: "featured", value: "1", class: "meme-checkbox", ...(product.featured ? { checked: true } : {}) }), + br(), + br(), + ...(isEdit ? [] : [ + input({ type: "hidden", name: "sendToMarket", value: "0" }), + label(i18n.shopSendToMarket), + input({ id: "sendToMarket", type: "checkbox", name: "sendToMarket", value: "1", class: "meme-checkbox" }), + br(), + br(), + ]), + button({ type: "submit" }, isEdit ? i18n.shopUpdate : i18n.shopProductAdd) + ) + ) +} + +exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => { + const q = safeText(params.q || "") + const sort = safeText(params.sort || "recent") + const list = safeArr(shops) + + const title = + filter === "mine" ? i18n.shopMineSectionTitle : + filter === "recent" ? i18n.shopRecentSectionTitle : + filter === "top" ? i18n.shopTopSectionTitle : + filter === "products" ? i18n.shopProductsSectionTitle : + filter === "prices" ? (i18n.shopPricesSectionTitle || "Products by Price") : + filter === "favorites" ? i18n.shopFavoritesSectionTitle : + filter === "create" ? i18n.shopCreateSectionTitle : + filter === "edit" ? i18n.shopUpdateSectionTitle : + i18n.shopAllSectionTitle + + const isForm = filter === "create" || filter === "edit" + + const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetShops) + const header = [ + div({ class: "tags-header" }, + h2(title), + p(i18n.shopDescription) + ), + div({ class: "shop-title-row" }, renderReachChip(viewerClearnet, i18n)), + br() + ] + + const searchBar = div({ class: "filters" }, + form({ method: "GET", action: "/shops" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", placeholder: i18n.shopSearchPlaceholder, value: q }), + br(), + button({ type: "submit" }, i18n.search), + br() + ) + ) + + const sortedProducts = filter === "products" + ? [...list].sort((a, b) => safeArr(b.opinions_inhabitants).length - safeArr(a.opinions_inhabitants).length) + : filter === "prices" + ? [...list].sort((a, b) => Number(b.price || 0) - Number(a.price || 0)) + : list + + return template( + title, + section(...header), + section(renderModeButtons(filter)), + !isForm ? section(searchBar) : null, + section( + isForm + ? renderShopForm(filter, filter === "edit" ? (shopToEdit || {}) : {}, params) + : filter === "products" || filter === "prices" + ? div({ class: "shop-products-grid" }, + sortedProducts.length + ? sortedProducts.map(prod => renderProductCard(prod, prod.shopId, buildReturnTo(filter, params))) + : p(i18n.shopNoItems) + ) + : div({ class: "tribe-grid" }, + list.length + ? list.map(shop => renderShopCard(shop, filter, { q, sort, spreadMap: params.spreadMap })) + : p(i18n.shopNoItems) + ) + ) + ) +} + +exports.singleShopView = async (shop, filter, products = [], comments = [], params = {}) => { + const q = safeText(params.q || "") + const sort = safeText(params.sort || "recent") + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }) + const isAuthor = String(shop.author) === String(userId) + const fullShareUrl = `/shops/${encodeURIComponent(shop.key)}` + + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetShops && shop.visibility !== 'CLOSED'); + const shopSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, shop.title || i18n.shopUntitled) + ), + div({ class: "card-chips-row" }, + shop.visibility === "CLOSED" + ? renderStateChip("closed", "✗", i18n.shopClosed) + : renderStateChip("mutuals", "✓", i18n.shopOpen), + shop.encrypted ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") : null, + renderLifespanChip(shop.lifetime, i18n), + renderReachChip(isClearnet, i18n) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreads)), + renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }), + shop.description ? p({ class: "tribe-side-description" }, ...renderUrl(shop.description)) : null, + div({ class: "shop-share" }, + span({ class: "tribe-info-label" }, `${i18n.shopShareUrl}: `), + input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" }) + ), + table({ class: "tribe-info-table" }, + tr( + td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"), + td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString()) + ), + tr( + td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author)) + ), + shop.location ? tr( + td({ class: "tribe-info-label" }, i18n.shopLocation), + td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.location)) + ) : null, + tr( + td({ class: "tribe-info-label" }, i18n.shopStatus || "STATUS"), + td({ class: "tribe-info-value", colspan: "3" }, shop.visibility === "CLOSED" ? i18n.shopClosed : i18n.shopOpen) + ), + shop.url ? tr( + td({ class: "tribe-info-label" }, i18n.shopUrl), + td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.url)) + ) : null + ), + renderMapEmbed(params.mapData, shop.mapUrl), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`) + ), + div({ class: "tribe-side-actions" }, + renderFavoriteToggle(shop, returnTo), + shop.author && String(shop.author) !== String(userId) + ? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: shop.author }), button({ type: "submit", class: "tribe-action-btn" }, i18n.privateMessage)) + : null, + isAuthor + ? form({ method: "GET", action: `/shops/edit/${encodeURIComponent(shop.key)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.shopUpdate) + ) + : null, + isAuthor + ? form({ method: "GET", action: `/shops/${encodeURIComponent(shop.key)}/orders` }, + button({ type: "submit", class: "tribe-action-btn" }, + i18n.shopOrdersTitle || "Orders", + Number(shop.pendingOrders || 0) > 0 ? span({ class: "shop-orders-badge", title: i18n.shopOrdersPending || "Pending orders" }, ` ● ${shop.pendingOrders}`) : null + ) + ) + : null, + isAuthor + ? form({ method: "POST", action: `/shops/delete/${encodeURIComponent(shop.key)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.shopDelete) + ) + : null + ), + isAuthor + ? div({ class: "tribe-side-actions shop-visibility-row" }, + span({ class: "card-label" }, `${i18n.visibilityLabel || "Visibility"}: `), + shop.encrypted + ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") + : renderStateChip("mutuals", "👁", i18n.shopOpen), + shop.encrypted + ? form({ method: "POST", action: `/shops/generate-invite/${encodeURIComponent(shop.key)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)) + : null, + (shop.encrypted && shop.author === userId && !shop.openInviteCode) + ? form({ method: "POST", action: `/shops/open-invite/create/${encodeURIComponent(shop.key)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)) + : null, + (shop.encrypted && shop.author === userId && shop.openInviteCode) + ? span({ class: "tribe-open-invite" }, + span({ class: "card-label" }, i18n.tribeInviteCodeText), + span({ class: "tribe-open-invite-code" }, shop.openInviteCode)) + : null, + (shop.encrypted && shop.author === userId && shop.openInviteCode) + ? renderInviteQrCard({ qrDataUrl: shop.openInviteQr, name: shop.title }) + : null, + (shop.encrypted && shop.author === userId && shop.openInviteCode) + ? form({ method: "POST", action: `/shops/open-invite/remove/${encodeURIComponent(shop.key)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)) + : null, + form({ method: "POST", action: `/shops/visibility/${encodeURIComponent(shop.key)}`, class: "inline-form" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "visibility", value: shop.encrypted ? "OPEN" : "CLOSED" }), + button({ type: "submit", class: "tribe-action-btn" }, + shop.encrypted ? i18n.shopMakePublic : i18n.shopMakePrivate)) + ) + : null, + safeArr(shop.tags).length + ? div({ class: "tribe-side-tags" }, safeArr(shop.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) + : null + ) + + const shopMain = div({ class: "tribe-main" }, + isAuthor + ? div({ class: "shop-add-product" }, + renderProductForm(shop.rootId || shop.key, {}, false, returnTo) + ) + : null, + div({ class: "shop-products-grid" }, + products.length + ? products.map(prod => renderProductCard(prod, shop.rootId || shop.key, returnTo)) + : p(i18n.shopNoProducts) + ) + ) + + return template( + shop.title || i18n.shopTitle, + section(renderModeButtons(filter)), + section( + div({ class: "tribe-details" }, + shopSide, + shopMain + ) + ) + ) +} + +exports.singleProductView = async (product, shop, comments = [], params = {}) => { + const returnTo = safeText(params.returnTo) || `/shops/${encodeURIComponent(params.shopId || product.shopId)}` + const isAuthor = String(product.author) === String(userId) + const stock = Number(product.stock) || 0 + + return template( + product.title || i18n.shopProductTitle, + section(renderModeButtons("products")), + section( + div({ class: "shop-detail" }, + div({ class: "bookmark-topbar transfer-topbar-single" }, + div({ class: "bookmark-topbar-left" }, + product.author && String(product.author) !== String(userId) + ? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: product.author }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)) + : null, + form({ method: "GET", action: `/shops/${encodeURIComponent(product.shopId)}` }, + button({ type: "submit", class: "filter-btn" }, `← ${i18n.shopBackToShop}`)) + ), + isAuthor + ? div({ class: "bookmark-actions transfer-actions" }, + form({ method: "GET", action: `/shops/product/edit/${encodeURIComponent(product.key)}` }, + input({ type: "hidden", name: "shopId", value: product.shopId }), + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.shopUpdate) + ), + form({ method: "POST", action: `/shops/product/delete/${encodeURIComponent(product.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.shopDelete) + ) + ) + : null + ), + product.image ? div({ class: "shop-detail-media" }, renderMediaBlob(product.image)) : null, + h2(product.title), + renderStarRating(product.opinions, safeArr(product.opinions_inhabitants).length), + product.description ? div({ class: "shop-detail-desc" }, ...renderUrl(product.description)) : null, + div({ class: "shop-product-price" }, `${Number(product.price || 0).toFixed(6)} ECO`), + div({ class: "confirmations-block stock-block" }, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.shopProductStock}: `), + span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock) + ), + progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 }) + ), + !isAuthor && stock > 0 + ? form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}`, class: "shop-buy-form" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + p({ class: "shop-buy-form-note" }, i18n.shopBuyEncryptedNote || "Your delivery details are sent encrypted only to the shop owner."), + label(i18n.shopBuyDeliveryAddress || "Delivery address"), + br(), + textarea({ name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }), + br(), + br(), + label(i18n.shopBuyContact || "Contact"), + br(), + input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder || "email, phone, etc." }), + br(), + br(), + label(i18n.shopBuyNotes || "Notes"), + br(), + textarea({ name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }), + br(), + br(), + button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy) + ) + : null, + br(), + p({ class: "card-footer" }, + span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")), + " ", + userLink(product.author) + ), + !isAuthor && params.canRate + ? renderOpinionsVoting('/shops/product/opinions', product.key, product.opinions, returnTo, product.opinions_inhabitants) + : null + ), + renderCommentsSection(product.key, returnTo, comments) + ) + ) +} + +exports.editProductView = async (product, shopId, params = {}) => { + const returnTo = safeText(params.returnTo) || `/shops/${encodeURIComponent(shopId)}` + return template( + i18n.shopProductUpdate, + section( + div({ class: "tags-header" }, h2(i18n.shopProductUpdate)), + renderProductForm(shopId, product, true, returnTo) + ) + ) +} + +const ORDER_STATUS_VARIANT = { PENDING: "whole", ACCEPTED: "mutuals", REJECTED: "closed", PAID: "mutuals", SHIPPED: "hidden", RECEIVED: "mutuals" } +const orderStatusChip = (st) => { + const s = String(st || "PENDING").toUpperCase() + const labels = { + PENDING: i18n.shopOrderStatusPending || "Pending", + ACCEPTED: i18n.shopOrderStatusAccepted || "Accepted", + REJECTED: i18n.shopOrderStatusRejected || "Rejected", + PAID: i18n.shopOrderStatusPaid || "Payment received", + SHIPPED: i18n.shopOrderStatusShipped || "Shipped", + RECEIVED: i18n.shopOrderStatusReceived || "Received" + } + return renderStateChip(ORDER_STATUS_VARIANT[s] || "whole", "", labels[s] || s) +} + +exports.shopOrdersView = async (shop, orders) => { + const sid = shop.rootId || shop.key || shop.id || "" + const title = `${i18n.shopOrdersTitle || "Orders"}: ${shop.title || ""}` + const statusForm = (orderId, status, label, cls) => + form({ method: "POST", action: `/shops/orders/${encodeURIComponent(orderId)}/status`, class: "inline-form" }, + input({ type: "hidden", name: "shopId", value: sid }), + input({ type: "hidden", name: "status", value: status }), + button({ type: "submit", class: cls || "tribe-action-btn" }, label) + ) + const orderActions = (o) => { + const s = String(o.status || "PENDING").toUpperCase() + const acts = [] + if (s === "PENDING") { + acts.push(statusForm(o.id, "ACCEPTED", i18n.shopOrderAccept || "Accept")) + acts.push(statusForm(o.id, "REJECTED", i18n.shopOrderReject || "Reject", "tribe-action-btn danger-btn")) + } else if (s === "ACCEPTED") { + const concept = `${i18n.shopOrderInvoiceConcept || "Invoice"} — ${i18n.shopOrderContractConcept || "Shop order"}: ${o.title || ""}` + const cHref = `/transfers?filter=create&to=${encodeURIComponent(o.buyer)}&amount=${encodeURIComponent(Number(o.price || 0).toFixed(6))}&concept=${encodeURIComponent(concept)}&category=ECONOMIC` + acts.push(a({ href: cHref, class: "tribe-action-btn" }, i18n.shopOrderInvoice || "Invoice")) + acts.push(statusForm(o.id, "PAID", i18n.shopOrderMarkPaid || "Payment received")) + } else if (s === "PAID") { + acts.push(statusForm(o.id, "SHIPPED", i18n.shopOrderMarkShipped || "Mark shipped")) + } + acts.push(a({ href: `/pm?recipients=${encodeURIComponent(o.buyer)}`, class: "tribe-action-btn" }, i18n.shopOrderPmBuyer || i18n.privateMessage || "PM")) + return div({ class: "tribe-side-actions" }, ...acts) + } + const rows = (orders || []).map(o => div({ class: "shop-order-card card-section" }, + div({ class: "card-chips-row" }, renderEncryptedChip(i18n), orderStatusChip(o.status)), + div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderProduct || "Product"}:`), span({ class: "card-value" }, String(o.title || o.productId || ""))), + div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderPrice || "Price"}:`), span({ class: "card-value" }, `${Number(o.price || 0).toFixed(6)} ECO`)), + div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderBuyer || "Buyer"}:`), userLink(o.buyer)), + div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))), + o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null, + o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null, + p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))), + orderActions(o) + )) + return template( + title, + section( + div({ class: "tags-header" }, h2(title), p(i18n.shopOrdersDescription || "Encrypted purchase orders received by this shop.")), + a({ href: `/shops/${encodeURIComponent(shop.key || shop.id || "")}`, class: "filter-btn" }, i18n.goBack || "Go back") + ), + section( + rows.length ? div({ class: "shop-orders-list" }, ...rows) : p(i18n.shopOrdersEmpty || "No orders yet.") + ) + ) +} + +exports.myPurchasesView = async (purchases) => { + const title = i18n.shopMyOrdersTitle || "My orders" + const buyerActions = (o) => { + const acts = [] + if (o.seller) acts.push(a({ href: `/pm?recipients=${encodeURIComponent(o.seller)}`, class: "tribe-action-btn" }, i18n.shopOrderPmSeller || i18n.privateMessage || "PM")) + if (String(o.status || "PENDING").toUpperCase() === "SHIPPED") { + acts.push(form({ method: "POST", action: `/shops/orders/${encodeURIComponent(o.id)}/status`, class: "inline-form" }, + input({ type: "hidden", name: "status", value: "RECEIVED" }), + input({ type: "hidden", name: "returnTo", value: "/shops/purchases" }), + button({ type: "submit", class: "tribe-action-btn" }, i18n.shopOrderConfirmReceived || "Confirm received") + )) + } + return acts.length ? div({ class: "tribe-side-actions" }, ...acts) : null + } + const rows = (purchases || []).map(o => div({ class: "shop-order-card card-section" }, + div({ class: "card-chips-row" }, renderEncryptedChip(i18n), orderStatusChip(o.status)), + div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderProduct || "Product"}:`), span({ class: "card-value" }, String(o.title || o.productId || ""))), + div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderPrice || "Price"}:`), span({ class: "card-value" }, `${Number(o.price || 0).toFixed(6)} ECO`)), + o.seller ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderSeller || "Seller"}:`), userLink(o.seller)) : null, + div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))), + o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null, + o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null, + p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))), + buyerActions(o) + )) + return template( + title, + section( + div({ class: "tags-header" }, h2(title), p(i18n.shopMyOrdersDescription || "Your encrypted purchase orders.")) + ), + section(renderModeButtons("purchases")), + section( + rows.length ? div({ class: "shop-orders-list" }, ...rows) : p(i18n.shopMyOrdersEmpty || "You have no purchases yet.") + ) + ) +} + +exports.clearnetShopView = async (shop, products = []) => { + const fmtPrice = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n.toFixed(6) : '0.000000'; + }; + const productCards = (products || []).filter(p => Number(p.stock) > 0 || p.featured).map(prod => { + const pImg = cnBlobUrl(prod.image); + return `
+ ${pImg ? `` : ''} +

${cnEscapeHtml(prod.title || '')}

+ ${prod.description ? `

${cnEscapeHtml(prod.description)}

` : ''} +

${fmtPrice(prod.price)} ECO

+ ${Number(prod.stock) > 0 ? `

Stock: ${prod.stock}

` : ''} +
`; + }).join('\n'); + const shopBlobUrl = cnBlobUrl(shop.image); + const shopImg = shopBlobUrl ? `${cnEscapeHtml(shop.title || '')}` : ''; + const desc = cnEscapeHtml(shop.shortDescription || shop.description || ''); + const extraCss = ` +.cn-hero{display:flex;gap:24px;margin-bottom:24px;flex-wrap:wrap;align-items:flex-start} +.cn-shop-img{display:block;max-width:280px;width:100%;border:3px solid var(--fg);border-radius:8px;background:#000} +.cn-hero-body{flex:1 1 320px;min-width:0} +.cn-shop-title{color:var(--fg);margin:0 0 12px 0;font-size:32px;font-weight:700;letter-spacing:0.3px} +.cn-shop-desc{color:var(--fg-soft);margin:0 0 16px 0;font-size:15px;white-space:pre-wrap} +.cn-shop-meta{display:flex;gap:12px;flex-wrap:wrap;background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:13px;color:var(--fg-soft)} +.cn-shop-meta-item{display:inline-flex;align-items:center;gap:6px} +.cn-products{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px} +.cn-product{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:16px;transition:border-color .15s ease} +.cn-product:hover{border-color:var(--fg)} +.cn-product-img{width:100%;height:180px;object-fit:cover;border-radius:6px;margin-bottom:10px;background:#000;border:1px solid var(--border)} +.cn-product-title{color:var(--fg);font-size:16px;margin:0 0 8px 0;font-weight:600} +.cn-product-desc{color:var(--fg-soft);font-size:13px;margin:0 0 10px 0;line-height:1.4} +.cn-product-price{color:var(--fg);background:var(--bg-sub);border:1px solid var(--fg);display:inline-block;padding:4px 10px;border-radius:4px;font-weight:bold;margin:0;font-size:14px} +.cn-product-stock{color:var(--fg-dim);font-size:12px;margin:8px 0 0 0;text-transform:uppercase;letter-spacing:1px} +.cn-empty{background:var(--bg-elev);border:1px dashed var(--border);border-radius:8px;padding:32px;text-align:center;color:var(--fg-dim)} +`; + const body = ` +
+ ${shopImg} +
+

${cnEscapeHtml(shop.title || '')}

+ ${desc ? `

${desc}

` : ''} +
+ ${shop.createdAt ? `📅 ${new Date(shop.createdAt).toISOString().slice(0,10)}` : ''} + ${shop.location ? `📍 ${cnEscapeHtml(shop.location)}` : ''} +
+
+
+

Products

+ ${productCards ? `
${productCards}
` : '
No products available.
'} +`; + return renderClearnetPage({ + title: `${shop.title || 'Shop'} — Oasis`, + ogTitle: shop.title || 'Oasis', + ogDescription: shop.shortDescription || shop.description || '', + ogImage: shopBlobUrl, + extraCss, + body, + hubFeedId: shop.author || null + }); +}; + +exports.renderShopInvitePage = (code) => { + const pageContent = div({ class: "invite-page" }, + h2(i18n.tribeInviteCodeText, code), + form({ method: "GET", action: "/shops" }, + button({ type: "submit", class: "filter-btn" }, i18n.walletBack) + ) + ); + return template(i18n.invitesShopsTitle || "Shops", section(pageContent)); +}; diff --git a/src/views/stats_view.js b/src/views/stats_view.js new file mode 100644 index 0000000..a2cd700 --- /dev/null +++ b/src/views/stats_view.js @@ -0,0 +1,505 @@ +const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, thead, tbody, tr, td, th } = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink } = require('./main_views'); + +Object.assign(i18n, { + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsPad: "Pads", + statsPadEntry: "Pad entries", + statsGameScore: "Game scores", + statsParliamentCandidature: "Parliament candidatures", + statsParliamentTerm: "Parliament terms", + statsParliamentProposal: "Parliament proposals", + statsParliamentRevocation: "Parliament revocations", + statsParliamentLaw: "Parliament laws", + statsCourtsCase: "Court cases", + statsCourtsEvidence: "Court evidence", + statsCourtsAnswer: "Court answers", + statsCourtsVerdict: "Court verdicts", + statsCourtsSettlement: "Court settlements", + statsCourtsSettlementProposal: "Settlement proposals", + statsCourtsSettlementAccepted: "Settlements accepted", + statsCourtsNomination: "Judge nominations", + statsCourtsNominationVote: "Nomination votes" +}); + +const C = (stats, t) => Number((stats && stats.content && stats.content[t]) || 0); +const O = (stats, t) => Number((stats && stats.opinions && stats.opinions[t]) || 0); + +const wClass = (pct) => { + const n = Math.max(0, Math.min(100, Math.round((pct || 0) / 5) * 5)); + return `stats-w-${n}`; +}; + +exports.statsView = (stats, filter) => { + const title = i18n.statsTitle; + const description = i18n.statsDescription; + const modes = ['ALL', 'MINE', 'TOMBSTONE']; + const types = [ + 'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project', + 'image', 'torrent', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', + 'market', 'forum', 'job', 'aiExchange', 'map', 'shop', 'shopProduct', + 'chat', 'chatMessage', 'pad', 'padEntry', 'gameScore', 'calendar', 'calendarDate', 'calendarNote', + 'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw', + 'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote' + ]; + const labels = { + bookmark: i18n.statsBookmark, + event: i18n.statsEvent, + task: i18n.statsTask, + votes: i18n.statsVotes, + report: i18n.statsReport, + feed: i18n.statsFeed, + project: i18n.statsProject, + image: i18n.statsImage, + torrent: i18n.statsTorrent, + audio: i18n.statsAudio, + video: i18n.statsVideo, + document: i18n.statsDocument, + transfer: i18n.statsTransfer, + post: i18n.statsPost, + tribe: i18n.statsTribe, + market: i18n.statsMarket, + forum: i18n.statsForum, + job: i18n.statsJob, + aiExchange: i18n.statsAiExchange, + map: i18n.statsMap, + shop: i18n.statsShop, + shopProduct: i18n.statsShopProduct, + chat: i18n.statsChat, + chatMessage: i18n.statsChatMessage, + pad: i18n.statsPad, + padEntry: i18n.statsPadEntry, + gameScore: i18n.statsGameScore, + calendar: i18n.statsCalendar, + calendarDate: i18n.statsCalendarDate, + calendarNote: i18n.statsCalendarNote, + parliamentCandidature: i18n.statsParliamentCandidature, + parliamentTerm: i18n.statsParliamentTerm, + parliamentProposal: i18n.statsParliamentProposal, + parliamentRevocation: i18n.statsParliamentRevocation, + parliamentLaw: i18n.statsParliamentLaw, + courtsCase: i18n.statsCourtsCase, + courtsEvidence: i18n.statsCourtsEvidence, + courtsAnswer: i18n.statsCourtsAnswer, + courtsVerdict: i18n.statsCourtsVerdict, + courtsSettlement: i18n.statsCourtsSettlement, + courtsSettlementProposal: i18n.statsCourtsSettlementProposal, + courtsSettlementAccepted: i18n.statsCourtsSettlementAccepted, + courtsNomination: i18n.statsCourtsNomination, + courtsNominationVote: i18n.statsCourtsNominationVote + }; + const totalContent = types.filter(t => t !== 'karmaScore').reduce((sum, t) => sum + C(stats, t), 0); + const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0); + + const fmtNum = (n) => { + if (typeof n !== 'number' || !isFinite(n)) return '0'; + if (Math.abs(n) >= 100) return n.toFixed(0); + if (Math.abs(n) >= 10) return n.toFixed(1); + return n.toFixed(2); + }; + + const isZero = (value) => { + if (value === 0 || value === '0') return true; + const s = String(value == null ? '' : value).trim(); + if (!s) return true; + const n = parseFloat(s); + if (!Number.isFinite(n)) return false; + return n === 0; + }; + + const kpi = (label, value) => { + if (isZero(value)) return null; + return div({ class: 'stats-kpi' }, + div({ class: 'stats-kpi-label' }, label), + div({ class: 'stats-kpi-value' }, String(value)) + ); + }; + + const kpiBar = (label, value, pct) => { + const n = Math.max(0, Math.min(100, Number(pct) || 0)); + return div({ class: 'stats-kpi' }, + div({ class: 'stats-kpi-label' }, label), + div({ class: 'stats-kpi-value' }, String(value)), + n > 0 + ? div({ class: 'stats-bar-track stats-kpi-bar' }, + div({ class: `stats-bar-fill ${wClass(n)}` }) + ) + : null + ); + }; + + const kpiGrid = (...tiles) => div({ class: 'stats-grid' }, tiles.filter(Boolean)); + + const renderTopList = (items, getName, getCount, max) => { + if (!items || !items.length) return p({ class: 'no-content' }, i18n.no_results || 'No data'); + const m = Math.max(1, max || items[0] && getCount(items[0]) || 1); + return ul({ class: 'stats-toplist' }, + ...items.map(it => { + const cnt = getCount(it); + const pct = (cnt / m) * 100; + return li( + span({ class: 'stats-toplist-name' }, getName(it)), + div({ class: 'stats-bar-track' }, + div({ class: `stats-bar-fill ${wClass(pct)}` }) + ), + span({ class: 'stats-toplist-num' }, String(cnt)) + ); + }) + ); + }; + + const carbonChart = (() => { + const parseSize = (s) => { + if (!s) return 0; + const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i); + if (!m) return 0; + const v = parseFloat(m[1]); + const u = m[2].toUpperCase(); + if (u === 'GB') return v * 1024; + if (u === 'MB') return v; + if (u === 'KB') return v / 1024; + return v / (1024 * 1024); + }; + const blobsMB = parseSize(stats.statsBlobsSize); + const chainMB = parseSize(stats.statsBlockchainSize); + const totalMB = blobsMB + chainMB; + const kWhPerMB = 0.0002; + const gCO2PerKWh = 475; + const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2)); + const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1; + const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2)); + const maxAnnualCO2 = 500; + + if (filter === 'MINE') { + const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100) : 0; + return div({ class: 'carbon-chart' }, + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonUser || 'Your footprint'), + span(`${userCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` }) + ), + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonNetwork || 'Network total'), + span(`${networkCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' }) + ), + p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`), + p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) + ); + } + if (filter === 'TOMBSTONE') { + const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0; + const avgTombBytes = 500; + const tombMB = (tombCount * avgTombBytes) / (1024 * 1024); + const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4)); + const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0; + return div({ class: 'carbon-chart' }, + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonTombstone || 'Tombstoning footprint'), + span(`${tombCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` }) + ), + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonNetwork || 'Network total'), + span(`${networkCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' }) + ), + p({ class: 'carbon-bar-note' }, strong(`${tombPct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`), + p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) + ); + } + const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100); + return div({ class: 'carbon-chart' }, + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonNetwork || 'Network footprint'), + span(`${networkCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` }) + ), + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'), + span(`${maxAnnualCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' }) + ), + p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`), + p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) + ); + })(); + + const headerCard = div({ class: 'stats-card' }, + table({ class: 'block-info-table' }, + tr(td({ class: 'card-label' }, i18n.statsCreatedAt), td({ class: 'card-value' }, stats.createdAt)), + tr(td({ class: 'card-label' }, 'ID'), td({ class: 'card-value' }, userLink(stats.id))), + tr(td({ class: 'card-label' }, i18n.statsBlobsSize), td({ class: 'card-value' }, stats.statsBlobsSize)), + tr(td({ class: 'card-label' }, i18n.statsBlockchainSize), td({ class: 'card-value' }, stats.statsBlockchainSize)), + tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize)) + ) + ); + + const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0; + const networkKPIs = stats.networkKPIs || {}; + + const topStrip = div({ class: 'stats-block' }, + kpiGrid( + kpi(i18n.bankingUserEngagementScore, C(stats, 'karmaScore')), + kpi(i18n.statsUsersTitle, totalInhabitants), + kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0), + kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0), + kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0), + kpi(i18n.statsPUBs, stats.pubsCount || 0) + ) + ); + + const carbonCard = div({ class: 'stats-card' }, + h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'), + carbonChart + ); + + const bankingCard = div({ class: 'stats-card' }, + table({ class: 'block-info-table' }, + tr(td({ class: 'card-label' }, i18n.statsEcoWalletLabel), td({ class: 'card-value' }, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured))), + tr(td({ class: 'card-label' }, i18n.profileGpgChip || 'GPG'), td({ class: 'card-value' }, + stats?.gpgFingerprint + ? a({ href: `/profile/${encodeURIComponent(stats.id)}/gpg.asc`, class: 'stats-link-break' }, String(stats.gpgFingerprint).slice(-8).toUpperCase()) + : a({ href: '/profile/edit', class: 'stats-link-break' }, i18n.statsEcoWalletNotConfigured) + )) + ) + ); + + const networkBlock = div({ class: 'stats-block' }, + kpiGrid( + filter === 'MINE' + ? kpi(i18n.statsMyShare || 'Your share of the network', `${fmtNum(networkKPIs.myShare || 0)}%`) + : null, + kpi(i18n.statsAvgPerInhabitant || 'Avg per inhabitant', fmtNum(networkKPIs.avgMsgsPerInhabitant || 0)), + kpi(i18n.statsMsgsPerDay || 'Messages/day (lifetime)', fmtNum(networkKPIs.networkMsgsPerDay || 0)), + kpi(i18n.statsNetworkSpan || 'Network span', `${fmtNum(networkKPIs.networkSpanDays || 0)} d`), + kpi(i18n.statsTombstoneRatioLabel || 'Tombstone ratio', `${fmtNum(stats.tombstoneKPIs?.ratio || 0)}%`) + ) + ); + + const activityBlock = (() => { + const rows = Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []; + const max = Math.max(1, ...rows.map(r => Number(r.count) || 0)); + return div({ class: 'stats-block' }, + h2(i18n.statsActivity7d), + rows.length + ? ul({ class: 'stats-toplist' }, + ...rows.map(row => { + const cnt = Number(row.count) || 0; + const pct = (cnt / max) * 100; + return li( + span({ class: 'stats-toplist-name' }, row.day), + div({ class: 'stats-bar-track' }, + div({ class: `stats-bar-fill ${wClass(pct)}` }) + ), + span({ class: 'stats-toplist-num' }, String(cnt)) + ); + }) + ) + : p({ class: 'no-content' }, i18n.no_results || 'No data'), + div({ class: 'stats-activity-totals' }, + span(`${i18n.statsActivity7dTotal}: `, strong(String(stats.activity?.daily7Total || 0))), + span(`${i18n.statsActivity30dTotal}: `, strong(String(stats.activity?.daily30Total || 0))) + ) + ); + })(); + + const topTypes = Array.isArray(stats.topTypes) ? stats.topTypes : []; + const topTypesBlock = topTypes.length ? div({ class: 'stats-block' }, + h2(i18n.statsTopTypesTitle || 'Top Content Types'), + renderTopList( + topTypes, + it => labels[it.type] || it.type, + it => it.count, + topTypes[0] ? topTypes[0].count : 1 + ) + ) : null; + + const topTags = Array.isArray(stats.topTags) ? stats.topTags : []; + const topTagsBlock = topTags.length ? div({ class: 'stats-block' }, + h2(i18n.statsTopTagsTitle || 'Top Tags'), + div({ class: 'stats-mb-16' }, + topTags.map(t => a({ class: 'stats-pill', href: `/search?query=%23${encodeURIComponent(t.tag)}` }, `#${t.tag} (${t.count})`)) + ) + ) : null; + + const marketTiles = [ + kpi(i18n.statsMarketTotal, stats.marketKPIs?.total || 0), + kpi(i18n.statsMarketForSale, stats.marketKPIs?.forSale || 0), + kpi(i18n.statsMarketReserved, stats.marketKPIs?.reserved || 0), + kpi(i18n.statsMarketClosed, stats.marketKPIs?.closed || 0), + kpi(i18n.statsMarketSold, stats.marketKPIs?.sold || 0) + ].filter(Boolean); + const marketBlock = marketTiles.length + ? div({ class: 'stats-block' }, h2(i18n.statsMarketTitle), kpiGrid(...marketTiles)) + : null; + + const projectsTiles = [ + kpi(i18n.statsProjectsTotal, stats.projectsKPIs?.total || 0), + kpi(i18n.statsProjectsActive, stats.projectsKPIs?.active || 0), + kpi(i18n.statsProjectsCompleted, stats.projectsKPIs?.completed || 0), + kpi(i18n.statsProjectsPaused, stats.projectsKPIs?.paused || 0), + kpi(i18n.statsProjectsCancelled, stats.projectsKPIs?.cancelled || 0), + kpi(i18n.statsProjectsGoalTotal, `${stats.projectsKPIs?.ecoGoalTotal || 0} ECO`), + kpi(i18n.statsProjectsPledgedTotal, `${stats.projectsKPIs?.ecoPledgedTotal || 0} ECO`) + ].filter(Boolean); + const projectsBlock = projectsTiles.length + ? div({ class: 'stats-block' }, h2(i18n.statsProjectsTitle), kpiGrid(...projectsTiles)) + : null; + + const allTribesPublic = Array.isArray(stats.allTribesPublic) ? stats.allTribesPublic : []; + const memberTribesDetailed = Array.isArray(stats.memberTribesDetailed) ? stats.memberTribesDetailed : []; + const myPrivateTribesDetailed = Array.isArray(stats.myPrivateTribesDetailed) ? stats.myPrivateTribesDetailed : []; + + const buildContentRows = () => { + const excluded = new Set([ + 'karmaScore', 'shopProduct', 'padEntry', 'chatMessage', 'calendarDate', 'calendarNote', + 'parliamentTerm' + ]); + const rows = []; + types.filter(t => !excluded.has(t)).forEach(t => { + const cnt = C(stats, t); + if (cnt <= 0) return; + rows.push([labels[t], cnt]); + }); + return rows; + }; + const buildContentTable = () => { + const rows = buildContentRows().slice().sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)); + if (!rows.length) return p({ class: 'no-content' }, i18n.no_results || 'No data'); + return table({ class: 'tag-table' }, + thead(tr( + th(i18n.statsContentTypeColumn || 'Type'), + th(i18n.statsContentCountColumn || 'Count') + )), + tbody(...rows.map(([label, count]) => tr(td(label), td(String(count))))) + ); + }; + + const buildOpinionTiles = () => + types.map(t => O(stats, t) > 0 ? kpi(labels[t], O(stats, t)) : null).filter(Boolean); + + const tribeListBlock = (label, list) => div({ class: 'stats-block' }, + h2(`${label}: ${list.length}`), + list.length + ? table({ class: 'stats-table-mt8' }, + ...list.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name)))) + ) + : p({ class: 'no-content' }, i18n.no_results || 'No data') + ); + + const allMode = filter === 'ALL' + ? div({ class: 'stats-container' }, [ + networkBlock, + activityBlock, + totalOpinions > 0 + ? div({ class: 'stats-block' }, + h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`), + kpiGrid(...buildOpinionTiles()) + ) + : null, + totalContent > 0 + ? div({ class: 'stats-block' }, + h2(`${i18n.statsNetworkContent}: ${totalContent}`), + buildContentTable() + ) + : null + ]) + : null; + + const mineMode = filter === 'MINE' + ? div({ class: 'stats-container' }, [ + networkBlock, + activityBlock, + totalOpinions > 0 + ? div({ class: 'stats-block' }, + h2(`${i18n.statsYourOpinions}: ${totalOpinions}`), + kpiGrid(...buildOpinionTiles()) + ) + : null, + totalContent > 0 + ? div({ class: 'stats-block' }, + h2(`${i18n.statsYourContent}: ${totalContent}`), + buildContentTable() + ) + : null + ]) + : null; + + const tombMode = filter === 'TOMBSTONE' + ? (() => { + const userTomb = Number(stats.userTombstoneCount || 0); + const netTomb = Number(stats.tombstoneKPIs?.networkTombstoneCount || 0); + if (userTomb === 0 && netTomb === 0) { + return div({ class: 'stats-container' }, + div({ class: 'stats-block' }, + p({ class: 'no-content' }, i18n.statsTombstoneEmpty || 'No tombstones in the network yet.') + ) + ); + } + return div({ class: 'stats-container' }, [ + div({ class: 'stats-block' }, + kpiGrid( + kpi(i18n.TOMBSTONEButton, userTomb), + kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`) + ) + ) + ]); + })() + : null; + + const ets = stats.ecoTaxStats || null; + const userTax = Number(stats.userEcoinTax || 0); + const ecoTaxBlock = ets ? div({ class: 'stats-card' }, + table({ class: 'block-info-table' }, + filter === 'MINE' + ? tr(td({ class: 'card-label' }, i18n.bankTaxesUserAmount || 'Your ECO tax'), td({ class: 'card-value' }, `${userTax.toFixed(6)} ECO`)) + : null, + tr(td({ class: 'card-label' }, i18n.bankTaxesTotalEcoin || 'Total ECO tax (lifetime)'), td({ class: 'card-value' }, `${Number(ets.totalEcoinTax || 0).toFixed(6)} ECO`)), + tr(td({ class: 'card-label' }, i18n.bankTaxesAnnualEcoin || 'ECO tax (annual)'), td({ class: 'card-value' }, `${Number(ets.annualEcoinTax || 0).toFixed(6)} ECO`)), + tr(td({ class: 'card-label' }, i18n.bankTaxesMonthlyEcoin || 'ECO tax (monthly)'), td({ class: 'card-value' }, `${Number(ets.monthlyEcoinTax || 0).toFixed(6)} ECO`)), + tr(td({ class: 'card-label' }, i18n.bankTaxesTotalCarbon || 'Total carbon'), td({ class: 'card-value' }, `${Number(ets.totalGramsCO2 || 0).toFixed(3)} g CO₂`)), + tr(td({ class: 'card-label' }, i18n.bankTaxesRate || 'Rate'), td({ class: 'card-value' }, `${Number(ets.ecoinPerGramCO2 || 0).toFixed(4)} ECO / g CO₂`)) + ) + ) : null; + + return template( + title, + section( + div({ class: 'tags-header' }, + h2(title), + p(description) + ), + div({ class: 'mode-buttons stats-mode-row' }, + modes.map(m => + form({ method: 'GET', action: '/stats' }, + input({ type: 'hidden', name: 'filter', value: m }), + button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button']) + ) + ) + ), + section( + topStrip, + headerCard, + filter !== 'TOMBSTONE' ? ecoTaxBlock : null, + bankingCard, + allMode, + mineMode, + tombMode + ) + ) + ); +}; diff --git a/src/views/tags_view.js b/src/views/tags_view.js new file mode 100644 index 0000000..2976f5b --- /dev/null +++ b/src/views/tags_view.js @@ -0,0 +1,91 @@ +const { form, button, div, h2, p, section, table, thead, tr, th, td, a, tbody } = require("../server/node_modules/hyperaxe"); +const { template, i18n } = require('./main_views'); + +const getFilteredTags = (filter, tags) => { + let filteredTags = Array.isArray(tags) ? tags : []; + if (filter === 'top') { + filteredTags = filteredTags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + } else { + filteredTags = filteredTags.sort((a, b) => a.name.localeCompare(b.name)); + } + return filteredTags; +}; + +const renderTagsTable = (filteredTags) => { + return table({ class: 'tag-table' }, + thead( + tr( + th(i18n.tagsTableHeaderTag), + th(i18n.tagsTableHeaderCount) + ) + ), + tbody( + filteredTags.map(tag => + tr( + td(a({ href: `/search?query=%23${encodeURIComponent(tag.name)}` }, tag.name)), + td(`${tag.count}`) + ) + ) + ) + ); +}; + +const renderTagsCloud = (mergedTags) => { + const maxCount = Math.max(...mergedTags.map(t => t.count), 1); + return div({ class: 'tag-cloud-wrap' }, + mergedTags.map(tag => { + const angle = Math.random() * 2 * Math.PI; + const radius = 10 + Math.random() * 40; + const x = 50 + Math.cos(angle) * radius; + const y = 50 + Math.sin(angle) * radius; + const weight = tag.count / maxCount; + const fontSize = 12 + Math.round(weight * 32); + const hue = 200 + Math.round(weight * 120); + + return a({ + href: `/search?query=%23${encodeURIComponent(tag.name)}`, + class: 'tag-cloud-item', + style: ` + left: ${x}%; + top: ${y}%; + font-size: ${fontSize}px; + color: hsl(${hue},70%,60%); + ` + }, tag.name); + }) + ); +}; + +exports.tagsView = async (tags, filter) => { + const filteredTags = getFilteredTags(filter, tags); + + const title = + filter === 'top' ? i18n.tagsTopSectionTitle : + filter === 'cloud' ? i18n.tagsCloudSectionTitle : + i18n.tagsAllSectionTitle; + + return template( + title, + section( + div({ class: 'tags-header' }, + h2(i18n.tagsTitle), + p(i18n.tagsDescription) + ), + div({ class: 'filters' }, + form({ method: 'GET', action: '/tags' }, + button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterAll), + button({ type: 'submit', name: 'filter', value: 'top', class: filter === 'top' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterTop), + button({ type: 'submit', name: 'filter', value: 'cloud', class: filter === 'cloud' ? 'filter-btn active' : 'filter-btn' }, i18n.tagsFilterCloud) + ) + ), + div({ class: 'tags-list' }, + filteredTags.length === 0 + ? p(i18n.tagsNoItems) + : filter !== 'cloud' + ? renderTagsTable(filteredTags) + : renderTagsCloud(filteredTags) + ) + ) + ); +}; + diff --git a/src/views/task_view.js b/src/views/task_view.js new file mode 100644 index 0000000..40a2624 --- /dev/null +++ b/src/views/task_view.js @@ -0,0 +1,488 @@ +const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span, table, tr, td, img, video } = require("../server/node_modules/hyperaxe"); +const moment = require("../server/node_modules/moment"); +const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl"); + +const renderTaskMediaBlob = (value, attrs = {}) => { + if (!value) return null; + const s = String(value).trim(); + if (!s) return null; + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }); + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, class: attrs.class || 'post-image' }); + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); + if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }); + return null; +}; + +const userId = config.keys.id; + +const opt = (value, isSelected, text) => + option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text); + +const safeArray = (v) => Array.isArray(v) ? v : []; + +const normalizeStatus = (v) => { + const up = String(v || "").toUpperCase(); + if (up === "OPEN" || up === "IN-PROGRESS" || up === "CLOSED") return up; + return "OPEN"; +}; + +const statusLabel = (s) => { + const up = normalizeStatus(s); + if (up === "OPEN") return i18n.taskStatusOpen; + if (up === "IN-PROGRESS") return i18n.taskStatusInProgress; + return i18n.taskStatusClosed; +}; + +const visibilityLabel = (v) => { + const vv = String(v || "").toUpperCase(); + if (vv === "PRIVATE") return i18n.taskPrivate; + return i18n.taskPublic; +}; + +const renderTaskOwnerActions = (task, returnTo) => { + const st = normalizeStatus(task.status || "OPEN"); + const setStatusLabel = i18n.taskSetStatus; + + return [ + form( + { method: "GET", action: `/tasks/edit/${encodeURIComponent(task.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "update-btn" }, i18n.taskUpdateButton) + ), + form( + { method: "POST", action: `/tasks/delete/${encodeURIComponent(task.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "delete-btn" }, i18n.taskDeleteButton) + ), + form( + { method: "POST", action: `/tasks/status/${encodeURIComponent(task.id)}`, class: "project-control-form project-control-form--status" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + select( + { name: "status", class: "project-control-select" }, + option({ value: "OPEN", selected: st === "OPEN" }, i18n.taskStatusOpen), + option({ value: "IN-PROGRESS", selected: st === "IN-PROGRESS" }, i18n.taskStatusInProgress), + option({ value: "CLOSED", selected: st === "CLOSED" }, i18n.taskStatusClosed) + ), + button({ class: "status-btn project-control-btn", type: "submit" }, setStatusLabel) + ) + ]; +}; + +const renderTaskAssignAction = (task, isAssignedToMe, returnTo) => { + const st = normalizeStatus(task.status || "OPEN"); + if (st === "CLOSED") return null; + return form( + { method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton) + ); +}; + +const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") => { + const commentsCount = Array.isArray(comments) ? comments.length : 0; + const returnTo = `/tasks/${encodeURIComponent(taskId)}?filter=${encodeURIComponent(currentFilter || "all")}`; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + return visibleComments.length + ? div( + { class: "comments-list" }, + visibleComments.map((c) => { + const author = c.value && c.value.author ? c.value.author : ""; + const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c.value && c.value.content ? c.value.content : {}; + const root = content.fork || content.root || ""; + const text = content.text || ""; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(String(text))) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); + })() + ); +}; + +const renderTaskStatusChip = (status) => { + const s = normalizeStatus(status); + const variant = s === "OPEN" ? "mutuals" : s === "IN-PROGRESS" ? "whole" : "closed"; + const icon = s === "OPEN" ? "✓" : s === "IN-PROGRESS" ? "↻" : "✗"; + return renderStateChip(variant, icon, statusLabel(s)); +}; + +const renderTaskPriorityChip = (priority) => { + const p = String(priority || "").toUpperCase(); + if (!p) return null; + const variant = + p === "URGENT" ? "closed" : + p === "HIGH" ? "lifespan-orange" : + p === "MEDIUM" ? "whole" : + "mutuals"; + const localized = + p === "URGENT" ? i18n.taskPriorityUrgent : + p === "HIGH" ? i18n.taskPriorityHigh : + p === "MEDIUM" ? i18n.taskPriorityMedium : + i18n.taskPriorityLow; + return renderStateChip(variant, "⚑", localized || p); +}; + +const renderTaskItem = (task, filter, spreadInfo) => { + const currentFilter = filter || "all"; + const assignees = safeArray(task.assignees); + const isPrivate = String(task.isPublic || "").toUpperCase() === "PRIVATE"; + + const heroNode = task.image + ? div({ class: "tribe-card-image-wrapper" }, + a({ href: `/tasks/${encodeURIComponent(task.id)}` }, + renderTaskMediaBlob(task.image, { class: "tribe-card-hero-image" }) + ), + div({ class: "tribe-visit-btn-wrapper" }, + form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` }, + input({ type: "hidden", name: "filter", value: currentFilter }), + button({ type: "submit", class: "filter-btn" }, i18n.viewTask || "View Task") + ) + ) + ) + : null; + + const chips = [ + renderTaskStatusChip(task.status), + isPrivate ? renderPrivacyChip(true, i18n) : null, + renderTaskPriorityChip(task.priority), + renderLifespanChip(task.lifetime, i18n) + ].filter(Boolean); + + const start = task.startTime ? moment(task.startTime).format("YYYY/MM/DD HH:mm") : ""; + const end = task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm") : ""; + + return div({ class: "tribe-card task-card" }, + heroNode, + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/tasks/${encodeURIComponent(task.id)}` }, task.title || i18n.tasksTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + (start || end) ? p({ class: "card-date-highlight" }, start && end ? `${start} → ${end}` : (start || end)) : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(task.id, spreadInfo)), + !heroNode + ? div({ class: "card-visit-btn-centered" }, + form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` }, + input({ type: "hidden", name: "filter", value: currentFilter }), + button({ type: "submit", class: "filter-btn" }, i18n.viewTask || "View Task") + ) + ) + : null + ) + ); +}; + +exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => { + const list = Array.isArray(tasks) ? tasks : [tasks]; + const currentFilter = filter || "all"; + + const title = + currentFilter === "mine" ? i18n.taskMineSectionTitle : + currentFilter === "create" ? i18n.taskCreateSectionTitle : + currentFilter === "edit" ? i18n.taskUpdateSectionTitle : + currentFilter === "open" ? i18n.taskOpenTitle : + currentFilter === "in-progress" ? i18n.taskInProgressTitle : + currentFilter === "closed" ? i18n.taskClosedTitle : + currentFilter === "assigned" ? i18n.taskAssignedTitle : + currentFilter === "priority-urgent" ? i18n.taskFilterUrgent : + currentFilter === "priority-high" ? i18n.taskFilterHigh : + currentFilter === "priority-medium" ? i18n.taskFilterMedium : + currentFilter === "priority-low" ? i18n.taskFilterLow : + i18n.taskAllSectionTitle; + + const canSee = (t) => { + const vis = String(t.isPublic || "").toUpperCase(); + if (vis === "PUBLIC") return true; + if (t.author === userId) return true; + return safeArray(t.assignees).includes(userId); + }; + + const visible = list.filter(canSee); + + let filtered; + if (currentFilter === "mine") filtered = visible.filter((t) => t.author === userId); + else if (currentFilter === "assigned") filtered = visible.filter((t) => safeArray(t.assignees).includes(userId)); + else if (currentFilter === "open") filtered = visible.filter((t) => normalizeStatus(t.status) === "OPEN"); + else if (currentFilter === "in-progress") filtered = visible.filter((t) => normalizeStatus(t.status) === "IN-PROGRESS"); + else if (currentFilter === "closed") filtered = visible.filter((t) => normalizeStatus(t.status) === "CLOSED"); + else if (currentFilter === "priority-urgent") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "URGENT"); + else if (currentFilter === "priority-high") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "HIGH"); + else if (currentFilter === "priority-medium") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "MEDIUM"); + else if (currentFilter === "priority-low") filtered = visible.filter((t) => String(t.priority).toUpperCase() === "LOW"); + else filtered = visible; + + filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + const editTask = list.find((t) => t.id === taskId) || {}; + const editTags = Array.isArray(editTask.tags) ? editTask.tags : []; + const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm"); + + const ret = typeof returnTo === "string" && returnTo.startsWith("/tasks") + ? returnTo + : "/tasks?filter=mine"; + + return template( + title, + section( + div( + { class: "tags-header" }, + h2(i18n.tasksTitle), + p(i18n.tasksDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/tasks" }, + button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine), + button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned), + button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen), + button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress), + button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed), + button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow), + button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium), + button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh), + button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton) + ) + ) + ), + section( + currentFilter === "edit" || currentFilter === "create" + ? div( + { class: "task-form" }, + form( + { action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: ret }), + label(i18n.taskTitleLabel), br(), + input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : "" }), br(), + label(i18n.taskDescriptionLabel), br(), + textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : ""), br(), + label(i18n.uploadMedia), br(), + input({ type: "file", name: "image", accept: "image/*" }), br(),br(), + label(i18n.taskStartTimeLabel), br(), + input({ + type: "datetime-local", + name: "startTime", + required: true, + min: currentFilter === "create" ? minCreate : undefined, + value: currentFilter === "edit" && editTask.startTime ? moment(editTask.startTime).format("YYYY-MM-DDTHH:mm") : "" + }), br(), br(), + label(i18n.taskEndTimeLabel), br(), + input({ + type: "datetime-local", + name: "endTime", + required: true, + min: currentFilter === "create" ? minCreate : undefined, + value: currentFilter === "edit" && editTask.endTime ? moment(editTask.endTime).format("YYYY-MM-DDTHH:mm") : "" + }), br(), br(), + label(i18n.taskPriorityLabel), br(), + select( + { name: "priority", required: true }, + opt("URGENT", String(editTask.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent), + opt("HIGH", String(editTask.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh), + opt("MEDIUM", String(editTask.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium), + opt("LOW", !editTask.priority || String(editTask.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow) + ), br(), br(), + label(i18n.taskLocationLabel), br(), + input({ type: "text", name: "location", value: editTask.location || "" }), br(), + label(i18n.taskTagsLabel), br(), + input({ type: "text", name: "tags", value: editTags.join(", ") }), br(), + label(i18n.taskVisibilityLabel), br(), + select( + { name: "isPublic", id: "isPublic" }, + opt("PUBLIC", String(editTask.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic), + opt("PRIVATE", String(editTask.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate) + ), br(), br(), + button({ type: "submit" }, currentFilter === "edit" ? i18n.taskUpdateButton : i18n.taskCreateButton) + ) + ) + : filtered.length > 0 + ? div({ class: "jobs-grid" }, filtered.map((t) => renderTaskItem(t, currentFilter, params.spreadMap && params.spreadMap.get(t.id)))) + : p(i18n.notasks) + ) + ); +}; + +exports.singleTaskView = async (task, filter, comments = [], params = {}) => { + const currentFilter = filter || "all"; + const assignees = safeArray(task.assignees); + const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0; + + const isPrivateNoAccess = String(task.isPublic || "").toUpperCase() === "PRIVATE" && + String(task.author) !== String(userId) && + !assignees.includes(userId); + + const filterBar = div( + { class: "filters" }, + form( + { method: "GET", action: "/tasks" }, + button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine), + button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned), + button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen), + button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress), + button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed), + button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow), + button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium), + button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh), + button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton) + ) + ); + + if (isPrivateNoAccess) { + return template( + task.title, + section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied)) + ); + } + + const isAuthor = String(task.author) === String(userId); + const isAssignedToMe = assignees.includes(userId); + const isPrivate = String(task.isPublic || "").toUpperCase() === "PRIVATE"; + const returnToSelf = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`; + + const lifespanChipNode = renderLifespanChip(task.lifetime, i18n); + const ecoTaxChipNode = renderEcoTax(task.msgSize, task.id); + + const chips = [ + renderTaskStatusChip(task.status), + renderPrivacyChip(isPrivate, i18n), + renderTaskPriorityChip(task.priority), + lifespanChipNode, + ecoTaxChipNode + ].filter(Boolean); + + const tagsNode = Array.isArray(task.tags) && task.tags.length + ? div({ class: "card-tags" }, + task.tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; + + const assigneesListNode = assignees.length + ? div({ class: "card-assigned-list" }, + ...assignees.filter(Boolean).map((id) => userLink(id)) + ) + : null; + + const sideActions = []; + if (task.author && task.author !== userId) { + sideActions.push(form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: task.author }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + )); + } + const assignNode = renderTaskAssignAction(task, isAssignedToMe, returnToSelf); + if (assignNode) sideActions.push(assignNode); + if (isAuthor) sideActions.push(...renderTaskOwnerActions(task, returnToSelf)); + + const infoRows = []; + const pushRow = (labelText, valueNode) => + infoRows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: "tribe-info-value" }, valueNode) + )); + if (task.startTime) pushRow(i18n.taskStartTimeLabel, moment(task.startTime).format("YYYY/MM/DD HH:mm")); + if (task.endTime) pushRow(i18n.taskEndTimeLabel, moment(task.endTime).format("YYYY/MM/DD HH:mm")); + pushRow(i18n.taskPriorityLabel, String(task.priority || "").toUpperCase() || "—"); + pushRow(i18n.taskStatus, statusLabel(task.status)); + if (task.location && String(task.location).trim()) pushRow(i18n.taskLocationLabel, task.location); + + const taskSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, task.title) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(task.id, params.spreads)), + task.image ? renderTaskMediaBlob(task.image, { class: "tribe-detail-image" }) : null, + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + tagsNode, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`) + ), + assigneesListNode, + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const returnToOpinions = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`; + const opinionsBar = renderOpinionsVoting('/tasks/opinions', task.id, task.opinions, returnToOpinions, task.opinions_inhabitants); + + const taskMain = div({ class: "tribe-main" }, + task.description + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, i18n.taskDescriptionLabel), + p({ class: "tribe-side-description" }, ...renderUrl(task.description)) + ) + : null, + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(task.author) + ), + opinionsBar, + renderTaskCommentsSection(task.id, comments, currentFilter) + ); + + return template( + task.title, + section( + filterBar, + div({ class: "tribe-details" }, taskSide, taskMain) + ) + ); +}; + diff --git a/src/views/torrents_view.js b/src/views/torrents_view.js new file mode 100644 index 0000000..dad95bc --- /dev/null +++ b/src/views/torrents_view.js @@ -0,0 +1,428 @@ +const { + form, + button, + div, + h2, + p, + section, + input, + br, + a, + span, + textarea, + select, + label, + option, + table, + tr, + th, + td +} = require("../server/node_modules/hyperaxe"); + +const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl"); + +const userId = config.keys.id; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all"); + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const parts = [`filter=${encodeURIComponent(f)}`]; + if (q) parts.push(`q=${encodeURIComponent(q)}`); + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`); + return `/torrents?${parts.join("&")}`; +}; + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div( + { class: "card-tags" }, + list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; +}; + +const renderTorrentFavoriteToggle = (torrentObj, returnTo = "") => + form( + { + method: "POST", + action: torrentObj.isFavorite + ? `/torrents/favorites/remove/${encodeURIComponent(torrentObj.key)}` + : `/torrents/favorites/add/${encodeURIComponent(torrentObj.key)}` + }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button( + { type: "submit", class: "filter-btn" }, + torrentObj.isFavorite ? i18n.torrentRemoveFavoriteButton : i18n.torrentAddFavoriteButton + ) + ); + +const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => { + const returnTo = buildReturnTo(filter, params); + const isAuthor = String(torrentObj.author) === String(userId); + const hasOpinions = Object.keys(torrentObj.opinions || {}).length > 0; + + if (!isAuthor) return []; + + const items = []; + if (!hasOpinions) { + items.push( + form( + { method: "GET", action: `/torrents/edit/${encodeURIComponent(torrentObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.torrentUpdateButton) + ) + ); + } + items.push( + form( + { method: "POST", action: `/torrents/delete/${encodeURIComponent(torrentObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.torrentDeleteButton) + ) + ); + + return items; +}; + +const renderTorrentCommentsSection = (torrentId, comments = [], returnTo = null) => { + const list = safeArr(comments); + const commentsCount = list.length; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/torrents/${encodeURIComponent(torrentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || ""; + const ts = c?.value?.timestamp || c?.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + const content = c?.value?.content || {}; + const rootId = content.fork || content.root || null; + const text = content.text || ""; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ); +}; + +const formatSize = (bytes) => { + const n = Number(bytes) || 0; + if (n === 0) return "—"; + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; +}; + +const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params); + + if (!torrents.length) return p(params.q ? i18n.torrentNoMatch : i18n.noTorrents); + + return table( + { border: "1", class: "torrent-table" }, + tr( + th(i18n.createdAt || "DATE"), + th(i18n.authorLabel || "AUTHOR"), + th(i18n.torrentTitleLabel || "TITLE"), + th(i18n.torrentSizeLabel || "SIZE"), + th(""), + th("") + ), + torrents.map((t) => + tr( + td(moment(t.createdAt).format("YYYY/MM/DD HH:mm")), + td(userLink(t.author)), + td(t.title || ""), + td(formatSize(t.size)), + td( + form( + { method: "GET", action: `/torrents/${encodeURIComponent(t.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: filter || "all" }), + params.q ? input({ type: "hidden", name: "q", value: params.q }) : null, + params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null, + button({ type: "submit", class: "filter-btn" }, i18n.viewDetails) + ) + ), + td( + t.url && t.url.startsWith("&") + ? a({ href: `/blob/${encodeURIComponent(t.url)}`, class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!") + : "" + ) + ) + ) + ); +}; + +const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => { + const returnTo = safeText(params.returnTo) || buildReturnTo("all", params); + const tribeId = safeText(params.tribeId || ""); + return div( + { class: "div-center audio-form" }, + form( + { + action: filter === "edit" ? `/torrents/update/${encodeURIComponent(torrentId)}` : "/torrents/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null, + span(i18n.torrentFileLabel), + br(), + input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" }), + br(), + br(), + span(i18n.torrentTitleLabel), + br(), + input({ type: "text", name: "title", placeholder: i18n.torrentTitlePlaceholder, value: torrentToEdit?.title || "", required: true }), + br(), + span(i18n.torrentDescriptionLabel), + br(), + textarea({ name: "description", placeholder: i18n.torrentDescriptionPlaceholder, rows: "4" }, torrentToEdit?.description || ""), + br(), + span(i18n.torrentTagsLabel), + br(), + input({ + type: "text", + name: "tags", + placeholder: i18n.torrentTagsPlaceholder, + value: safeArr(torrentToEdit?.tags).join(", ") + }), + br(), + br(), + button({ type: "submit" }, filter === "edit" ? i18n.torrentUpdateButton : i18n.torrentCreateButton) + ) + ); +}; + +exports.torrentsView = async (torrents, filter = "all", torrentId = null, params = {}) => { + const title = + filter === "mine" + ? i18n.torrentMineSectionTitle + : filter === "create" + ? i18n.torrentCreateSectionTitle + : filter === "edit" + ? i18n.torrentUpdateSectionTitle + : filter === "recent" + ? i18n.torrentRecentSectionTitle + : filter === "top" + ? i18n.torrentTopSectionTitle + : filter === "favorites" + ? i18n.torrentFavoritesSectionTitle + : i18n.torrentAllSectionTitle; + + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + + const list = safeArr(torrents); + const torrentToEdit = torrentId ? list.find((t) => t.key === torrentId) : null; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.torrentsDescription) + ), + (() => { + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetTorrents); + return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n)); + })(), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.torrentFilterFavorites + ), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton) + ) + ) + ), + section( + filter === "create" || filter === "edit" + ? renderTorrentForm(filter, torrentId, torrentToEdit, { ...params, filter }) + : section( + div( + { class: "audios-search" }, + form( + { method: "GET", action: "/torrents", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ + type: "text", + name: "q", + value: q, + placeholder: i18n.torrentSearchPlaceholder, + class: "filter-box__input" + }), + div( + { class: "filter-box__controls" }, + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.torrentSortRecent), + option({ value: "oldest", selected: sort === "oldest" }, i18n.torrentSortOldest), + option({ value: "top", selected: sort === "top" }, i18n.torrentSortTop) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.torrentSearchButton) + ) + ) + ), + div({ class: "audios-list" }, renderTorrentTable(list, filter, { q, sort })) + ) + ) + ); +}; + +exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], params = {}) => { + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }); + + const title = safeText(torrentObj.title); + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetTorrents); + + const chips = [ + renderLifespanChip(torrentObj.lifetime, i18n), + torrentObj.sizeBytes ? renderEcoTax(torrentObj.sizeBytes, torrentObj.key) : null + ].filter(Boolean); + + const ownerActions = renderTorrentOwnerActions(filter, torrentObj, { q, sort }); + const sideActions = []; + sideActions.push(renderTorrentFavoriteToggle(torrentObj, returnTo)); + if (torrentObj.author && String(torrentObj.author) !== String(userId)) { + sideActions.push(form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: torrentObj.author }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + )); + } + for (const a of ownerActions) sideActions.push(a); + + const tagsNode = renderTags(torrentObj.tags); + + const torrentSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + title ? h2({ class: "tribe-card-title" }, title) : null, + renderReachChip(isClearnet, i18n) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + safeText(torrentObj.description) + ? p({ class: "tribe-side-description" }, ...renderUrl(torrentObj.description)) + : null, + tagsNode, + div({ class: "card-spread-centered" }, renderSpreadButton(torrentObj.key, params.spreads)), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const torrentMain = div({ class: "tribe-main" }, + torrentObj.url && torrentObj.url.startsWith("&") + ? div({ class: "torrent-download" }, + a({ href: `/blob/${encodeURIComponent(torrentObj.url)}?name=${encodeURIComponent((torrentObj.title || 'download').replace(/\.torrent$/i, '') + '.torrent')}` , class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!") + ) + : p(i18n.torrentNoFile), + (() => { + const createdTs = torrentObj.createdAt ? new Date(torrentObj.createdAt).getTime() : NaN; + const updatedTs = torrentObj.updatedAt ? new Date(torrentObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(torrentObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })(), + renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants), + renderTorrentCommentsSection(torrentObj.key, comments, returnTo) + ); + + return template( + i18n.torrentsTitle, + section( + div({ class: "tags-header" }, + h2(i18n.torrentAllSectionTitle || i18n.torrentsTitle), + p(i18n.torrentDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.torrentFilterFavorites + ), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton) + ) + ), + div({ class: "tribe-details" }, torrentSide, torrentMain) + ) + ); +}; diff --git a/src/views/transfer_view.js b/src/views/transfer_view.js new file mode 100644 index 0000000..e3c3de5 --- /dev/null +++ b/src/views/transfer_view.js @@ -0,0 +1,571 @@ +const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress, table, tr, td } = require("../server/node_modules/hyperaxe") +const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") + +const userId = config.keys.id + +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() + +const parseNum = (v) => { + const n = parseFloat(String(v ?? "").replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} + +const fmtAmount = (v) => { + const n = parseNum(v) + return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "") +} + +const categoryOf = (t) => { + const c = String(t?.category || "ECONOMIC").toUpperCase() + return ["ECONOMIC", "TIME", "TRUST"].includes(c) ? c : "ECONOMIC" +} + +const fmtAmountWithUnit = (transfer) => { + const amt = fmtAmount(transfer.amount) + const cat = categoryOf(transfer) + const unit = cat === "TIME" ? (i18n.transfersUnitHours || "h") + : cat === "TRUST" ? (i18n.transfersUnitTrust || "trust") + : (i18n.transfersUnitEco || "ECO") + return `${amt} ${unit}` +} + +const categoryLabel = (cat) => ( + cat === "TIME" ? (i18n.transfersCategoryTime || "Time") : + cat === "TRUST" ? (i18n.transfersCategoryTrust || "Trust") : + (i18n.transfersCategoryEconomic || "Economic") +) + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all") + const q = safeText(params.q || "") + const minAmount = params.minAmount ?? "" + const maxAmount = params.maxAmount ?? "" + const sort = safeText(params.sort || "") + const parts = [`filter=${encodeURIComponent(f)}`] + if (q) parts.push(`q=${encodeURIComponent(q)}`) + if (String(minAmount) !== "") parts.push(`minAmount=${encodeURIComponent(String(minAmount))}`) + if (String(maxAmount) !== "") parts.push(`maxAmount=${encodeURIComponent(String(maxAmount))}`) + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`) + return `/transfers?${parts.join("&")}` +} + +const statusKey = (s) => { + const up = String(s || "").toUpperCase() + const pretty = up.charAt(0) + up.slice(1).toLowerCase() + return `transfersStatus${pretty}` +} + +const renderTags = (tags = []) => { + const arr = safeArr(tags).map(t => String(t || "").trim()).filter(Boolean) + return arr.length + ? div( + { class: "card-tags" }, + arr.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null +} + +const renderCardField = (labelText, valueNode) => + div( + { class: "card-field" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, valueNode) + ) + +const formatBlockSize = (bytes) => { + const n = Number(bytes || 0) + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(2)} KB` + return `${(n / (1024 * 1024)).toFixed(2)} MB` +} + +const renderBlockInfoCard = (block) => { + if (!block || !block.id) return null + return div( + { class: "transfer-block-card" }, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.blockchainBlockID || "Block ID"}: `), + span({ class: "card-value" }, + a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}` }, block.id) + ) + ) + ) +} + +const renderConfirmationsBar = (confirmedCount, required) => { + const req = Math.max(1, Number(required || 2)) + const cc = Math.max(0, Number(confirmedCount || 0)) + return div( + { class: "confirmations-block" }, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersConfirmations}: `), + span({ class: "card-value" }, `${cc}/${req}`) + ), + progress({ class: "confirmations-progress", value: cc, max: req }) + ) +} + +const renderOwnerActions = (transfer, returnTo) => { + const canEdit = transfer.from === userId && String(transfer.status || "").toUpperCase() === "UNCONFIRMED" + if (!canEdit) return [] + return [ + form( + { method: "GET", action: `/transfers/edit/${encodeURIComponent(transfer.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "update-btn" }, i18n.transfersUpdateButton) + ), + form( + { method: "POST", action: `/transfers/delete/${encodeURIComponent(transfer.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton) + ) + ] +} + +const renderUpdatedLabel = (createdAt, updatedAt) => { + const createdTs = createdAt ? new Date(createdAt).getTime() : NaN + const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs) + return showUpdated + ? span({ class: "votations-comment-date" }, ` | ${i18n.transfersUpdatedAt}: ${moment(updatedAt).format("YYYY-MM-DD HH:mm")}`) + : null +} + + +const renderTransferStatusChip = (status) => { + const up = String(status || "").toUpperCase() + const variant = + up === "CLOSED" ? "mutuals" : + up === "DISCARDED" ? "closed" : + "whole" + const icon = up === "CLOSED" ? "✓" : up === "DISCARDED" ? "✗" : "↻" + return renderStateChip(variant, icon, i18n[statusKey(up)] || up) +} + +const renderTransferCategoryChip = (cat) => + renderStateChip("encrypted", "", categoryLabel(cat)) + +const generateTransferCard = (transfer, filter, params = {}) => { + const confirmedBy = safeArr(transfer.confirmedBy) + const required = transfer.from === transfer.to ? 1 : 2 + const confirmedCount = confirmedBy.length + const dl = transfer.deadline ? moment(transfer.deadline) : null + const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false + const tags = Array.isArray(transfer.tags) ? transfer.tags.map(t => String(t).toUpperCase()) : [] + const isUbi = tags.includes("UBI") + const cat = categoryOf(transfer) + + const chips = [ + renderTransferStatusChip(transfer.status), + isUbi ? renderStateChip("mutuals", "🎁", "UBI") : null, + isExpired ? renderStateChip("closed", "⏰", i18n.transfersExpiredBadge) : null, + renderLifespanChip(transfer.lifetime, i18n) + ].filter(Boolean) + + const otherParty = transfer.from === userId ? transfer.to : transfer.from + const partyDir = transfer.from === userId ? "→" : "←" + const returnTo = buildReturnTo(filter, params) + + const isOwn = transfer.from && String(transfer.from) === String(userId) + return div({ class: "trending-card transfer-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`) + ), + div({ class: "card-section transfer-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/transfers/${encodeURIComponent(transfer.id)}` }, transfer.concept || i18n.transfersTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + !isUbi && dl && dl.isValid() + ? p({ class: "card-date-highlight" }, dl.format("YYYY-MM-DD HH:mm")) + : null, + cat !== "TRUST" + ? div({ class: "job-price-line card-salary" }, fmtAmountWithUnit(transfer)) + : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`) + ), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(Object.values(transfer.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) + ), + renderOpinionsVoting('/transfers/opinions', transfer.id, transfer.opinions, returnTo, transfer.opinions_inhabitants) + ), + div({ class: "card-spread-centered" }, renderSpreadButton(transfer.id, params.spreadMap && params.spreadMap.get(transfer.id))) + ) + ) +} + +exports.transferView = async (transfers, filter, transferId, params = {}) => { + const normalizedFilter = filter === "favs" ? "all" : (filter || "all") + + const title = + normalizedFilter === "mine" ? i18n.transfersMineSectionTitle : + normalizedFilter === "ubi" ? i18n.transfersUBISectionTitle : + normalizedFilter === "pending" ? i18n.transfersPendingSectionTitle : + normalizedFilter === "top" ? i18n.transfersTopSectionTitle : + normalizedFilter === "unconfirmed" ? i18n.transfersUnconfirmedSectionTitle : + normalizedFilter === "closed" ? i18n.transfersClosedSectionTitle : + normalizedFilter === "discarded" ? i18n.transfersDiscardedSectionTitle : + normalizedFilter === "create" ? i18n.transfersCreateSectionTitle : + normalizedFilter === "edit" ? i18n.transfersUpdateSectionTitle : + normalizedFilter === "economic" ? (i18n.transfersEconomicSectionTitle || (i18n.transfersCategoryEconomic + " " + i18n.transfersTitle)) : + normalizedFilter === "time" ? (i18n.transfersTimeSectionTitle || (i18n.transfersCategoryTime + " " + i18n.transfersTitle)) : + normalizedFilter === "trust" ? (i18n.transfersTrustSectionTitle || (i18n.transfersCategoryTrust + " " + i18n.transfersTitle)) : + i18n.transfersAllSectionTitle + + const q = safeText(params.q || "") + const minAmountRaw = params.minAmount ?? "" + const maxAmountRaw = params.maxAmount ?? "" + const minAmount = parseNum(minAmountRaw) + const maxAmount = parseNum(maxAmountRaw) + const sort = safeText(params.sort || "recent") + + const list = safeArr(transfers) + + let filtered = + normalizedFilter === "mine" ? list.filter(t => t.from === userId || t.to === userId) : + normalizedFilter === "ubi" ? list.filter(t => safeArr(t.tags).some(tag => String(tag).toUpperCase() === "UBI")) : + normalizedFilter === "pending" ? list.filter(t => String(t.status || "").toUpperCase() === "UNCONFIRMED" && t.to === userId && !safeArr(t.confirmedBy).includes(userId)) : + normalizedFilter === "top" ? list.filter(t => String(t.status || "").toUpperCase() === "CLOSED") : + normalizedFilter === "unconfirmed" ? list.filter(t => String(t.status || "").toUpperCase() === "UNCONFIRMED") : + normalizedFilter === "closed" ? list.filter(t => String(t.status || "").toUpperCase() === "CLOSED") : + normalizedFilter === "discarded" ? list.filter(t => String(t.status || "").toUpperCase() === "DISCARDED") : + normalizedFilter === "economic" ? list.filter(t => categoryOf(t) === "ECONOMIC") : + normalizedFilter === "time" ? list.filter(t => categoryOf(t) === "TIME") : + normalizedFilter === "trust" ? list.filter(t => categoryOf(t) === "TRUST") : + normalizedFilter === "market" ? list : + list + + if (q) { + const qq = q.toLowerCase() + filtered = filtered.filter(t => { + const concept = String(t.concept || "").toLowerCase() + const tags = safeArr(t.tags).join(" ").toLowerCase() + const from = String(t.from || "").toLowerCase() + const to = String(t.to || "").toLowerCase() + return concept.includes(qq) || tags.includes(qq) || from.includes(qq) || to.includes(qq) + }) + } + + if (Number.isFinite(minAmount)) filtered = filtered.filter(t => parseNum(t.amount) >= minAmount) + if (Number.isFinite(maxAmount)) filtered = filtered.filter(t => parseNum(t.amount) <= maxAmount) + + if (normalizedFilter === "top" || sort === "amount") { + filtered = filtered.sort((a, b) => parseNum(b.amount) - parseNum(a.amount)) + } else if (sort === "deadline") { + filtered = filtered.sort((a, b) => new Date(a.deadline || 0) - new Date(b.deadline || 0)) + } else { + filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + } + + const isForm = normalizedFilter === "create" || normalizedFilter === "edit" + const transferToEdit = normalizedFilter === "edit" ? (list.find(t => t.id === transferId) || {}) : (params.prefill || {}) + const returnToForForm = buildReturnTo("all", {}) + const validCategories = ["ECONOMIC", "TIME", "TRUST"] + const paramCategoryRaw = safeText(params.category || "").toUpperCase() + const selectedCategory = + transferToEdit.category && validCategories.includes(transferToEdit.category) + ? transferToEdit.category + : (validCategories.includes(paramCategoryRaw) ? paramCategoryRaw : "ECONOMIC") + const amountUnitLabel = + selectedCategory === "TIME" ? (i18n.transfersUnitHours || "h") : + selectedCategory === "TRUST" ? (i18n.transfersUnitTrust || "trust") : + (i18n.transfersUnitEco || "ECO") + + return template( + title, + section( + div({ class: "tags-header" }, h2(title), p(i18n.transfersDescription)), + div( + { class: "filters" }, + form( + { method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "minAmount", value: String(minAmountRaw ?? "") }), + input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine), + button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI), + button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")), + button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")), + button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")), + button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket), + button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending), + button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed), + button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed), + button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded), + button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton) + ) + ) + ), + section( + isForm + ? div( + { class: "transfer-form" }, + normalizedFilter === "create" + ? form( + { method: "GET", action: "/transfers", class: "transfer-category-picker" }, + input({ type: "hidden", name: "filter", value: "create" }), + label(i18n.transfersCategory || "Category"), + br(), + select( + { name: "category", class: "transfer-category-select" }, + option({ value: "ECONOMIC", selected: selectedCategory === "ECONOMIC" ? "selected" : undefined }, i18n.transfersCategoryEconomic || "Economic"), + option({ value: "TIME", selected: selectedCategory === "TIME" ? "selected" : undefined }, i18n.transfersCategoryTime || "Time"), + option({ value: "TRUST", selected: selectedCategory === "TRUST" ? "selected" : undefined }, i18n.transfersCategoryTrust || "Trust") + ), + " ", + button({ type: "submit", class: "filter-btn" }, i18n.transfersCategoryApply || "Apply"), + br(), br() + ) + : null, + form( + { action: normalizedFilter === "edit" ? `/transfers/update/${encodeURIComponent(transferId)}` : "/transfers/create", method: "POST" }, + input({ type: "hidden", name: "returnTo", value: returnToForForm }), + normalizedFilter === "create" ? input({ type: "hidden", name: "category", value: selectedCategory }) : null, + label(i18n.transfersToUser), + br(), + input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "" }), + br(), + label(i18n.transfersConcept), + br(), + input({ type: "text", name: "concept", required: true, value: transferToEdit.concept || "" }), + br(), + normalizedFilter === "edit" + ? [ + label(i18n.transfersCategory || "Category"), + br(), + select( + { name: "category", required: true }, + option({ value: "ECONOMIC", selected: selectedCategory === "ECONOMIC" ? "selected" : undefined }, i18n.transfersCategoryEconomic || "Economic"), + option({ value: "TIME", selected: selectedCategory === "TIME" ? "selected" : undefined }, i18n.transfersCategoryTime || "Time"), + option({ value: "TRUST", selected: selectedCategory === "TRUST" ? "selected" : undefined }, i18n.transfersCategoryTrust || "Trust") + ), + br(), br() + ] + : null, + selectedCategory === "TRUST" + ? input({ type: "hidden", name: "amount", value: "1" }) + : [ + label(`${i18n.transfersAmount} (${amountUnitLabel})`), + br(), + input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }), + br(), br() + ], + label(i18n.transfersDeadline), + br(), + input({ type: "datetime-local", name: "deadline", required: true, min: moment().format("YYYY-MM-DDTHH:mm"), value: transferToEdit.deadline ? moment(transferToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }), + br(), + br(), + label(i18n.transfersTags), + br(), + input({ type: "text", name: "tags", value: safeArr(transferToEdit.tags).join(", ") }), + br(), + br(), + button({ type: "submit" }, normalizedFilter === "edit" ? i18n.transfersUpdateButton : i18n.transfersCreateButton) + ) + ) + : section( + div( + { class: "transfers-search" }, + form( + { method: "GET", action: "/transfers", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: normalizedFilter || "all" }), + input({ type: "text", name: "q", value: q, placeholder: i18n.transfersSearchPlaceholder, class: "filter-box__input" }), + div( + { class: "filter-box__controls" }, + div( + { class: "transfer-range" }, + input({ type: "number", name: "minAmount", step: "0.000001", min: "0", value: String(minAmountRaw ?? ""), placeholder: i18n.transfersMinAmountLabel, class: "filter-box__number transfer-amount-input" }), + input({ type: "number", name: "maxAmount", step: "0.000001", min: "0", value: String(maxAmountRaw ?? ""), placeholder: i18n.transfersMaxAmountLabel, class: "filter-box__number transfer-amount-input" }) + ), + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" ? "selected" : undefined }, i18n.transfersSortRecent), + option({ value: "amount", selected: sort === "amount" ? "selected" : undefined }, i18n.transfersSortAmount), + option({ value: "deadline", selected: sort === "deadline" ? "selected" : undefined }, i18n.transfersSortDeadline) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.transfersSearchButton) + ) + ) + ), + br(), + filtered.length + ? div({ class: "jobs-grid" }, + filtered.map(t => generateTransferCard(t, normalizedFilter, { q, minAmount: minAmountRaw, maxAmount: maxAmountRaw, sort, spreadMap: params.spreadMap })) + ) + : p(q || String(minAmountRaw) || String(maxAmountRaw) ? i18n.transfersNoMatch : i18n.transfersNoItems) + ) + ) + ) +} + +exports.singleTransferView = async (transfer, filter, params = {}) => { + const normalizedFilter = filter === "favs" ? "all" : (filter || "all") + const q = safeText(params.q || "") + const sort = safeText(params.sort || "recent") + const returnTo = safeText(params.returnTo) || buildReturnTo(normalizedFilter, { ...params, q, sort }) + + const confirmedBy = safeArr(transfer.confirmedBy) + const required = transfer.from === transfer.to ? 1 : 2 + const confirmedCount = confirmedBy.length + const isUnconfirmed = String(transfer.status || "").toUpperCase() === "UNCONFIRMED" + const dl = transfer.deadline ? moment(transfer.deadline) : null + const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false + const tags = Array.isArray(transfer.tags) ? transfer.tags.map(t => String(t).toUpperCase()) : [] + const isUbi = tags.includes("UBI") + const showConfirm = isUnconfirmed && transfer.to === userId && !confirmedBy.includes(userId) && !isExpired && !tags.includes("PENDING") + + const tagsNode = renderTags(transfer.tags) + const cat = categoryOf(transfer) + const otherParty = transfer.from === userId ? transfer.to : transfer.from + const chips = [ + renderTransferStatusChip(transfer.status), + isUbi ? renderStateChip("mutuals", "🎁", "UBI") : null, + isExpired ? renderStateChip("closed", "⏰", i18n.transfersExpiredBadge) : null, + renderLifespanChip(transfer.lifetime, i18n) + ].filter(Boolean) + + const sideActions = [] + if (otherParty && String(otherParty) !== String(userId)) { + sideActions.push(form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: otherParty }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + )) + } + if (showConfirm) { + sideActions.push(form({ method: "POST", action: `/transfers/confirm/${encodeURIComponent(transfer.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, i18n.transfersConfirmButton) + )) + } + const canEdit = transfer.from === userId && String(transfer.status || "").toUpperCase() === "UNCONFIRMED" + if (canEdit) { + sideActions.push(form({ method: "GET", action: `/transfers/edit/${encodeURIComponent(transfer.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "update-btn" }, i18n.transfersUpdateButton) + )) + sideActions.push(form({ method: "POST", action: `/transfers/delete/${encodeURIComponent(transfer.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton) + )) + } + sideActions.push(form({ method: "GET", action: `/transfers/contract/${encodeURIComponent(transfer.id)}`, class: "transfer-contract-form" }, + button({ type: "submit", class: "filter-btn" }, i18n.transfersExportContract || 'Create Contract') + )) + + const infoRows = [] + const pushRow = (labelText, valueNode) => + infoRows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: "tribe-info-value" }, valueNode) + )) + pushRow(i18n.transfersFrom, userLink(transfer.from)) + pushRow(i18n.transfersTo, userLink(transfer.to)) + if (cat !== "TRUST") pushRow(i18n.transfersAmount, fmtAmountWithUnit(transfer)) + pushRow(i18n.transfersCategory || "Category", categoryLabel(cat)) + if (!isUbi) pushRow(i18n.transfersDeadline, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : "") + pushRow(i18n.transfersStatus, i18n[statusKey(transfer.status)] || String(transfer.status || "")) + + const transferSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, transfer.concept || i18n.transfersTitle) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(transfer.id, params.spreads)), + tagsNode, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`) + ), + params.block && params.block.id + ? div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.blockchainBlockID || "Block ID"}: `), + span({ class: "card-value" }, a({ class: "user-link", href: `/blockexplorer/block/${encodeURIComponent(params.block.id)}` }, params.block.id)) + ) + : null, + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ) + + const transferMain = div({ class: "tribe-main" }, + div({ class: "job-section" }, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersFrom}: `), + span({ class: "card-value" }, userLink(transfer.from)) + ), + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersTo}: `), + span({ class: "card-value" }, userLink(transfer.to)) + ), + cat !== "TRUST" ? div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersAmount}: `), + span({ class: "card-value card-salary" }, fmtAmountWithUnit(transfer)) + ) : null, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersCategory || "Category"}: `), + span({ class: "card-value" }, categoryLabel(cat)) + ), + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersConcept}: `), + span({ class: "card-value" }, transfer.concept || "") + ), + !isUbi && dl && dl.isValid() ? div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersDeadline}: `), + span({ class: "card-value" }, dl.format("YYYY-MM-DD HH:mm")) + ) : null, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.transfersStatus}: `), + span({ class: "card-value" }, i18n[statusKey(transfer.status)] || String(transfer.status || "")) + ) + ), + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), + userLink(transfer.from), + renderUpdatedLabel(transfer.createdAt, transfer.updatedAt) + ), + renderOpinionsVoting('/transfers/opinions', transfer.id, transfer.opinions, returnTo, transfer.opinions_inhabitants) + ) + + return template( + transfer.concept, + section( + div( + { class: "filters" }, + form( + { method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "minAmount", value: String(params.minAmount ?? "") }), + input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine), + button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI), + button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")), + button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")), + button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")), + button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket), + button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending), + button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed), + button({ type: "submit", name: "filter", value: "closed", class: normalizedFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterClosed), + button({ type: "submit", name: "filter", value: "discarded", class: normalizedFilter === "discarded" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterDiscarded), + button({ type: "submit", name: "filter", value: "top", class: normalizedFilter === "top" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTop), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton) + ) + ), + div({ class: "tribe-details" }, transferSide, transferMain) + ) + ) +} + diff --git a/src/views/trending_view.js b/src/views/trending_view.js new file mode 100644 index 0000000..cbdb142 --- /dev/null +++ b/src/views/trending_view.js @@ -0,0 +1,300 @@ +const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, img, video: videoHyperaxe, audio: audioHyperaxe, span, details, summary} = require("../server/node_modules/hyperaxe"); +const { template, i18n, userLink, renderSpreadButton, renderContentActions} = require('./main_views'); +const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); +const { config } = require('../server/SSB_server.js'); +const { renderUrl } = require('../backend/renderUrl'); +const opinionCategories = require('../backend/opinion_categories'); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); + +const userId = config.keys.id; + +const filterButton = (mode, currentFilter) => + form({ method: 'GET', action: '/trending' }, + input({ type: 'hidden', name: 'filter', value: mode }), + button( + { type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' }, + i18n[mode + 'Button'] || mode + ) + ); + +const voteLabelFor = (cat) => + i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat; + +const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new Map()) => { + const c = item.value.content; + const created = new Date(item.value.timestamp).toLocaleString(); + + let contentHtml; + + if (c.type === 'bookmark') { + const { url, description, lastVisit } = c; + contentHtml = div({ class: 'trending-bookmark' }, + div({ class: 'card-section bookmark' }, + url ? h2(p(a({ href: url, target: '_blank', class: "bookmark-url" }, url))) : "", + lastVisit + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.bookmarkLastVisitLabel + ':'), + span({ class: 'card-value' }, new Date(lastVisit).toLocaleString()) + ) + : "", + description ? [span({ class: 'card-label' }, i18n.bookmarkDescriptionLabel + ":"), p(...renderUrl(description))] : null + ) + ); + } else if (c.type === 'image') { + const { url, title, description, meme } = c; + contentHtml = div({ class: 'trending-image' }, + div({ class: 'card-section image' }, + title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "", + description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null, + meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "", + div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' })) + ) + ); + } else if (c.type === 'audio') { + const { url, mimeType, title, description } = c; + contentHtml = div({ class: 'trending-audio' }, + div({ class: 'card-section audio' }, + title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, title)) : "", + description ? [span({ class: 'card-label' }, i18n.audioDescriptionLabel + ":"), p(...renderUrl(description))] : null, + url + ? div({ class: 'card-field audio-container' }, audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType })) + : div({ class: 'card-field' }, p(i18n.audioNoFile)) + ) + ); + } else if (c.type === 'video') { + const { url, mimeType, title, description } = c; + contentHtml = div({ class: 'trending-video' }, + div({ class: 'card-section video' }, + title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, title)) : "", + description ? [span({ class: 'card-label' }, i18n.videoDescriptionLabel + ":"), p(...renderUrl(description))] : null, + br(), + url + ? div({ class: 'card-field video-container' }, videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(url)}`, type: mimeType, preload: 'metadata', width: '640', height: '360' })) + : div({ class: 'card-field' }, p(i18n.videoNoFile)) + ) + ); + } else if (c.type === 'torrent') { + const { url, title, description } = c; + contentHtml = div({ class: 'trending-torrent' }, + div({ class: 'card-section torrent' }, + title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, title)) : "", + description ? [span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ":"), p(...renderUrl(description))] : null, + url && url.startsWith("&") + ? div({ class: 'card-field' }, a({ href: `/blob/${encodeURIComponent(url)}`, class: 'filter-btn' }, i18n.torrentDownload || 'Download')) + : div({ class: 'card-field' }, p(i18n.torrentNoFile || 'No file')) + ) + ); + } else if (c.type === 'document') { + const { url, title, description } = c; + const t = title?.trim(); + if (t && seenTitles.has(t)) return null; + if (t) seenTitles.add(t); + + contentHtml = div({ class: 'trending-document' }, + div({ class: 'card-section document' }, + t ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, t)) : "", + description ? [span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), p(...renderUrl(description))] : null, + div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` }) + ) + ); + } else if (c.type === 'feed') { + const { text, refeeds } = c; + contentHtml = div({ class: 'trending-feed' }, + div({ class: 'card-section feed' }, + div({ class: 'feed-text', innerHTML: sanitizeHtml(renderTextWithStyles(text)) }), + refeeds + ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-value' }, refeeds)) + : "" + ) + ); + } else if (c.type === 'votes') { + const { question, deadline, votes: vmap, totalVotes } = c; + const votesList = vmap && typeof vmap === 'object' + ? Object.entries(vmap).map(([o, cnt]) => ({ option: o, count: cnt })) + : []; + contentHtml = div({ class: 'trending-votes' }, + div({ class: 'card-section votes' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteQuestionLabel + ':'), span({ class: 'card-value' }, question)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteDeadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)), + table( + tr(...votesList.map(v => th(i18n[v.option] || v.option))), + tr(...votesList.map(v => td(v.count))) + ) + ) + ); + } else if (c.type === 'transfer') { + const { from, to, concept, amount, deadline, status, confirmedBy = [] } = c; + contentHtml = div({ class: 'trending-transfer' }, + div({ class: 'card-section transfer' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(from)}`, target: '_blank' }, from))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(to)}`, target: '_blank' }, to))), + h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ': '), span({ class: 'card-value' }, `${confirmedBy.length}/2`)) + ) + ); + } else { + contentHtml = div({ class: 'styled-text' }, + div({ class: 'card-section styled-text-content' }, + div( + { class: 'card-field' }, + span({ class: 'card-value', innerHTML: sanitizeHtml(renderTextWithStyles(c.text || c.description || c.title || '[no content]')) }) + ) + ) + ); + } + + const detailPaths = { + bookmark: 'bookmarks', + image: 'images', + audio: 'audios', + video: 'videos', + torrent: 'torrents', + document: 'documents', + feed: 'feed', + votes: 'votes', + transfer: 'transfers' + }; + const detailHref = detailPaths[c.type] + ? `/${detailPaths[c.type]}/${encodeURIComponent(item.key)}` + : `/thread/${encodeURIComponent(item.key)}`; + const isOwn = item.value.author && String(item.value.author) === String(userId); + + return div( + { class: 'trending-card' + (isOwn ? ' own-content' : '') }, + div( + { class: 'card-header activity-card-header' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase()) + ), + renderContentActions(item.key, detailHref) + ), + div( + { class: 'card-section trending-card-body' }, + contentHtml, + div({ class: 'card-spread-left' }, renderSpreadButton(item.key, spreadMap.get(item.key))), + p( + { class: 'card-footer' }, + span({ class: 'date-link' }, `${created} ${i18n.performed} `), + userLink(item.value.author) + ), + (() => { + const ops = c.opinions || {}; + const entries = Object.entries(ops).filter(([, v]) => v > 0); + const dominantPart = (() => { + if (!entries.length) return null; + const maxVal = Math.max(...entries.map(([, v]) => v)); + const dominant = entries.filter(([, v]) => v === maxVal).map(([k]) => voteLabelFor(k)); + return [ + span({ class: 'trending-dominant-sep' }, '|'), + span({ class: 'trending-dominant-text' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`) + ]; + })(); + return h2( + `${i18n.trendingTotalOpinions || i18n.trendingTotalCount}: `, + span({ class: 'trending-total-count' }, String(votes)), + ...(dominantPart || []) + ); + })(), + details({ class: 'opinions-voting-collapse' }, + summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`), + div( + { class: 'voting-buttons' }, + categories.map(cat => + form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` }, + button( + { class: 'vote-btn' }, + `${voteLabelFor(cat)} [${c.opinions?.[cat] || 0}]` + ) + ) + ) + ) + ) + ) + ); +}; + +exports.trendingView = (items, filter, categories = opinionCategories, spreadMap = new Map()) => { + const seenDocumentTitles = new Set(); + const title = i18n.trendingTitle; + + const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT']; + const contentFilters = [ + ['votes', 'feed', 'transfer'], + ['bookmark', 'image', 'video', 'audio', 'document', 'torrent'] + ]; + + let filteredItems = items.filter(item => { + const c = item.value?.content || item.content; + return c && typeof c === 'object' && c.type !== 'tombstone'; + }); + + if (filter === 'MINE') { + filteredItems = filteredItems.filter(item => item.value.author === userId); + } else if (filter === 'RECENT') { + const now = Date.now(); + filteredItems = filteredItems.filter(item => now - item.value.timestamp < 24 * 60 * 60 * 1000); + } else if (filter === 'TOP') { + filteredItems.sort((a, b) => { + const aVotes = (a.value.content.opinions_inhabitants || []).length; + const bVotes = (b.value.content.opinions_inhabitants || []).length; + return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp; + }); + } else if (contentFilters.flat().includes(filter)) { + filteredItems = filteredItems.filter(item => item.value.content.type === filter); + } else if (filter !== 'ALL') { + filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0); + } + + if (filter !== 'TOP') { + filteredItems.sort((a, b) => b.value.timestamp - a.value.timestamp); + } + + const header = div({ class: 'tags-header' }, h2(title), p(i18n.exploreTrending)); + const cards = filteredItems + .map(item => + renderTrendingCard( + item, + Object.values(item.value.content.opinions || {}).reduce((s, n) => s + (n || 0), 0), + categories, + seenDocumentTitles, + spreadMap + ) + ) + .filter(Boolean); + + const hasDocument = filteredItems.some(item => item.value.content.type === 'document'); + + let html = template( + title, + section( + header, + div( + { class: 'mode-buttons' }, + div({ class: 'column' }, baseFilters.map(mode => filterButton(mode, filter))), + ...contentFilters.map(row => + div({ class: 'column' }, row.map(mode => filterButton(mode, filter))) + ) + ), + section( + cards.length + ? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' }, ...cards) + : div({ class: 'no-results' }, p(i18n.trendingNoContentMessage)) + ) + ) + ); + + if (hasDocument) { + html += ` + + + `; + } + + return html; +}; + diff --git a/src/views/tribes_view.js b/src/views/tribes_view.js new file mode 100644 index 0000000..13cf41e --- /dev/null +++ b/src/views/tribes_view.js @@ -0,0 +1,1900 @@ +const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td, thead, tbody, th } = require("../server/node_modules/hyperaxe"); +const moment = require("../server/node_modules/moment"); +const { template, i18n, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderModeChip, renderInviteQrCard, renderContentActions } = require('./main_views'); +const { renderEncryptedChip: renderTribeEncryptedChip } = require('./clearnet_view'); +const { config } = require('../server/SSB_server.js'); +const { renderUrl } = require('../backend/renderUrl'); +const { renderMapLocationUrl, renderMapLocationGrid, renderMapLocationVisitLabel } = require("./maps_view"); +const opinion_categories = require('../backend/opinion_categories.js'); + +const userId = config.keys.id; +const isLarpHouseTribe = (t) => Array.isArray(t && t.tags) && t.tags.some(x => String(x).startsWith('larp-')); + +const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256"; +const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/; +const isDefaultImageId = (v) => { + if (!v) return true; + if (typeof v === 'string') { + if (v === DEFAULT_HASH_ENC) return true; + if (DEFAULT_HASH_PATH_RE.test(v)) return true; + } + return false; +}; +const resolvePhoto = (photoField) => { + if (!photoField) return '/assets/images/default-avatar.png'; + if (typeof photoField === 'string') { + if (photoField.startsWith('/assets/')) return photoField; + if (photoField.startsWith('/blob/')) return photoField; + if (photoField.startsWith('/image/')) { + if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png'; + return photoField; + } + } + return '/assets/images/default-avatar.png'; +}; + +const MS_PER_DAY = 86400000; +const FEED_ITEMS_PER_PAGE = 5; +const MAX_MESSAGE_LENGTH = 280; + +const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => { + if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + const s = String(value).trim() + if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null + if (s.startsWith('/assets/') || s.startsWith('/blob/') || s.startsWith('/image/')) return img({ src: s, ...attrs }) + if (s.startsWith('&')) return img({ src: `/blob/${encodeURIComponent(s)}`, ...attrs }) + const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}` }) + const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` }) + const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) + if (mImg) return img({ src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs }) + return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null +} + +const toBlobUrl = (raw) => { + if (!raw) return null + const s = String(raw).trim() + if (s.startsWith('&')) return `/blob/${encodeURIComponent(s)}` + const m = s.match(/\((&[^)\s]+\.sha256)\s*\)/) + return m ? `/blob/${encodeURIComponent(m[1])}` : null +} + +const toImageUrl = (raw, fallback) => { + if (!raw) return fallback + const s = String(raw).trim() + if (/^\[video:|^\[audio:/.test(s)) return fallback + if (s.startsWith('/assets/') || s.startsWith('/blob/') || s.startsWith('/image/')) return s + const url = toBlobUrl(raw) + return url || fallback +} + +const filterAndSortFeed = (feed, feedFilter) => { + const parseDate = (d) => typeof d === 'string' ? Date.parse(d) : d; + if (feedFilter === 'MINE') return feed.filter(m => m.author === userId); + if (feedFilter === 'RECENT') { + const last24h = Date.now() - MS_PER_DAY; + return [...feed].filter(m => parseDate(m.createdAt) >= last24h).sort((a, b) => parseDate(b.createdAt) - parseDate(a.createdAt)); + } + if (feedFilter === 'ALL') return [...feed].sort((a, b) => parseDate(b.createdAt) - parseDate(a.createdAt)); + if (feedFilter === 'TOP') return [...feed].sort((a, b) => (b.refeeds || 0) - (a.refeeds || 0)); + return feed; +}; + +const renderGallery = (sortedTribes) => { + return div({ class: "gallery" }, + sortedTribes.length + ? sortedTribes.map(t => + a({ href: `#tribe-${encodeURIComponent(t.id)}`, class: "gallery-item" }, + img({ src: toImageUrl(t.image, '/assets/images/default-tribe.png'), alt: t.title || "", class: "gallery-image" }) + ) + ) + : p(i18n.noTribes) + ); +}; + +const renderLightbox = (sortedTribes) => { + return sortedTribes.map(t => + div( + { id: `tribe-${encodeURIComponent(t.id)}`, class: "lightbox" }, + a({ href: "#", class: "lightbox-close" }, "×"), + img({ src: toImageUrl(t.image, '/assets/images/default-tribe.png'), class: "lightbox-image", alt: t.title || "" }) + ) + ); +}; + +exports.renderInvitePage = (inviteCode) => { + const pageContent = div({ class: 'invite-page' }, + h2(i18n.tribeInviteCodeText.replace(/:\s*$/, '')), + p(i18n.tribeInviteCodeHint || 'Share this code with someone you want to invite. They can join via /invites → Tribes.'), + input({ type: 'text', value: inviteCode, readonly: 'readonly', class: 'tribe-invite-code-input' }), + br(), + form({ method: "GET", action: `/tribes` }, + button({ type: "submit", class: "filter-btn" }, i18n.walletBack) + ), + ); + return template(i18n.tribeInviteMode, section(pageContent)); +}; + +exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = null) => { + const now = Date.now(); + const search = (query.search || '').toLowerCase(); + + const allT0 = allTribes || tribes; + const isAncestorPrivate = (t) => { + let cur = t; + const seen = new Set(); + while (cur && cur.parentTribeId && !seen.has(cur.parentTribeId)) { + seen.add(cur.parentTribeId); + const p = allT0.find(x => x.id === cur.parentTribeId); + if (!p) break; + if (p.isAnonymous) return true; + cur = p; + } + return false; + }; + const effectivePrivate = (t) => !!t.isAnonymous || isAncestorPrivate(t); + const visible = (t) => !effectivePrivate(t) || t.author === userId || (Array.isArray(t.members) && t.members.includes(userId)); + const isMainTribe = (t) => !t.parentTribeId; + const filtered = tribes.filter(t => { + return ( + filter === 'all' ? visible(t) && isMainTribe(t) : + filter === 'mine' ? t.author === userId : + filter === 'membership' ? t.members.includes(userId) : + filter === 'subtribes' ? visible(t) && !!t.parentTribeId : + filter === 'recent' ? visible(t) && isMainTribe(t) && ((typeof t.createdAt === 'string' ? Date.parse(t.createdAt) : t.createdAt) >= now - MS_PER_DAY ) : + filter === 'top' ? visible(t) && isMainTribe(t) : + filter === 'gallery' ? visible(t) && isMainTribe(t) : + filter === 'create' ? true : + filter === 'edit' ? true : + false + ); + }); + + const searched = filter === 'create' || filter === 'edit' || !search + ? filtered + : filtered.filter(t => + (t.title && t.title.toLowerCase().includes(search)) || + (t.description && t.description.toLowerCase().includes(search)) + ); + + const sorted = filter === 'top' + ? [...searched].sort((a, b) => b.members.length - a.members.length) + : [...searched].sort((a, b) => { + const ca = typeof a.createdAt === 'string' ? Date.parse(a.createdAt) : a.createdAt; + const cb = typeof b.createdAt === 'string' ? Date.parse(b.createdAt) : b.createdAt; + return cb - ca; + }); + + const title = + filter === 'recent' ? i18n.tribeRecentSectionTitle : + filter === 'mine' ? i18n.tribeMineSectionTitle : + filter === 'create' ? i18n.tribeCreateSectionTitle : + filter === 'edit' ? i18n.tribeUpdateSectionTitle : + filter === 'gallery' ? i18n.tribeGallerySectionTitle : + filter === 'top' ? i18n.tribeTopSectionTitle : + filter === 'subtribes' ? (i18n.tribeSubTribes || 'SUB-TRIBES') : + i18n.tribeAllSectionTitle; + + const header = div({ class: 'tags-header' }, h2(title), p(i18n.tribeDescription)); + + const filters = div({ class: 'filters' }, + form({ method: 'GET', action: '/tribes' }, + input({ type: 'hidden', name: 'filter', value: filter }), + input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '' }), + br(), + button({ type: 'submit' }, i18n.applyFilters), + br() + ) + ); + + const modeButtons = div({ class: 'tribe-mode-buttons' }, + ['all','recent','mine','membership','subtribes','top','gallery'].map(f => + form({ method: 'GET', action: '/tribes' }, + input({ type: 'hidden', name: 'filter', value: f }), + button({ type: 'submit', class: filter === f ? 'filter-btn active' : 'filter-btn' }, + i18n[`tribeFilter${f.charAt(0).toUpperCase()+f.slice(1)}`] + ) + ) + ), + form({ method: 'GET', action: '/tribes/create' }, + button({ type: 'submit', class: 'create-button' }, i18n.tribeCreateButton) + ) + ); + + const isEdit = filter === 'edit' && tribeId; + const tribeToEdit = (isEdit ? tribes.find(t => t.id === tribeId) : null) || {}; + const isSubEdit = isEdit && !!tribeToEdit.parentTribeId; + const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' }, + h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle), + form({ + method: 'POST', + action: isEdit ? `/tribes/update/${encodeURIComponent(tribeToEdit.id)}` : '/tribes/create', + enctype: 'multipart/form-data' + }, + label({ for: 'title' }, i18n.tribeTitleLabel), + br, + input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeTitlePlaceholder, value: tribeToEdit.title || '' }), + br(), + label({ for: 'description' }, i18n.tribeDescriptionLabel), + br, + textarea({ name: 'description', id: 'description', required: true, rows: 4, cols: 50, placeholder: i18n.tribeDescriptionPlaceholder }, tribeToEdit.description || ''), + br, + label({ for: 'location' }, i18n.tribeLocationLabel), + br, + input({ type: 'text', name: 'location', id: 'location', placeholder: i18n.tribeLocationPlaceholder, value: tribeToEdit.location || '' }), + br, + label(i18n.mapLocationTitle || 'Map Location'), + br, + input({ type: 'text', name: 'mapUrl', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', value: tribeToEdit.mapUrl || '' }), + br, + label({ for: 'image' }, i18n.tribeImageLabel), + br, + input({ type: 'file', name: 'image', id: 'image' }), + br(), br(), + label({ for: 'tags' }, i18n.tribeTagsLabel), + br, + input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }), + br, + isSubEdit ? null : label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel), + isSubEdit ? null : br, + isSubEdit ? null : select({ name: 'isAnonymous', id: 'isAnonymous' }, + option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate), + option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic) + ), + isSubEdit ? null : br(), + isSubEdit ? null : br(), + label({ for: 'inviteMode' }, i18n.tribeModeLabel), + br, + select({ name: 'inviteMode', id: 'inviteMode' }, + option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict), + option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen) + ), + br(), br(), + button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton) + ) + ) : null; + + const allT = allTribes || tribes; + + const tribeCards = sorted.map(t => { + const isMember = t.members.includes(userId); + const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null; + + const isOwn = String(t.author) === String(userId); + return div({ class: 'trending-card tribes-card' + (isOwn ? ' own-content' : '') }, + div({ class: 'card-header activity-card-header' }, + span(), + renderContentActions(t.id, `/tribe/${encodeURIComponent(t.id)}`) + ), + div({ class: 'card-section tribes-card-body' }, + div({ class: 'tribe-card-image-wrapper' }, + a({ href: `/tribe/${encodeURIComponent(t.id)}` }, + renderMediaBlob(t.image, '/assets/images/default-tribe.png', { class: 'tribe-card-hero-image' }) + ), + isMember + ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-visit-btn-wrapper' }, + button({ type: 'submit', class: 'filter-btn' }, t.parentTribeId ? String(i18n.tribeviewSubTribeButton || 'VISIT SUB-TRIBE').toUpperCase() : String(i18n.tribeviewTribeButton || '').toUpperCase()) + ) + : null + ), + div({ class: 'tribe-card-body' }, + div({ class: 'shop-title-row' }, + h2({ class: 'tribe-card-title' }, a({ href: `/tribe/${encodeURIComponent(t.id)}` }, t.title)) + ), + div({ class: 'card-chips-row' }, + t.isAnonymous ? renderPrivacyChip(true, i18n) : renderPrivacyChip(false, i18n), + t.isAnonymous ? renderTribeEncryptedChip(i18n) : null, + renderModeChip(t.inviteMode, i18n), + renderLifespanChip(t.lifetime, i18n) + ), + t.description ? p({ class: 'tribe-card-description' }, ...renderUrl(t.description)) : null, + renderMapLocationVisitLabel(t.mapUrl), + (parentTribe || t.location) ? table({ class: 'tribe-info-table' }, + parentTribe ? tr( + td({ class: 'tribe-info-label' }, i18n.tribeRootLabel || 'ROOT'), + td({ class: 'tribe-info-value', colspan: '3' }, a({ href: `/tribe/${encodeURIComponent(parentTribe.id)}` }, parentTribe.title)) + ) : null, + t.location ? tr( + td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'), + td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(t.location)) + ) : null + ) : null, + div({ class: 'tribe-card-members' }, + span({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${t.memberCount != null ? t.memberCount : t.members.length}`) + ), + (t.openInviteCode && !isMember && String(t.author) !== String(userId)) ? div({ class: 'tribe-card-join' }, + form({ method: 'POST', action: '/tribes/open-invite/join' }, + input({ type: 'hidden', name: 'tribeId', value: t.id }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeJoinOpen) + ), + renderInviteQrCard({ qrDataUrl: t.openInviteQr, name: t.title }) + ) : null, + isMember ? div({ class: 'tribe-card-actions' }, + !isLarpHouseTribe(t) + ? form({ method: 'POST', action: '/tribes/generate-invite' }, + input({ type: 'hidden', name: 'tribeId', value: t.id }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite) + ) + : null, + form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(t.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeLeaveButton) + ) + ) : null, + filter === 'mine' ? div({ class: 'tribe-actions' }, + form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(t.id)}` }, button({ type: 'submit' }, i18n.tribeUpdateButton)), + form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(t.id)}` }, button({ type: 'submit', class: 'danger-btn' }, i18n.tribeDeleteButton)) + ) : null + ) + ) + ); + }); + + return template( + title, + section(header), + section(filters), + section(modeButtons), + section( + (filter === 'create' || filter === 'edit') + ? createForm + : filter === 'gallery' + ? renderGallery(sorted) + : div({ class: 'tribe-grid' }, + tribeCards.length > 0 ? tribeCards : p(i18n.noTribes) + ) + ), + ...renderLightbox(sorted) + ); +}; + +const renderFeedTribeView = async (feedItems, tribe, query = {}, filter) => { + const feed = Array.isArray(feedItems) ? feedItems : []; + const feedFilter = (query.feedFilter || 'RECENT').toUpperCase(); + const filteredFeed = filterAndSortFeed(feed, feedFilter); + return div({ class: 'tribe-feed-full' }, + div({ class: 'feed-actions' }, + ['TOP', 'MINE', 'ALL', 'RECENT'].map(f => + form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` }, + input({ type: 'hidden', name: 'section', value: 'feed' }), + input({ type: 'hidden', name: 'feedFilter', value: f }), + button({ type: 'submit', class: feedFilter === f ? 'filter-btn active' : 'filter-btn' }, i18n[`tribeFeedFilter${f}`]) + ) + ) + ), + filteredFeed.length === 0 + ? p(i18n.tribeFeedEmpty) + : div({ class: 'feed-list' }, + filteredFeed.map(m => div({ class: 'feed-item' }, + div({ class: 'feed-row' }, + div({ class: 'refeed-column' }, + h1(`${m.refeeds || 0}`), + !(m.refeeds_inhabitants || []).includes(userId) + ? form( + { method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` }, + button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed) + ) + : null + ), + div({ class: 'feed-main' }, + p(`${new Date(m.createdAt).toLocaleString()} — `, userLink(m.author)), + br, + p(...renderUrl(m.description)) + ) + ) + )) + ) + ); +}; + +const sectionLink = (tribe, sectionKey, label, currentSection) => + form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` }, + input({ type: 'hidden', name: 'section', value: sectionKey }), + button({ type: 'submit', class: currentSection === sectionKey ? 'filter-btn active' : 'filter-btn' }, label) + ); + +const renderSectionNav = (tribe, section) => { + const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }]; + if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes }); + if (!tribe.parentTribeId) firstGroup.push({ key: 'governance', label: i18n.tribeSectionGovernance || 'GOVERNANCE' }); + const sections = [ + { items: firstGroup }, + { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] }, + { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }, { key: 'torrents', label: i18n.tribeSectionTorrents || 'TORRENTS' }, { key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] }, + { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }] }, + { items: [{ key: 'tags', label: i18n.tribeSectionTags || 'TAGS' }, { key: 'search', label: i18n.tribeSectionSearch }] }, + ]; + return div({ class: 'tribe-section-nav no-border' }, + sections.map(g => + div({ class: 'tribe-section-group no-border' }, + g.items.map(s => s.href + ? form({ method: 'GET', action: s.href }, + button({ type: 'submit', class: 'filter-btn' }, s.label) + ) + : sectionLink(tribe, s.key, s.label, section)) + ) + ) + ); +}; + +const statusI18n = () => ({ + 'OPEN': i18n.tribeStatusOpen, + 'CLOSED': i18n.tribeStatusClosed, + 'IN-PROGRESS': i18n.tribeStatusInProgress, +}); + +const priorityI18n = () => ({ + 'LOW': i18n.tribePriorityLow, + 'MEDIUM': i18n.tribePriorityMedium, + 'HIGH': i18n.tribePriorityHigh, + 'CRITICAL': i18n.tribePriorityCritical, +}); + +const inviteModeI18n = () => ({ + 'strict': i18n.tribeStrict, + 'open': i18n.tribeOpen, +}); + +const forumCatI18n = () => ({ + 'GENERAL': i18n.tribeForumCatGeneral, + 'PROPOSAL': i18n.tribeForumCatProposal, + 'QUESTION': i18n.tribeForumCatQuestion, + 'ANNOUNCEMENT': i18n.tribeForumCatAnnouncement, +}); + +const mediaTypeI18n = () => ({ + 'all': i18n.tribeMediaFilterAll, + 'image': i18n.tribeMediaTypeImage, + 'video': i18n.tribeMediaTypeVideo, + 'audio': i18n.tribeMediaTypeAudio, + 'document': i18n.tribeMediaTypeDocument, + 'bookmark': i18n.tribeMediaTypeBookmark, +}); + +const taskFilterI18n = () => ({ + 'all': i18n.tribeTaskFilterAll, + 'open': i18n.tribeStatusOpen, + 'in-progress': i18n.tribeStatusInProgress, + 'closed': i18n.tribeStatusClosed, +}); + +const statusBadge = (status) => { + const cls = status === 'OPEN' ? 'tribe-content-status-open' + : status === 'CLOSED' ? 'tribe-content-status-closed' + : 'tribe-content-status-in-progress'; + return span({ class: `tribe-content-status ${cls}` }, statusI18n()[status] || status); +}; + +const priorityLabel = (priority) => { + const cls = `tribe-priority-${(priority || 'low').toLowerCase()}`; + return span({ class: cls }, priorityI18n()[priority] || (priority || '').toUpperCase()); +}; + +const contentTypeVerb = (ct) => { + const map = { event: i18n.tribeActivityCreated, task: i18n.tribeActivityCreated, votation: i18n.tribeActivityCreated, forum: i18n.tribeActivityPosted, 'forum-reply': i18n.tribeActivityReplied, media: i18n.tribeActivityCreated, feed: i18n.tribeActivityPosted }; + return map[ct] || i18n.tribeActivityCreated; +}; + +const contentTypeName = (ct) => { + const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS' }; + return map[ct] || ct; +}; + +const activitySectionMap = { + event: 'events', task: 'tasks', votation: 'votations', + forum: 'forum', 'forum-reply': 'forum', + feed: 'feed', + pad: 'pads', chat: 'chats', calendar: 'calendars', map: 'maps', torrent: 'torrents' +}; + +const activitySectionForItem = (item) => { + if (item.contentType === 'media' && item.mediaType) { + const map = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks', torrent: 'torrents' }; + return map[item.mediaType] || 'media'; + } + return activitySectionMap[item.contentType] || 'activity'; +}; + +const activityMediaTypeName = (mt) => { + const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks, torrent: i18n.tribeSectionTorrents }; + return map[mt] || i18n.tribeSectionMedia || 'MEDIA'; +}; + +const renderTribeActivitySection = (tribe, sectionData) => { + const { activities } = sectionData || { activities: [] }; + if (activities.length === 0) return div({ class: 'tribe-content-list' }, p(i18n.tribeActivityEmpty)); + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + return div({ class: 'tribe-content-list tribe-content-list-spaced' }, + activities.slice(0, 50).map(item => { + if (item.encrypted) return div({ class: 'card card-rpg' }, div({ class: 'tribe-card-body' }, p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content'))); + const date = item.timestamp ? new Date(item.timestamp).toLocaleString() : ''; + const typeLabel = item.contentType === 'media' && item.mediaType + ? activityMediaTypeName(item.mediaType) + : contentTypeName(item.contentType); + const headerText = item.tribeName + ? `[${String(typeLabel).toUpperCase()} · ${item.tribeName}]` + : `[${String(typeLabel).toUpperCase()}]`; + const targetSection = activitySectionForItem(item); + const blobUrl = item.contentType === 'media' ? toBlobUrl(item.image) : null; + const mediaContent = + item.contentType === 'media' && item.mediaType === 'image' && blobUrl + ? a({ href: blobUrl, target: '_blank' }, img({ src: blobUrl, alt: item.title || '', class: 'tribe-media-thumb' })) + : item.contentType === 'media' && item.mediaType === 'audio' && blobUrl + ? audio({ src: blobUrl, controls: true, class: 'tribe-media-audio' }) + : item.contentType === 'media' && item.mediaType === 'video' && blobUrl + ? video({ src: blobUrl, controls: true, class: 'tribe-media-thumb' }) + : item.contentType === 'media' && item.mediaType === 'document' && blobUrl + ? a({ href: blobUrl, target: '_blank', class: 'tribe-action-btn' }, i18n.readDocument || 'Read Document') + : item.contentType === 'media' && item.mediaType === 'bookmark' && (item.url || item.description) + ? a({ href: item.url || item.description, target: '_blank', class: 'tribe-action-btn' }, item.url || item.description) + : null; + return div({ class: 'card card-rpg tribe-card-padded' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, headerText), + item.directUrl + ? a({ href: item.directUrl, class: 'filter-btn' }, i18n.viewDetails || 'View Details') + : form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: targetSection }), + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details') + ) + ), + div({ class: 'tribe-card-body' }, + item.title ? div({ class: 'card-field' }, + span({ class: 'card-value' }, item.title) + ) : null, + mediaContent, + item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? p(item.description.substring(0, 200)) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${date} ${i18n.performed || ''} `), + userLink(item.author, item.authorName) + ) + ); + }) + ); +}; + +const engagementScore = (item) => (item.refeeds || 0) + (Array.isArray(item.attendees) ? item.attendees.length : 0) + Object.values(item.votes || {}).reduce((s, arr) => s + (Array.isArray(arr) ? arr.length : 0), 0) + (Array.isArray(item.assignees) ? item.assignees.length : 0) + (Array.isArray(item.opinions_inhabitants) ? item.opinions_inhabitants.length : 0); + +const renderTribeTrendingSection = (tribe, sectionData, query) => { + const { items, period } = sectionData || { items: [], period: 'all' }; + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + const periodBtn = (key, label) => form({ method: 'GET', action: tribeUrl }, input({ type: 'hidden', name: 'section', value: 'trending' }), input({ type: 'hidden', name: 'period', value: key }), button({ type: 'submit', class: period === key ? 'filter-btn active' : 'filter-btn' }, label)); + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTrending)), + div({ class: 'tribe-filter-bar' }, periodBtn('day', i18n.tribeTrendingPeriodDay), periodBtn('week', i18n.tribeTrendingPeriodWeek), periodBtn('all', i18n.tribeTrendingPeriodAll)), + items.length === 0 ? p(i18n.tribeTrendingEmpty) : + items.slice(0, 30).map((item, idx) => { + if (item.encrypted) return div({ class: 'tribe-content-card' }, div({ class: 'tribe-content-meta' }, span(`#${idx + 1}`)), p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content')); + return div({ class: 'tribe-content-card' }, + div({ class: 'tribe-content-meta' }, + span(`#${idx + 1}`), + span({ class: 'tribe-badge' }, contentTypeName(item.contentType)), + span(`${i18n.tribeTrendingEngagement}: ${engagementScore(item)}`), + (() => { const entries = Object.entries(item.opinions || {}); if (!entries.length) return null; const top = entries.reduce((a, b) => b[1] > a[1] ? b : a); return span(`| ${i18n.tribeTopCategory}: ${i18n['opinionCat' + top[0].charAt(0).toUpperCase() + top[0].slice(1)] || top[0]}`); })() + ), + item.title ? h2(item.title) : item.description ? h2(item.description) : null, + div({ class: 'tribe-content-meta' }, + item.refeeds ? span(`${i18n.tribeActivityRefeed}: ${item.refeeds}`) : null, + Array.isArray(item.attendees) && item.attendees.length ? span(`${i18n.tribeEventAttendees}: ${item.attendees.length}`) : null + ), + p({ class: 'tribe-meta-label' }, userLink(item.author)) + ); }) + ); +}; + +const renderTribeTagsSection = (tribe, sectionData, query) => { + const tagsList = Array.isArray(sectionData?.tags) ? sectionData.tags : []; + const selectedTag = sectionData?.selectedTag || ''; + const filteredItems = Array.isArray(sectionData?.filteredItems) ? sectionData.filteredItems : []; + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + const sortedTags = tagsList.slice().sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); + return section( + div({ class: 'tags-header' }, + h2(i18n.tribeSectionTags || i18n.tagsTitle || 'TAGS'), + p(i18n.tagsDescription || '') + ), + div({ class: 'tags-list' }, + tagsList.length === 0 + ? p(i18n.tagsNoItems || i18n.tribeTagsEmpty || 'No tags yet') + : table({ class: 'tag-table' }, + thead(tr( + th(i18n.tagsTableHeaderTag || 'Tag'), + th(i18n.tagsTableHeaderCount || 'Count') + )), + tbody( + sortedTags.map(t => tr( + td(a({ href: `${tribeUrl}?section=tags&tag=${encodeURIComponent(t.tag)}` }, t.tag)), + td(`${t.count}`) + )) + ) + ) + ), + selectedTag ? div({ class: 'tribe-content-list' }, + h2(`#${selectedTag} (${filteredItems.length})`), + filteredItems.length === 0 ? p(i18n.tribeTagsEmpty || 'No items') : + filteredItems.slice(0, 50).map(item => div({ class: 'card card-rpg' }, + div({ class: 'card-header' }, h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`)), + div({ class: 'card-body' }, + item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null, + item.description ? p(item.description.substring(0, 200)) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, new Date(item.createdAt).toLocaleString()), + userLink(item.author) + ) + )) + ) : null + ); +}; + +const renderTribeSearchSection = (tribe, sectionData, query) => { + const { results } = sectionData || { results: [] }; + const sq = sectionData?.query || ''; + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionSearch)), + form({ method: 'GET', action: tribeUrl, class: 'tribe-search-form' }, + input({ type: 'hidden', name: 'section', value: 'search' }), + input({ type: 'text', name: 'q', value: sq, placeholder: i18n.tribeSearchPlaceholder, minlength: 2 }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeSectionSearch) + ), + sq.length > 0 && sq.length < 2 ? p(i18n.tribeSearchMinChars) : null, + sq.length >= 2 ? div( + h2(`${i18n.tribeSearchResults}: ${results.length}`), + results.length === 0 ? p(i18n.tribeSearchEmpty) : + results.map(item => div({ class: 'card card-rpg' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`) + ), + div({ class: 'card-body' }, + item.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), + span({ class: 'card-value' }, item.title) + ) : null, + item.description ? p(item.description.substring(0, 200)) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, new Date(item.createdAt).toLocaleDateString()), + userLink(item.author) + ) + )) + ) : null + ); +}; + +const renderOverviewSection = (tribe, query, sectionData) => { + const feed = Array.isArray(sectionData?.feed) ? sectionData.feed : []; + const recentFeed = [...feed] + .sort((a, b) => (Date.parse(b.createdAt) || b._ts || 0) - (Date.parse(a.createdAt) || a._ts || 0)) + .slice(0, 5); + const events = Array.isArray(sectionData?.events) ? sectionData.events.slice(0, 3) : []; + const tasks = Array.isArray(sectionData?.tasks) ? sectionData.tasks.slice(0, 3) : []; + + return div({ class: 'tribe-overview-grid' }, + div({ class: 'tribe-overview-section' }, + h2(i18n.tribeSectionFeed), + recentFeed.length === 0 ? p(i18n.tribeFeedEmpty) : + recentFeed.map(m => + div({ class: 'feed-item' }, + p(`${new Date(m.createdAt).toLocaleString()} — `, userLink(m.author)), + p(...renderUrl(m.description)) + ) + ) + ), + div({ class: 'tribe-overview-section' }, + h2(i18n.tribeSectionEvents), + events.length === 0 ? p(i18n.tribeEventsEmpty) : + events.map(e => + div({ class: 'tribe-content-card' }, + h2(e.title), + div({ class: 'tribe-content-meta' }, + e.date ? span(e.date) : null, + e.location ? span(e.location) : null, + statusBadge(e.status) + ) + ) + ) + ), + div({ class: 'tribe-overview-section' }, + h2(i18n.tribeSectionTasks), + tasks.length === 0 ? p(i18n.tribeTasksEmpty) : + tasks.map(t => + div({ class: 'tribe-content-card' }, + h2(t.title), + div({ class: 'tribe-content-meta' }, + priorityLabel(t.priority), + statusBadge(t.status) + ) + ) + ) + ), + div({ class: 'tribe-overview-section' }, + h2(i18n.tribeSectionInhabitants), + p(`${i18n.tribeMembersCount}: ${tribe.members.length}`), + tribe.members.slice(0, 6).map(m => + userLink(m), + ) + ) + ); +}; + +const renderInhabitantsSection = (tribe, members) => { + const resolved = Array.isArray(members) ? members : []; + if (resolved.length === 0) return p(i18n.tribeInhabitantsEmpty); + return div({ class: 'tribe-thumb-grid' }, + resolved.map(m => + a({ href: `/author/${encodeURIComponent(m.id)}`, class: 'tribe-thumb-link', title: m.name || m.id }, + img({ src: resolvePhoto(m.photo), class: 'tribe-thumb-img', alt: m.name || m.id }) + ) + ) + ); +}; + +const createButtonI18n = { + events: () => i18n.tribeEventCreate || 'Create Event', + tasks: () => i18n.tribeTaskCreate || 'Create Task', + votations: () => i18n.tribeVotationCreate || 'Create Votation', + forum: () => i18n.tribeForumCreate || 'Create Forum', + subtribes: () => i18n.tribeSubTribesCreate || 'Create Sub-Tribe', +}; + +const renderCreateForm = (tribe, contentType, fields) => { + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + const hasFile = fields.some(f => f.type === 'file'); + const formAttrs = { method: 'POST', action: `${tribeUrl}/${contentType}/create` }; + if (hasFile) formAttrs.enctype = 'multipart/form-data'; + const btnLabel = createButtonI18n[contentType] ? createButtonI18n[contentType]() : i18n.tribeCreateButton; + return div({ class: 'create-tribe-form' }, + form(formAttrs, + ...fields.map(f => { + const prefix = f.spaceBefore ? [br()] : []; + if (f.type === 'textarea') return [...prefix, label({ for: f.name }, f.label), br, textarea({ name: f.name, id: f.name, rows: f.rows || 4, required: f.required, placeholder: f.placeholder }, ''), br()]; + if (f.type === 'select') return [...prefix, label({ for: f.name }, f.label), br, select({ name: f.name, id: f.name }, ...f.options.map(o => option({ value: o.value }, o.label))), br()]; + if (f.type === 'file') return [...prefix, label({ for: f.name }, f.label), br, input({ type: 'file', name: f.name, id: f.name, accept: f.accept || '*/*' }), br()]; + const attrs = { type: f.type || 'text', name: f.name, id: f.name, required: f.required, placeholder: f.placeholder }; + if (f.min) attrs.min = f.min; + return [...prefix, br, label({ for: f.name }, f.label), br, input(attrs), br()]; + }).flat(),br(), + button({ type: 'submit', class: 'create-button' }, btnLabel) + ) + ); +}; + +const renderEventsSection = (tribe, items, query) => { + const events = Array.isArray(items) ? items : []; + const action = query.action; + const today = new Date().toISOString().split('T')[0]; + if (action === 'create') { + return renderCreateForm(tribe, 'events', [ + { name: 'title', label: i18n.tribeEventTitle, required: true, placeholder: i18n.tribeEventTitle }, + { name: 'description', type: 'textarea', label: i18n.tribeEventDescription, required: true, placeholder: i18n.tribeEventDescription }, + { name: 'date', type: 'date', label: i18n.tribeEventDate, required: true, min: today }, + { name: 'location', label: i18n.tribeEventLocation, placeholder: i18n.tribeEventLocation }, + ]); + } + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, + h2(i18n.tribeSectionEvents), + form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: 'events' }), + input({ type: 'hidden', name: 'action', value: 'create' }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeEventCreate) + ) + ), + events.length === 0 ? p(i18n.tribeEventsEmpty) : + events.map(e => div({ class: 'tribe-content-card' }, + h2(e.title), + e.description ? p(...renderUrl(e.description)) : null, + e.date ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeEventDate || 'Date') + ':'), + span({ class: 'card-value' }, e.date) + ) : null, + e.location ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeEventLocation || 'Location') + ':'), + span({ class: 'card-value' }, ...renderUrl(e.location)) + ) : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeEventAttendees || 'Attendees') + ':'), + span({ class: 'card-value' }, String((e.attendees || []).length)) + ), + statusBadge(e.status), + div({ class: 'tribe-content-actions' }, + form({ method: 'POST', action: `${tribeUrl}/events/attend/${encodeURIComponent(e.id)}` }, + button({ type: 'submit', class: 'filter-btn' }, + (e.attendees || []).includes(userId) ? i18n.tribeEventUnattend : i18n.tribeEventAttend + ) + ), + e.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(e.id)}` }, + button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.tribeContentDelete) + ) : null + ) + )) + ); +}; + +const renderTasksSection = (tribe, items, query) => { + const tasks = Array.isArray(items) ? items : []; + const action = query.action; + const today = new Date().toISOString().split('T')[0]; + if (action === 'create') { + return renderCreateForm(tribe, 'tasks', [ + { name: 'title', label: i18n.tribeTaskTitle, required: true, placeholder: i18n.tribeTaskTitle }, + { name: 'description', type: 'textarea', label: i18n.tribeTaskDescription, required: true, placeholder: i18n.tribeTaskDescription }, + { name: 'priority', type: 'select', label: i18n.tribeTaskPriority, options: [ + { value: 'LOW', label: i18n.tribePriorityLow }, { value: 'MEDIUM', label: i18n.tribePriorityMedium }, + { value: 'HIGH', label: i18n.tribePriorityHigh }, { value: 'CRITICAL', label: i18n.tribePriorityCritical } + ]}, + { name: 'deadline', type: 'date', label: i18n.tribeTaskDeadline, min: today }, + ]); + } + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, + h2(i18n.tribeSectionTasks), + form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: 'tasks' }), + input({ type: 'hidden', name: 'action', value: 'create' }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeTaskCreate) + ) + ), + tasks.length === 0 ? p(i18n.tribeTasksEmpty) : + tasks.map(t => div({ class: 'tribe-content-card' }, + h2(t.title), + t.description ? p(...renderUrl(t.description)) : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeTaskPriority || 'Priority') + ':'), + priorityLabel(t.priority) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeStatusLabel || 'Status') + ':'), + statusBadge(t.status) + ), + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeTaskAssignees || 'Assignees') + ':'), + span({ class: 'card-value' }, String((t.assignees || []).length)) + ), + br(), + t.deadline ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeTaskDeadline || 'Deadline') + ':'), + span({ class: 'card-value' }, t.deadline) + ) : null, + div({ class: 'tribe-content-actions' }, + form({ method: 'POST', action: `${tribeUrl}/tasks/assign/${encodeURIComponent(t.id)}` }, + button({ type: 'submit', class: 'filter-btn' }, + (t.assignees || []).includes(userId) ? i18n.tribeTaskUnassign : i18n.tribeTaskAssign + ) + ), + t.status !== 'IN-PROGRESS' && t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/tasks/status/${encodeURIComponent(t.id)}` }, + input({ type: 'hidden', name: 'status', value: 'IN-PROGRESS' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.tribeTaskStatusInProgress) + ) : null, + t.status !== 'CLOSED' && t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/tasks/status/${encodeURIComponent(t.id)}` }, + input({ type: 'hidden', name: 'status', value: 'CLOSED' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.tribeTaskStatusClosed) + ) : null, + t.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(t.id)}` }, + button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.tribeContentDelete) + ) : null + ) + )) + ); +}; + +const renderVotationsSection = (tribe, items, query) => { + const votations = Array.isArray(items) ? items : []; + const action = query.action; + const today = new Date().toISOString().split('T')[0]; + if (action === 'create') { + return div({ class: 'create-tribe-form' }, + form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/votations/create` }, + label({ for: 'title' }, i18n.tribeVotationTitle), br, + input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeVotationTitle }), br(), + label({ for: 'description' }, i18n.tribeVotationDescription), br, + textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeVotationDescription }, ''), br(), + label({ for: 'deadline' }, i18n.tribeVotationDeadline), br, + input({ type: 'date', name: 'deadline', id: 'deadline', min: today }), br(), + br(), + label(i18n.tribeVotationOptions), br, + input({ type: 'text', name: 'option1', placeholder: `${i18n.tribeVotationOptionPlaceholder} 1`, required: true }), br(), + input({ type: 'text', name: 'option2', placeholder: `${i18n.tribeVotationOptionPlaceholder} 2`, required: true }), br(), + input({ type: 'text', name: 'option3', placeholder: `${i18n.tribeVotationOptionPlaceholder} 3` }), br(), + input({ type: 'text', name: 'option4', placeholder: `${i18n.tribeVotationOptionPlaceholder} 4` }), br(), + button({ type: 'submit', class: 'create-button' }, i18n.tribeVotationCreate) + ) + ); + } + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, + h2(i18n.tribeSectionVotations), + form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: 'votations' }), + input({ type: 'hidden', name: 'action', value: 'create' }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeVotationCreate) + ) + ), + votations.length === 0 ? p(i18n.tribeVotationsEmpty) : + votations.map(v => { + const opts = Array.isArray(v.options) ? v.options : []; + const votes = v.votes || {}; + const totalVotes = Object.values(votes).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0); + const hasVoted = Object.values(votes).some(arr => Array.isArray(arr) && arr.includes(userId)); + const isOpen = v.status === 'OPEN'; + + return div({ class: 'tribe-content-card' }, + h2(v.title), + v.description ? p(...renderUrl(v.description)) : null, + statusBadge(v.status), + v.deadline ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeVotationDeadline || 'Deadline') + ':'), + span({ class: 'card-value' }, v.deadline) + ) : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.tribeVotationResults || 'Votes') + ':'), + span({ class: 'card-value' }, String(totalVotes)) + ), + br(), + div({ class: 'tribe-votation-options' }, + opts.map((opt, idx) => { + const count = Array.isArray(votes[String(idx)]) ? votes[String(idx)].length : 0; + const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0; + const roundedPct = Math.round(pct / 5) * 5; + return div({ class: 'tribe-votation-option' }, + span({ class: 'tribe-votation-label' }, opt), + div({ class: 'tribe-votation-bar' }, + div({ class: `tribe-votation-fill tribe-fill-${roundedPct}` }) + ), + span({ class: 'tribe-votation-count' }, `${count} (${pct}%)`), + isOpen && !hasVoted ? form({ method: 'POST', action: `${tribeUrl}/votations/${encodeURIComponent(v.id)}/vote` }, + input({ type: 'hidden', name: 'optionIndex', value: String(idx) }), + button({ type: 'submit', class: 'filter-btn' }, i18n.tribeVotationVote) + ) : null + ); + }) + ), + v.author === userId ? div({ class: 'tribe-content-actions' }, + isOpen ? form({ method: 'POST', action: `${tribeUrl}/votations/close/${encodeURIComponent(v.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeVotationClose) + ) : null, + form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(v.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeContentDelete) + ) + ) : null + ); + }) + ); +}; + +const renderForumSection = (tribe, items, query) => { + const allItems = Array.isArray(items) ? items : []; + const threads = allItems.filter(i => i.contentType === 'forum'); + const action = query.action; + const threadId = query.thread; + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + + if (action === 'create') { + return renderCreateForm(tribe, 'forum', [ + { name: 'title', label: i18n.tribeForumTitle, required: true, placeholder: i18n.tribeForumTitle }, + { name: 'description', type: 'textarea', label: i18n.tribeForumText, required: true, placeholder: i18n.tribeForumText, rows: 6 }, + { name: 'category', type: 'select', label: i18n.tribeForumCategory, options: [ + { value: 'GENERAL', label: i18n.tribeForumCatGeneral }, { value: 'PROPOSAL', label: i18n.tribeForumCatProposal }, + { value: 'QUESTION', label: i18n.tribeForumCatQuestion }, { value: 'ANNOUNCEMENT', label: i18n.tribeForumCatAnnouncement } + ]} + ]); + } + + if (threadId) { + const thread = allItems.find(i => i.id === threadId); + const replies = allItems.filter(i => i.contentType === 'forum-reply' && i.parentId === threadId) + .sort((a, b) => (a.refeeds || 0) - (b.refeeds || 0) !== 0 ? (b.refeeds || 0) - (a.refeeds || 0) : (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0)); + if (!thread) return p(i18n.tribeForumEmpty); + const replyCount = replies.length; + return div({ class: 'tribe-content-list' }, + form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: 'forum' }), + button({ type: 'submit', class: 'filter-btn' }, i18n.walletBack) + ), + div({ class: 'forum-card forum-thread-header' }, + div({ class: 'forum-score-col' }, + div({ class: 'forum-score-box' }, + form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(thread.id)}/refeed` }, + button({ type: 'submit', class: 'score-btn' }, '▲') + ), + div({ class: 'score-total' }, String(thread.refeeds || 0)), + ) + ), + div({ class: 'forum-main-col' }, + div({ class: 'forum-header-row' }, + thread.category ? span({ class: 'forum-category' }, `[${forumCatI18n()[thread.category] || thread.category}]`) : null, + span({ class: 'forum-title' }, thread.title) + ), + div({ class: 'forum-footer' }, + span({ class: 'date-link' }, `${new Date(thread.createdAt).toLocaleString()} ${i18n.performed || ''}`), + userLink(thread.author) + ), + div({ class: 'forum-body' }, ...renderUrl(thread.description || '')), + div({ class: 'forum-meta' }, + span({ class: 'forum-positive-votes' }, `▲: ${thread.refeeds || 0}`), + span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`) + ) + ) + ), + div({ class: 'tribe-forum-reply-form' }, + form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(threadId)}/reply` }, + textarea({ name: 'description', rows: 3, required: true, placeholder: i18n.tribeForumReply }), + br(), + button({ type: 'submit', class: 'forum-send-btn' }, i18n.tribeForumReply) + ) + ), + replies.length > 0 + ? replies.map((r, idx) => + div({ class: `forum-comment${idx === 0 ? ' highlighted-reply' : ''}` }, + div({ class: 'comment-header' }, + span({ class: 'date-link' }, `${new Date(r.createdAt).toLocaleString()} ${i18n.performed || ''}`), + userLink(r.author), + div({ class: 'comment-votes' }, + span({ class: 'forum-positive-votes' }, `▲: ${r.refeeds || 0}`) + ) + ), + div({ class: 'comment-body-row' }, + div({ class: 'comment-vote-col' }, + div({ class: 'forum-score-box' }, + form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(r.id)}/refeed` }, + button({ type: 'submit', class: 'score-btn' }, '▲') + ), + div({ class: 'score-total' }, String(r.refeeds || 0)) + ) + ), + div({ class: 'comment-text-col' }, + ...(r.description || '').split('\n').map(l => l.trim()).filter(l => l).map(l => p(...renderUrl(l))) + ) + ), + r.author === userId ? div({ class: 'tribe-content-actions' }, + form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(r.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeContentDelete) + ) + ) : null + ) + ) + : null + ); + } + + const replyMap = new Map(); + allItems.filter(i => i.contentType === 'forum-reply').forEach(r => { replyMap.set(r.parentId, (replyMap.get(r.parentId) || 0) + 1); }); + const sortedThreads = [...threads].sort((a, b) => ((b.refeeds || 0) + (replyMap.get(b.id) || 0)) - ((a.refeeds || 0) + (replyMap.get(a.id) || 0))); + + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, + h2(i18n.tribeSectionForum), + form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: 'forum' }), + input({ type: 'hidden', name: 'action', value: 'create' }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeForumCreate) + ) + ), + sortedThreads.length === 0 ? p(i18n.tribeForumEmpty) : + div({ class: 'forum-list' }, + sortedThreads.map(t => { + const replyCount = allItems.filter(i => i.contentType === 'forum-reply' && i.parentId === t.id).length; + return div({ class: 'forum-card' }, + div({ class: 'forum-score-col' }, + div({ class: 'forum-score-box' }, + form({ method: 'POST', action: `${tribeUrl}/forum/${encodeURIComponent(t.id)}/refeed` }, + button({ type: 'submit', class: 'score-btn' }, '▲') + ), + div({ class: 'score-total' }, String(t.refeeds || 0)) + ) + ), + div({ class: 'forum-main-col' }, + div({ class: 'forum-header-row' }, + t.category ? span({ class: 'forum-category' }, `[${forumCatI18n()[t.category] || t.category}]`) : null, + form({ method: 'GET', action: tribeUrl, class: 'forum-title-form' }, + input({ type: 'hidden', name: 'section', value: 'forum' }), + input({ type: 'hidden', name: 'thread', value: t.id }), + button({ type: 'submit', class: 'forum-title' }, t.title) + ) + ), + t.description ? div({ class: 'forum-body' }, ...renderUrl((t.description || '').substring(0, 200))) : null, + div({ class: 'forum-meta' }, + span({ class: 'forum-positive-votes' }, `▲: ${t.refeeds || 0}`), + span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`), + form({ method: 'GET', action: tribeUrl, class: 'visit-forum-form' }, + input({ type: 'hidden', name: 'section', value: 'forum' }), + input({ type: 'hidden', name: 'thread', value: t.id }), + button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton || 'VISIT') + ) + ), + div({ class: 'forum-footer' }, + span({ class: 'date-link' }, `${new Date(t.createdAt).toLocaleString()} ${i18n.performed || ''}`), + userLink(t.author) + ), + t.author === userId ? div({ class: 'forum-owner-actions' }, + form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(t.id)}`, class: 'forum-delete-form' }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeContentDelete) + ) + ) : null + ) + ); + }) + ) + ); +}; + +const sectionKeyForMediaType = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks', torrent: 'torrents' }; +const acceptForMediaType = { image: 'image/*', audio: 'audio/*', video: 'video/*', document: 'application/pdf,.pdf,.doc,.docx,.txt,.odt', bookmark: null, torrent: '.torrent' }; +const sectionTitleForMediaType = (mt) => { + const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks, torrent: i18n.tribeSectionTorrents }; + return map[mt] || mt; +}; + +const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => { + const allMedia = Array.isArray(items) ? items : []; + const media = allMedia.filter(m => m.mediaType === mediaType); + const action = query.action; + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + const sectionKey = sectionKeyForMediaType[mediaType] || 'media'; + const sTitle = sectionTitleForMediaType(mediaType); + const createMediaLabel = { + image: () => i18n.tribeCreateImage || 'Create Image', + audio: () => i18n.tribeCreateAudio || 'Create Audio', + video: () => i18n.tribeCreateVideo || 'Create Video', + document: () => i18n.tribeCreateDocument || 'Create Document', + bookmark: () => i18n.tribeCreateBookmark || 'Create Bookmark', + torrent: () => i18n.tribeCreateTorrent || 'Upload Torrent', + }; + const mediaBtnLabel = createMediaLabel[mediaType] ? createMediaLabel[mediaType]() : i18n.tribeCreateButton; + + if (action === 'create') { + if (mediaType === 'bookmark') { + return div({ class: 'create-tribe-form' }, + form({ method: 'POST', action: `${tribeUrl}/media/upload` }, + input({ type: 'hidden', name: 'mediaType', value: 'bookmark' }), + input({ type: 'hidden', name: 'returnSection', value: sectionKey }), + label({ for: 'title' }, i18n.tribeMediaTitle), br, + input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(), + label({ for: 'url' }, i18n.bookmarkUrlLabel || 'URL'), br, + input({ type: 'url', name: 'url', id: 'url', required: true, placeholder: 'https://' }), br(),br(), + label({ for: 'description' }, i18n.tribeMediaDescription), br, + textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(), + button({ type: 'submit', class: 'create-button' }, mediaBtnLabel) + ) + ); + } + return div({ class: 'create-tribe-form' }, + form({ method: 'POST', action: `${tribeUrl}/media/upload`, enctype: 'multipart/form-data' }, + input({ type: 'hidden', name: 'mediaType', value: mediaType }), + input({ type: 'hidden', name: 'returnSection', value: sectionKey }), + label({ for: 'title' }, i18n.tribeMediaTitle), br, + input({ type: 'text', name: 'title', id: 'title', required: true, placeholder: i18n.tribeMediaTitle }), br(), + label({ for: 'description' }, i18n.tribeMediaDescription), br, + textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(), + label({ for: 'media' }, i18n.tribeMediaUpload), br, + input({ type: 'file', name: 'media', id: 'media', accept: acceptForMediaType[mediaType] || '*/*', required: true }), br(), br(), + button({ type: 'submit', class: 'create-button' }, mediaBtnLabel) + ) + ); + } + + const mediaFooter = (m) => [ + p({ class: 'tribe-media-date' }, span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString())), + p({ class: 'tribe-media-author' }, userLink(m.author)), + m.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(m.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeContentDelete) + ) : null + ]; + + const renderMediaItem = (m) => { + const blobUrl = toBlobUrl(m.image); + if (mediaType === 'image') { + return div({ class: 'tribe-media-item' }, + blobUrl ? a({ href: blobUrl, target: '_blank' }, img({ src: blobUrl, alt: m.title || '', class: 'tribe-media-thumb' })) : null, + div({ class: 'tribe-media-item-info' }, + m.title ? h2(m.title) : null, + m.description ? p(...renderUrl(m.description)) : null, + ...mediaFooter(m) + ) + ); + } + if (mediaType === 'audio') { + return div({ class: 'tribe-media-item' }, + blobUrl ? audio({ src: blobUrl, controls: true, class: 'tribe-media-audio' }) : p(i18n.tribeMediaEmpty), + div({ class: 'tribe-media-item-info' }, + m.title ? h2(m.title) : null, + m.description ? p(...renderUrl(m.description)) : null, + ...mediaFooter(m) + ) + ); + } + if (mediaType === 'video') { + return div({ class: 'tribe-media-item' }, + blobUrl ? video({ src: blobUrl, controls: true, class: 'tribe-media-thumb' }) : p(i18n.tribeMediaEmpty), + div({ class: 'tribe-media-item-info' }, + m.title ? h2(m.title) : null, + m.description ? p(...renderUrl(m.description)) : null, + ...mediaFooter(m) + ) + ); + } + if (mediaType === 'document') { + return div({ class: 'tribe-media-item' }, + blobUrl ? a({ href: blobUrl, target: '_blank', class: 'tribe-action-btn' }, i18n.readDocument || 'Read Document') : p(i18n.tribeMediaEmpty), + div({ class: 'tribe-media-item-info' }, + m.title ? h2(m.title) : null, + m.description ? p(...renderUrl(m.description)) : null, + ...mediaFooter(m) + ) + ); + } + if (mediaType === 'bookmark') { + const url = m.url || m.description || ''; + return div({ class: 'tribe-media-item' }, + div({ class: 'tribe-media-item-info' }, + m.title ? h2(m.title) : null, + url ? div({ class: 'card-field' }, + span({ class: 'card-label' }, 'URL:'), + a({ href: url, target: '_blank', class: 'card-value' }, url) + ) : null, + m.description && m.description !== url ? p(...renderUrl(m.description)) : null, + ...mediaFooter(m) + ) + ); + } + if (mediaType === 'torrent') { + return div({ class: 'tribe-media-item' }, + blobUrl ? a({ href: blobUrl, class: 'tribe-action-btn' }, i18n.torrentDownloadButton || 'DOWNLOAD IT!') : p(i18n.tribeMediaEmpty), + div({ class: 'tribe-media-item-info' }, + m.title ? h2(m.title) : null, + m.description ? p(...renderUrl(m.description)) : null, + ...mediaFooter(m) + ) + ); + } + return null; + }; + + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, + h2(sTitle), + form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: sectionKey }), + input({ type: 'hidden', name: 'action', value: 'create' }), + button({ type: 'submit', class: 'create-button' }, mediaBtnLabel) + ) + ), + media.length === 0 ? p(i18n.tribeMediaEmpty) : + div({ class: 'tribe-media-grid' }, media.map(renderMediaItem)) + ); +}; + +const renderSubTribesSection = (tribe, items, query) => { + const action = query.action; + const isMemberSub = Array.isArray(tribe.members) && tribe.members.includes(userId); + const isAuthorSub = tribe.author === userId; + const canCreate = tribe.inviteMode === 'open' ? (isMemberSub || isAuthorSub) : isAuthorSub; + + if (action === 'create' && canCreate) { + return renderCreateForm(tribe, 'subtribes', [ + { name: 'title', label: i18n.tribeTitleLabel, required: true, placeholder: 'Name of the sub-tribe' }, + { name: 'description', type: 'textarea', label: i18n.tribeDescriptionLabel, required: true, placeholder: 'Description of the sub-tribe' }, + { name: 'location', label: i18n.tribeLocationLabel, placeholder: 'Where is this sub-tribe located?' }, + { name: 'mapUrl', label: i18n.mapLocationTitle || 'Map Location', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', spaceBefore: true }, + { name: 'image', type: 'file', label: i18n.tribeImageLabel }, + { name: 'tags', label: i18n.tribeTagsLabel, placeholder: i18n.tribeTagsPlaceholder, spaceBefore: true }, + { name: 'inviteMode', type: 'select', label: i18n.tribeModeLabel, options: [ + { value: 'open', label: i18n.tribeOpen }, { value: 'strict', label: i18n.tribeStrict } + ], spaceBefore: true }, + ]); + } + + const subTribes = Array.isArray(items) ? items : []; + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; + + return div({ class: 'tribe-content-list' }, + canCreate ? form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: 'subtribes' }), + input({ type: 'hidden', name: 'action', value: 'create' }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate) + ) : null, + !canCreate && tribe.inviteMode === 'strict' + ? p({ class: 'tribe-strict-denied' }, i18n.tribeSubTribesStrictDenied) + : null, + subTribes.length === 0 + ? null + : div({ class: 'tribe-thumb-grid' }, + subTribes.map(st => { + return a({ href: `/tribe/${encodeURIComponent(st.id)}`, class: 'tribe-thumb-link', title: st.title }, + img({ src: toImageUrl(st.image, '/assets/images/default-tribe.png'), class: 'tribe-thumb-img', alt: st.title }) + ); + }) + ) + ); +}; + +const renderTribeMapsSection = (tribe, maps) => { + const items = Array.isArray(maps) ? maps : []; + const createBtn = form({ method: 'GET', action: '/maps' }, + input({ type: 'hidden', name: 'filter', value: 'create' }), + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'create-button' }, i18n.mapUploadButton || 'Create Map')); + if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn), p(i18n.noMaps || 'No maps yet')); + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn), + items.map(m => + div({ class: 'card card-rpg tribe-card-padded' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${(i18n.typeMap || 'MAP').toUpperCase()}]`), + form({ method: 'GET', action: `/maps/${encodeURIComponent(m.key)}` }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')) + ), + div({ class: 'tribe-card-body' }, + m.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), + span({ class: 'card-value' }, a({ href: `/maps/${encodeURIComponent(m.key)}` }, m.title)) + ) : null, + m.description ? p(m.description.substring(0, 200)) : null, + m.lat && m.lng ? span({ class: 'map-coords' }, `📍 ${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}`) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()), + userLink(m.author) + ) + ) + ) + ); +}; + +const renderTribeTorrentsSection = (tribe, torrents) => { + const items = Array.isArray(torrents) ? torrents : []; + const createBtn = form({ method: 'GET', action: '/torrents' }, + input({ type: 'hidden', name: 'filter', value: 'create' }), + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeCreateTorrent || 'Upload Torrent')); + if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTorrents || 'TORRENTS'), createBtn), p(i18n.tribeTorrentsEmpty || 'No torrents, yet.')); + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTorrents || 'TORRENTS'), createBtn), + items.map(m => { + const blobName = encodeURIComponent((m.title || 'download').replace(/\.torrent$/i, '') + '.torrent'); + const blobUrl = m.url ? `/blob/${encodeURIComponent(m.url)}?name=${blobName}` : null; + return div({ class: 'card card-rpg tribe-card-padded' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${(i18n.typeTorrent || 'TORRENT').toUpperCase()}]`), + m._isMedia + ? (blobUrl ? a({ href: blobUrl, class: 'filter-btn' }, i18n.torrentDownload || 'Download') : null) + : form({ method: 'GET', action: `/torrents/${encodeURIComponent(m.rootId || m.key)}` }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')) + ), + div({ class: 'tribe-card-body' }, + m.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), + span({ class: 'card-value' }, m._isMedia + ? (blobUrl ? a({ href: blobUrl }, m.title) : m.title) + : a({ href: `/torrents/${encodeURIComponent(m.rootId || m.key)}` }, m.title)) + ) : null, + m.description ? p(String(m.description).substring(0, 200)) : null, + blobUrl && !m._isMedia ? div({ class: 'card-field' }, a({ href: blobUrl, class: 'filter-btn' }, i18n.torrentDownload || 'Download')) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()), + userLink(m.author) + ) + ); + }) + ); +}; + +const renderTribePadsSection = (tribe, pads) => { + const items = Array.isArray(pads) ? pads : []; + const createBtn = form({ method: 'GET', action: '/pads' }, + input({ type: 'hidden', name: 'filter', value: 'create' }), + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'create-button' }, i18n.tribePadCreate || 'Create Pad')); + if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn), p(i18n.tribePadsEmpty || 'No pads, yet.')); + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn), + items.map(m => + div({ class: 'card card-rpg tribe-card-padded' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${(i18n.typePad || 'PAD').toUpperCase()}]`), + form({ method: 'GET', action: `/pads/${encodeURIComponent(m.rootId)}` }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')) + ), + div({ class: 'tribe-card-body' }, + m.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), + span({ class: 'card-value' }, a({ href: `/pads/${encodeURIComponent(m.rootId)}` }, m.title)) + ) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()), + userLink(m.author) + ) + ) + ) + ); +}; + +const renderTribeChatsSection = (tribe, chats) => { + const items = Array.isArray(chats) ? chats : []; + const createBtn = form({ method: 'GET', action: '/chats' }, + input({ type: 'hidden', name: 'filter', value: 'create' }), + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeChatCreate || 'Create Chat')); + if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn), p(i18n.tribeChatsEmpty || 'No chats, yet.')); + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn), + items.map(m => + div({ class: 'card card-rpg tribe-card-padded' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${(i18n.typeChat || 'CHAT').toUpperCase()}]`), + form({ method: 'GET', action: `/chats/${encodeURIComponent(m.key)}` }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')) + ), + div({ class: 'tribe-card-body' }, + m.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), + span({ class: 'card-value' }, a({ href: `/chats/${encodeURIComponent(m.key)}` }, m.title)) + ) : null, + m.description ? p(m.description.substring(0, 200)) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()), + userLink(m.author) + ) + ) + ) + ); +}; + +const renderTribeCalendarsSection = (tribe, calendars) => { + const items = Array.isArray(calendars) ? calendars : []; + const createBtn = form({ method: 'GET', action: '/calendars' }, + input({ type: 'hidden', name: 'filter', value: 'create' }), + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'create-button' }, i18n.tribeCalendarCreate || 'Create Calendar')); + if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn), p(i18n.tribeCalendarsEmpty || 'No calendars, yet.')); + return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn), + items.map(m => + div({ class: 'card card-rpg tribe-card-padded' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${(i18n.typeCalendar || 'CALENDAR').toUpperCase()}]`), + form({ method: 'GET', action: `/calendars/${encodeURIComponent(m.rootId)}` }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details')) + ), + div({ class: 'tribe-card-body' }, + m.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), + span({ class: 'card-value' }, a({ href: `/calendars/${encodeURIComponent(m.rootId)}` }, m.title)) + ) : null, + m.deadline ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.calendarDeadlineLabel || 'Deadline') + ':'), + span({ class: 'card-value' }, new Date(m.deadline).toLocaleDateString()) + ) : null + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()), + userLink(m.author) + ) + ) + ) + ); +}; + +exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => { + if (!tribe) { + return div({ class: 'error' }, i18n.tribeNotFound); + } + + section = section || 'activity'; + sectionData = sectionData || {}; + + const imageSrc = tribe.image; + const pageTitle = tribe.title; + + let sectionContent; + switch (section) { + case 'inhabitants': sectionContent = renderInhabitantsSection(tribe, sectionData); break; + case 'feed': + sectionContent = div( + query.sent ? div({ class: 'card card-rpg tribe-banner' }, + p({ class: 'bold' }, i18n.tribeFeedSent || 'Message sent successfully!') + ) : null, + tribe.members.includes(config.keys.id) + ? form({ class: 'tribe-feed-compose', method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` }, + textarea({ name: 'message', rows: 4, maxlength: MAX_MESSAGE_LENGTH, placeholder: i18n.tribeFeedMessagePlaceholder }), + button({ type: 'submit', class: 'tribe-feed-send' }, i18n.tribeFeedSend) + ) + : null, + await renderFeedTribeView(sectionData, tribe, query, query.filter) + ); + break; + case 'events': sectionContent = renderEventsSection(tribe, sectionData, query); break; + case 'tasks': sectionContent = renderTasksSection(tribe, sectionData, query); break; + case 'votations': sectionContent = renderVotationsSection(tribe, sectionData, query); break; + case 'forum': sectionContent = renderForumSection(tribe, sectionData, query); break; + case 'subtribes': sectionContent = renderSubTribesSection(tribe, sectionData, query); break; + case 'search': sectionContent = renderTribeSearchSection(tribe, sectionData, query); break; + case 'images': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'image'); break; + case 'audios': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'audio'); break; + case 'videos': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'video'); break; + case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break; + case 'bookmarks': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'bookmark'); break; + case 'torrents': sectionContent = renderTribeTorrentsSection(tribe, sectionData); break; + case 'maps': sectionContent = renderTribeMapsSection(tribe, sectionData); break; + case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break; + case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break; + case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break; + case 'governance': sectionContent = renderGovernance(tribe, sectionData); break; + case 'trending': sectionContent = renderTribeTrendingSection(tribe, sectionData, query); break; + case 'tags': sectionContent = renderTribeTagsSection(tribe, sectionData, query); break; + case 'activity': + default: sectionContent = renderTribeActivitySection(tribe, sectionData); break; + } + + const subTribes = Array.isArray(tribe.subTribes) ? tribe.subTribes : []; + const isMemberOfTribe = Array.isArray(tribe.members) && tribe.members.includes(userId); + const isAuthorOfTribe = tribe.author === userId; + const isOutsider = !isMemberOfTribe && !isAuthorOfTribe; + const canCreateSub = !tribe.parentTribeId && (tribe.inviteMode === 'open' ? (isMemberOfTribe || isAuthorOfTribe) : isAuthorOfTribe); + const isLarpHouse = isLarpHouseTribe(tribe); + const larpHouseKey = tribe.larpHouseKey || null; + const canGenerateInvite = isLarpHouse + ? (isMemberOfTribe && !!larpHouseKey && larpHouseKey !== 'academia') + : (isAuthorOfTribe || (tribe.inviteMode === 'open' && isMemberOfTribe)); + const canLeave = isMemberOfTribe && (isLarpHouse || !isAuthorOfTribe); + const showOwnerControls = !isLarpHouse && isAuthorOfTribe; + const inviteHref = isLarpHouse ? '/invites#invites-houses' : '/invites#invites-tribes'; + const showInviteTop = isOutsider && !tribe.parentTribeId; + const hasOpenInvite = !!tribe.openInviteCode; + const canManageOpenInvite = hasOpenInvite && (tribe.openInviteBy === userId || isAuthorOfTribe); + const hasSideActions = canGenerateInvite || showOwnerControls || canLeave || (isOutsider && !showInviteTop); + + const tribeDetails = div({ class: 'tribe-details' }, + div({ class: 'tribe-side' }, + tribe.parentTribe + ? div({ class: 'tribe-parent-box' }, + h2({ class: 'tribe-info-label' }, i18n.tribeMainTribeLabel || 'MAIN TRIBE'), + a({ href: `/tribe/${encodeURIComponent(tribe.parentTribe.id)}`, class: 'tribe-parent-link' }, + img({ src: toBlobUrl(tribe.parentTribe.image) || '/assets/images/default-tribe.png', alt: tribe.parentTribe.title, class: 'tribe-parent-image' }) + ) + ) + : null, + div({ class: 'shop-title-row' }, + h2({ class: 'tribe-card-title' }, tribe.title) + ), + div({ class: 'card-chips-row' }, + tribe.isAnonymous ? renderPrivacyChip(true, i18n) : renderPrivacyChip(false, i18n), + tribe.isAnonymous ? renderTribeEncryptedChip(i18n) : null, + renderModeChip(tribe.inviteMode, i18n), + renderLifespanChip(tribe.lifetime, i18n) + ), + renderMediaBlob(imageSrc, '/assets/images/default-tribe.png', { alt: tribe.title, class: 'tribe-detail-image' }), + table({ class: 'tribe-info-table' }, + tr( + td({ class: 'tribe-info-label' }, i18n.tribeCreatedAt || 'CREATED'), + td({ class: 'tribe-info-value', colspan: '3' }, new Date(tribe.createdAt).toLocaleString()) + ), + tr( + td({ class: 'tribe-info-value tribe-author-cell', colspan: '4' }, userLink(tribe.author)) + ), + tribe.location ? tr( + td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'), + td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(tribe.location)) + ) : null + ), + h2({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${tribe.memberCount != null ? tribe.memberCount : tribe.members.length}`), + (canGenerateInvite || (isOutsider && !showInviteTop)) ? div({ class: 'tribe-side-actions' }, + canGenerateInvite + ? (isLarpHouse + ? form({ method: 'POST', action: '/larp/invite/create' }, + input({ type: 'hidden', name: 'houseKey', value: larpHouseKey }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite) + ) + : form({ method: 'POST', action: '/tribes/generate-invite' }, + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite) + )) + : null, + (canGenerateInvite && !hasOpenInvite) + ? form({ method: 'POST', action: '/tribes/open-invite/create' }, + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeOpenInvitation) + ) + : null, + (isOutsider && !showInviteTop) + ? a({ class: 'tribe-action-btn', href: inviteHref }, i18n.tribeEnterInvite) + : null + ) : null, + (hasOpenInvite && canManageOpenInvite) ? div({ class: 'tribe-open-invite-panel' }, + div({ class: 'tribe-open-invite' }, + span({ class: 'tribe-members-count' }, i18n.tribeInviteCodeText), + span({ class: 'tribe-open-invite-code' }, tribe.openInviteCode) + ), + renderInviteQrCard({ qrDataUrl: tribe.openInviteQr, name: tribe.title }), + form({ method: 'POST', action: '/tribes/open-invite/remove' }, + input({ type: 'hidden', name: 'tribeId', value: tribe.id }), + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeRemoveInvitation) + ) + ) : null, + tribe.description ? p({ class: 'tribe-side-description' }, ...renderUrl(tribe.description)) : null, + renderMapLocationVisitLabel(tribe.mapUrl), + (!tribe.parentTribeId && (canCreateSub || subTribes.length > 0 || showInviteTop)) ? div({ class: 'tribe-side-subtribes' }, + canCreateSub + ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` }, + input({ type: 'hidden', name: 'section', value: 'subtribes' }), + input({ type: 'hidden', name: 'action', value: 'create' }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate) + ) + : null, + showInviteTop + ? a({ class: 'tribe-action-btn', href: inviteHref }, i18n.tribeEnterInvite) + : null, + subTribes.length > 0 + ? div({ class: 'tribe-subtribes-list' }, + subTribes.map(st => + form({ method: 'GET', action: `/tribe/${encodeURIComponent(st.id)}` }, + button({ type: 'submit', class: 'tribe-subtribe-link' }, st.isAnonymous ? "\uD83D\uDD12 " : "", st.title) + ) + ) + ) + : null + ) : null, + tribe.tags && tribe.tags.filter(Boolean).length ? div({ class: 'tribe-side-tags' }, tribe.tags.filter(Boolean).map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) + )) : null, + (showOwnerControls || canLeave) ? div({ class: 'tribe-side-actions tribe-side-actions-bottom' }, + showOwnerControls + ? form({ method: 'GET', action: `/tribes/edit/${encodeURIComponent(tribe.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeUpdateButton) + ) + : null, + showOwnerControls + ? form({ method: 'POST', action: `/tribes/delete/${encodeURIComponent(tribe.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeDeleteButton) + ) + : null, + canLeave + ? (isLarpHouse + ? form({ method: 'POST', action: '/larp/leave' }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeLeaveButton) + ) + : form({ method: 'POST', action: `/tribes/leave/${encodeURIComponent(tribe.id)}` }, + button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeLeaveButton) + )) + : null + ) : null, + ), + div({ class: 'tribe-main' }, + query.inviteCode ? div({ class: 'card card-rpg tribe-banner' }, + p({ class: 'bold' }, i18n.tribeInviteCodeText, query.inviteCode) + ) : null, + renderSectionNav(tribe, section), + sectionContent + ) + ); + + return template( + pageTitle, + tribeDetails + ); +}; + +const GOVERNANCE_METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY']; + +const governanceFilterBar = (tribeId, currentFilter, showPublish) => { + const row1 = [ + { key: 'government', label: i18n.tribeGovFilterGovernment || 'GOVERNMENT' }, + { key: 'candidatures', label: i18n.tribeGovFilterCandidatures || 'CANDIDATURES' }, + { key: 'proposals', label: i18n.tribeGovProposals || 'PROPOSALS' }, + { key: 'laws', label: i18n.tribeGovFilterLaws || 'LAWS' } + ]; + const row2 = [ + { key: 'revocations', label: i18n.tribeGovRevocations || 'REVOCATIONS' }, + { key: 'historical', label: i18n.tribeGovHistorical || 'HISTORICAL' }, + { key: 'leaders', label: i18n.tribeGovLeader || 'LEADERS' }, + { key: 'rules', label: i18n.tribeGovRules || 'RULES' } + ]; + const mkRow = (filters) => div({ class: 'mode-buttons-row' }, + filters.map(f => + form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribeId)}` }, + input({ type: 'hidden', name: 'section', value: 'governance' }), + input({ type: 'hidden', name: 'filter', value: f.key }), + button({ type: 'submit', class: currentFilter === f.key ? 'filter-btn active' : 'filter-btn' }, f.label) + ) + ) + ); + return div({ class: 'filters' }, + mkRow(row1), + mkRow(row2), + showPublish + ? div({ class: 'mode-buttons-row' }, + form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribeId)}/governance/publish-candidature` }, + button({ type: 'submit', class: 'create-button' }, i18n.tribeGovCandidatureProposeBtn || 'Publish Candidature') + ) + ) + : null + ); +}; + +const fmtTermDate = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss'); +const fmtTimeLeft = (end) => { + const diff = moment(end).diff(moment()); + if (diff <= 0) return '0d 00:00:00'; + const dur = moment.duration(diff); + const d = Math.floor(dur.asDays()); + const h = String(dur.hours()).padStart(2, '0'); + const m = String(dur.minutes()).padStart(2, '0'); + const s = String(dur.seconds()).padStart(2, '0'); + return `${d}d ${h}:${m}:${s}`; +}; +const methodImageSrc = (method) => `/assets/images/${String(method || '').toLowerCase()}.png`; + +const governmentCard = (tribe, term, leaders) => { + if (!term) { + return div({ class: 'card' }, + h3(i18n.tribeGovernanceNoGov || 'No active government'), + p(i18n.tribeGovernanceNoGovDesc || 'This tribe has not yet elected a government. Propose candidatures to start the process.') + ); + } + const method = String(term.method || 'ANARCHY').toUpperCase(); + const isAnarchy = method === 'ANARCHY'; + const i18nMeth = i18n[`parliamentMethod${method}`]; + const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : method).toUpperCase(); + const termStart = term.startAt || moment().toISOString(); + const termEnd = term.endAt || moment(termStart).add(60, 'days').toISOString(); + const population = Array.isArray(tribe.members) ? tribe.members.length : 0; + const winnerVotes = Number(term.winnerVotes || 0); + const totalVotes = Number(term.totalVotes || 0); + const votesDisplay = `${winnerVotes} (${totalVotes})`; + const leaderId = term.leaderId || (Array.isArray(leaders) && leaders[0]) || null; + return div({ class: 'card' }, + h2(i18n.tribeGovCardTitle || 'Current Government'), + div({ class: 'cycle-info' }, + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, ((i18n.tribeGovCycleSince || 'CYCLE SINCE') + ': ').toUpperCase()), + span({ class: 'kpi__value' }, fmtTermDate(termStart)) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, ((i18n.tribeGovCycleEnd || 'CYCLE END') + ': ').toUpperCase()), + span({ class: 'kpi__value' }, fmtTermDate(termEnd)) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, ((i18n.tribeGovTimeRemaining || 'TIME REMAINING') + ': ').toUpperCase()), + span({ class: 'kpi__value' }, fmtTimeLeft(termEnd)) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, ((i18n.tribeGovPopulation || 'POPULATION') + ': ').toUpperCase()), + span({ class: 'kpi__value' }, String(population)) + ), + div({ class: 'kpi' }, + span({ class: 'kpi__label' }, ((i18n.tribeGovMethod || 'METHOD') + ': ').toUpperCase()), + span({ class: 'kpi__value' }, methodLabel) + ), + !isAnarchy + ? div({ class: 'kpi' }, + span({ class: 'kpi__label' }, ((i18n.tribeGovVotesReceived || 'VOTES RECEIVED') + ': ').toUpperCase()), + span({ class: 'kpi__value' }, votesDisplay) + ) + : null + ), + div({ class: 'method-image-centered' }, + img({ src: methodImageSrc(method), alt: methodLabel }) + ), + !isAnarchy && leaderId + ? div({ class: 'tribe-leader-block' }, + h3(i18n.tribeGovLeader || 'LEADER'), + userLink(leaderId) + ) + : null + ); +}; + +const candidaturesBlock = (tribe, candidatures, alreadyPublishedThisGlobalCycle) => { + const list = (Array.isArray(candidatures) ? candidatures : []) + .filter(c => (c.status || 'OPEN') === 'OPEN') + .map(c => { + const voters = (Array.isArray(c.voters) ? c.voters : []).filter(v => String(v) !== String(c.candidateId)); + return { ...c, votes: voters.length }; + }) + .sort((a, b) => Number(b.votes || 0) - Number(a.votes || 0)); + return div({}, + alreadyPublishedThisGlobalCycle + ? p({ class: 'notice' }, i18n.tribeGovernanceAlreadyPublished || 'This tribe already has an open candidature in the current global parliament cycle.') + : null, + h3(i18n.tribeGovernanceProposeInternal || 'Propose internal candidature'), + form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/propose` }, + label({}, i18n.tribeGovCandidatureId || 'Candidate'), br(), + input({ type: 'text', name: 'candidateId', placeholder: '@...ed25519', required: true }), br(), br(), + label({}, i18n.tribeGovCandidatureMethod || 'Method'), br(), + select({ name: 'method' }, + GOVERNANCE_METHODS.map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m)) + ), br(), br(), + button({ type: 'submit', class: 'create-button' }, i18n.tribeGovCandidatureProposeBtn || 'Publish Candidature') + ), + h3(i18n.tribeGovernanceInternalCandidatures || 'Internal candidatures'), + list.length === 0 + ? p(i18n.tribeGovernanceNoCandidatures || 'No open candidatures.') + : ul({}, list.map(c => + li({}, + c.candidateId + ? userLink(c.candidateId) + : '?', + ` — ${c.method || 'DEMOCRACY'} — ${c.votes || 0} ${i18n.votes || 'votes'}`, + ' ', + form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/vote`, class: 'inline-form' }, + input({ type: 'hidden', name: 'candidatureId', value: c.id }), + button({ type: 'submit', class: 'filter-btn' }, i18n.vote || 'Vote') + ) + ) + )) + ); +}; + +const rulesBlock = (tribe, rules, isCreator) => div({}, + isCreator + ? div({}, + h3(i18n.tribeGovernanceAddRule || 'Add rule'), + form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` }, + label({}, i18n.tribeGovRuleTitle || 'Title'), br(), + input({ type: 'text', name: 'title', required: true }), br(), br(), + label({}, i18n.tribeGovRuleBody || 'Body'), br(), + textarea({ name: 'body', rows: 4 }), br(), br(), + button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save') + ) + ) + : null, + h3(i18n.tribeGovRules || 'Rules'), + (!Array.isArray(rules) || rules.length === 0) + ? p(i18n.tribeGovernanceNoRules || 'No rules yet.') + : ul({}, rules.map(r => + li({}, + span({ class: 'bold' }, r.title || '-'), + r.body ? p(r.body) : null, + isCreator + ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/delete`, class: 'inline-form' }, + input({ type: 'hidden', name: 'ruleId', value: r.id }), + button({ type: 'submit', class: 'filter-btn danger-btn' }, i18n.delete || 'Delete') + ) + : null + ) + )) +); + +const renderGovernance = (tribe, data) => { + const { filter, term, candidatures, rules, leaders, isCreator, canPublishToGlobal, alreadyPublishedThisGlobalCycle, hasElectedCandidate } = data || {}; + const f = filter || 'government'; + const header = div({ class: 'tags-header' }, + h2(i18n.tribeSectionGovernance || 'GOVERNANCE'), + p(i18n.tribeGovernanceDesc || 'Internal governance for this tribe. Propose candidatures, debate rules, elect leaders — mirrors the global Parliament.') + ); + let body; + if (f === 'government') body = governmentCard(tribe, term, leaders); + else if (f === 'candidatures') body = candidaturesBlock(tribe, candidatures, alreadyPublishedThisGlobalCycle); + else if (f === 'rules') body = rulesBlock(tribe, rules, !!isCreator); + else if (f === 'leaders') body = div({ class: 'card' }, + h3(i18n.tribeGovLeader || 'LEADERS'), + (!Array.isArray(leaders) || leaders.length === 0) + ? p(i18n.tribeGovernanceNoLeaders || 'No leaders elected yet.') + : ul({}, leaders.map(l => li({}, userLink(l)))) + ); + else { + const govEmptyMsg = { + proposals: i18n.tribeGovNoProposals || 'Not any proposal, yet', + laws: i18n.tribeGovNoLaws || 'Not any law, yet', + revocations: i18n.tribeGovNoRevocations || 'Not any revocation, yet', + historical: i18n.tribeGovNoHistorical || 'Not any record, yet' + }; + body = div({ class: 'card' }, + h3(i18n[`tribeGovFilter${f.charAt(0).toUpperCase()}${f.slice(1)}`] || i18n[`tribeGov${f.charAt(0).toUpperCase()}${f.slice(1)}`] || f.toUpperCase()), + p(govEmptyMsg[f] || i18n.tribeGovernanceComingSoon || 'Coming soon in this tribe\'s governance module.') + ); + } + + const showPublish = !!(canPublishToGlobal && hasElectedCandidate && !alreadyPublishedThisGlobalCycle); + return div({ class: 'tribe-governance' }, header, governanceFilterBar(tribe.id, f, showPublish), body); +}; + +exports.renderGovernance = renderGovernance; diff --git a/src/views/video_view.js b/src/views/video_view.js new file mode 100644 index 0000000..5dbfe9c --- /dev/null +++ b/src/views/video_view.js @@ -0,0 +1,484 @@ +const { + form, + button, + div, + h2, + p, + section, + input, + br, + a, + video: videoHyperaxe, + span, + textarea, + label, + select, + option +} = require("../server/node_modules/hyperaxe"); + +const moment = require("../server/node_modules/moment"); +const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl") +const { renderMapLocationVisitLabel } = require("./maps_view"); + +const userId = config.keys.id; + +const safeArr = (v) => (Array.isArray(v) ? v : []); +const safeText = (v) => String(v || "").trim(); + +const buildReturnTo = (filter, params = {}) => { + const f = safeText(filter || "all"); + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const parts = [`filter=${encodeURIComponent(f)}`]; + if (q) parts.push(`q=${encodeURIComponent(q)}`); + if (sort) parts.push(`sort=${encodeURIComponent(sort)}`); + return `/videos?${parts.join("&")}`; +}; + +const renderPMButton = (recipient, className = "filter-btn") => { + const r = safeText(recipient); + if (!r) return null; + if (String(r) === String(userId)) return null; + + return form( + { method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: r }), + button({ type: "submit", class: className }, i18n.privateMessage) + ); +}; + +const renderTags = (tags) => { + const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); + return list.length + ? div( + { class: "card-tags" }, + list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; +}; + +const renderVideoFavoriteToggle = (videoObj, returnTo = "") => + form( + { + method: "POST", + action: videoObj.isFavorite + ? `/videos/favorites/remove/${encodeURIComponent(videoObj.key)}` + : `/videos/favorites/add/${encodeURIComponent(videoObj.key)}` + }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + button( + { type: "submit", class: "filter-btn" }, + videoObj.isFavorite ? i18n.videoRemoveFavoriteButton : i18n.videoAddFavoriteButton + ) + ); + +const renderVideoPlayer = (videoObj) => + videoObj?.url + ? div( + { class: "video-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" }, + videoHyperaxe({ + controls: true, + src: `/blob/${encodeURIComponent(videoObj.url)}`, + preload: "metadata" + }) + ) + : p(i18n.videoNoFile); + +const renderVideoOwnerActions = (filter, videoObj, params = {}) => { + const returnTo = buildReturnTo(filter, params); + const isAuthor = String(videoObj.author) === String(userId); + const hasOpinions = Object.keys(videoObj.opinions || {}).length > 0; + + if (!isAuthor) return []; + + const items = []; + if (!hasOpinions) { + items.push( + form( + { method: "GET", action: `/videos/edit/${encodeURIComponent(videoObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton) + ) + ); + } + items.push( + form( + { method: "POST", action: `/videos/delete/${encodeURIComponent(videoObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.videoDeleteButton) + ) + ); + + return items; +}; + +const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => { + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + const commentsCount = list.length; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/videos/${encodeURIComponent(videoId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + list.length + ? div( + { class: "comments-list" }, + list.map((c) => { + const author = c?.value?.author || ""; + const ts = c?.value?.timestamp || c?.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + const content = c?.value?.content || {}; + const rootId = content.fork || content.root || null; + const text = content.text || ""; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(text)) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ); +}; + +const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) => { + const returnTo = buildReturnTo(filter, params); + + return videos.length + ? videos.map((videoObj) => { + const commentCount = typeof videoObj.commentCount === "number" ? videoObj.commentCount : 0; + const title = safeText(videoObj.title); + const ownerActions = renderVideoOwnerActions(filter, videoObj, params); + + const isOwn = videoObj.author && String(videoObj.author) === String(userId); + return div( + { class: "trending-card video-card" + (isOwn ? " own-content" : "") }, + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`) + ), + div( + { class: "card-section video-card-body" }, + div( + { class: "bookmark-topbar" }, + div( + { class: "bookmark-topbar-left" }, + renderVideoFavoriteToggle(videoObj, returnTo), + renderPMButton(videoObj.author) + ), + ownerActions.length + ? div({ class: "bookmark-topbar-right" }, div({ class: "bookmark-actions" }, ...ownerActions)) + : null + ), + title ? h2(title) : null, + videoObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(videoObj.lifetime, i18n)) : null, + renderVideoPlayer(videoObj), + div( + { class: "card-comments-summary" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ":"), + span({ class: "card-value" }, String(commentCount)), + br(), + br(), + form( + { method: "GET", action: `/videos/${encodeURIComponent(videoObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "filter", value: filter || "all" }), + params.q ? input({ type: "hidden", name: "q", value: params.q }) : null, + params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null, + button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton) + ) + ), + div( + { class: "card-comments-summary feed-opinions-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), + span({ class: "card-value" }, String(Object.values(videoObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) + ), + renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants) + ), + div({ class: "card-spread-left" }, renderSpreadButton(videoObj.key, (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads)), + renderMapLocationVisitLabel(videoObj.mapUrl), + br(), + (() => { + const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN; + const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(videoObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })() + ) + ); + }) + : p(params.q ? i18n.videoNoMatch : i18n.noVideos); +}; + +const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => { + const returnTo = safeText(params.returnTo) || buildReturnTo("all", params); + + return div( + { class: "div-center video-form" }, + form( + { + action: filter === "edit" ? `/videos/update/${encodeURIComponent(videoId)}` : "/videos/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + span(i18n.videoFileLabel), + br(), + input({ type: "file", name: "video", required: filter !== "edit" }), + br(), + br(), + span(i18n.videoTitleLabel), + br(), + input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }), + br(), + span(i18n.videoDescriptionLabel), + br(), + textarea({ name: "description", placeholder: i18n.videoDescriptionPlaceholder, rows: "4" }, videoToEdit?.description || ""), + br(), + span(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: videoToEdit?.mapUrl || "" }), + br(), + span(i18n.videoTagsLabel), + br(), + input({ + type: "text", + name: "tags", + placeholder: i18n.videoTagsPlaceholder, + value: safeArr(videoToEdit?.tags).join(", ") + }), + br(), + br(), + button({ type: "submit" }, filter === "edit" ? i18n.videoUpdateButton : i18n.videoCreateButton) + ) + ); +}; + +exports.videoView = async (videos, filter = "all", videoId = null, params = {}) => { + const title = + filter === "mine" + ? i18n.videoMineSectionTitle + : filter === "create" + ? i18n.videoCreateSectionTitle + : filter === "edit" + ? i18n.videoUpdateSectionTitle + : filter === "recent" + ? i18n.videoRecentSectionTitle + : filter === "top" + ? i18n.videoTopSectionTitle + : filter === "favorites" + ? i18n.videoFavoritesSectionTitle + : i18n.videoAllSectionTitle; + + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + + const list = safeArr(videos); + const videoToEdit = videoId ? list.find((v) => v.key === videoId) : null; + + return template( + title, + section( + div({ class: "tags-header" }, + h2(title), + p(i18n.videoDescription) + ), + (() => { + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetVideos); + return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n)); + })(), + br(), + div( + { class: "filters" }, + form( + { method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.videoFilterFavorites + ), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton) + ) + ) + ), + section( + filter === "create" || filter === "edit" + ? renderVideoForm(filter, videoId, videoToEdit, { ...params, filter }) + : section( + div( + { class: "videos-search" }, + form( + { method: "GET", action: "/videos", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ + type: "text", + name: "q", + value: q, + placeholder: i18n.videoSearchPlaceholder, + class: "filter-box__input" + }), + div( + { class: "filter-box__controls" }, + select( + { name: "sort", class: "filter-box__select" }, + option({ value: "recent", selected: sort === "recent" }, i18n.videoSortRecent), + option({ value: "oldest", selected: sort === "oldest" }, i18n.videoSortOldest), + option({ value: "top", selected: sort === "top" }, i18n.videoSortTop) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.videoSearchButton) + ) + ) + ), + div({ class: "videos-list" }, renderVideoList(list, filter, { q, sort })) + ) + ) + ); +}; + +exports.singleVideoView = async (videoObj, filter = "all", comments = [], params = {}) => { + const q = safeText(params.q || ""); + const sort = safeText(params.sort || "recent"); + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }); + + const title = safeText(videoObj.title); + const { renderReachChip } = require('./clearnet_view'); + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetVideos); + + const chips = [ + renderLifespanChip(videoObj.lifetime, i18n), + videoObj.sizeBytes ? renderEcoTax(videoObj.sizeBytes, videoObj.key) : null + ].filter(Boolean); + + const ownerActions = renderVideoOwnerActions(filter, videoObj, { q, sort }); + const sideActions = []; + sideActions.push(renderVideoFavoriteToggle(videoObj, returnTo)); + if (videoObj.author && String(videoObj.author) !== String(userId)) { + sideActions.push(renderPMButton(videoObj.author)); + } + for (const a of ownerActions) sideActions.push(a); + + const tagsNode = renderTags(videoObj.tags); + + const videoSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + title ? h2({ class: "tribe-card-title" }, title) : null, + renderReachChip(isClearnet, i18n) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + safeText(videoObj.description) + ? p({ class: "tribe-side-description" }, ...renderUrl(videoObj.description)) + : null, + tagsNode, + div({ class: "card-spread-centered" }, renderSpreadButton(videoObj.key, params.spreads)), + renderMapLocationVisitLabel(videoObj.mapUrl), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const videoMain = div({ class: "tribe-main" }, + renderVideoPlayer(videoObj), + (() => { + const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN; + const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + userLink(videoObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ) + : null + ); + })(), + renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants), + renderVideoCommentsSection(videoObj.key, comments, returnTo) + ); + + return template( + i18n.videoTitle, + section( + div({ class: "tags-header" }, + h2(i18n.videoAllSectionTitle || i18n.videoTitle), + p(i18n.videoDescription) + ), + div( + { class: "filters" }, + form( + { method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "q", value: q }), + input({ type: "hidden", name: "sort", value: sort }), + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent), + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop), + button( + { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, + i18n.videoFilterFavorites + ), + button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton) + ) + ), + div({ class: "tribe-details" }, videoSide, videoMain) + ) + ); +}; + diff --git a/src/views/vote_view.js b/src/views/vote_view.js new file mode 100644 index 0000000..557c64f --- /dev/null +++ b/src/views/vote_view.js @@ -0,0 +1,371 @@ +const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe"); +const { template, i18n, renderOpinionsVoting, userLink, renderOpenClosedChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { config } = require("../server/SSB_server.js"); +const { renderUrl } = require("../backend/renderUrl"); + +const userId = config.keys.id; +const VOTE_QUORUM = 2; + +const safeArray = (v) => Array.isArray(v) ? v : []; + +const voteLabel = (opt) => + i18n["vote" + opt.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join("")] || opt; + +const computeVoteOutcome = (baseCounts, voteOptions, totalVotesNum) => { + const noResult = { text: i18n.voteNoQuorum || "NO QUORUM", color: "#ffcc00" }; + if (totalVotesNum < VOTE_QUORUM) return noResult; + const opts = voteOptions.filter((o) => o !== "FOLLOW_MAJORITY"); + const maxCount = opts.reduce((m, o) => Math.max(m, baseCounts[o] || 0), 0); + const topOpts = opts.filter((o) => (baseCounts[o] || 0) === maxCount); + if (maxCount === 0 || topOpts.length > 1) return noResult; + const winner = topOpts[0]; + const color = winner === "YES" ? "#4caf50" : winner === "NO" ? "#e53935" : "#ffcc00"; + return { text: voteLabel(winner), color }; +}; + +const normalizeStatus = (v) => { + const up = String(v || "").toUpperCase(); + if (up === "OPEN" || up === "CLOSED") return up; + return up || "OPEN"; +}; + +const statusLabel = (s) => { + const up = normalizeStatus(s); + if (up === "OPEN") return i18n.voteStatusOpen || i18n.voteFilterOpen || "OPEN"; + if (up === "CLOSED") return i18n.voteStatusClosed || i18n.voteFilterClosed || "CLOSED"; + return up; +}; + +const renderVoteOwnerActions = (v, returnTo, mode) => { + const showUpdateButton = mode === "mine" && !Object.keys(v.opinions || {}).length; + const showDeleteButton = mode === "mine"; + + const actions = []; + if (showUpdateButton) { + actions.push( + form( + { method: "GET", action: `/votes/edit/${encodeURIComponent(v.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.voteUpdateButton) + ) + ); + } + if (showDeleteButton) { + actions.push( + form( + { method: "POST", action: `/votes/delete/${encodeURIComponent(v.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.voteDeleteButton) + ) + ); + } + return actions; +}; + +const renderVoteButtons = (v, voteOptions, firstRow, secondRow, returnTo) => { + if (normalizeStatus(v.status) !== "OPEN") return null; + if (v.createdBy && String(v.createdBy) === String(userId)) return null; + + const allOptions = Array.isArray(v.options) && v.options.length ? v.options : [...firstRow, ...secondRow]; + return div( + { class: "vote-buttons-block" }, + div( + { class: "vote-buttons-row-single" }, + ...allOptions.map((opt) => + form( + { method: "POST", action: `/votes/vote/${encodeURIComponent(v.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", name: "choice", value: opt }, voteLabel(opt)) + ) + ) + ) + ); +}; + +const renderOpinionsBar = (v, returnTo) => + renderOpinionsVoting('/votes/opinions', v.id, v.opinions, returnTo, v.opinions_inhabitants); + +const renderVoteStatusChip = (status) => { + const localized = statusLabel(status); + return renderOpenClosedChip(status, { statusChipOPEN: localized, statusChipCLOSED: localized }); +}; + +const renderVoteListItem = (v, voteOptionsDefault, activeFilter, spreadInfo) => { + const voteOptions = Array.isArray(v.options) && v.options.length ? v.options : voteOptionsDefault; + const baseCounts = voteOptions.reduce((acc, opt) => { + acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0; + return acc; + }, {}); + const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0; + const outcome = computeVoteOutcome(baseCounts, voteOptions, totalVotesNum); + const chips = [ + renderVoteStatusChip(v.status), + renderLifespanChip(v.lifetime, i18n) + ].filter(Boolean); + const returnTo = `/votes?filter=${encodeURIComponent(activeFilter || "all")}`; + const totalOpinions = Object.values(v.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); + const isOwn = v.createdBy && String(v.createdBy) === String(userId); + + return div({ class: "trending-card vote-card" + (isOwn ? " own-content" : "") }, + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(v.id, `/votes/${encodeURIComponent(v.id)}`) + ), + div({ class: "card-section vote-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/votes/${encodeURIComponent(v.id)}` }, v.question || i18n.votationsTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + v.deadline ? p({ class: "card-date-highlight" }, moment(v.deadline).format("YYYY/MM/DD HH:mm")) : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`) + ), + div({ class: "job-meta-line", style: `color:${outcome.color};font-weight:bold;` }, `${i18n.voteResults || "Results"}: ${outcome.text}`), + div({ class: "card-spread-centered" }, renderSpreadButton(v.id, spreadInfo)) + ) + ); +}; + +const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, activeFilter, params = {}) => { + const voteOptions = Array.isArray(v.options) && v.options.length ? v.options : voteOptionsDefault; + const baseCounts = voteOptions.reduce((acc, opt) => { + acc[opt] = (v.votes && v.votes[opt]) ? v.votes[opt] : 0; + return acc; + }, {}); + + const totalVotesNum = typeof v.totalVotes === "number" ? v.totalVotes : parseInt(String(v.totalVotes || "0"), 10) || 0; + const outcome = computeVoteOutcome(baseCounts, voteOptions, totalVotesNum); + + const returnTo = `/votes/${encodeURIComponent(v.id)}?filter=${encodeURIComponent(activeFilter || "all")}`; + const chips = [ + renderVoteStatusChip(v.status), + renderLifespanChip(v.lifetime, i18n), + renderEcoTax(v.msgSize, v.id) + ].filter(Boolean); + + const sideActions = []; + if (v.createdBy && v.createdBy !== userId) { + sideActions.push(form({ method: "GET", action: "/pm" }, + input({ type: "hidden", name: "recipients", value: v.createdBy }), + button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) + )); + } + for (const a of renderVoteOwnerActions(v, returnTo, mode || "")) sideActions.push(a); + + const cleanTags = (Array.isArray(v.tags) ? v.tags : []).filter((t) => t && !String(t).includes(":")); + const tagsNode = cleanTags.length + ? div({ class: "card-tags" }, + cleanTags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) + ) + : null; + + const infoRows = []; + const pushRow = (labelText, valueNode) => + infoRows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: "tribe-info-value" }, valueNode) + )); + if (v.deadline) pushRow(i18n.voteDeadline, moment(v.deadline).format("YYYY/MM/DD HH:mm")); + pushRow(i18n.voteStatus, statusLabel(v.status)); + + const voteSide = div({ class: "tribe-side" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, v.question) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + div({ class: "card-spread-centered" }, renderSpreadButton(v.id, params.spreads)), + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + tagsNode, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`) + ), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + ); + + const voteButtonsNode = renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo); + + const voteMain = div({ class: "tribe-main" }, + voteButtonsNode + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"), + voteButtonsNode + ) + : null, + div({ class: "job-section" }, + h2({ class: "job-section-title" }, + `${i18n.voteResults || "Results"}: `, + span({ style: `color:${outcome.color} !important;font-weight:bold;` }, outcome.text) + ), + div({ class: "vote-table" }, + table( + tr(...voteOptions.map((opt) => th(voteLabel(opt)))), + tr(...voteOptions.map((opt) => td(baseCounts[opt]))) + ) + ) + ), + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(v.createdBy) + ), + renderOpinionsBar(v, returnTo) + ); + + return div({ class: "tribe-details" }, voteSide, voteMain); +}; + +const renderCommentsSection = (voteId, comments, activeFilter) => { + const commentsCount = Array.isArray(comments) ? comments.length : 0; + const returnTo = `/votes/${encodeURIComponent(voteId)}?filter=${encodeURIComponent(activeFilter || "all")}`; + + return div( + { class: "vote-comments-section" }, + div( + { class: "comments-count" }, + span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), + span({ class: "card-value" }, String(commentsCount)) + ), + div( + { class: "comment-form-wrapper" }, + h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), + form( + { method: "POST", action: `/votes/${encodeURIComponent(voteId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + textarea({ + id: "comment-text", + name: "text", + rows: 4, + class: "comment-textarea", + placeholder: i18n.voteNewCommentPlaceholder + }), + div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), + br(), + button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) + ) + ), + comments && comments.length + ? div( + { class: "comments-list" }, + comments.map((c) => { + const author = c.value && c.value.author ? c.value.author : ""; + const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; + const relDate = ts ? moment(ts).fromNow() : ""; + + const content = c.value && c.value.content ? c.value.content : {}; + const root = content.fork || content.root || ""; + const text = content.text || ""; + + return div( + { class: "votations-comment-card" }, + span( + { class: "created-at" }, + span(i18n.createdBy), + author ? userLink(author) : span("(unknown)"), + absDate ? span(" | ") : "", + absDate ? span({ class: "votations-comment-date" }, absDate) : "", + relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", + relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : "" + ), + p({ class: "votations-comment-text" }, ...renderUrl(String(text))) + ); + }) + ) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + ); +}; + +exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam, params = {}) => { + const list = Array.isArray(votes) ? votes : [votes]; + + const standardFilters = ["all", "mine", "open", "closed"]; + const activeFilter = standardFilters.includes(activeFilterParam) + ? activeFilterParam + : (standardFilters.includes(mode) ? mode : "all"); + + const title = + mode === "mine" ? i18n.voteMineSectionTitle : + mode === "create" ? i18n.voteCreateSectionTitle : + mode === "edit" ? i18n.voteUpdateSectionTitle : + mode === "open" ? i18n.voteOpenTitle : + mode === "closed" ? i18n.voteClosedTitle : + mode === "detail" ? (i18n.voteDetailSectionTitle || i18n.voteAllSectionTitle) : + i18n.voteAllSectionTitle; + + const voteToEdit = list.find((v) => v.id === voteId) || {}; + const editTags = Array.isArray(voteToEdit.tags) ? voteToEdit.tags.filter(Boolean) : []; + + let filtered = + mode === "mine" ? list.filter((v) => v.createdBy === userId) : + mode === "open" ? list.filter((v) => normalizeStatus(v.status) === "OPEN") : + mode === "closed" ? list.filter((v) => normalizeStatus(v.status) === "CLOSED") : + list; + + filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + const voteOptions = ["ABSTENTION", "YES", "NO", "CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"]; + const firstRow = ["ABSTENTION", "YES", "NO"]; + const secondRow = ["CONFUSED", "FOLLOW_MAJORITY", "NOT_INTERESTED"]; + + const header = div( + { class: "tags-header" }, + h2(i18n.votationsTitle), + p(i18n.votationsDescription) + ); + + const listReturnTo = standardFilters.includes(activeFilter) ? `/votes?filter=${encodeURIComponent(activeFilter)}` : "/votes"; + + const deadlineMin = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm"); + const deadlineValue = voteToEdit.deadline ? moment(voteToEdit.deadline).format("YYYY-MM-DDTHH:mm") : ""; + + return template( + title, + section( + header, + div( + { class: "filters" }, + form( + { method: "GET", action: "/votes" }, + button({ type: "submit", name: "filter", value: "all", class: mode === "all" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterAll), + button({ type: "submit", name: "filter", value: "mine", class: mode === "mine" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterMine), + button({ type: "submit", name: "filter", value: "open", class: mode === "open" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterOpen), + button({ type: "submit", name: "filter", value: "closed", class: mode === "closed" ? "filter-btn active" : "filter-btn" }, i18n.voteFilterClosed), + button({ type: "submit", name: "filter", value: "create", class: mode === "create" ? "create-button active" : "create-button" }, i18n.voteCreateButton) + ) + ) + ), + section( + (mode === "edit" || mode === "create") + ? div( + { class: "vote-form" }, + form( + { action: mode === "edit" ? `/votes/update/${encodeURIComponent(voteId)}` : "/votes/create", method: "POST" }, + input({ type: "hidden", name: "returnTo", value: listReturnTo }), + h2(i18n.voteQuestionLabel), + input({ type: "text", name: "question", id: "question", required: true, value: voteToEdit.question || "" }), br(), br(), + label(i18n.voteDeadlineLabel), br(), + input({ + type: "datetime-local", + name: "deadline", + id: "deadline", + required: true, + min: mode === "create" ? deadlineMin : undefined, + value: deadlineValue + }), br(), br(), + label(i18n.voteTagsLabel), br(), + input({ type: "text", name: "tags", id: "tags", value: editTags.join(", ") }), br(), br(), + button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton) + ) + ) + : mode === "detail" && voteId + ? renderVoteDetail(filtered[0] || list.find(v => v.id === voteId) || {}, voteOptions, firstRow, secondRow, mode, activeFilter, params) + : filtered.length > 0 + ? div({ class: "jobs-grid" }, filtered.map((v) => renderVoteListItem(v, voteOptions, activeFilter, params.spreadMap && params.spreadMap.get(v.id)))) + : p(i18n.novotes), + (mode === "detail" && voteId) ? renderCommentsSection(voteId, comments, activeFilter) : null + ) + ); +}; + diff --git a/src/views/wallet_view.js b/src/views/wallet_view.js new file mode 100644 index 0000000..ee8ca73 --- /dev/null +++ b/src/views/wallet_view.js @@ -0,0 +1,168 @@ +const { form, button, div, h2, p, section, input, span, table, thead, tbody, tr, td, th, ul, li, a, br, label, img } = require("../server/node_modules/hyperaxe"); +const QRCode = require('../server/node_modules/qrcode'); +const { template, i18n } = require('./main_views'); + +const walletViewRender = (balance, address, ...elements) => { + const header = div({ class: 'tags-header' }, h2(i18n.walletTitle), p(i18n.walletDescription)); + return template( + i18n.walletTitle, + section( + header, + div({ class: "wallet-section" }, + h2(i18n.walletAddress), + div({ class: "wallet-address" }, h2({ class: "element" }, address || "-")), + h2(i18n.walletBalanceTitle), + div({ class: "div-center" }, h2(i18n.walletBalanceLine({ balance: Number(balance || 0).toFixed(6) }))), + div({ class: "div-center" }, + span({ class: "wallet-form-button-group-center" }, + form({ action: "/wallet/send", method: "get" }, button({ type: 'submit' }, i18n.walletSend)), + form({ action: "/wallet/receive", method: "get" }, button({ type: 'submit' }, i18n.walletReceive)), + form({ action: "/wallet/history", method: "get" }, button({ type: 'submit' }, i18n.walletHistory)) + ) + ) + ) + ), + elements.length > 0 ? section(...elements) : null + ); +}; + +exports.walletView = async (balance, address) => { + return walletViewRender(balance, address); +}; + +exports.walletHistoryView = async (balance, transactions, address) => { + const rows = Array.isArray(transactions) ? transactions : []; + return walletViewRender( + balance, + address, + h2(i18n.walletHistoryTitle), + table( + { class: "wallet-history" }, + thead( + tr( + { class: "full-center" }, + th({ class: "col-10" }, i18n.walletCnfrs), + th(i18n.walletDate), + th(i18n.walletType), + th(i18n.walletAmount), + th({ class: "col-30" }, i18n.walletTxId) + ) + ), + tbody( + ...rows.map(tx => { + const date = new Date((tx.time || tx.timereceived || 0) * 1000); + const amount = Number(tx.amount) || 0; + const fee = Number(tx.fee) || 0; + const totalAmount = amount + fee; + return tr( + td({ class: "full-center" }, String(tx.confirmations || 0)), + td(date.toLocaleDateString(), br(), date.toLocaleTimeString()), + td(tx.category || "-"), + td(totalAmount.toFixed(6)), + td({ width: "30%", class: "tcell-ellipsis" }, + a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(tx.txid || '')}`, target: "_blank" }, tx.txid || "-") + ) + ); + }) + ) + ) + ); +}; + +exports.walletReceiveView = async (balance, address) => { + const qrDataUrl = await QRCode.toDataURL(address || '', { type: 'image/png', width: 320, margin: 1 }); + return walletViewRender( + balance, + address, + h2(i18n.walletReceiveTitle), + div({ class: 'div-center qr-code' }, img({ src: qrDataUrl, alt: 'QR', class: 'wallet-qr-img' })) + ); +}; + +exports.walletSendFormView = async (balance, destination, amount, fee, statusMessages, address) => { + const type = statusMessages?.type || 'info'; + const titleKey = statusMessages?.title || ''; + const messages = statusMessages?.messages || []; + const statusBlock = messages.length > 0 + ? div( + { class: `wallet-status-${type}` }, + span(i18n.walletStatusMessages[titleKey] || titleKey || ''), + ul(...messages.map(error => li(i18n.walletStatusMessages[error] || error))) + ) + : null; + + return walletViewRender( + balance, + address, + h2(i18n.walletWalletSendTitle), + div( + { class: "div-center" }, + statusBlock, + form( + { action: '/wallet/send', method: 'POST' }, + label({ for: 'destination' }, i18n.walletAddress), br(), + input({ type: 'text', id: 'destination', name: 'destination', placeholder: 'ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX', value: destination || '' }), br(), + label({ for: 'amount' }, i18n.walletAmount), br(), + input({ type: 'text', id: 'amount', name: 'amount', placeholder: '0.25', value: amount || '' }), br(), + label({ for: 'fee' }, i18n.walletFee), br(), + input({ type: 'text', id: 'fee', name: 'fee', placeholder: '0.01', value: fee || '' }), br(), + input({ type: 'hidden', name: 'action', value: 'confirm' }), + div({ class: 'wallet-form-button-group-center' }, + button({ type: 'submit' }, i18n.walletSend), + button({ type: 'reset' }, i18n.walletReset) + ) + ) + ) + ); +}; + +exports.walletSendConfirmView = async (balance, destination, amount, fee) => { + const a = Number(amount || 0); + const f = Number(fee || 0); + const totalCost = a + f; + return walletViewRender( + balance, + destination, + p( + i18n.walletAddressLine({ address: destination || '-' }), br(), + i18n.walletAmountLine({ amount: a.toFixed(6) }), br(), + i18n.walletFeeLine({ fee: f.toFixed(6) }), br(), + i18n.walletTotalCostLine({ totalCost: totalCost.toFixed(6) }) + ), + form( + { action: '/wallet/send', method: 'POST' }, + input({ type: 'hidden', name: 'action', value: 'send' }), + input({ type: 'hidden', name: 'destination', value: destination || '' }), + input({ type: 'hidden', name: 'amount', value: String(a) }), + input({ type: 'hidden', name: 'fee', value: String(f) }), + div({ class: 'form-button-group-center' }, + button({ type: 'submit' }, i18n.walletConfirm), + a({ href: `/wallet/send`, class: "button-like-link" }, i18n.walletBack) + ) + ) + ); +}; + +exports.walletSendResultView = async (balance, destination, amount, txId) => { + return walletViewRender( + balance, + destination, + p( + i18n.walletSentToLine({ destination: destination || '-', amount: Number(amount || 0).toFixed(6) }), br(), + `${i18n.walletTransactionId}: `, + a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(txId || '')}`, target: "_blank" }, txId || '-') + ) + ); +}; + +exports.walletErrorView = async (error) => { + const header = div({ class: 'tags-header' }, h2(i18n.walletTitle), p(i18n.walletDescription)); + return template( + i18n.walletTitle, + section( + header, + div({ class: "wallet-error" }, h2(i18n.walletStatus), p(i18n.walletDisconnected)) + ) + ); +}; +

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 z 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 N002ovPDHLkV1lb~0ww?e literal 0 HcmV?d00001 diff --git a/src/client/assets/images/democracy.png b/src/client/assets/images/democracy.png new file mode 100644 index 0000000000000000000000000000000000000000..5f16b535ae6fa3508bf8cb7e3914c99c437c8844 GIT binary patch literal 29550 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 + oasis favicon + 🏝️ + diff --git a/src/client/assets/images/karmatocracy.png b/src/client/assets/images/karmatocracy.png new file mode 100644 index 0000000000000000000000000000000000000000..157a1bbc1af7cc492835a79d6a87b1be4c9e5108 GIT binary patch literal 36717 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 M#pT7yMGSoZKQ#dDmjD0& literal 0 HcmV?d00001 diff --git a/src/client/assets/images/majority.png b/src/client/assets/images/majority.png new file mode 100644 index 0000000000000000000000000000000000000000..081c1cf5bd05af33f0caf715a0640e2997457219 GIT binary patch literal 33470 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 literal 0 HcmV?d00001 diff --git a/src/client/assets/images/minority.png b/src/client/assets/images/minority.png new file mode 100644 index 0000000000000000000000000000000000000000..76bb010ff4de256362a11685d2883846a2b2fa63 GIT binary patch literal 26411 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 jZ}W0cnhbp44Tgq_u5#65+gJYqNf=Du literal 0 HcmV?d00001 diff --git a/src/client/assets/images/snh-oasis.jpg b/src/client/assets/images/snh-oasis.jpg new file mode 100644 index 0000000000000000000000000000000000000000..98b1b5e9161e15110c9a66e8cb0ae8333870c2ef GIT binary patch literal 53493 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 E+4a=v^8f$< literal 0 HcmV?d00001 diff --git a/src/client/assets/images/world-map.svg b/src/client/assets/images/world-map.svg new file mode 100644 index 0000000..cf308be --- /dev/null +++ b/src/client/assets/images/world-map.svg @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/src/client/assets/images/worldmap-z2.png b/src/client/assets/images/worldmap-z2.png new file mode 100644 index 0000000000000000000000000000000000000000..ce8e6ac3bd59229d7847764f6761398d59c70867 GIT binary patch literal 135491 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 BmhJ!m literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/houses.json b/src/client/assets/larp/houses.json new file mode 100644 index 0000000..2034a85 --- /dev/null +++ b/src/client/assets/larp/houses.json @@ -0,0 +1,92 @@ +{ + "academia": { + "name": "ACADEMIA", + "short": "aca", + "motto": "Nosce te ipsum…", + "roles": "Educators, teachers, coordinators", + "function": "Education, understanding, talent discovery, selection process", + "description": "All inhabitants start here. ACADEMIA receives newcomers, provides information on the simulation mechanics, presents each house's objectives and runs the selection process.", + "month": 1, + "image": "/assets/larp/images/academia.jpg" + }, + "solaris": { + "name": "SolarIS", + "short": "sol", + "motto": "The word makes us human. Violence, beasts.", + "roles": "Rulers, lawyers, diplomats", + "function": "Governance, law, dialogue, politics, communication", + "description": "Holds the political voice. Mediates disputes, drafts norms, builds coalitions through dialogue and persuasion.", + "month": 2, + "image": "/assets/larp/images/solaris.jpg" + }, + "arrakis": { + "name": "ARRakis", + "short": "arr", + "motto": "How long do you say that it must be resolved?", + "roles": "Engineers, scientists, technicians", + "function": "Technology, invention, problem-solving, building and maintaining", + "description": "The makers. They keep the machinery running, prototype, repair, automate; favour pragmatic solutions over endless debate.", + "month": 3, + "image": "/assets/larp/images/arrakis.jpg" + }, + "terraverde": { + "name": "TERRA.VErDE", + "short": "ter", + "motto": "Love nature, universe and being, because we are all one.", + "roles": "Farmers, ecologists, doctors, nutritionists", + "function": "Healthcare, food, nature, resources, climate management", + "description": "Stewards of the living systems. Manage food, health and the relationship with the biosphere; long timescales, regenerative principles.", + "month": 4, + "image": "/assets/larp/images/terraverde.jpg" + }, + "unsystem": { + "name": "UNSYSTem", + "short": "uns", + "motto": "We do not make statements!", + "roles": "Chaos agents, trolls, punks", + "function": "Organization of disorder, tactical chaos", + "description": "Holds the right to question, sabotage, provoke. Resists capture by any other house and keeps the system uncomfortable.", + "month": 5, + "image": "/assets/larp/images/unsystem.jpg" + }, + "dogma": { + "name": "DogmA", + "short": "dog", + "motto": "The question is not when to do it, the question is how.", + "roles": "Thinkers, journalists, philosophers", + "function": "Information control, knowledge, philosophy, memory, AI", + "description": "The archive and the discourse. Curates knowledge, drafts narratives, governs how information is produced and propagated.", + "month": 6, + "image": "/assets/larp/images/dogma.jpg" + }, + "helix": { + "name": "HeliX", + "short": "hlx", + "motto": "If you do not smile, it's not my revolution.", + "roles": "Clowns, influencers, musicians, priests", + "function": "Entertainment, culture, humor, celebration, human relationships", + "description": "Carries culture, ritual and joy. Holds the social fabric together through art, festival and shared emotion.", + "month": 7, + "image": "/assets/larp/images/helix.jpg" + }, + "quark": { + "name": "QuarK", + "short": "quk", + "motto": "Without security, there is no freedom!", + "roles": "Athletes, soldiers, street people", + "function": "Protection, security, defense, survival, family", + "description": "The guard. Trains, defends, organises mutual aid; demands clear loyalties and clear lines.", + "month": 8, + "image": "/assets/larp/images/quark.jpg" + }, + "hermandad": { + "name": "HERmanDAD", + "short": "hrm", + "motto": "Grow. Build. Expand!", + "roles": "Architects, builders, investors, industrialists", + "function": "Construction, development, sustainability, logistics, production", + "description": "The builders. Plan large works, organise production lines and supply chains; measure success in things shipped and capacity created.", + "month": 9, + "image": "/assets/larp/images/hermandad.jpg" + } +} diff --git a/src/client/assets/larp/images/academia.jpg b/src/client/assets/larp/images/academia.jpg new file mode 100644 index 0000000000000000000000000000000000000000..920a58c06d7dc8ae7e7c3c1deec568eba7b11b23 GIT binary patch literal 20452 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 literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/arrakis.jpg b/src/client/assets/larp/images/arrakis.jpg new file mode 100644 index 0000000000000000000000000000000000000000..068a27ef3d2c6ce1d2bda1e4db7acf645eaf291e GIT binary patch literal 17030 zcmbVz1z1$u+VG~4lnyDSh6V}g5NYWca!^oGq&pP>C8bNcb3|w8kS>w#(4j+=X6S#= zbIv{Ie)s;*_y6#$XZGybd#(3fZ^ycty7~s(R8>?_1kljXfJdl*;OY{Pkk?U=)6>)u zXR?06W9RhLn#n_em!Apb?rQtonn^`fiAhyUL5ayj`7+K+MWIFsLf5u1a{e7~xdLT7k~MbN_K9RyKAJ2d9v*$URXp zarp-dib~2Vs=9jm24F)YW6LK`t*mWq?Ofg5Jv_agHa;;qH9dn^Tv}dPU0dJS+}+zhI6OK= zo}6Ceg$7{!M(Z!k{tYh@6kh08Hi*Lb0!d!hyg2`1KU0c=t^ZJfu>WOweqz$L#Q zn^V<#gIQ2#hr+^T7>|-g2*J90joL5F{&$GI{Qtu2FU0Yo9L|7p zKTvBckORMliUPnxo&fv(<+I+MXf?76rARegZdzrgz|QD9 z-jB9f-T+i;jQI+9^YqgdP-vai*65*o)^$3`O9_QHi0_KJD^B=kf!m?B_ZT_w>>{5Hch-r};w2<7&(o(ydsp@*ZD-&DQq65SJX#(N+Lx3i3O70CF9jAv6VYE!0TO#`;ZJRe-}5bp0iK(Q7-` zw$MH5=97gB^MVRBwuiTBziD4A^`7GGL#fU!Li;~|9MZQ+cycS04r6-@ zB3HTvKJ6TxGdJy(sOp`Y3(!}+?K#s5X&NYC+C+}_bKw@UJ>RZ@X`FoO zix;_=U9dgL!M%76cg%nMl1(}Y{L*W5 zk*#PkA~afzcBX?djzThaQ{BQVti$t?dCYk4$iPKqrVgQ$UH&fKm<2FQlUJZGc@4@9 z2Y!trK%ggoF$fJT6zy*E_zF;`IvE#l$Pet%K~aHtO;;u)O=rU92*3R@gnxUb2DQz# zsO`Q2jE~X>k-O=abtqEs$ega7rtR&`7i{{ZMi1W+(isjvjG8Ie_L!$}HX@CfO+Qx8!%1@pkQF^a%~M(;3kj3;I** z>FA7jW}~B2(Er%~{M@7<(zxW2_l;(Y5t{fmdk_tIRXGx+Orv7-m}p67TZEKwbu8=m zyqldrH~q`j{LKxTCLHq{n==X$zqn{(Oz9pD*zKE*tu!~ig~idmm{xkkB@R=@WEoaK zPYH(87hj7QuV{h(oD&w>W0*m8I$}Ux_wn+lR^KXWib>lmfS4y2?|71R@R*sJVo;Y% zH8ajyU6RK*op~@x^3t}H*MK#Lk;O%H}zg1W|ze&){Dz1f#4I_#VR_CTavhBcD_>(#!2#m+24L(=|aFO zn#1&LS8LX5g^$nTrNmvyF1#Dh6YrLVCz#jUYGhKVxmIQh@-)U)-d$I!c80h+f8#N6 zeo9iUsXo3Q*T4K}?;Cx@J22}NfKNpJwmiu?c)BbCM|>+imOiCcE3s~RK>gc*9a_Ad zVc-W>qmLWO9vyESmLK|*K<|fj&zR0B$0=$SW9lQ}+q4G&FSFPsRi_U=1w@9Nh`4kp zxt+O}26sE$7HU$zvl>&vVd+RSxU1-7pE(x5fKZ##d>4ord^+*IYt%YCE=+wyLChYomonPw!+=};h<~`;W~slg&hOsIOX1m{Kx!Q zvc1}aa&auc-vrS@GUb*2&}5b!$MdVkx<8>^_=#`p0#DOUVP&$GfQ?E=m31h^L19dp zvAxfedq+Arq56Xmy<_ZxY(8xT3XDIkA1P`X^rX0?ATl9XN&$-FP+!A2U;vC(EMKf! zhyYq_Zn9CWvtXvm9Lmc5SyS}{+O@!0y9aO+pjPpuE zwVU<>?xl;2JW?c^r9$uf_Qx|Ejv5NqcfWgUQP3$d8ZW!cFhDdDK5FA-uEm=s#KEGC zrs90!Y!jQf2}SLBjQYX1#JH3mnAVL!dEcd0((iAM>pHWS6b_q}uvT@faXjy4fI|^p zjq?G+{&ck6mC2umXyEI!jLs_MTN1j@CSI-<$Eent9gP%~l0f*p8hG<{r(Z*#G2ad= z?(|x1AdW4YD-?J`=xt;QkCQU(6M1DMFO>6IO1Y4hYerJ-qvD`ZOVzVhmyPJzvf@!` zCh;dZC|>K)kR4pScqUiZ+ryk-IW@tL}s7F=_rn zXDKlU!kse^O>6$78%ETgy5T>BH0fBrzqOMTTNUA(i85nYWAzg( zDxDh0%}`cwduO4ofE`5Pn#t9Ie7t0d7|=7E?+Ivi@u=3sosaUq5L2NW!kOIM+$}4? zQtn}H5}iytXW30Ys!ntBokpv#HlHUuM=6jAs0vS~)p#fmd2kaC(={uSHIZw}V)3w& z1F!^`7W!{3h#iEt+UUOcWM?wWst(iphsb@I8Q~FYiv6#hF{ByR9qP9DRttm6)KVsO zg^8aKKdj?cJu|`9af;j>$HEl6wp?<*lxG2YbS~VDp25uc#6tTOU4su^s){1-!&9d^6ZxC}Z3F>+;WFsGSI9=ADtY6=h~O7h(jI zXY3isTeBd=1>im;w~H$t?Lh`^g|o`oF(m8;A{T^kV$d{5Z`Z z%^A37GvxP$|mfL^6beiitk$_g=>M()x03KNkX?$ zqXCM|G=^mCkb;*;BCuP`hH1--gBQZ>kiI&@TGP|E_|4(!xT7j6j*AIlXg++|%@!)P0bkdBSy-pNs#m=Fqb(d%CWr^x zRshJg)nrNKY2x(ddMyB`#YlxhbfTkUB!#TzMB{#Iz4`2+TWt3a#7X+rOp|D)3Y1K! zEcd0<+=s;YbaDg6Nbct8?7qG_n}EkA-nq&XUJUFQ_Ad#&4Qg~AW0gxXGvWw0p|S5#pVcm7aoJz=6RYmy&gH!%!i8$@6uuXB;8);aaAUSVeSZkO zN1;9vSH_sS_1KQ{{gIAj(^%O}+r(k7Zl%jFW9L(oB%1qtRX?~ggx7V`-g(Eajt9S* zv9O5L8P>C6g$%OFpS}&>_Nx5SD7!p-q$TFI4%)AmW%y9I_z>Z$z-&ATcf(jvd zD${=w_ZKKn#|`WST>);B1$qJ~7gg^sr|DX4=+GLg0zjb}Q#hOJu%fyQ{4ugy>+HB*(a6aK|k^59m>s#e%EoHboYHtD{^X`i0?x`%+Z}{Q3 zJzGkjsC0t_qGhIqTio$xLL(O@80i)8qQy ze`eX}rAkSeAas+;wsoH9V<hysXrMn&`@7_4lX=Kq-mj_xv-f|{ zG&|&FLj~gQDYbLxh_Avv&47!KpDdl^CQ$agj^+^1xD(ez){6>ZRRK@|J*ZOxu*eUk zOp^`2Vc`T6M6)L*TIzJ{25~j-WQ9m~QE4@sFStiD&(2LyY;`3#vbhbl_mz(*)`T8} zjJUxK7@OZd_nD*zS;i`hgZm6hH+y@t^i|V}C`iCiq3jWlf&t61$(#YN!TYo!sf{mO zCFS4ByauP;O%xUWAx6!#A8X1N?#D8ld8QeeDB1YPMXDR_TT5VkNjFH$_{w3w15u?Y zRzgE_g8=tVn$IN6E6T*V5XBu2xmkIrvt5!6M$lMqb zr(l-Pza0K@vaMdfaX{=<-^!bllWqh=icZhQ^}adtH1w5womGtZC=CBX<?3I%MBk;^LY4PY=ls|vh*FnC?^tzss#-FCh5J*Pv)Fn% zJT93v%V`Z)cy=j#=%29oqYXb%QjNCuT+!|cRyYS1&u=aGhc?8u$&DTZz?tVyTh91` z&x4$vv*07a*-O#zWf^+jK=(ouj&Dh>>d$eaSL1~|b741glUa0avhh9ALI%=*zO;@C zdq{im6W@G(VMEQ?qcwt#qgxxI70k^w;Rd5*U<_uy#WWX~;a!XDY_%=f{Kn2}^)ttV z$D(c3vqml9E=Q}5^TUP_p{>^R2*X*K&PdullZa&dURE$u5e7Dc-o}&%?02f3H)0ic3F3Ey7{ag{2>BpYdk&NJZBX;!V~L~&;sRWgxROm*Mz_>ba}~oQ?3A7qN0U5wXf)Q zT5%42pPSx$LlyFc=hYaEGs7rM2|1(J;#CkC#)6?$$vZTKF+`i?PSt|f=y-Sg+_i_E z=^u#&epnjLG$DQ^oT?$+2fkPeAA=!%J&^aPNV=qO*cwiWfVqxGo>FCER_;&pwewwO zw8!6V$GkY`x1crKuc=$Cx&0CL;bAAHQ!%}4xr1ewi1G7vgtTAYdr3URp(dG~czV`d zo!35hcDPjC1Vp2(zDI6?+lNN*bw{!%U}t5c)&z)U?M!h4s;EP*Eoy{`t*R2|+gX46 z*q!-NldYtmnH9li(CCWBdE(FX7EYQY(9PYP*D-qSTcj2D)or{RKT1~AD_L2N#cQty zE8EYQFyMR6$q$fg%P|R>Yxs$^Tn_Dsq~8{MB7cj~U3mY_J8+91McrMb0D?W`IW=+4 zYM}|ifhs8XkFexAGy!E}j~wL}kOPT}jCA?zrd2T?TC*ma8YdI{G@uHzwnc@fO<+Trr#lBzOHXbq zt%P^LkpVnEEEB$B<9_;f`>+B^`OeE@IYyQ%f&@ZgC#P^I;dH>R`tDt_eDBn9+H?O6 z<8(;tl2q^G41~i5GjsGS+V3c*U4?Si|AF_L(a(%tfl4dX;>X4Z*p`%~ID96P?FC0out3WrI?;sacj{}yzu?qnynlVyegNJ=v0$fr zx0_i3Kxk_S9#$rI*YGi<(y2PJJP*X%k2jY~ew>}N%>tLC3t+P|eVx9oi#^Bc>;eLS4`0fWftnL04&p9 z=WNcp!K6;5k(vwpD$ZC3SVR#AY z9MO&Ma5$b|1+QTq?RbvrJ*%#wvKd?cpLK81eL#{~1x~bUk-Ud@EMBENI?(MsP3veH zV{b|6cV?Dg)YZ3Ne?LX71NQm2RwcSI?h)Od+E1#|TNn+~0cU#;d>D@mmFRgFh>vkP zSz=#xQP!SJXU+fP{Jni26DnWSVtgcuI5UWT8;6sQJ(uMSfk>;8z8W+E>Xty6cjs*h zJ^6Mv4aBIbPz=MCj$*Y{$nmf1dg%uFlW6M~75*gc!)Al$Tt!zv*76n5Ob9|cecivz zI&RI|f(53SYgbGm%4Vf8KL>tr2l?L6uR?;U1L#DrfaL3^)qU98Dsb1;{K+1&?Bj}I zP-u6Il;j(cwT%@*S$x=>)iMuKda|PhjOp5jl{swwNE)dEs>Yyf3r{EyGGkHZJCEJG zUzXtK2r1`u%JkDZue9}a5zyTKl?{?;3Xvu3P@p@9Zng9Mc`;0 z3Mc(`9fLsKoT-h|!{=-6s2mDK%UW;X&pb8MCC%?kQ&t{Eot*QW!_E0Gm!sX=uI_X^ zRzVV2J|i8 zX$bE6I-fdrNls)7!fq5vlS(WZ^qOe>0gJgSP}?>wdH@xCBnN%|)QCK<`03-9#Lf$~ z{*IsaQjHyC2r9mOc?E7rUi_BR%jPw8&Ygd<=yb56$O4E@jCg4p+KNQnV}x)O4f&B* zKd+&E0d^N7Axb@ls#bC)F#BdcpPsL7uobzUiw{ zm!+*p_f{KE=YG^@S&T|`%n;0~Pjv4laTW(DX!;F}q^DZjYh`@kZHNMh@iXf;9g2~T zV_6-|d`wcAnaDvxkW62#EnP#BWt_JV6}O5|->IFDrK)6=5-ODA{Lq-9-AQiHElLE_ z5+9Z&{Upfgui#6oe&oQ)BKaCYc!IZwPrlE)sA+)SWNz{pC#+J!A=q&P0+Di6C6S73 z+UwF0t44Oq27{u|M+W^HYsH|FZlrvF)rTQ29qD?&sIw-yIS;pYseq`LKGP(3i zzC0Cf8&(=C4g@tfnbgf?y@t+Qvk@d>r^v*?Lt-QCHe-^$EW_b9;UA3XIkUNTf+WX< zTEqx_I$@AKxwWLeYWRqC$*{^y5=;ygO#L{+}@c{lte{MWdU{_odU}}V$es!1+-ydk9fYUd!TZ`(u6)r zL5_{vylOhrHt9^hwBNeBkNx^{)*rFk&epA+zQSHOvUz=0skEhUU$om4Htn7itJKdw zZ~BF+GNJBU>Hvf;-Uel`VEja5sAwNlawjqO(Yo>@7u;=aYjW-S_oLOt2ZMOXNIQ?k ziH$2@P+%pX788@oOKOs*w7c3Hd8Jc9~5$Qji3A_tO(o_!y zZ=<67Z~)66f5&dHXGb(4lBUS=u$y9k=A|-vbKZ`^n^8ZSLX+6gJ$e3P$q`?)mTO1Y zia+;X#XU3uH54uN=`9BM4kF47TK6rUb|b(gxTZHHh6I;Qrf4UQY^Wb~tvDYDLmC*v zX~jnTp1Ou~crrF=c2_vhZ|U4e@evjd#jw{s?K2`+_`s{=6}vSo74lFqim`9OvD!L# zPPS959OHv;aBHcuD48Ftxuw+pfA?>&N=4D&Hhz=W(Geb#*vbUK-i3{&&x(@UZ> z<#j0<0}~PXRg5Idx7;GKDUB$JvndctT+cMr`lIqlStH3;E}dM`r53R9Wg4fdoXNJY zLqy@>w<0P^TaD+W>I@6icV8AcF$XKztu@k=UNorPRPN}p4qT zhiKYj-4-8KCKfFsQg=;%J8#-;ZO_`#!P!+Oyqwlt!dnu^+L6`a#qhFzkio8#LTEi5 zI>06)XL?&q_#oAT{(hFke3nFtg;-Hv_03@w1t06UU*y(S1Oc5Yq{*>*1aY=0l@V>U z5_Ys{eeK6u=b!C3)$j8@oJgQs&5i!re#BK^Qxyh?%>S_e;(oSYyg90&A@_`Lq@jg+ zGw{(!q(C!6U^2t_>ND?3I&!-7GM?#mORGEz6@uJg1t0c=>(%VMp0~m^Z%$2@t_`Hh zG0bmm*5=;)6bow04PgxG^t#vT&$Qr`^D{$`zT6v!vBL-4jG@a!?u-im3H~Yv{VEH^ z1yQ~Y$Ku$K9ppn!XWwd~CA47eH?E%M7n|{&41M1Ut$>?J^MF3vno8u3C z$r{}6afDPj+sXR6@$%Rb2t3L)sUR0I_Y*Kelb0DM8q;nbC6a3zz)m>o7+lNEtMf_* zT)HJvmNsUJtrDYE3e(PT3n-rW`7N)oP5nw28LNVTg8ZV=BvFibS>xr2qz`6uECL)> zu0aBjvV7a2b#d`LeuA=k@Nsz{j#uD!t%{4jfRpCMEJSI5&@Ppk$aF2^mZ8rnIN4Z} zM0Z;0iLVgT`-6-88nK@wn?Fl8`Pej*ig2aM7!uPZM_nNack;+%SM<`~)uo_xkm6$v zdJZ@oAw{Hl6B#|IA?wl0beH5DB*wm%P3(wN%f14hAn!|*kUg6$`Yhex3wFW5PQ?_M zm7^o!om$c=l>&;Egb5y@O8@!SVZ>h}E-76WUrj~PhPp`%%77e3JZGM)$dj4RV$6X) zmf|cdDl*xed3JP>l3DM)u~kFaq9tvXwIUxe*ELtVDL!Rg8ygYGmc(k|cdI&PAsAM|y=0 zNhY89DXH`=+vvwZ>sxP|Ch5cuHT(yi^s4VAUkaEVly;RiYD&XtbUdEu>BOoSr{LJ3 z^bS=HRDDP4YgSH5jA1%shIi_Q8MM7Wer?e4Vr7mif}Y5Z`{yUkqz@9Mi_*8}A8&8V zZS2RIeHv8VXS`+VeR_~5$%KCbj!n9M=s0yGNV7Lw^@Uay1kIOz1jG_0Y~*aPH{Qyg4ZD znP17YojI9TDGx7O4czINJu>^ETw1ZbL>aD()a&7c;8J1VvfxkfSBn{D$ zKvQ{f56&Z$Y1^G$eBGK7^e?4@QaEwp#wZVjgAm|h*RUzbw#M33Yt7n;wOKtKmKk** zfB$J^vKE>-Pw-6t1pyH*bAkVhZq=PDAp8h4x4GR&|D!7z67RS;xVK&7~j=+V@i8f}& ztTdPtqFLoyQ3dHl(Tb*99mg};c+ge(1(9Vi;X`K4%=nK|w1%)rR&^!21+p=>Hly5q zi|souGNB`z}MTaAcuc1zre32kXhPhQ?8%UX)bIWM)0OuW~x zPr*&c$!qs;&r*>K)`SdndzCIWqP;#x+upGArNT-j?#n};;=#Sj+Q}lG)NV;(4$Q4Y zbz&C?C?x&$6GsKMM!WsH<`0#`Tp^13x5NY|KCcW==T?F`jl$bDEzcUfZ>{G~6hL3W zeaju-PjDJ`A)ia{OML8(qK%uTmbJ={zQOXSs>H$<&8#bHXJ_Yll9_Q%bGI^gP;CTe z=&3~D?6AcgoAr5;^`X1y2EEawCfdF715q=^Pm_}cPwKA##jx@EthNSUse5WW+l62| zEJmS9Z{gH3uhp(e4m-&cDJ3~m-ZCg;^FvamF6==26UECAKh5skd#*X5kk#@HN%+PkGm&~C0s!V{@&8bH@j`&k@bC5w^y>>YvEs@Kre=8D%&Gv34)28ShOQ1-_#?ly zUE)vsUM9M>j}+~1yb)+Q*378{g`a-a9r=EkA#HMN%D|9KhnitwX{6bGw>#*kA8WuA zwmp?heF{Z zyQdVaR;JdVhC+{{z?)CCD1S`dE4$sjVN#tbFv6>clinvC&DAd`E8H(hA?A=XN#LN+ zTm;qhE|{P0|6p&;N+d=D6&gvme$CT&^e#~R3n~A4hd-go@D;Gk<1ly*WBgJ|E|oxA zTVOsjX3Wsy9&0Ut>Vo zvv@46JU8%}wXYY>7OQ&|SQoQlR3j}mlHC*Im+#i;o$4A5o14@eE zzyEAzf~dsWl6DZg_Ey>*gPX_b-~;U~Rg(I2iwXW*g&BBGNbP=I5*^A>u^;r zOZ*5G1#(=8VFmhipg>D-P!$;P{7QUDXX=%N;DaW98?_XZN;7}FElZ4a+_^Lh*cHBo zOy#~K21I&?jc!2B2((*C+i9^LP-|fW_^}Me+_uyWdbjx=8$EV@S-8kX-nFGjnhX_+Z~ z5YRo-c$mQBAJ7+(G7SD%01f;doea2o>a+ffSXbY?Gws=LSQnma%Pe z!)1RjpoWSRU6>_8^jR{445A~l?TimMXEr9FLa+8;w4KGzG_|zK;B!k#mw1;rG&XOI zF2`(a9Tqr=@uB&FlJ@(@h1WkDoKtTP5?lf1a*$yMN5b+Om?W>4s!=mY~;9&(Uydv6+ zmEKf*1|OB0Gjf-;^DRN{EoQw+tB!tBJ9-6-I)usaeZ`mqw=54&!X;ohJ%aKqcd1tt zqgq=HsPE)$m}}c#Vh>3w(NHDXeaMXK5P1(3m<+(#6qzj;hBDV{|> zTmf&Nc3-%lHeTn~#y=aY8|9VRioMidbPcRF{|(EQ*lx9>wSCJ`BM-61SdCItKlC9GvwzjTa~0$k23?3Lh(< zRGpa)OJTsrI;5GzGL8#~*x%X6B>m%(E{(1s&t&d&LwKCun7V+di zaN;)^(JNS3HfHj=0vqlugT=ge)H>dhI;_BJwb#!yw;;!AgB8+`r0r^vmWx}KU*dj7 z@4mH7^9+_$ax9e{wK?W2V`ACf81Uy)*?o*P4glES^vN|u6shiuX4qXT+m(l9_}-(T(IyE7 z=EZEV7}b-a#jx`PDe%SN6)=P)REg6P*!CC^b4m?sIVXrYx48mdwYM`K2-pT9X&8}{ zeK38OkipUx1piL=1%a{oCAQL~P|Hq=m*r(B58oAlfoig)J<$mlyT3NB)Wtx31>{7a zGNhk+u7Iu>i6$&sf-DXn8M%`$T`<$N2qX*q<*p>u>$>pxQZq z;Ht&Q6|i$xW;gGW^a>y#K1ulvs{M*F#AZd#bu;VV*J0dAMq2+JK7*L9XxX|$@Hra; zD$o0O2-VI7|09HI=l_755B26haBJ4fjn?`-<3H}d^=faYk=fPS_+$IVwEL)}OBwig zx-`%A{@A{MY&Z%F&?{*9ai|6$30o9jPdXZvq}!+*`96#0u; z{$h&%x#jx*dCUJBoAsOXUIj8Y^ zloTodmb5=4uGt2ecLlJL>7y)e#1)W@cWNTMui43X0!o9Pq1@4Iyy6$V!wM7@crzoC zJSAJkPk-du5;=U=bt~6gm%;1o^aZ!|a`Q;?T}1hMcQ-d#H#phlcjH- zZ(>X$P||{dYDsD#eqLD~K(a;Up##%x8@y8=85>0CT$4pEX`5C3LVF*^l98poVQM<0f>|hrO${zNzq`9oQ-X{V@;CmWwq*~0^x&(2 z?%_eD1cU75BFvMLtjzYWu_JYSG2+h<+b1pJX5STro(&=z2h=TfD% z9>a5xS!x4WTmvS4oE+wA`}!T3kY_Ln;oZRSDLH$pL3<41G=uNXUuWt9X}!9ZI!{L- zDmOzhZ{2?Fu74~w0JyqcS2TvOqpG9)h3sXfco(oYShSL`;Rh|~b~7+nb;733#+$dT z;#TkE?a*&Qk*>RZdj8;tJKLWr2do0!XmstPMc0Zpj2F0(t%2EI!MejaBiqX+9(#)k zb(ntH%h4A;&aw=AuJ|T>!}s(>S=@wCJ;zSRx{Z5ujgf{IcANmf*1$(_&bLeThG$|^ zRA*%R@_P-=t*~Nxd-hfXk<*w6*sNN?JV7!Zjmo!Ulw^bp4>&B1mBW+^!_LIwghQuF z2bh>8Lgm_`Q!r#yffe&_i5CUwRcQ++yYHBVo=d-4X6y(MUZedId1_jYlM26OS^fsT zZEBK=7=F+*N`djK=r|Ew; z`u}0bG3-~N;WB&L!sf@5SAZ(2JBO{0I>6IOGS6c~tfZC={O@lD;vl+rfzEw#DJd zS{>X16aav4EKQf^?JBHPgk}-uw(?>;P@dz*U8+IIGE@|K>E1`$ zlC(ryw~YNjQ`39)^bKPhj-xI_Zn4S0Urpz+WEuDC?<05VN<6lL*ZFY}kT9Ym=S9uX zBk@MDWUZn6Y4K<%1ApM|*A&QSm$CbMe zmmziSAe(5=bkq`+3TyY5QJ;EwrX};d){3r_xL&G0=JR+*1Ip+^5f*cS~r;@9bG!rl6smW)_?A*qD80!U2dON==;COrnp zKeG@NF|VB4VDvx{hR=<(Rz*(YFGhPx4~M+66ysIKc%MDNaZl*t*K}j&w9jHDN1OKJ zn_wQ6RO4Ch`AIv|XQW%wwpt^J_APl5j;Pp-eKzX1=4-QhTfP0sTR=)Fu&qj(Y^Kw9 zDZ-0g!7Gj9UKv5?Elmph?#$a`UrnnULUkP|-hEDTQuy@xHinEbP_lc=V8We`bZ*v8 z3*PAA@PW(ytT-cPY0K1h3o`9V(b56Ep;S5D5oT1So_sS}5)lpF-G;~%8}XE%L`BH6 z3&ipJw;t0I0=lRu4I^iV%whM-+rW(#oGsmR)hi$_xSa9ynalx+o+RChcn#Ek#f^zPSI~5mgn$L}dDr^Bu zf#io{=vcORA4o2@pr~MOvTdGEef-=StGa|bjwiC`)i=Rtg=c(ta}oE+EplziY5M@>PPhrqkR6D^91!je13l(bD?u> z)V94rIe5)2r$Fgzx(vW7T+o1df?Jqshp&A=d`YP;>`YB7elX=N^)s3i%^Ku{;JL-F ziAu}%O5l{T1)hvB`(Y))z-_I9S(|miPlZp0a0p;|}s2Y1rA zh^rOTLSAKBbPR;{JX_ls@TLG+h|_6d#`ip}O|lU4;0~lcFb{!%M$1Re+E3?RbMtAv zeR7t)6pNfpTTZ*H0N89zBqbR)br|S+Ph47ht6z3`U2usfrd0Kc2_>Lywh+5V-M(bp zoT1;IbDHZj%2j+uxfE$V2Tr#;cJwbZo$HaaWnpLDD}D49v?WVF*)~Ytir9AFhN8~F z?RmTWI4kufJ1=AGsy2RF^oI9K?A>0gJu-%cphpZ+A5oo=b7?xCecl~qW769XH{Vin zC|I8ve8Zq;(qMC5rZ38BR)3SwdqB!677g!v_8~BY6X?1d*qMD|b#k+NFCLR)pJXnH z@YJGsa96YQo=-^ro}Ej%#}~$avr@)Mk0C+iBvtL{&<%35%`1S4N9aOHWAt3t(w*T9 zX1MZ3nPvCgB|TRkp;$reTV@FgdMZgg2f))D2bb|C&j;s}d{oq@ZtbYbUo*{mN8%Md z9;%EHX8b*X+sjdHH2KV6TrBSF`0@~wTgF)s3U~o zWe`)gJ1THJ$O>yAgqxgI2zK06p`)}FO~96sz!LlBui!Z|*bd@@n`s)x9g#l&8kpz| zc#UBJ2TW_x*^@8W%8Hd}yXn8LM;z-vVZuxGlPl*Y1jc?e)=)p)*TBujbtiwrXJyFG zOmsg;g|R|EJI-fVmeTEPE9&kM*R0Az-A{<&ZVRrdyxo|WmrS;AO}t9^s=nTrMzSjU zCO)1zrQLnF_%2Rd)ZK#GO$crK)Gz*X7Cb^m7oy9DuWC`n4m5s5;Z-V@R~#M0)J~=JWerTIJ7oC&IaweoH4Sqb{^^DdEr;I zPjMLKmw^~Op+5L|dZM|?wMCnHUCI6Dx!jYye+Vm|DR0Y8p5iVM+_}`ubS{7Ntsyf3 z0fCRNzQPo7Mlsau3J9S`{5&~tLR@zQ9H-y zmyp1(^5-aB_>ULd1Y6ht(<~DInk9E#DR>yNi<yi0P= zu$HyG{mWBEgu)O5v4U?ak{)HN{toUx79z&UZTfos*5G;b-@yIb8*VtSv>`u#!Ey!s ziRU#ixBfn_8_r)4|Ldv$08Yy}=qE9%?Qv!8;y|V?2C2Qkh-&WXA3R$R455gp7ud}u&7n}+Y)!9dVou2%a_xj^x%&&^!LsTLTgNz(? zvgP*~+3N&ZZnyzOTR5nY>hCGGU)LOzguH)BguQeO(9u literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/dogma.jpg b/src/client/assets/larp/images/dogma.jpg new file mode 100644 index 0000000000000000000000000000000000000000..de22f87ded236330bf542ffbf2c79c38aee00672 GIT binary patch literal 14894 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 literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/helix.jpg b/src/client/assets/larp/images/helix.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7c16c21706ab35d5fa43569cca77c0f9f183eff7 GIT binary patch literal 16289 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 literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/hermandad.jpg b/src/client/assets/larp/images/hermandad.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c4cac1f7649500ea9087786eb66171d237683812 GIT binary patch literal 17429 zcmb`u1z1$=_6It235X67Qqm!agyfLYATX4)lr%_pONex-1JYe1(ycT|*8tMp-FY|o zednC-f8&4deJ(uvVP<$|zpLN1erxTUv71@YeOXBvNe~he66g)^3%a=m35Y`_L{$}` z{8XmKtQPM~OsSlov$0b#Iysozm{Q5eN>RxwNk~ySzu;hcN@eF{U}f&;Mg^hbVaLAt z4H5@oV_;xnpkrfVV&Y(9Lq;#=*hE#Us0apG<&}no;2Y_0P=@5CJCASEMKuBx(>c z0TK!U(oGA95(Gj*16aM~_OBl#WE501bPP-^Y#d-g`F#*F5()}3Dhe7JDk`wr3-~_> zl>qI*ljox750wotsO`WUe$i=|G-4$`384eKw48?a{#e+Lh=@r@>F604nV7k_U-0nq z@r%Edkd%^^kyTMuQ`dN{sbyqr^48SM+`_@p$=Su#%{}0KU{G+#htQbVxcG!mpTEG; zGcvQXb8_?Yzn7MkS5#J2*EBV^w6?W(bao964Udc>#>OY+<`)*1mRDBS*7x=g4v&sc zPS4J7`9cDr{9)@~oc#~J2mrp2QBhG)F>d)nLUsWz6arMVC(qFzh$>?k*gd4?@WTX) zMW>bg#G>JZ?h+c>4`4r{<(i}0yJhW;v;U4U|Nl>%{fn`G^ECmwhk^ts4}}0E4B8SR ze|Q7Jz5z+CTR(EZ{B{NHCp2Wf2@0r;G%VL27ko4_Mim6y5An0r-n_ zIsm)d(|ACF=nYAFC}SclZb(L+p09GAN+Z^e75*bAU`b5Gz<&`Y#f*kz@j3 zT8w}iZ!cMp7UR~f@MJ^+?>-5e9dt@H{6qBJ_wEFRDFH51LcU`(2KZhpD_|=U;NaX; zJkzjZ)yU z&IS&Jec<>7SWY`j&|BSSveNCQU-o^T=wzL=K6^Bs(3WXEd>n{;U2hRb!Xp%UvL){} z8@ba%di7ePt5S%1)Yjg{NxQQY;koHk1hxD!Q#eRjK8ZP zuvM-b(8p=!GMc`s&@Cx*BNE||%(ckR=R^TAeMYTr>hXJRgJOC4Yl@HD>+q}ae;^z( zTz@yimPfu1&mTO_v?nh*BmIPf)-xf00bQx5jF%CWAm&m+R+f_yL1U$3y|r{Iz=A)c z0p5CNT2P9zvf!e>Y-3RP&NiKkjqbD}^+zlG0b{p@#h4!Bdut!aQGA+y?6&?-L_DqD()SN%a#M8h8k+J%Wn*rjln)`)ysU` z--GRKY&WL45OrdUGcz4V5Nh#qj9C^Gt~j+coLCt-S#)#XNo6f}=U|__%5yGSxY&xX zFzFiWCGv{^2d98Zs$z@JFx6{%Cuoye!EQ!K2H`5h4`+dQY$knq$fa;k3_H?<@~ zLau%HQj|5T#tL9%N*Ls3xs38kOlJQM9We<3BAECVTC!RoCd{l%L2S@+!>vF0{pC;D zw`!$8`a1#*3{%{C7l?_;oDs?FPQjrf;Lqs^pKpB|>ABPt>I%xV|4-4kyeKh>7JH2! zgdc==etJ1z(ju}2n^T-Q!uDdyt!QBd_PZd-%I@Xp*Y)Z zB|bB{SM&2?to+*^j!M~+n2=aXyhq~=NUP)!!nN+6 zq`r{GXS6DS$ZN+I#}+5`4Pghtc_>32jXKH4H?M3gYB~* zi)Or@O!sXtGGS|3mOq^$MeFQTEX&-ZrgU|M-_gj;%+f(px>!xn!$Kd|oVwUez^~Z9 zS+#69{eU$KcfG!Sv^e`gIh`8~ziSsad3wA4+GtIDb?7qt`0Ch_WzW$So-W0P5$-@E z<8*qxc)jxI;nGN1CzZj0ue@nL;uQw*nu4S@+1MtT#n18@!MIS_7-Pnz*KY!p5kr*p zL-MyljHHO1NV~BgK6;{9WoDw%i+>M-xy`~KN2Y`^FpY5Z@V(yH zI4o{zkgBLj?ww<(j80GBtR{bjNnL+1H`Bs+DQEN430?Y#vAr0zdD!DZ-9FMVR-pGZ zv*(#N6PmG-vYfOTlIu}=EPAL25V_@1vu@Rlm6e^W<;(YvlW^n9sl(qVWijP2LP1K& zgX%VpXQuY^Dgt14i`*Jrvh&hT!r|1Mz^o`p(CTRQ3D(x!c2|#Z9KH zdy4Zo;>)f+(uwyZ*|J~FY@%FlbQTrblw;M^nk7p@0(A$ueuaA|Q7Gn94R#l3BK@qICWP-r@FB|CB5O+RF-wV~F5nE3HN%hMto*Ses+ zd#77|=jz#kv!~CVrq@+}j*ogkAmIfqB6U>F89U<%juLUzt zwXDrA@lM3(bfTBy7fm(iXSOmU&n{Vu+udwh>HQi^Hk+%2Mh=Su3MV$sAq^Q8KVwW# zBi5&W!qsIU%5$5WJxEgSxP%}&upy8w{mJI!k)tBa%^88j5pV};^vD~Kj&NQvtUB^P z)5g_xeB;|p5j_@M4PIX>6j$#g0naKy17H8{q-ZM^6di zP(e}1Wi@6`1?}}bo`1dY0?&u=>_qfHTYB1RA>&iQO>&HRpLo~h6ru@kOzP`PU+?lV zwfHf*9@^U}~k4y>7{JsrDoWlr@hC|hu`Gj&Nl(O?wly%Xi z_nJv!G?5wCU(e_IG>K;fP1w%&E$AqQmghYyCwU@8O`K@q>wYnczSCCk&ZndEFi|k{ ztT2Ozm*xHsa<(l%Ub8x z{S;!wZPrIBFX*?U_!Lg}%(S0VUeJWXI|pUDtPH}YprRB(sPc`h z)*ZXZ#Dx2(>nHM~x4atFG`>!^y|*Z7 zD@kq2{XxhmXqwutWkwi1@1^P3!#eWXZ3sC8QVTnSsZLqsxd^2fE=b1Oru|qyxT@7m zR$cK7wswCTk5oQbl-7nViFA%gaj-TyOta)N)OcwD5Vi`2fxlAr)j2 z|JX9GgQf(Y{%hSs7W)eSOSgHhdd<3*y~A3l*2*QpJmKE6AXc~>3P&W{8v)*hqhjAB z%d`TrClcVhO!H3(^e|M*e4P;`@y=86{^_Z!e-(Itc`EY#!9h~J;d{4Pjx6A=_mfnN zqp8D_#mnzMGW0-FX zND;zIsNJ0UqW&%Z<&pl1W;Wa`#!aiW;9Rz`w*q!T3~Wn4QRY6Rv-KfFFp%qdIjVSx zWgXYk*qh6%b`-BYLZ`$sm?6(7r8kY+@K~5_-&nY5(Yss+@tZYzcj)vpP`7~ZGYSrW zpkeHwB^V+kB*{jNWRZK~V%Y5-JB(1A6Ta^ic-`uF1FCY^FS$m!ChQ8ek4(PfF zNYR%+Uz24{&dHxJS8uZn0xc}b?~lxP4!5Xo_ZcudJ{hN$`!&XOpMc(qI$fL~VluDBR>OR?g2SunP-A{<&d zW3#ulji(MO!T)jZy4>;PA@AI1R)r7SJP&2lk72GQ!Q~3~SRzx0af2v1WLZAE)N8{m z^b${2x8ok{2d^J}T(oVf5S?$}s3YeuW|&k;fzVzgYtMVASQbxavy)Utw!_+KLbnAy za$*-}s5;u(+u;^-Jzjc`((@!agycwMh6?-Rlx0~`7ak-effI;Hia$#3hL7xN+K5&L z6u-8g5mn}o&W1A>S*V(-O)$2Ueox+43&s30mh7uxo$ku<*he~=2 z*H~Tc>k9XhrEpyo2+Pmbx6n_uiZ8v;iJrcPtiwt`*vPrqsc%61D!!1lLN##debBms zy%5!2h|*|{!{rpI{%$cTulwf@W-jV2;`rnW5Qo-B6I!3sSyVfdIxNN=idx*e@IyU|wU{mBVv#vz<%*?v z>vYunqcPVNGmfW0#kOr;5gM@|qSMI2L$)Vl^IP22SJVj0b`2rDSTM=5CWp6w-6leL zp7ZLUp9-39yU{VVN}f`bdGtIH-kcP34341-J)b={t3R=;sBfwVLI1&0}92FqCC9C z)HXif1>{G61?1$2LfgA!>0i)VTv;55^h~>z2z{evB5u6*;*)TZmja8GztkbrNA`^E z=<@g4BEyb!`%w{7R0ktiiA9;*$FCd-cQWCX>t_?6Y04{$?VnqE5c7A(l>~i{4Hn*r z!qtYjG0Rynw~c6)^*_5)%I-SI3)JLBW_E2%3-!cUJhEGV#PM5Zpq-+9v@ZF)OIskv zU#&UsCtM%>o2SCuKz{S_N4t-4 z@*XO+l^`|gld)-(r&MLu2Q(24ZS|BA2&-~l!Y9A)h4KYw3&n5F7rct&OD%S-5)@?a zOR#Bngx?!852bvQYN83}+xT1cM!2<;bciEFU%psi;b*bLr0_K?ISTZ~o6}dg! zsF<9g>AFkc*DMPjDFdgD^)MccUR)gUG`_m?dELxPUCD4t;$vJ-K+|d zppx-fNlc<_9xZFvi=USzWR%k-*=loKZ&y(6T))PM5HT4_v~=44oil40R{O2$dt9sC zLmGT#y%tc9Lj(J8*@cI2>8}Th_On|mmlVtOo_PF)OC2M9G>`qWh-*HK9auT&@&p_P zICvaa$37SD=vW_#%`#W9s4PHbey7+y6{nH&dR)T5lDv#&Nfa@Bj?fbT=ZVML^a?g& zt}7;MQ#V~PebQhtHD_%JM&@RP=Kd@eZYQl$)#Ybcyc~RUEmlXyN~D*si|<3EshqiJ zcXGn&=EB;XtotFqY~e5)fyj*cF2teGSP18btjC-~tR z1!Pd47u`B%4?T7)cFo&A;xr3oDRW2XN&z_(_YRB$f!_}f5{N`t{s#URUcbBXdBQg&B^dRNBx^f>LlLa6rH=rHT^J%*pEUA2gbB#_HY3RDDptZfQ z*65d~L7|LASY({K7=cG^L(fp5DTi)&#O{<@EA6stmkeoM4GUW?SQjBRD3=Orujk1i zi7R94O&*FC)jSXs=cRl61krLy4)Uv2TN7Vlr)yg0lxDF@!yO1u9_vC$V|fXO@pj2` zM9S3o^eV!`SpCEBZu2?mk+0hV4DMrKnI~dU6T%C@7i$#POCVy87q-vNJ$UX)JaRIZ z!Mi2g&e4n|*YUC!qiZ4T%92Q94{f{P3CFLH3&}bpjiWxy$+gz3N_r3PYLsRD++yo^ zzn{c#n?Qct3t6+>jp3G75B@M7ise`?sL^Om?=2j8mn6Q8dnh9Tl8zCyB6;o4Z&+hZ z)8?Y?8oG0`hqtnW-&XyG`DKt)=S$XzR~WLrS&XXT+2O0Bc?#3_wvY55Ykw5snwKBM zlf$nD3X@{3wx$c+h97j}5qgqaM}@0D9z6SuH+c|x2*>Nos*n1p$XrFqUx}OIzL1;o zK68q9-DN0lFP^A8p|yxXx^j?JFqhBhWb(+O{wTvnU?3@4p9h+mF9%apSu-CBqxtn9 zyEI=abFJ@P%cC{|F9kQXso_eOXM*tR$wk-^tEH@hR%fcPr^91+(*57Hn$~O-Byk04 zKhf(a3&t;atou-2IM{29`7XzNg2^8B-DC0iDZI~Tu79*!?r)j4OJ>aw?wF;QJxi+i zMktxmIaD!=_08)zQY~?5135rKtWUBsxsZB1`EEeb7(lb+4gv!5mB_RET#@H`_SWL#_UW)>34J(4MP2fs z!P5s|yZ!*(?C^z`pRD*p#>U!V7#Qm;@~^ZOc2q{3Ih&U4QfgHsoqh-L5e62VzWSvy z^^~{r^NI4$^U&Yn3$W+3HYE5|0a0crgexiq(D!A|5Yo6j^~m~sLAYwaZCN#DGEJ!< z?;>SesVw4qC)lA4s;VKzt{R4To_|U|?KNn=ZRp*g?Il)6$nqk(%hT0 zad?d5C6wj{#7gO>+YmcviOa?E?O=I(6F78q;`icmi}Z4wDN)o*Cpwn6Y%!;1;u98$ zsWuYGQnFlLXqhCZU_{%3aKau)%6VDL%*kSqZ~SO6%2#(ci%f5xHCNUh@3j=VUs11~ z(2eE zVuxXI#Brt(XRYj!6aQKAJ^AbwO&KO(JB!7k$50+ayw0Fc zFVN%;O5?5#v$Dd^QicQND}Ch0Bpu`V zb2Mj49MZkusWc<&qsXrQf8-Zd2q{v<$n(SNX`Kd&b1>bO`9JxOaCXev&VKLWeM6JC|d19U-RfotqUOdcl#OLMYV%0^{ z&_#GambO#gRB*dmMrjQsk%pX8CRWcj^Yr_PVw1GzY4C3Kq8Gl!mSf-jF^f=CdpCQR z@Lv464Lw3nG%fxjm^9`_;VJ+Pc=UMgFw?NfIpP8!TobS9-X-!`d^>*NaCRHTcrpi$@{fh%a5 zxv^GkH>Es|d-G#9AWDKGPT7DJUv+mm(}|Uyk9hgz8Rc$~$#RtsNSCmts+=KM=-sEQp%_l4z7D8pu@}46L}*kUk6qP?MVv$3_1^8U>Wi>w_P#2ce>1gd&Sa& z>oM;2${nZUY_lze_3cFn%VYzV`LtB94Sf8ajVW`^Fu*DqBDgJUk~%Db7iGF3b=NzN zOCM+K^;R>jNaIC&?B&LHZ3_1n7tr~bfY)HA%=A!naM^8_n?Ozi}!Oz;=fap^#wSw(B#*GPLtQ~0Bf z_v2Qo`p`eURGJt`h1@H++!z$%cD4HHVhjI~0`&B2k|MhKA0AP5)%D56b`EbX3O7@y zj8-IkGs?<(>Au#hI2zQKFkO|JloYBwl;!z$X!yb>6eE1fc(OCOHu4}B*YY&z=O(w? zf=N@t)0|<7_!tSVOxi5)z#d`x!i&#w(D^Vm5_G2LiYSnLGi!#g{;d!BgaWwCLX~X| z8)e-E+QeDzOWCPRjp=A|6t%qNXy%1jDDcQt4R52ev&rm?VZV|^cn}iH{ED|R(>g6x ztE^xzti3=th+&4DA7}plw8de1QQhk7E){6Swc0G}KkvOch0(7*24YkKp?o8TlNJkw z^La$qLb_gKA5qt57P>_baL*k)mWwQUZc|?Wd|zw+sgDOQTxo%F5Vn`O4xB71<3SRJ zr4r9VNgoRq!Go;!d&G5yxv3s4+pFT&@Djol>)q)~XE(IV%&p?Yc}_5NuZhsQBZ$pIRK|OP!cTerz7i-hs%DXAl}bew-L*>QDc!(Zj5lDH0r=#7`(RUtQDP<4q+U$$J}P?|oJ`E{rgB`l~hXu;C!jqmDk!6W7cb!eCju@5<|LkQmA!WmAw%3DFn==dn-aDwb58^mW7*`P=eX@S?@;&vX%$*(Sp0A?Bw6rwhE3LK?XF7X* z1H$n++NisSPZPbdq0b%BT%)GF+6VtJPoo~At1R_;pvdX7SWkSk_eMl|wPqcfOXG=3 zOYBPBLm&yu*}a*G~rp-(1lN0w8Nb9p#97hgs*L zBg<;zF~1nug19T+A0`^&N14#nrmShbs+jP~j44!ZiOyrN@N3m13|NqZm#!+_zZhcI zZz|1z7E|YkeaSLYNt5Yh3&!$tcmdDn3Qus;U!v^LNMD5sYDvVjfJt7X(0CvyXA7P< z6@K|_@&?qg9CbcZmb3fB)B!U?tdBpV_%O-P? zYL;TTt`<7N=ERZ%Gi6}#OGdMs-Bl#m;W2hLdOpy+8b7|;Ij=xBWBU6c-_BrJMm(L; z-uLgx@5vAmHbq}i^ipK2lwOzJd$;OF8R#`JKYoy{{*D`aCs0uU9mVpO<}$oOK92>|wSoGk5-;EcX%%0PDKls*Tj~+G9Uy z?Po!(jCEaE4<1Bm7NKl|i^7&JL%@NfT^%e`Gd`@5g=L z)m8BlyGY9wPhoayG9UgffWB7Gtcb!?GlM%p>D7`43oqA&iT{*y`gP7|VWfq;`;cj} zfVK`_kzODJ_w4U`7`Yw z)Tm#tYs(Kaix&hQ80kXaC_IwNPNI;Izue!(#|r)-dfRJ8T9Z$pO(qJJz<->l4&|jzayN`J#zytr z*~9-?>ZDyZHa{cn*0Fc58)P;RWBpS?})?dvWwIA(Xs+Z zDr1piEGvq{u97EBung_qtXYT8Z&89M7c>~f@|R+`vb3^@Tubu;S|ToS zE5CE6G{`Far*yt7moHTWYq%`@IzxK2Cxv;mZi#*aQm-FQb; z`uhFj9aSw*R@By6=jHH5Bz89aFqg+0Gp=I|jGj=tSb1x=v@7`~(#&}VKd0i<*i3q0eq3nb~ zBH5=gvpO4}I8dU<{7%)CsgB`AISR6IMzo%RTHnLp&h~a8+PgtSFH$ekS}jBubeZLC zIa)X3d)(UWBrUOK8RgOj?se7iPag4J%4KwMJd5&Rr_L0j8WidKJ$D0YDCiPbt~wtT z9(S~@<+(-_SVv0N>?OzB;K%g|xHHdsJ9TUb(bSIowxncxGRS=c5*%ghEMA#A@ipC( zn3&crkji;^1L8EgG9V9h9QZkqQK;OhO|DfDzG`{NyvwOSMPHvM=G{ZkjXBfSeA?h& zU*byPpH@*3)H@;IN<+{}8u(HVgq%ap;wHO7iZ>irpZ8o7e2Z%WvlYN}+ifBQRMddk zrvTmJCuV>3p#B0x|J4Ujlv2NIA0uOnk0wMNr5cC=hYe%8-`(UPJGQO66FpKY>Z&WZ zxbDDHPl;?91E2ej-oZ5~%35G#^CHk0yiE^d*ptcqW$55|wUF8mN~U9>plZWRjZwtt zi<+;Q37?{9cRScS7N`&(PZ4wE(}@iiqUtqeWMzE6a)yT0OX54s4zQU9B?qddpoMst zf&>ahsKn`E10qC@1&E=Y`RZ4>wtvj1VP)7QgMbc#J0Z^W}a$8I+KdyRNvHvv0od7y} z2u}*jS5AO&u4x6n-pJ=G+m%fCZo|>t+&`gi=) zxONZMYFp7cdrUVbfNb0%5z>L!c@NEduUJGe_Douueg(+jkPvPl1uWvzs~PyL^eVJF(FM&Du_f+vYH#8o&9q z38~+h+AJ*EWX&iq|6mwI*OVUyVt&0KouL}8rYs3&oW=G=d%Fk~O&&JoN^;KHQex+d zQx<6oRgnQkD(|KyZ|gzvVI>UCyJY##geU}<4;5Za`8#Unu9GAGl{TZM1JjSU;q~u} ziT`fo(Y)9M_MKPgTw?^1uVT$1 zoOiSVcjFi>QPFH?p8UmE_?ddBd8|?IDmq+$)jj+3(lf`45X|dKWBlIS*+`2tA}c5D z9!RtHtImk=*=xc9?bGu$FwRHEsPhP{VsWrHcf2fk-BWrs=R<+woasAns^aOVue@e% zKt-B}WZq#maN!dwjG8dm(pmcV2)y^Y!wM!5a5__Jx9#lesJg+@lI?bGT;eQjMbVTj zCpzm0$=@R(67(!83cJ(g1=0`N<*O4RqnwewwLSQ=#d*%^3VK^lGqyh*G@fEBq${#x z%e3KhPq=E&B~;HtO3U;OOZzzGF`o*LlW&Y2;s+;_?Vf7*cbb0nSt^_5^rna(yaBz6 z$1{>%@q9iT{DXeb3HU%V4?f@f_)iY}&J z9kr(iQZDFyks#ZA`tG<9Q;^O^)RoN!|B5>?vLAc8WY;#BYtrkJR0UM{L#*9pkF~Pg zm%#VZgWNcz?0>Auv1KM)hh`cUp z2x<`H&V1kzGrmc=+emaBm~xp6{Lj&+>4Nf*)n{?%24o4R%))^+sIrtI`r1owK)0pq zkDY6T43LSh`t+Ci>@xCjbu7lD;uulH`$6){^evY zi1qBeB8DTa;md{;t=Q&~?WsPn)89170W@v;^q1d&VsE$eXkb2y_>ga5SC&BP)iapZ zOl)WhI)BHwBGIhvGl*+NpW_;Z>lG&hc+|Wt#C{Y``hgBUFh@zx^%5 z6M(f;PY;w^$=^!XY2h%u$*!!!Av`Xw&94Xp47^El#pX)p$F#R~^U=PvZmA2sfK0QLt&p1XH{HriXi z)v*W0AHDp=!yiffZEJA4&_TC_80j<@=}(Sf`O>0E#^Ook-;;SMK#8Gv_-Cj>XFejWADHCxy9Jxt z8qX`vNzX752>4&|poqg5xt^XJLt@CApuPB)DcQ+M%;%Qx#+xJtKUVooxtE*jOPdp5 zY}LE&ZGBpe+AOZyx-Y|i;GP*`2y6Anwz|U(A*OOejyvyRkf|gjTPG$Q#b?ktduhH^ zbPSZiti-wP6bF>DWZr(sRytJWw!}!Q*)Z>NpO#~D{ANmA?ZUlo0yb?=g?L)Y#n~$} zl+Mmhxc;g9g2Hb2avFIO4-pT4oO9mtE{MwI=fYFV_6@`}(eK|qyEhy)2m+{ zt_E={v0LgC|Lar_DQUO@aJ!GPZ)!KM`VhA>_o zVfN3tf+kF>SaG${%LNQdJ5&Q` zk{N;Dj&_aicx?MM=YIAK=8&mGyGVX22oeZ#lepfgkbTWayBMgHk?e}%rxvVLU&z+; zO6~CX4Jb7(t1m+7v>|=v6(Ap~mkzsQq?UxpnAR;b%gHasg`I)N8v^ z1&}4!4JchNeMXy|%qljBG5{Yj?aS*oS}#0D6!MhoqcwC!!$kY>mnXpvY~C)D>|P-l zYoQ0&hr$@<1NxqPqXW^No$xn-v@~>BXQGrR1D8D@rPB}Tayga`36q0jVjU}mAwDCW zJJCN+^xY?R7aLrDZu~MLHB?Zee*5Fsv#v4Wko0LyPlwqVc9a)I8Da)iiVM{hfUJ^1{W1h9rs>IQA%*H#IFh8 z!?eY8oe*07!L#^X32n<^0Y3!5FXR{pTSt)}$v3@QX%MnOd+T~W^9oVNoqal_P%dX$ z9re=-?Da?qUpVcGC35faSu#~!?8TtzAtB;a8TX`_)qBwD^L$NZH0St7kQquJE=m!< zXt2Smg3ts;byWmro1O+{#&E2k2t~$j+jN)hJqs;~;eo8)8rski%%uu?MWHXkX>hJ# zR@&04=hBI~GU_UqPjl`GKi7qa?Wi=@6vt;MsRsizlu$~-=<`84McD|CF> z2<=d2zk1W|X1U@tBe1*qSwENC-Cg_^3G}@&dx3#b&;A+`0=MdRf@N_^i zUd`Af(Wl%9ck|euC8J80w1dm zj&QH(tB^X2eno<(CSApbn)OuWEUh`^$x!M)#}1!+w)wKxBJxi88U}V@*H6e|Lb#VvagtP=lGD||_KN^x=#5k*m+Win5$ z@@iq4$AVsz!o>NVQy^Mn42ew5Tvwj!s~a<&qga6 z=!b$6b1b?U1l;ybOwNKABD+Zs!bj4s8uPwVnWJxu8y0FBFf2jcqpv;s(a=cVK@{9q zS|zl7(AVC@a3HX|AW~6Zt}Hu5fA*MJz>aeYL(nta=sGGD*{D_oH2Re=bOCVYBY$x~ zYH?ITlzd6SQwJdzzko6?B;ikl3{6ML=Q}Na4saU>wW1x)O*o zX(}9OXR|a`vTX_d)dC9-`e>K&yTgBQSt3D#kwf-yWhjSY7-_A~XcxXYs4e5;VOQN9 zqhlyu-B=%0b;ShhCS?d|ppbHy8>xapkR?A1Du21hdJ;KA7I~tW<$7lL%F;-5?=5_; zYI{iGtC3vCWwlxqJ593NPG{=9ATmUjE12h!MZs=Bj@ULeZs8j=Gi9)Dh_47&SZ}!O z7fS()c7mK`>u*8bltEwG0d6<0f&-?fyFHIZ-}zphezrC^b3AEvkM9jf_uKU!G~1Tw zrnHr7iwEx)Vn;ySl#k#fecTPx9HW#?jk^($ffu0gymEVx^;j<_5V6@YTiq3ZwrGFx z6whf`)-K|X%MXkF9q9wB8yhL&QEM1vOW07XNtiHM-K)R_+3K7=ZX?nHNktf% zN;#~-muGPT%dFkb?8RbEeRJ>V(yNc&mx+hIrZ3oC-lc(xH2yc2GpShOuP>n0hfzY+ zgq;szBSV5xx#n6X+g~kKS$B@z)-~w-ll+oXl`!%;t#@nm%L>l}w-zR=V=N=5$=mtX ziOAO~UtCk}VfdPs?*6FyT87n~8|zaT{aB!6Q__cdufo3j^}bAJkq@3LTz$6)D4kR{ z9(N~_0fr+KHg7~vlhqTcY*X9yE z7fc;elOC>yV__7q#&IAYfayQ+SGn7vIzgma4>prg;YWdbIvs2-?cfK1`T z7fG=jB49K5gTbm+(H}s@h5;n((5G&-uB}B1?Y(*0vUafkP}L@2cKA zcyVW}iSE<8=CB&jP6heKnsZ*14T?B=eb5?36jFOX_I~z_A_ym9lT%HKE=BPJ9aU z!2k0q@KrjVx@UnT%R2m2q7HHc5@7{OV8Gr|#IKm%;}-`!5a zOYNVmQT~JN-v35qcl4M2uNxkR@M-QB5guv$Q_0|eT0q%<$K@Yo&j0_Zr~g&5|NWkP z&j5F#6aLGc(7uLerx3-OSmKsx_I6}oavl;w-cF56Gd<4D0{lv3`I_7Zc>481`v#<< z%L;f|@TC%|@D)&BtLSn+$Lb*ln!IL6WzYd>)C_Q$3KC-$fV+j=zP&|U<&}Zy2D;nl z*8lSb`-4y|MkK}@DD-yuXDkU;FevWNR}y?5OYp}44~5+>xV_asUI4u@6`2S#l$-JY E10J%rqyPW_ literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/quark.jpg b/src/client/assets/larp/images/quark.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b640f4240d50a8dd688b1d52cd8e0e8062823303 GIT binary patch literal 15292 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 literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/solaris.jpg b/src/client/assets/larp/images/solaris.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d7f0ae501bbd693714fd7f8e80f31821ea88753f GIT binary patch literal 19047 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 literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/terraverde.jpg b/src/client/assets/larp/images/terraverde.jpg new file mode 100644 index 0000000000000000000000000000000000000000..feca5584facbde7932ffc1b9667c13fd356b5fe1 GIT binary patch literal 21767 zcmbTdWl&tt6E?ayfnW&|B)BgS+@0VA3oeTX*#N=agS&6zI%*!;rc#^%=c&hFm+!6E$o;_~YH=JxLX zAGweKDE}7gf0F$_$$i zb@vN89?df%bLUAcVtU>U2KYas{Y$d{cY+1~|B~!~g8g5)76C6&kPwrHLI98eoQuXA z@chx<9}~f>PCSy6uhRV^XY-SoTg+zYs{F1XgAFYv-hi96olp;(l(Np!d)9YSHcW5m zs(@~;Vlsn`c0j(0^rrk$!?f9gZVo{TO_n7l{m*R%kL=g~lK$_d>jK7Tr2i1e^ZvJ2 zZ|47wdHE^&znqLCM&-~ODxj^nK=3s3zwrbA;Z#<4j5NQ8&C+`^@kskF|9^eTeIUk8 z`#1ZS8fO2wd-yM_l=UXO|Cd+U|B?PfK!U|$%Mz#|O9=R;${Guld|jp2EyjTF%;YxMCbnu@UP~P z9|d6kZzI%L4aDM7mPLM*7R8LjoS&ACM(AEZ7|8tAriifX3NM@rukHk_EDR|PvrLWZ ze`C&x)XmuZC*lb|q^G4Wz{1?#L95PARcWh~?@$8kMF8?5fGa#1ei!S!MuA+Iq51B{ z=&3$9d9T`i;@r*v&p<_I8;!7?xe!@%N^=59mM{hJLH=P=L$XVAN<$J`QI;LSN>UWS zK#4imO!i-20>O7mojQW}KaHIbQ;9i0D2zE*a{^g&QWhCW@m6!f22fB1(3-nWUQwV6 zCQgi(Ea2r&z~78I;vEMZlIZyq8@gVKW*l9vA=_M!#tb=7iJ2sTUzpPn%%3;0K^zqD zyXn6^1Kbp=M&2QL#@(Q~UdJkI6jEbd+jIP$niH$;Cse`%03-~W-8R|rRJ+<{*e4$U zeD8YT(r0^LRKr5=^`WtQjbJ=@+>vVb8E`*aVSgr~E;?TRKBP%&{0h<$5Y<6aKV;aL zOyL`yk>>=jm5zRtts3q4PHuNs&b9E?Bvh2Y69Gj)wJ$B)ySL=N73ps8oxM>g zC8{ipAL!s{HOZvGpDD+p{?Ob`Z`|qLBpSVUdR(48GKmC{wy~0H5O5t@k~_&{y=P3^ zgstMjjs-~T4)~!uMg)52hGQ@K5?TV9`jZ9DK{I=zjgs zOkY}dKVq1#mR$-}o%Ka3srgPbN}q`nPTTxJt=pg|Z1?@e7U%$WUS$D#MetjlN7ZwM z9L)0Z&xbvt_AVtBn_Sd_c6P#-|GGmgXvAh~rHhyvDfvJWr@QRr^Iltu{7b9xdOO=}ii~5gWFRZba9sV|!J;pJ zEgqw&bcFN5fQXCH6_9y9+~T$3S@7_oqtV0g8`hAJlZ<0_-XmoSd5mxP6mdci;GCH@ zqf!=&;QM|^S1Pl`yvQ+LI zUJwuU+wbn{#W{8fP|5LM5Q-#K#ln8am!fn{Ml)@2j#~enlFsz)$BB8W)K9go6t%DX z6vy;yr=ZX{_A0C76|Kz{Y^v~#mDQJ#Z(FiY*EV!^FeA1PykP}WXFr|x4k>Qt>yDlQ z;beYs-<_N}7!o&Qer}=A7MaudAno4fhjjPb-+q(}F`gqH_OR5^bncj~O;TDno6NXe z@FzA57y&L|%-V&H_bkmCErDd1YAtoJUw^nl`5L!rqsObsO|YU0v{r+swU*?pthPd3 z6HckBgfF_icM?U8zjvMH@L7CtXDQ`W%up zqBG5>?O3y1k+vQ9rOOZ4U)?XxQWPc$7m|C6m8rwiD|?Jlc?hDIhHfD1g&jLlukS6x zNUJg^x#1xDmFf#X;@)2R+57-<GFwlF+>wA=u*5E4in zrDPz9&?gARqpT>|gV3D+Xiwyzf2}o!f2}OEf3)!1{ro^I=KORl;R)|a9k%1$wr`e7 zYGs2`imfyR-S!NlM}!t^gUBt9uY2K3pY(&3IhIe%Zv3=crAxN)Et+|^A{LJ6kFw6*ah)FC#u}p(nE~nH9zo4nFC0fiojT`!arXNmGr+%9-@ol_Ygyl$vE^CS!b8g1M>Co zwO5Pjnf|L6(%y zh+|7ix!*g`Q}r|5>Pq?ffh`D<&MkvTGOPQYT1xtsfX2uZM$Pe_b*FQ z4nZ`LbQ%kCEVXNkhL&S4g#NARy77mULUVr>I;TWaBzoTr9_jHIeGvJUrt2;?znjsc z_@IJk+XRmbarojmxg_B({=s05Q8^yRST!)z6C2>YMYPpH`txZTj#gBS0an1am~a8X zHnF8h!v65T8=`g)6U^7LP*v*kL^=eQ-nuKmRo=VJuMIa_+{C(>;Ho`nC;Y7lU_CB7 z;}kPgpRKHGno>TPLvslP&YC*7-aNG9P{)mhL+BA^23}5}g1FDR~t2XR)-NIP)l`sg0js(+@QC*vMP*b&OD-TAwae)i z29j-mY|_!&35AtzlfjjiCfd|I3Wmz{MjUf8_@7c^RV4Hxrr}gyDPeB2QQsoYKQ`U# z(rcA~LnZ&R30@;WyXo03@SUE{6}KOjV#MUhtUR znW6q*nwZ|D#wpmdUkZIU!23@JxowN=WsTyOeB~i8y`{vsKHS}=A4@ECf1BAI@C&OO zFllp&4?ToG92zewXucOEcn{-sMOiy#YN!qzapCTZ;AzT4G9roaXB?z7owRt_m9sV^ zNF`;YTEA(p4EpA_JUBw-&bJ|JxyARDnV~XxXG})BW0r{rwA}VPpv@(!Bx+`=O{Dbj z6s?ZQ(ThBkXvJ0>P+hB?bQjxKkkn47ty;&DN9lqSI@?1Wwkoep%Al2r6&ORI+ahn} zNWu7KLra>g?xhn*WD`g9zy|ax&-RGvE-a z3TF-I=kdoQH+6WL4LQqE0Gr_aXeP03aUMA>D7k_p_cLRJUQuk(q92M?kT$ZUq=k=# zL${38dUNi`s|L#(&-q3gKVCs{Hr5{Eh_g4I0hh0h-S|T2uMN|6e7tBxBk>^;Sa*Lk z8^B_6n+rjdMlnqf;Qb`5#0>bK$TVbFUyhA-&vNePzn4#`{h_Iao5TCg8ipf6w9E@b zq%?9)A1qx3t*Gw_Kp|&GY8tjq-Ums0rsGAfuzU2ZvN96lVYQpUZlXFk>#JJEz35=6 zp{g9>`ZnMl^c%GTt0aq0k`nP_fB1O|CW2!~u!NBP*&L zvFP3>zUlh*>NMgTfaSno*>zRx3jc^sT@rayfvZE?Io>5SXLjKY_p3q=qAA1M3*8W< zw7w?igP%%jqxscoREB*ovz+JVM)aB*Yg0yE+6NBQ9_UANnLDd})V&!2|cjEhMN zu@$Z#NcL>R=jXclR*R?9Q$@t**7UX{MwFRp#qU#)B??=}ZRh>f-e_f|%u9k9-CA4W zs)p*4$}@}Xw`%I(1b9xEJC7ZIboP09{9H|ee*d`wE@e_mjD~S8VamS^WB{S&zl?2l zWP~a8QUqZ!2O=pIxB!G0qg*xL6zP-vp+WU0L^|*`>0XLHdGU_16MseN#}xZ_6fq>e z-lGoknKltu`zvhc@LMD2s!P5T|CjLV)T((Yd$wO_=cONCXF3QDNI|0YCy(7)8S6Sj z8Ddj1Z%hV$?%5t{D(1Wk$_{Rc$2(AdRn=EIB^rVxfA$w9RqD(4)IT$|!E|x_aDv($2F2A|0Yp4v9Up|$_rlRz?61qoncG5?UMFMh_L{x?-gN!aHg&n3EO97 z^MROiR_0$8fxrpG87(-$FjsaVp#9bL0X!~6jhJ|->X~k&u4bblc?t3^S1A1>T+iHS z#&7bi7Uv)`=0>arV4b;NG0t1Zp$cld$ZNTxin0{}OOpuodaB=QeUwZ;QCl!W99R#7 za43J)iy2dI$~?$HhQaRP0a0g531eGl|6+e}eR=c`g#~HIlwrHMGH`fj+udDEnIgl= zTk9vh_!L7khoDUcEk;MD@r6C16486*I3WK`f5vqlxx8E1IIUnWizwMIy@e)irhV3B zVbwgRr}8P?dzPy98IhE`GWNZUMGZD?UWay4Rm>`evw^yRU8;AF7b?lU8EqEqX8aN> z1=(-=)NHqK{D+>7G=ETj-6k{k=@(d~jY91t*hN-wHF}-tR$2D>)J!n#1qo%j9fhWn zNUj)O&+5CW&zD1=8Fd-drIZw< z>S-?D(7G}VItdr595a-xo}*!L!pB>famF#T$-!qoZRjmy4!wVYA#-&J=@7C=qv2Ml z{A&G0)V#@vRWmJEG`WwGxOHNiBf!f+ZG`%%KGZlMPE>&p82{6ti62g>7eES;e3@}o zCL{=wp$L3E=3^bqUe{`ZH+cO5wROPC;cq)8^wl<%(#lz9T4Q~^e@YZNZM3q zuGKaPR9&leM*_bzr{WcHnuMp5$iKyDwBH2U8tL;k$MLclFMda=C9(`%i>Lk$RKy0& zmfLdKUB5CMYFg$bpSYjtAimWou`LF!NcA5VT4}{62H;-q!`eRIkm-s(1A01f`5Xyq zhKKF#@$@a<`iHdZ!^eAVeVOdO{WSI~UWl7xa8Knsof-p|7({+b0(z=D>xE(g2c8%o zBx_;z1Bt~{+-QF1DghC3y^HlRg$p0_DXnGMiJEB?5zz-M7G~Z+L{I{q5Q{k%t)Pfd zoksin8}6h^Tq%qqZ@jPmOm=hJ3}LxsPb(4H<7*~^?OvzA(zCF%3l&FP0EXRO=TvR^ zRP6k;n2FBJ){K2ZAEw5IX?Ag2bbkx%I`n2CU!plA4)5j4m^&rIOjvS$lMbb84y#iz z56DRI6MeQK3i}!G>7c^TiR50kXGie~4Fc@Srh1y6{aVY+ zfBg*LL0P^gI=V0#EY?#LJuQ31;%J1`G97?L#?@xLsqi3ClvLHSDXGJ7CElq72UnCq zIVd+h-qB?ysToYbiJSRb%N3os?@Tx8pAP5j+qjKX5(bL0eglYH*Yn6DYX-g*FWY^K zw09`1rSuE0@(#@jWn-@k`)Skron+5tCd1_AK7madunE06!D@}F$f5MDn$jjT!a*k@ zo_Ma#s}6kAkWPVeE^rg7Ro|TP+bsiwh1U6vJEU=Nai{3=z{;V5=4;k8o{5x^mB+M$ zx>1+1iwocK81Ff664$R!Vs@O|Uxa5@1Qc6mIZ*klN$AggurBQ}x~>hpBOL+FIZ(PC zB0ivW21@9ZFRqpRt*RD&PxLU zJB8Ufv7m4T*zO&auQ+~@Kwl~I+aQh6fzlLZ>JM;{D9X)Z=;+>KNk8cQJSR56)8v@x zu`;9CoUWKj66tdyAv$SaMLcaWg!P|n(hFAuIGUnp}uicWfL(kM4wM-?9GYRx& z@>cR6q$;|cb)Q8zpvs;){ za7T`s(jpu$7{WUSBlgNzD+BnB7?o+_6?qe+*a35K6?a?ypm|yzzJ6>ukeJ+(V3z0u zCrJ~{Bqrd|wukqUWn~u?$zQ+MYs;(8r|Q+;WZc3*%G`^7psXUdBXjjM8%<1oms%2yBw-Jtmo>#GmG9 zfCj3FcD|G9(1KAYz7}|{V zjx}Q!a%zrtdm6)>R)W;qWH$jsHcm$^^goHPsmTi*n(WRhB0#GiVY00I{R)>AMdCJx zFRwZ=!Qe_;r76yfydK3r`{43UxT`mA5dAn(lTCFsn&-E#S1yG|1hv)p!|sZgS5V!S z#<0`bDOudg5~wfit-@d7Z~HIi&!Mk8dv)QW$FN5B9cv)gS;egmF6(KP>7siPUoBqK zAeB-fcrvys*GM&`PE$L=faPDVdm(3j%%mh5VOtOd8VDQzcfv3F|lo;SfnQGzWI)0;`AhP!p!lnY5QKt;pGFDgx zvm+FgdiIB(rd2;`>ZK;%xl62(6L^;6&I z0hi;LHZKbIzx3ZQIJN(xNCKzc7H#5Pd9$}QcdhYDNP!|tS9*&Ml8u`B)M?Cl?)>X7 zR!agnib}`5xJE{I;v4H-ri{*at;DdJ`S!(8&-T&^bXrsu`jq6=yf=>XK7Ieh$y+Ta zmGG_@Jk9ptlIMl_wT)bk|KvbBBBj}$;4L>)2qJ?}w|`OPRqF!Q1Ru9hZsbems@86| z6vNfhl=b^+Pwrm7`32=@ZcaPg*!>bA0hggWI%Ff1s=y$z0O{9X< zNQaT_bU%(VcG^)L5%y7}z2g4=yxdZ?Ab7 z-*&qbYYl$lteGUrkAJ;l@f9QVe$FJi&Fq?c(LduE&~ZQ?a_bjraqHN_V^>pkny)9l z9H=&WHArUSkc-cRP^&Qz=X35ZJWxbE%;xFkwCeBEh{|*}LAElad1P5@N`QHkuZAtm zF}pF^ZT7v#24YJYMn9bI;bx`le%x}zw#O;gXCfUf#Xu|pJPe}a9;XVVQ=in(tBwMz zx3jJ2is+svCz?i5L@RA+7tEg9Z>VA4Z<+eoOH8nZ)*dM>f^s(OE}z<>&MQ8;mgL5h z!zrT~Bg++$Q2YFCmS(iEfz6c;o6SKDzh0w%yo7#)$3?Y363&@N&u~WKwwj=f)M0dQ zfzx>(7iBcEp8`H2UO(tN8S%VDL1`jf@LR047su=;v+_p1J^Q> zofOBI*AFtKRN?24y}o;;!+VCYig%-0HLvI6Qp4&FU!KXDS8{smR^5u|k;Q$DeaF4@ z5F2-U08*y6C#;O)99E!PZRree$yumRwxwQq+mjgYG*oren}Ph!_x*IYjT_M$rtJjW z)zltWs3n{|vTe$ts>j-ynDh{Hx9v-rQOJx&c*G}V~0mfdGz>3@Wa{hwN zvI0~u_b$1nhw%%D!|)2^bhAAU`V zkX1x0n+pz@ynF`Gt~>*#nBGtw$L%&|D6B_R(`aA_9Rr8=cswimMWh(so=Gfdfb98B z828u{NZRN&8fF=UWB1u#HE41DJpskOLXBZ9HTr^KXH}G`<6gbg)2hh_z{-hxly%Xq z+)UaBh)P0v!3>KHHhX`63y?KFCYNj>55Ej(0;X=6HxO(J(k-U7 zrv>2d-(cz(VutyYuRpnkl==$Sq!U+XScnj5uXG8r%y+{JFH{K`kl}vv80q zwp*o|;kE7`W%}glG{pC9+{IY@c!F{;_cm$VIMB=5a@}__yE7D=2qL%k5{n%+G91#B z6++dmytVElIGPiQ#Qu`0&^gyOs!El-8rQ?OO1(d{<@~c7z3p3n3+ZIN%ud-k!(GC? zk1m9)5B@2I&zM+C{be=IzJF>qF_gX%^PGXL5_3Id00uw{qttsPtcMs(`%a+j13iN8r-Y(6K1ufBUTt*ZJu7>%m~UkF33FlUX~Py%0~``zgP= z=yWWvJzl)1pq;_}6=C~+VIlI=zjLK1#0W)!$*SxbGd+lZcueTeSnw98-23xx@iEFm zD9iJ~R@p53qS*GxzSwX;cSkRLRO}0|S5)8SI_I@Xr&W6HJ+4HNAGT*K&R0loR&{2A z|9-cCkvtxSP0Nj8gKNJx*;;G?5yFpTm zOBBA%XTXcxSGP? zG&Rnz)=(Uy_OEV4zBzX_^4=N5MTl?Bm$wlO%E2|yTm=+2_#D!@wAvIONfN|_Y*20yL?#C z-0!0k{h6&UT0z69fh+M;?t|MFla1cXXTY1~$K>AHpMvgp7+d{TzT#646^d>cCK(^= zUmV3OPZGKC<)s-(Wh!&$=d};C7g0`8Wnb~#*yJ?FT!0C09doh*b42;0z zzYQ6)P6CGxm39Acbe%{E&SsL zxQcvtoNr%aEM2v4iJV0=R=0;gPW}vNS%-y+)+j7xEkvHG+QN<1P#mIkWezK4B}yUd zKD8Vy04V$9gInpmaM841G37=}{6IUo^6Uwpk5yA$0>OilcNs*u$!i+9zrRiQOC3*N z4V{|~Dbb!k>zScm1?XVC?X5nB)-&Kpx9}#nye5?uQ%tI?$Uti?ff73&1eAF{TReI7 z=G5FAAn5eCVc5ZcHb3nkzF=2!e4Z^y;knkeL|xB`r0elIb4Zmb1#^O< zZr#ei+wV*l3UNKaAH{$TP_jy~%s$VXsLPGwquRbUy0;^jeBT^-496)-zi0#zE%_?d z_BAvHc~Tcp?HDn6Uo($nXPEjFg$}j<4BieHd5@zGPV2dlZ3&Fo<%zGd_M|*EzO^Iz zZSYE?Q(Qu6Umu0#<3x;Z@;m2JO7x@ZA+b1M$E~g}U3+V-%lS}H;J6b)N{E171Aj*S zSnC%}X&`vxc$12FiACbbIrT_wwU}qh*U9{yD3eEZz%0kZqYxDDkBO{_B!}x#w7Iw< ze%YIosJm6|1)h=u0{sn88tLIP0Da!bV9lUff)1Tx2C?!g8*Hv!OsP0Di#j@=$_H-W ziM)$At-1*Hah|`Tj)y~*Hv7|?&Wy(6%o0uRo&mA^RqBxNAdS^z-lokK1uP}zVkB8Y zO$4rj5Kwn(ztfaJukd@YCT5z;c)T~dN}Tlc)q1{>;Ny}+Np4vg)0-8{O{*{kia}OF zvl)V@#WN#+$LP9!c;=`eUkM!dyk}1{)?S}d5B93X$FQ~VYCL&Z+o-%+tc=DeZ(Hqy#GB{r;=uBQtTCV9V;@Ic;US@|;P7`P|CUXrOz#%iX7e=AvS z-ufrkM4#ji)-(`ACwr+5?Bo5-C*u1I7?*u=I~85mB^Jn~fJIlO`Lr{0@8AsS5KX;% z^ER;G`iL{qG)h`xX1KaK-0`HR@y;;XOyKv?e65$4t+oXYVCt*QG!?`# zy7HEfcm3$`IpZs)au4S^ZUxA}8`d74k%EuLL|B(PfutWjBk${AhlbVD&DQB8zl+c+tYtefpVc3_ixi@iztLS z6`JGt43J!q_)`sXTZ{uMs+Tai2XZ>mVywgPV zccc%nYvKxVSy``gu-9zX&f;Vv&EiMY)`0l9PQSrQ$xa5eYK)>^WQjcVl3vsserMOh zB$N4xr&MIXpF`6eH#tp~-}j=?ThZ-< z$&8f}CvLHO)BG7`i|X1ECw16|Kf?HCJUcgjC+FSz`y2^<^0MegYH_QS2-QV?M^b2+ zxF5Py(yY!oxoEjR;vad!Y>6BB|MJ#~YkJ zkuBqKpDt%Bykvpdf3$yP7tqr&CH%?c?_t|tgKA>|-KIKecez`md)<1<`W=7K22Rh& zwKuOyO~K!=bu?28ySi)bza=ylgxtfLrI*%Yp8;*Tu#hs=?r?SRk}I&Ned2^sCczx= zD32cV*>9$+=@1gCXUW-3xC-bhnN~A6M}TGU449EfwI?b9wPNsnU@=E$@?glS)T@(N z1{(PyAE9mCl-tem<5k-4Ki$VJLO^j?26Y%B%CbarpcI&Y-Uer%StxToUJ+*T@&hJ8@OITwdVa z;?_^ZWaAOZ7^~kiY`Sw1=xMr_rc(l`UW(VBEJ{f%5Pv_~VfQR#25qS0DkFh5tVq6D zV+FVYpfcn&C@7n#KScSZo`e8TZadQLD4W^`3-;zE!OB0MOnx^Z8E#nE+2FSJfQahn zjxC#7tjSGmmM$S7PeiLp-iq(`bVW%%v$yd%CWa-m1qiQN6AVmIA_sqPnyHeowe0gO zuC*nKt8xJ&F$(6`FlOc19+e;IG;k>B=h)JfmfwFJ*iz5_B*d}32d_fp)vupCqE42c zyn^qbimlRHA_BMi1rfjgYOM!l&+h0dBpNGHSf=VwzdisxdbwPSiEnN+>m{bgoXG+A z4&v^fh~N zU%c7KCAcMeTGgUGmQqQ4+&$ zFx{V#@|o-VLMO7zBY3`p>X31-hd(@kxd@kQhU@|w)gWOF=WND4HyR)p-->># zbhJR}@hI-?Yv4g&6rHH_Lt2MTaM!4Ph7&YfN_Pf9GxA&rBdG;hrHC#mW8L4?8G;Li zUdiDGVCjW^O3(M?fI41_fezP7z5(%i`Ks2z<29af_^QO}>Q8(Yn;bdUYOj8A8nO3; zKX~g3SbdpmbW+Xa=CoUYc$-_doZ(eDQN8-?v1ABB+rjPeN1WNmY>wVR@-I;_}NXolFHLz&Srn|O$Fpl$9w*U(V!?#B>A5Wy^Z zDx~k6f4=aJ3J%q2^E!yRy0@j6uE87$8527V`#(ck;FjYv@0I& z;=cQi1=60`g#tLEgO+!Kf`ckrD<~OSs_{^<;nrlxR^7zFC{Z; z9*f&hXx+7(YzuADA+xv?@1qpK$`YJ7mrkT^bOM zzYdRJvie+TZT>`*Xp3UBB9Dx$KMz2YrK?LKu}B(ZENsX&H2j)PyhKY;b_tc0o2)@u zsJ) ze9whQqR1<;KmuGEhnZQ;x9ZDT$L{6k5l1Ik5O4Af(EgHo6o6OR-xyAijz2Br^k?Z_ z*PYhrCuzWdN2m2!`xXaMqPw39h$S05EzV>IB#Q0GA>EZ*eFjKPUqN|LGW4u$jPO9W zKsz0ndb8NuRQXPWT6pxVqJ}gk>xf`QvQ}7G3*4~Q>JcT^i8P3IcX9jXj`a|4g(gRsAEa53%AV1 z)g0#tq|ost?atQ3vX#$tlO(j*jX1caf5W0(bap7hx2(%)_Xc2-SLD|R;;!s z3BA%w(wir8N|1uEX^SgfV4gn%-rB4!ELJy1^7lGjymS{k`i2^Y>JGJmT}E7Gu_}N1 zdpj(U2c>9kOq$wNV9X zA;F5Y*T;*Oj~fS|-eNm-y9Tc1rJpq6bS*hboBirLz9VMD3cEklc&Cl1|G^3yv0_(= z&)xAaFBKL%7LDoMYsTD^Yl6+vH9dhcDcg%g2q15@rpyLAF*3hC(4zIikT<_#Nv+Aq z^2;C=Y9tY%D;Ae3N`@N^-w*p!q+e7%@dnlm+#-}LD?-Jc!^3Knf$D+kFOX`lAZq<1 zj1@lC1xvxBV;w548h?!6#|{06AC;FB`KxU&p=#)5)tZjtA~cdCHIKEJlJa(*n2l{? zx^k;A!d_uO;{IxPRTw5$Q0ABPLO{yFX;9A5;NZb_Q+t}-U>E@to+^6dcun#E9wp~c z{H=Hd3L{JI6b&R&<^)(~1EA7Vq9|4jUwJ@vVYF%rnlBZC5bBy))couG;mlZbxVE zoJ2b?lq#~cD6uGY+ahT@30!g;P+Y&-z@hYXI`1&vX?Nrv%0)S5ZeIU0YDLfg1ldA7 zbz_yU<7pE9VY|DL**)+a9K6MX z!(j`oLrzOd3q&|?yf6O)ZAV39gzAtO+QuA(m9jKKux@bPsee#b3O*I}-50Qcbsm98 zK%r-UbY97Gd}G=6oqQCD+U}Qzx1f*=wwTt;jrG|^oQgXLTerJ#waU`AB$290L5<*H zNp51KJr`^y!gxhquVZ7ReoO5_@%RMw@SM&%swr#pdIdiZ6?_hvh}spWw?Ws_)d@nf zB)1wYvCyMLhW!&oQ>z)hZEO}4dLljs&qAoD*jCt9C)rEO8g{116{Ns{}%#X_;2Z9w6vT|?#cQyOz9=@8$7AnQ+*6{#x- z)1W_v?ZjbPS(?V*S(MT%NJwwXw(x0Y4GXg6=KFCOgVDThIZ{;95Ri&d>8O10)?2V+ zZ#G?ybQ|ic38A}*X8@N0rNG&?!H1p|!=L!S2e-#r1A3Z;uVCzY%ht_$hs5_;`9_9< zu4Z@IF`wGNvT(HBNS9s7JDHmTF&uA)$jw+0{a9^!0#$h~uI>lmNLAPu4?|2CZ8G5| zj~tg#8a56fLt`!|17!P>MV0~KV5e`UC1(J*QRdnmdkkq4@{(A?)r%ldHMGO2_sPH)wPI@S3O&dfINMg3klWi+iG;8rY?++!;88jpKW z);1bG_$)MvM^xHFBFWF$WsM*}zngE(V3slyB$FbQMVYO|VpL+QC8{qBlDRwFtR$C!%C%BxIz zsrbbsr^N2!&r4gJN;|kr-JOz$f*+a>{`q6%_2;~p#GaHg_bU!AxN^b&Pr zpt|xMb)dniXD?^#zK1X7z7Z1rc?D%@uS**^Rkls382I9%Y3rRjkR0%KrPbb5=ssTQ zqzp}yRxG?BfNptS06sjuNst+B*yW_QF!Z$kv56zzr1)$uG=&TP1;2lrTXr}3Dx@vr_Avw zY{+HQ(t~lch$8GLvNWbJohU8l!!KGfrW3Ua5t9`^<^B%ip*L1Y^YIasY_dDsJ8F8Z z#HZoXsY&Vo>}K!F7fj3=mn>i`I0AiN&%yBv8?_@*dU+n7sPH#GbB|0NWlZ0IpLJLY zBf&UVNdM$IcwQ`UEOu%;ECgkk}iGSbMs9Ow!9$wjh*5HV@# z7?vPPJ5M$ah}h!?>#z~wgnR@cY2Xko#?FKLxXFQS{W z)8Kb8E0?>ZXE<%^IFjc#3mn#yr;kr%>6h1)YQ(pGm*8ugiN}nODS`(lM)$RJ=$cw_ zQbZLR6#y}+0dY=c&m7h1!qKX}B$mG&J#Fhrql!XIWxT|nelgKsG1fnbY#c%HO)$+h ztMRUqEJ6vT35xJJJSBS7s@7fMH zLdIKVYKF*c;zhrjVw}9Z>8s=t!CR-yG1$pE93FBPa-{H#I1QgS?znR#h(VC*onPFH&-7ykIIFw#mz&FO*);V&FIwh6ga z?voH-*RpY|v}*q-Y1e9M3GSIZWi+GE;82^;j>mjZn7Fbu6tX1t!0xFG54%BcJ9J7_ z$~-36G*}D2Vn^QZE;X#PzBQx*`r&-C76TtvsVV-PV{eN-vMKo*C*Trt1Omb_HpU<5 z6x`UwM$=U0t;8(GFQIaL9LK{uqEAsGlZLMl)}DfyEb_lA)C0u zpEVvMD)Cr^v1C6@LIBZ2y6N|7WzkubLcCwAw?c}PSxP{ZjJJ&1p4zF#U z@$j~frMC{-V-`%fobX20*>)w-`@Afs{x`f8_Hsl)3hibsMy`XO|H6>HuPO}E3Y^N< zMtod%c2a8;i>6&ivNW$n3@z8_a=LsnrhsX}5f$Wh&Oiuv2_4@-HU+%C>e6X(%|7@G zCwYGT7hy=OPr-6J$0l%pC02tJJ-?7n1L+Snx_VbIo-|7htwQKq8J0s#DC1Wh9MIxjtX8{>FM^ba(DXQv;;C0E%RUvl#!uJxl#Sr<6onR31$w+lN2uS^GXqIp^O{k_gHh?@2oLADHr-o%1;84k3?ia;8&_UM{0-Nz z1c8;A(8cpBGd|?`4k__6LTKU366F_6m4T~pljtQQ4KS)1duq)KDlWc}3G@%(?u}T3 zEtO<-OkBcAt{qlgq2KVJn2 z&^xBfg5N|uO-o!GSA-A-9ATdN0V7evh6L&Iiae|nT3?M0(q~@||0($($1mxz`uet{ z+(y>^&#(4+6ad%Vw|%?8xF^Q`O=D);IsQL{pSugK51kgqh)%V85k-fomZ5|i zDhODJ=D(^AbN_4AXvlJ5OXkOX3KS4UAnJI1;yI)Z7RG;fWN)@JPVJIcNd5|olD+de zrM%jbDRR7Z=}WSG<;7!ll6fxCdJ0Ucr*!ld3Ci2ddBIoavqZ>_?!1q&f=JKP_EPB9 zN;#n&1L;+3-fxwP$rl5Y;S#}-AN@)gU+ip9P36b>_|%|;K$UMz?}Z0xd4t?A1nnPu zJC9*Mgx}0pAY&fl2n$(;_73dmxl*r39cFrMnTiL^x<-qsPV{xdpp8 zcKES8?01c)t$F&1+4>5>S$9X}?aQG#_-|gtcNE;i@@-pY!)h_UTL~+Wmtvs$6LtR` zRvYpV#r5&3D&iU^r;97^MKAb8^e5w6*yWg+K1Y?tGhk_7e#;0{eO8wGvc-77=gt|C zKXa8KAa$S%t%mWv6?qbKD3tVucs`V;)bQ4jQtYH}XUuh;L8?n!#8N^7+CsiO0$BLr) zfZCyBoxR^moOWM7RK(TPzaRL%F8_z@;E5_=2ajeZfiNhv^zZ`n_$2GmkOHp^DmVE=X4}w8Kf?h2j`T}43Ru@r zA3R0HI`?KH1~-bU-%M2LFO5aQ8LStq{I>Q`rSWzA`e6a0UeLIS84?OzlyjF;}Y_=;8&$F)*35uJ0?W@>5Esr z2W$o_ao(oZ9by#}n&);;$pyxTH_B+0R*{D*-;RvGX&6L6ZLP&ldZbXTRuCBEL;p8t zGr|l_Nz!B|gQJ7F*F+Y*Pj4C~?7LA$o6IhL9bA@x?;nf9Z4AsQKT*+JltKSb8CM<- z)!T-Lq3IVQAzLW>7RuNf3}q)D9Ns=|$*Nm};Q1)fWZtP=ch-4qxl8J;YV-M*& z)9?HK`u;fQ%ypgfp6fZ!yl3Wq-siq=rE=%{{s{~V49F@cmRR0K=K?AXRzSUWVF!8g zYoDW`sqy*d3hbbG&F|f(_rAaif_YY0O}xte^@><#y%%UdxM*K{PXef>v`o1i~Dq30EIZ&e!iu+O|GKfB;xL^im^Z; z7nc4+o31nw+*1gWyWIQOEDo!v<3f{rS@NWY6lTrtR48&OwIkpvZR#$*pI!deNLWG_ z=F1NSs}BN5p|_WZKcJ@~r`Huyf+=Tq%sYO7-G7i&k0T0YoxA7G@z7B@)_EOsxw|1)Qn3H2=6bJt>K_n`g=#c*vGc>{g>S|# z%9o`#G_;%RPxul(h}oO93*=8vHaBwF1(GT<7fF3Fu5GcWu^xO&-|Jx#rIxGgOR{OV zaag){`GUC>GtrIGkZL4?uDsouplyxh*)M^r<)f0~yp6~K zhr>B)#|Nh-?{-{&n&`W(oQi&m5Fu1?#kkrdos`{?v=3>&@-XSWKqA44y*=(2W&Gz# z?sn2yi+`VMN62~n=c(f?VzlNMjn3%-%kkDYKnK%_*+ z+u2-HG=S%&5`5n;WwjaobwgIW8*BTbdCUE60JWa(T^5qub&4kw`7-;+^7`yJffX47Sn7i!kzSFDJ5+M6s5n(`(b}3aO`KsaquQ; z337*M!#1;eegsi7>@^z+`kUB%?slF3PwsR>5k$?IJ?DgdcbGVi~-`MkJ^|M?^U&JpA{$8bxHpOUle{`ZDG zebzOFJEZ{GBd)sX%w%}%{=lWATJRJ$o7EYC0S$;RZzgdDs@E4q(qK?J>^dCYygXCk zn<6Ub>ZRM6*D_35I6_C`zXqzg57Q86O>YnBbRbL)5OW;P%G0aA%tSH%ij%I-2`4w0 zf;_*TnY0w2YGPlDEFiFY^9f64diE8oX+PM%?C`c!KjH2t`0tl&LgF5*`Vj1*H|%wN z?4kDo*eSZ8osU)!1+U#QaoGM86Y%jsibc8S$~kPDXfwE5O6$lBtFt|>(zN_I@uG6b zTJkA(&FizZV>_-j`u%cK0a|ZZ5^CRL+d_V}w0#7xj1ND$#qbAYQPNMJgLXfjeCd0! z@3n0lo!?L8^LvfMTW*dC0hE*dZCt*7pY0I1?5%niE|K#ZXtAd&7Yec;Axy2=Cbu*h zrZqk0BOxo+A6)aD>$yqm_Oy0BG($RkiSU+pW6*s;W^{GzsSrJE{EXw?5%MGj?Q?f& z%ChLlQhS{8jcdxgpjf<~tO4n1Ofk4^!#-%UZ|HeOaZmb^G1t2%*RK`?>MvW&v>GT< z5{X~NByJMzq9NXL6&i@6Xe6@vDin6e`66l)wR`g0^u+k}2cKs<+#VgJUahd}h(QAh z7ew#Dd#ThN?}P)kuYt^WzwT-gQd=0coG7%^*vL$le>wY>-3)an zP+rJirx9ZS0wLi1YCm>hqSyQQ z7?6F{>PT7DtHH-n-5HY&uRP!EbJ*FVRN?efVHR1EB(JE5z~O`bCGWS4x#!$b#oJhl zuN(q&Xk0q@_qPYx_=Ar#hOn#g3ySN^+BZz4e<%dS)T_Xw^b?0q^w0#gmEO#0jbkX< zFsN;|y$g3JZ8}J==XpyZrt+1-KHhsy%;Jo=S9ZJA^o%q(XZ7LQ(0zMfE3G^*CsU8}C9K=Y!a$lP`DMPX2LVu^z?Tx8HGqlQx{ z&A0f=IqtET2vmALs#Q6vF4Ib}jwbfTCdd7U<;{`S~zgyd#IX?QTT-$ENqDy-}LZ8z-7~1SX!?kSqRXc&;>&7%k`xvKHRPg zTRp5F_IUD-Q$53lOx z7iPZC*&aOiK>JxD{^rMk2$No0WqdL(Au%NpRVAX*38^k2j&C{EWF3fFSkKI1GE9t2 zZ_eL*=@1j~s&-4OuUrOg_^fJ^Y!OsIeqbK2g937hOb#hL87fWJy}3#Y##-xoT-Y`3 z;C?JdKsMZ%9KU3$0crY1K%b1elRuFYh6V2P6LnLV&Eg&_L%=FyFHI%;tOjL{9h%l5X`Jmm;6f zf8njG$Z`lje_y1^iw!To82dupn~O@<*;+WW#`&(}`gMTH1458|dWMh>YFkGIXAdm+ zyX}PKOsO75ur8}qTxqqKXkQZc0(jrqe?Y2GM6hDJIidWNe{t}BSoe(Agn{L3Uz?7> za(e!p4k!Ae!tMiyYLe-p$@V@0B`q#9Mw0_(Qc#P3yV3?)g>x>ygq-rv^o0j< z_&WU{f>&mB#~|_URGRYZXQP$em98~YQ{4c7!y+TG)s2swul3HU?ZD$ut8##d|VQdv^TpUNnO1Xt) z>Wg&_s(eK>n+yHNJRp|_GwX&+fcOh@M;DI3oCI#&*U>)&tM<5(WwmuDw<%4||JcvOM$pbKW)n`W1Z0RpbvmQ6L; zTb;aj`%ZfOWQi@7XVh>uCPRcw7WbsvUS_!|VL$?7KsgL7U;Ut0_ZK7Om3_ng_8%RYX3i;v zZ5J)*)74wKIJUg;*?AqVa#RHn(~i5{9f0RhppR174hT;S9`bkn9IBiyJg54HN#d$oKu8F;C|1ooK&5H`5N(a$Xp4=7f^CwY8H^ zhOXbaG$h@UgN!8J40MQh&(_{OUOjzG3=r&*GVd!q@?Ow5xmgv&7xvwe^#??ktF7*% z0i@!yZ?Wlic2_+8aDEy8imZ}t^HaWPUqubLo?ti*`QBe;a(VmkmH5U=bz{iq51hC4 zYK8g}xaN)zp+Vg1yFt&DG4BTy^r z>4&?B9m2G=dBdVb+~!S1vv!x=bgSi*f`sMI%kd?ZD%*D#NNzBA`|j3DN2vo2QubJ| zxN|lmcds7~A%XHd`Nwm1YUd8$ZR@#)3j@>Ns8G8bHuusr2@=w)ZNJNgFUhTAzex|t z%)A^jBFs$d=!^9yhD=oRGLN3>CH>>?j)Bp#=@u|525?k7IIHarUzL{ff@FRUk@_keQ@^0W1UoSdmfeMaJ!!Fam8S@CEh4 z0%=J`lu*fEiY^d*BuJJe`UcJ(7ov;`NVAxZb@KS!XXe zye_cAn|iZdMh<@k^41Hwf;FJ9QZFGkLg}ej5h5yLJ~Bci&0o}fs*8Oiq51n2)+o>_ zjDzS4Q-tofZ08yMyEeo2w2Xzou>r3`_ocF`IRZkf{dpVh=)XY``F1iz}h} zSjBOD+5z>^W@YgZ?#KQAExgJyftGdX@sePs3iR9p~z#LO36NQg?*1(EM zQ}(@(oVp}+C*tEI)IHTRFluT${*}1DEK9y$(cMXebR6CU-UX|-6I9#=aryxN)$ptK zQ@!-8^WA)UWdkvS#ti3!C=~*khBnx;DX3P%*mPl^Du&Fl0-XUK+x$>W9GGJ-)W8I~y@KHZ&R3;Cs-Q zMxz=(oDO;TJdfTiNMO14Cc(0V>ujHy31n9I2lUjJ-e$xT152L1GzFL{>=Mkr+;?x<__}E| zho0t|FE=H$+(`^I;6xvMfcbGn4OeWkuPuLn6x&zi5n>2rLOIxL{iw3#}o5 zW~e)Q1(}R4fDlsff0s^Z4axp~z?nzEdJ?P!IR+FcDK&Aae^5e&Ih4sLp&=eJoe8A< z_XfQ{#c(PF>?ui_l1_QSSGcwHyW;UEklbE=yf)$v9J2U-#%xA$Sip~sTrj|a$N-n0 zzl8@-JqQ($%ZO{vfaM7IN*zCw0S~!*EDpqPO9omL8h(HiAi%WmxDC*QdC27~CBU#E fzug5C8h{6!WDfB3;=s7S)yNDPssS7QpXvVqE>{Vw literal 0 HcmV?d00001 diff --git a/src/client/assets/larp/images/unsystem.jpg b/src/client/assets/larp/images/unsystem.jpg new file mode 100644 index 0000000000000000000000000000000000000000..eb8443215c36470e705fe5b01b29d0c3487fd8bc GIT binary patch literal 18395 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 z z9J`bT+2(vOvpiPo{)-KA5<42H=^p_9&f$6_c{@25r{-|r!XH?H-CEpd#p*9Ige%AX z#G5AM-^t0KQ3J$2P~k}q{9lEx9Pa7#fmo!=W-1%A>de;~Ib+S!PvZPDPI#l5-R@I~pm~mNAyWIL?M%a!<*ENeKOp;KIef<5lG?BGb2=ca6P1**jfQ zJnqHolvKOiMMjrMQ(}6hDStR&6P7XMr!5oCNP{;9*@k;pfX#hgAbzVc5go+;-dX{R z+%k5G$*i?O=DQ@Alcp$nZOmV42K&50JcYw5$x>&U(yWrylr9T$QR5~w=aBw+K1D8C z4QXGz@)fJNWU~55B9O*qN{Oa$D+@E zD^9g9wa3+ReL{8{r_7CI6EO?Xr}CNT-vWxkH>ar*5zVJ5p}G;+E09YbeBXQEeFc6| z84DUN^RKp8AA=g)ZXJc1RK4mf0c$M;pXa_jM!@O?>ZlxMNR*0_6wL@mmc+{39@%+V zY#i?eR9U7$8VhnZ!ZzkHi#He89G2Ph0A9A^=G&yqJiIlc)MMSxifQMtET-|VE~JK^ z)+5NaCzQ^|U8??0;BzU1nn+K>8<4R_F8(fqQoij5N@@f)|DaF*+_?Nn+UF7@Bl#zF z(QdE3zjUrmhY^D2KHLMfstD4s_4p_{ipgeB$?la%yXiARm|P9$sQCA#0-T{SvH*NN2|B44 zqr3+s&%n!R_W%xI4T!eXjp;7O*j@71 z3B#f>?%uYUJ#Oo0Yyp{!d+w`O|B1W0vz6&`H0PRjG-9#cWY;PTb&H1>WeR;2dShj~ z^LB6j>TYjhV#HIxm-~~$$|unHM`Wj%Sp+^tmdl^Omu2pZ{X>7{8}!;D;QS@9d~Fq4 z3F5A^qo4z;i435uVCQ;n5$0cY zvU<7}_TtK5H4MKa35x=g9tk!yyT}viPXptWjnfyLWy{{Lx5=W9eF%ol+q7D z6hb&|{Jxnqtgd~|Cc1jv*_%RXB52PO!~@zBFG1V%V{`7NBb^9fEv-p~+hBdzMB7a8 zgLM6+U9E^>q|LU&xxj^igbNy|8QXA~ zeAQAubhe!ON&7tn_G- zX2w#Lw30HtY3~8-qWqnlKRXVwmx#It0<{&84_w{_wEf|dbObq8m^!>|;2sE~hO-SL zetzEa1|wMpZPi5Kt`s?43*5n0lKt~f56OY6C(iJD069X=X4PN22f_$VUZ~%l+yko$ zSE__uh;itKg-^%QUB?nAS(}xb)KjY9Hsd_(^pWoH03I3w)u92Feu~za&2SQvMrFOO zBB~ri($g92yKl<7)M&TDb)vV~RvL3!vvoWesI(d_Dz4or;t8MXv#zgGq6t}ubI5~` zL~zsClj;X!*`I^41C&l$Kd$2Q{JPeBbwppM<1wRzu2%(XT4}!rxSvWY8o}9$NInyW zzuHXMm@UdeGuGJjc*vrNI_{I&A0@?G;WW7qrbfePLEFtkjX|O zsMo~vHUXsc0%x~u6Oya44$Bu0zrp9>tYBon?Skk}p9O88tOX`ovN-3nwlsxlg+KPK zKkGe^0LIh%0>_>@KezWX2mgq_fJ&W!I*rt^jvJF)Jp<|Ix2#aeM>MdUO^Hs|cv_jp zH+u2kDr`(1lWs`TU2OT|^J?m@2~$0l_(%!r8^MaY%8SuASTRFc_UY6BFe7?+?{@pv zcf?fzos;-5eo1nBhNWNP39NK)4xX6vJ~kb5-r*jT-^SO_nv-%`9$!AKu$^O% zjI5q1=Yz>=GANxUVpY*CQ{Jrq#5ZuC8HUEFY8 z7~gFr5T*A5`h{cb;jAk63e(%=1ZV>EjUf5=LA94jyg@d6D1XwKWYtZ{Hecq9!@}_T z)=%iCrxO&PgfnR;>`??Uv+{lHPxKcDks z%j5@6gM0}~_|J=D#qdjDFD}aat*hPdK84c=R$cI41P;IVxD&UheY$1#bZt%i;&9FNrO@E~PM3GB390-UCWvN{5T_$iAV|AO>Yp>+%m1L^_8ISba>yata zWjI+(TYSFrkx`%eCgk>6v*?OI=9mLjM4tX8gy^Bf#`dj#eYP#yk5(bGMU}Oga@rXq z8K(*=ub;nEeX|0&*Ow!wvUwwfW!v7gyt5Q`O;Gp@6?6`vx(YIR(HHO5d8>hyK5F_I z$vTXtNP`iczO}rngd8!O{bM9WXCdbo3?qWx10yd6&Ye#RK`57<)0*Ew*V8cM(@0Gp zPZq#F(flM|pxD#jna^TUpohS>k}epBZIsVB%&+x(f#IIU@?3$pW)!KBFn}O+fn!g% z?J!#3)FBthm{;eo=<Cm~msq3^ejw7^T1czOat4(@CVhaIa= z-nX&MoPlBb66})a81?XQ|K846k$r^|x$xMFr9le>wRbcyM|Xa!#?}cbc`oiN7=cQ%FfZhPy3L2yJLqv zWN3?Z^*xiwAN<;eB%#bP782TZ>r zu%csSjHdWi8t4r0>?;^Ela!cm_2Yjs_8%N|e~eRNX3DsB4j~KDk8rN++j7Xt-}xfh z6D;aXxfl(?w4bJIH_`|05<}qoo5iV}Rr8O0%;CnS3 zSqo%-Q0WT%+6yWSm|SX^UJ<|kxVnGG;AUP=fc2daQ3FVyt}U+Dj5)78F%&isB*;y` zM(8X@ER%Fkr{_DIV|+U5^yrPeat8Y?z^30giD8eUBs;srU;I+Igl41AhZ0~POM%Rd zjf|F!lrXxK_;@NQU%lqyUzL2=O1fa{__Q>iQPm=Am|_ztPeg{2_=soJGGwt(wIPBR z?QVw?gBaxy4oui(`eQ`gyE=io|@|uSa z)6LG$Igy+g^cUy+#bC|Q!w+EA-Fx8mFp|gmt_qm^rZWN?Prw6%ZP&B;htoOdmwz6) z2j0ru$o)>1+!6?SX6KzT9QvmEYn|Qpxs|mnB{NpW$?3^{cO=3*T`~cxCwQH8RCo`x z#tPY!w^VK+dqe!0;|M)M)>3%iff&e6Jn|i1nMw-x5a~Cz&wItRY_|HJvb28dBP5?7 zCzmyi&dC%SRsS-XMR~rc>Sf_t)g_QuD|9NPv^wVIpB}+#hr#ZhY4Is$+1A#x@b@fGG{|q-Qz%CLdLU3>uwFyE8PmLNj(e`F=i8OlXcF)p=bd1ZZ6xn~I)#8=s1m-#S+2 zcsduxw~3TWc&~}z-0B3JYOhIldpG)42co|C8u-&Gw3w^o7T3wB`89Fa?ZFLV!LAn0O;GqOfH+^>=|&buZ~Lgl}m22FR} zx-ehiK_~P%IMQqKS+ax-vwu~;_MIeosDM>Lda7!WW0}>*S)p~?C1sRO zdn1O^@hRm^rZ;~XDGNV)yhcrZ+#bbN%#l7NW>F&PL>Y^@UnsAs^QvSN9j!scrzfZK z-W|N9xXJVUyDWQqlxotHSW~S_F@?|Uej4OZ>pgqq8r>-D=)T$;;W=U$DEt^%UmsAE zkh#^*-j_t>c;t~GkoS#X^cNSH+jPdZB4#*^AA|~vmT!(@JLIv}OJGTq-xQX}YyJG~ zozW|4U`2tdc!OJuhb@LDR2;jgZ;fSk7)APNu!J3~H1O*9y+|}#;)7R_D{NS@BmA5X z&De{w?9Us-5`@g${PnH!eF$|ix)J^XwpH>hDlXB9qNq{imZYj!5)0J^-o z2Y!9?SdUekxCh2xBP@dj#I#q2j5^XKOb*aHdavDc`|EG7_!TrYrC71YVy zaAENHl2Odg1b^XCDpmSXN!_y~Fmka;^|mpXoFM3R{MMZ0T(5F}c_D=$sh18zA=5`0 zy*829yTPEH8x4Qm$C~Y~_!De=#LpA!9;aNEVkyHi#!`wF>LmJOtSFw)h6&lPh*_*U z)J-t8eUB=Nvr5yYeCE(rxJ_D1PWqhhCeJ3Sv!~hDU}MfK+*AC;re;Mp#re5dbTEcr zYT*}y*-l6Dg(%dRiG>Wco|^9U;;3)4jA9%FLjKj1TjBe1oPOt{3s)i_ta;z`Zwo|S z4%LCGUb=~^Ws=|QY+qRv-VD8+o?Wr?7a@8HgFBkpnZ88&u!q7(&HB$g>_7V-I%^~{ z{Km|Ek6Ns+*tO))9xxBK#lArab*Mj;&$=2Q6yg5(c*8&LvP-|uYRCUsPEVP@OvSU6r<3mG2iZALSe7 zLf;&Sm?j5QI24oBHgXfRbj_E|d@7sK)w6`EHA;xqbeFMB__Q^j^T#u`?7ZtX#%=DI zZT*Faj!@~LK&+FtawC;pcmD4m)@;NYJwfTpr|N_-w{4^P4tN6RazY#y(o1Yn zSF4Uzsf8>}vs4ja@8=lKlgEYQvd_j*V~Fe>%lge&L9~2^+!_U=&TE%oD;LC9l7@T4 zvWaMHnA#&@CM4VsE2z6OYf98&8eT_^GsRlV$M)!kp!N*FC*Z`MOrge{Ts-{X)!#`u_izZ2o521uO)NbeE)DN0T#Aa)QsYx1@zX;-I)KfX$Z3XO;B(%>W| z^S#yp)a})KJ~5VCxpM)YPa7(%y0JrYt3-1?2X!>;uRWF*Ocp4V_bjw!XzOw&6^w+l zdt4jy zkQy0ty!yqq$&5v5a1_Y&x}Y))@^Vy3>s5`r%6GxWcN!t>Z|g=pzgRXAKGwUquN5 zSaDp^$Aq;*C8sZX#-V*^*IEI6;6gu2QU&dC%ZbAn&h|a(k!b>6+{4}ZFQQyN=`RXz zd4H`(6_~#d|8N0liI7;9V>5>0`;q138`+JnlDlVDDm<+cv$C-N9!2#?3lEBXqRl0BR#)m2DNza?XVJ?t#l|=Kq&G>}&sZWBKypkEuD3+<5Ga#I7La$2<^B zc>C@i@Blvxyr&ihv;~i0dNwnxSbsq9Df9`Vyo z7LZR3JG+FeyN2ScK%Ox&lVz}XD;Z?#u9@KvG zGg`gC>l|f|uRn1Q6Df^GAPXn5UQ{g*`zponm_oR}JCDR2N?XPbq3f)C=9w-(+}Qey zQ+2XL>)v?l3_YgGEH$B_`)EEFekPT_xwk!{pC?V+N!$~LlMm>!b1Fo>RKWHYrF#{( zl$wV{x^K>vqaWGQBsS>8Hb1PIV3`JC8x{Je$@uroNb*!#*jO-&{cTNlbdf3;%mu-Y zP-2FDy@qBsd#?{HQ`j@1j?Bn|-3h?j$0l{XWle>M{%VZ0+0G`E(GTW?1Gm$$D!ur$GvZq3w;s8AkZut3x{`*(>sqh6oUyxOlxzqTk=Irgm`qs$0$gsT(vjH0K~J z6fI@nkU8hDBBVlY8_}n!ne`DxNPh=UTfxKqKI-4KZ*GBqh)`h<(UuXf_XJVb)F&+k zT22XDT2{uuOD~}160dLS{=v?Fc}V5pf3oe!sr8^>5Zfe&n$Vkdf}w!!fmJNpDzsbPjX69QZ!Au_ZN`n9g~!HvYNbh*x8jLNsxkm;$z+y z9#`)dDLSbiH-ny@bj>~QQEX3|9PYmd7O(h2Ojliuhbep(%)?AA)|~Ow^M<#2 zaOZ??%#;oP#&zP7lTcPG-!oU;`Q_5d|FVAAX`GJPpoC?L{Ug<0Av4 zt=3F9*k5#*oVMW=A2WJmw!UP3ScEe+<4YyaMUdkNJ!8+Olw7y`;H zc_IIXyf(eUTe0@L?3G>}exKq=m;U!L zA}N{z1YXtGvGuw!3nFZN6mZ*-dWAa=qXHviCdkL-L(o!H;he9ZfZ!*yA5YR1n}-^3S4!&FfDjxVL9 z^qI1S96Nn37S2I4jpUk%;BQYvXnng$P8fKD_OC(9%d+1q=e&|Dv=4U8IDN)d+Q5|d zztXh7@sB5T@JLfaOH}!KBEQ7;v*fW?0a4DnEm$bQhgR^eg(_YdJS41D8lW`0@&xK6DSnv=HX^x4rupUP%ag*E%XeYA!Adm!`u!FoboYSa_s=j_$NmLV zVC+)opk_1}*fo<$UlFD%)eegMwSL+UZYr*G@8v|GeP0%Rwb!3^;6^6t! zeovF+Bc?ys9$W0h1$BjRwE0hR8_-nKt$d8o<6!#RX-qZ@wZ?;Ag91E-_%#Y?msj~{ zHC~N5;kgY@Ropk)jJ@~=mh8N}IAlj@z&;-Q=O1G54MofHcWkZf^k!#M8nvjjW5%df zMV&b958%}I0R1j_A9_LBgL43<0k0Wx{@MG#Z@2miEv|kN(x_1zy_~E(kgN*(WM?S8 zV-0m$3p1B};o7qqe=kBfE@uSArDhjtf07VM@$gh(Lx;Y|!~v%}YL%gGO*KD~B_C z1wl-%;y&H6V38W{SWAc>s{eHG4zf5WrjA)^#FDl z{!u0G#PogF5{zU%#jW2=WBP9B46K8ypp! ztwd$DgnMMzylWsf6L7r;o=@Lq?0tS$W^h-s1&uD0^B>m6QXf02p+s$`oXs}AAf4S6 z!EgP5OkYkZ`!yg^^YNQq=;N5M!*WpzfDkjK@PXaY$-UvMuJtea2rd(0mbQkC;!p zJm!`r-JTjKnVgymQt*84Y42ycb9VDMO<wli{{0$R?FhH?8Qks>`CBZoD2jOD)pq$8fP1+Hth@xS4e>n9Gu``^IaMy3+g8Nbp>tYE|(2Z0dz~A-AfBrWAl{fyEIl|CK zFmjnwO#@^^x6Se+gSVz?_xR<>bmwoZj&t8Df$mD|-YnLIAO3#&+Dd<&Z0JC;sWdWG zhVsAFkWxj;k2}QqLqFY%Tx;H#DG?*>>9bA_l!`2?A!8m(f^Q0rYO+q)xc!AT(>n~|QPJbileJk}ioO{iV?>G0B z52GypR%XJcwAgQK{$abEGtz3L*NrRye8xEn;;IxgE8#x=bMm5%O^OnEPKMKJZTOLR zT;3Ubv)(YTuMPT2urS;mrq3p3$(luSq~RlxUsbuiik5mR@Hy_*_#U`pcO@4hjsXwz zA-P-*@W=*E+46uPC3#Y*8F9$)7Ky6?&8k@v_WyX zM0+}ZBUs{`@0xQ^=V6JJquF~K-88sxmd|x$5)GQYE$*p1c=a%Gi|lK1S@3#ZGa^_t z2RlGOWGl&Y$Vxj}Y3$nzW!U0V0sG%=ft%69>RCzWMZViIK2+S)B4-qpk+krs#n zfEyMnk!3&pj`{1ULg)eC%yC8Dga^#J)hcnguP@%^nqAnNO0a(< zMwaxljIT5%FZt(I@$~VN34Y(oq8Cs$a_3K?(ZpTbXrW5wbHMUxafEt!fRbxbK`bBe zJVANUgyJ=w&?N|I+i=^xmKco=74#eY#Thzytg~Dm1h`-Zisbz8u5d(E40;T66yx>U z$v6x}2JN+*zr^~!pUd~QVydqmxD&?mJ2+A+g>@K;70en`5i1J`@@{V@+@U3iw5B*}|E>IerPp*G(8{?v_Q*5_^6LldJ1AgXU`5~89&XdCF z@JqqkZ1K;E?O4x#Tv#LVQwtBJca!IRidVDiN{yBLi)K3A#w1pswB|Pmi@&JvyTuf$ z)bABZYwY>j$3Ltk~buUjGd1P@y(ePqc)iq7Q>xaBDV*$!n zOLPt7WTslRvY$HYT$|dU>qa0FTzpe&OC{84Nwe>el@ipR94n5(lKEHC@d8`AQM}{N zT_m&oWS7zSSrmrX@VV7pHq&qQDOY@nIcav{kQo&-xxeI3%z0JSTQg0%*cOTQo_4dg zu6?Q6_~y0w%8?{1TH;(V%f93T%h3RC|1Se;DoRDQCIckqj!u3~zub4beY@=)TB#?S z`CUm{D{QC6ls}DdV=?aGTaTb$3kd4DKNsW@L)J3Bd~JW$Ngr=XV2^WTVHE#pe%4ly zsF$qR+JV|Gotb_R=lIMxTNrEeqDOQ}B7U_&Oku6b5bSteGtVkp6?iHU}l&2J%(k^cdg1{nzLr-@;LljAi?lSMNLF0ZB4Vvge<1M zcbVr;C}S=`{OU411^*GEb*i-(nkGR<6CgDzxqs-e=eRThRBJh1<$Kql{zL6y%Iq=*6t9EGWS3}S*uI#1NO}`4yzPT1^gST z3uw&L_kq&(FVeP$7QR%TtsCqdU;>^|47ORFS4-xdjUgAsmh2LD9VU$! zG#GegRw9C@~DI_Vt7y_KR)p{*#skUeaO(8LEXM=5>rGftP&~ z*XeGi3(aR3T8pIesBu4ZNKj@1m@V6gt24cM;^FTuhxeJH-><(tjx{inXtkQirk<`8 z-x#TBa23<~Jktn<;e{FC16G}E zrmZlQb8@QrB!HK2s6XgKOB#YDDcFYEyjShP4%RwZD#(aPcd^KlC-ih(AX?Bsp}qQS z9oX7&r1<8|`a#U-`2i;P1ylz1k!ff(kXTh0n0oRR^?>W<8?6A@b??WU8fn5FW;_ni zi*d&0z42bVwBbq>oOLnPH3um1a?RjMOlHF7do)8`i?r6TM6VqHn9^WKgrf%9V$*iaDp!&qk36A?U`AX<7Fux< zxcIgcxOuZk%K8V6}>*?bsX1V7jy#W z{LxgfxM+{wJuuq}jB-}o1DUUNQXK4 zvSn5`Zb(NRpCQ3p!~2C}&4wA?p_d>+YSl3l<0oDE)NPaB4iDqy_OmaCG_d4Ot`Q2q z|58MS(6m-$W(5hN$^%c&O;1$+uMX3rbi$DbHIrNek;(DL$b+iUW;Y~(M z^K$OE4_~^|dkXEgm)`?0*=GKG;05C2JrJCTM0ElIIdXv(ND)qtXxlLxmpmbsJjq88 zapHqV+KRv{OyE^15c84G#V{N_9*%?vyi@5kyWrf-fjvM1MX`Qf=ud%8 zqI?_>;lFwB0Tf0g08Da~_+83|`Fr9zq)@JnPIqKJg|#{b{f z`9Jd4IZy9gz5!jB-QF4ACVgjz^XAU|N+-MJ$iW-UEJ&PZ_?Dv~wN}0JZvn%w KzJ_)``#%5*i7x*D literal 0 HcmV?d00001 diff --git a/src/client/assets/styles/highlight.css b/src/client/assets/styles/highlight.css new file mode 100644 index 0000000..0aa4998 --- /dev/null +++ b/src/client/assets/styles/highlight.css @@ -0,0 +1,67 @@ +.hljs-comment, +.hljs-quote { + color: var(--base03); +} + +.hljs-variable, +.hljs-template-variable, +.hljs-tag, +.hljs-name, +.hljs-selector-id, +.hljs-selector-class, +.hljs-regexp, +.hljs-deletion { + color: var(--base08); +} + +.hljs-number, +.hljs-built_in, +.hljs-builtin-name, +.hljs-literal, +.hljs-type, +.hljs-params, +.hljs-meta, +.hljs-link { + color: var(--base09); +} + +.hljs-attribute { + color: var(--base0A); +} + +.hljs-string, +.hljs-symbol, +.hljs-bullet, +.hljs-addition { + color: var(--base0B); +} + +.hljs-title, +.hljs-section { + color: var(--base0D); +} + +.hljs-keyword, +.hljs-selector-tag { + color: var(--base0E); +} + +.hljs { + display: block; + overflow-x: auto; + color: var(--base05); + padding: 0.5em; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +.hljs-string, .hljs-link { + background: #FFA500; + user-select: text; +} diff --git a/src/client/assets/styles/mobile.css b/src/client/assets/styles/mobile.css new file mode 100644 index 0000000..18c1720 --- /dev/null +++ b/src/client/assets/styles/mobile.css @@ -0,0 +1,916 @@ +html { + -webkit-text-size-adjust: 100%; + box-sizing: border-box; + overflow-x: hidden !important; +} + +*, *::before, *::after { + box-sizing: inherit; +} + +body { + font-size: 15px; + overflow-x: hidden; + max-width: 100vw; +} + +img, +video, +canvas, +iframe, +table { + max-width: 100% !important; +} + +pre, +code { + white-space: pre-wrap !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; +} + +.header { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + padding: 4px !important; + gap: 4px !important; + overflow: visible !important; +} + +.header-content { + display: flex !important; + flex-wrap: wrap !important; + width: 100% !important; + max-width: 100% !important; + padding: 2px !important; + gap: 4px !important; + overflow: visible !important; +} + +.top-bar-left, +.top-bar-mid, +.top-bar-right { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + align-items: stretch !important; + gap: 6px !important; +} + +.top-bar-left nav ul, +.top-bar-mid nav ul, +.top-bar-right nav ul { + display: flex !important; + flex-wrap: wrap !important; + gap: 6px !important; + padding: 0 !important; + margin: 0 !important; + overflow: visible !important; +} + +.top-bar-left nav ul li, +.top-bar-mid nav ul li, +.top-bar-right nav ul li { + margin: 0 !important; +} + +.top-bar-left nav ul li a, +.top-bar-mid nav ul li a, +.top-bar-right nav ul li a { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + padding: 6px 8px !important; + font-size: 0.9rem !important; + white-space: nowrap !important; +} + +.search-input, +.feed-search-input, +.activity-search-input { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + height: 40px !important; + font-size: 16px !important; +} + +.main-content { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + gap: 12px !important; +} + +.sidebar-left, +.sidebar-right, +.main-column { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + padding: 10px !important; + border-left: none !important; + border-right: none !important; +} + +.sidebar-left { + order: 1 !important; +} + +.main-column { + order: 3 !important; +} + +.sidebar-right { + order: 2 !important; +} + +.sidebar-left nav ul, +.sidebar-right nav ul { + display: flex !important; + flex-direction: column !important; +} + +.oasis-nav-header { + font-size: 0.85rem !important; +} + +.oasis-nav-list li a { + font-size: 0.9rem !important; + padding: 8px 12px !important; +} + +button, +input[type="submit"], +input[type="button"], +.filter-btn, +.create-button, +.edit-btn, +.delete-btn, +.join-btn, +.leave-btn, +.buy-btn { + min-height: 44px !important; + font-size: 16px !important; + white-space: normal !important; + text-align: center !important; +} + +.feed-row, +.comment-body-row, +table { + display: block !important; + width: 100% !important; + overflow-x: auto !important; +} + +textarea, +input:not([type="checkbox"]):not([type="radio"]), +select { + width: 100% !important; + max-width: 100% !important; + font-size: 16px !important; +} + +input[type="checkbox"]:not(.oasis-nav-toggle):not(.oasis-nav-side-toggle), +input[type="radio"] { + width: auto !important; + max-width: none !important; + flex: none !important; +} + +.gallery { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + gap: 8px !important; +} + +footer, +.footer { + display: block !important; + width: 100% !important; + max-width: 100% !important; + padding: 12px !important; + overflow-x: auto !important; +} + +footer div { + display: flex !important; + flex-direction: column !important; + gap: 8px !important; +} + +h1 { font-size: 1.35em !important; } +h2 { font-size: 1.2em !important; } +h3 { font-size: 1em !important; } + +.small, +.time { + font-size: 0.8rem !important; +} + +.created-at { + display: block !important; + width: 100% !important; + white-space: normal !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; + line-height: 1.5 !important; + font-size: 0.8rem !important; +} + +.header-content .created-at { + display: block !important; + flex: 1 1 100% !important; + min-width: 0 !important; + max-width: 100% !important; +} + +.post-meta, +.feed-post-meta, +.feed-row .small, +.feed-row .time, +.feed-row .created-at { + display: block !important; + width: 100% !important; + white-space: normal !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; + line-height: 1.4 !important; +} + +.post-meta, +.feed-post-meta { + flex-direction: column !important; + gap: 4px !important; +} + +.mode-buttons, +.mode-buttons-cols, +.mode-buttons-row, +.filter-group, +.activity-filter-grid, +.activity-sub-filter, +.filters .ui-toolbar, +.filter-group, +.inhabitant-action, +.mode-buttons { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + justify-content: center !important; + align-items: center !important; + width: 100% !important; + max-width: 100% !important; + gap: 6px !important; + overflow-x: hidden !important; + scroll-snap-type: none !important; + grid-template-columns: none !important; +} + +.mode-buttons .column, +.mode-buttons > div, +.activity-filter-col { + display: contents !important; +} + +.mode-buttons form, +.filter-group form, +.inhabitant-action form, +.activity-filter-col form { + width: auto !important; + flex: 0 1 auto !important; + scroll-snap-align: none !important; + margin: 0 !important; +} +.inhabitant-action .filter-btn { + min-height: 0 !important; + padding: 7px 12px !important; + font-size: 13px !important; + white-space: nowrap !important; +} +.profile-sensors-box { + display: flex !important; + flex-direction: column !important; + align-items: flex-start !important; + gap: 6px !important; + width: 100% !important; + max-width: 100% !important; +} +.profile-sensors-box > * { + max-width: 100% !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; +} +.inhabitant-activity-group { + display: flex !important; + flex-wrap: wrap !important; + gap: 6px !important; +} + +.activity-filter-grid .filter-btn, +.activity-filter-col .filter-btn, +.activity-sub-filter .filter-btn { + min-height: 0 !important; + padding: 7px 12px !important; + font-size: 13px !important; + white-space: nowrap !important; +} + +.mode-buttons .filter-btn, +.mode-buttons button, +.filter-group .filter-btn, +.filter-group button, +.activity-filter-grid .filter-btn, +.activity-filter-grid button, +.activity-sub-filter .filter-btn, +.filters .filter-btn, +.filters button { + width: auto !important; + flex: 0 0 auto !important; + white-space: nowrap !important; + min-width: 0 !important; +} + +.inhabitant-card { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + overflow: visible !important; +} + +.inhabitant-left { + width: 100% !important; + text-align: center !important; + flex: 0 0 auto !important; + height: auto !important; +} + +.inhabitant-details { + width: 100% !important; +} + +.inhabitant-left > a:first-of-type, +.inhabitant-left > a:first-child { + display: inline-block !important; + width: 150px !important; + height: 150px !important; + max-width: 150px !important; + margin: 0 auto 12px !important; +} + +.inhabitant-photo, +.inhabitant-photo-details { + width: 150px !important; + height: 150px !important; + max-width: 150px !important; + object-fit: cover !important; + border-radius: 50% !important; + display: block !important; + margin: 0 auto !important; +} + +.inhabitants-list, +.inhabitants-grid { + display: flex !important; + flex-direction: column !important; + gap: 10px !important; +} + +.tribe-card { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + overflow: hidden !important; +} + +.tribe-grid { + display: flex !important; + flex-direction: column !important; + gap: 10px !important; + grid-template-columns: 1fr !important; +} + +.tribes-list, +.tribes-grid { + display: flex !important; + flex-direction: column !important; + gap: 10px !important; +} + +.tribe-card-image { + width: 100% !important; + max-width: 100% !important; +} + +.tribe-section-nav { + display: flex !important; + flex-wrap: wrap !important; + justify-content: center !important; + align-items: center !important; + gap: 6px !important; + overflow-x: visible !important; + width: 100% !important; + max-width: 100% !important; +} + +.tribe-section-group { + display: contents !important; +} + +.tribe-section-btn { + font-size: 12px !important; + padding: 7px 12px !important; + white-space: nowrap !important; + flex: 0 0 auto !important; +} + +.tribe-details { + flex-direction: column !important; +} + +.tribe-side { + width: 100% !important; +} + +.tribe-main { + width: 100% !important; +} + +.tribe-content-grid { + grid-template-columns: 1fr !important; +} + +.tribe-inhabitants-grid { + 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; +} + +.tribe-mode-buttons { + flex-wrap: wrap !important; +} + +.tribe-card-hero-image { + 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; +} + +.forum-score-col, +.forum-main-col, +.root-vote-col { + width: 100% !important; +} + +.forum-header-row { + flex-direction: column !important; + gap: 4px !important; +} + +.forum-meta { + flex-wrap: wrap !important; + gap: 8px !important; +} + +.forum-thread-header { + flex-direction: column !important; +} + +.forum-comment { + margin-left: 0 !important; + padding-left: 8px !important; +} + +.comment-body-row { + flex-direction: column !important; +} + +.comment-vote-col, +.comment-text-col { + width: 100% !important; +} + +.forum-score-box, +.forum-score-form { + flex-direction: row !important; + justify-content: center !important; +} + +.new-message-form textarea, +.comment-textarea { + width: 100% !important; +} + +[style*="grid-template-columns: repeat(6"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns: repeat(3"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns:repeat(6"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns:repeat(3"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns: repeat(auto-fit"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns:repeat(auto-fit"] { + grid-template-columns: 1fr !important; +} + +[style*="width:50%"] { + width: 100% !important; +} +.inhabitant-karma-ubi .ubi-line{display:block;font-size:0.9em} +.pad-editor-container{flex-direction:column !important} +.pad-editor-area,.pad-members-list{width:100% !important;min-width:unset !important} +.chat-main-split{flex-direction:column !important} +.chat-messages-column,.chat-participants-column{width:100% !important;min-width:unset !important;flex:unset !important} +.games-grid{grid-template-columns:1fr !important} +.game-card{width:100% !important} + +.main-column [class*="-row"], +.main-column [class*="-card"], +.main-column [class*="-item"], +.main-column [class*="-entry"], +.main-column .card, +.main-column article { + flex-wrap: wrap !important; + min-width: 0 !important; +} +.main-column [class*="-row"] > *, +.main-column [class*="-card"] > * { + min-width: 0 !important; +} + +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 { + background-color: #FFA500 !important; + color: #1a1a1a !important; + border: 1px solid #FFA500 !important; + border-radius: 8px !important; + font-weight: 600 !important; + font-size: 14px !important; + min-height: 42px !important; + padding: 9px 14px !important; + text-align: center !important; + white-space: normal !important; + max-width: 100% !important; + overflow-wrap: anywhere !important; +} +.filter-btn.active { + background-color: #FFD700 !important; + border-color: #FFD700 !important; +} + +.audios-search, +.videos-search, +.images-search { + width: 100% !important; + max-width: 100% !important; + margin: 8px 0 !important; +} +.filter-box { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + gap: 8px !important; + align-items: stretch !important; +} +.filter-box__input { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + height: 42px !important; + font-size: 16px !important; + box-sizing: border-box !important; +} +.filter-box__controls { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + width: 100% !important; + gap: 8px !important; + align-items: stretch !important; +} +.filter-box__controls .transfer-range { + display: flex !important; + flex-direction: row !important; + flex: 1 1 100% !important; + width: 100% !important; + gap: 8px !important; +} +.filter-box__number { + flex: 1 1 0 !important; + min-width: 0 !important; + width: auto !important; + height: 42px !important; + font-size: 15px !important; + box-sizing: border-box !important; +} +.filter-box__select { + flex: 1 1 auto !important; + min-width: 0 !important; + height: 42px !important; + font-size: 15px !important; +} +.filter-box__button { + flex: 0 0 auto !important; + width: auto !important; + min-height: 42px !important; + white-space: nowrap !important; + background-color: #FFA500 !important; + color: #1a1a1a !important; + border: 1px solid #FFA500 !important; + border-radius: 8px !important; + font-weight: 600 !important; + padding: 9px 16px !important; +} +.filters .ui-toolbar--filters { + justify-content: center !important; + align-items: center !important; + gap: 6px !important; +} + +.main-column button, +.main-column input, +.main-column select, +.main-column textarea, +.main-column .btn, +.main-column .create-button, +.main-column .update-btn, +.main-column .delete-btn, +.main-column .edit-btn, +.main-column .join-btn, +.main-column .leave-btn, +.main-column .buy-btn, +.main-column .submit-button { + max-width: 100% !important; + box-sizing: border-box !important; + overflow-wrap: anywhere !important; +} +.main-column button, +.main-column .btn, +.main-column .create-button, +.main-column .update-btn, +.main-column .delete-btn, +.main-column .submit-button { + white-space: normal !important; +} +.main-column form { + max-width: 100% !important; + min-width: 0 !important; +} + +.card-field { + display: flex !important; + flex-wrap: wrap !important; + min-width: 0 !important; + max-width: 100% !important; + gap: 4px !important; +} +.card-field .card-value, +.card-field .card-label { + min-width: 0 !important; + max-width: 100% !important; + white-space: normal !important; + overflow-wrap: anywhere !important; + word-break: break-word !important; +} +.card-value .user-link, +.card-value a { + white-space: normal !important; + max-width: 100% !important; + overflow-wrap: anywhere !important; +} + +.market-grid, +.shop-products-grid, +.jobs-grid { + display: grid !important; + grid-template-columns: 1fr !important; + gap: 12px !important; + width: 100% !important; + max-width: 100% !important; +} +.market-card, +.market-item { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; +} + +.cycle-info { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 8px !important; +} +.kpi { + min-width: 0 !important; + padding: 8px !important; +} +.kpi__label { + letter-spacing: 0 !important; + word-break: normal !important; + overflow-wrap: break-word !important; + font-size: 11px !important; +} +.kpi__value { + word-break: break-word !important; + overflow-wrap: anywhere !important; + font-size: 13px !important; +} + +.table-wrap { + display: block !important; + width: 100% !important; + max-width: 100% !important; + overflow-x: hidden !important; +} +.table-wrap table, +.table-wrap .table { + display: table !important; + width: 100% !important; + max-width: 100% !important; + table-layout: fixed !important; +} +.table-wrap th, +.table-wrap td { + white-space: normal !important; + word-break: normal !important; + overflow-wrap: break-word !important; + font-size: 10px !important; + padding: 4px 3px !important; + text-align: center !important; +} + +.search-filters-row { + display: block !important; + width: 100% !important; + max-width: 100% !important; + overflow-x: hidden !important; +} +.search-filters-table, +.search-filters-table tbody, +.search-filters-table tr, +.search-filters-table td { + display: block !important; + width: 100% !important; + max-width: 100% !important; + overflow-x: hidden !important; +} +.search-filters-table td { + padding: 4px 0 !important; +} +.search-filters-table input, +.search-filters-table select, +.search-oasis-id { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box !important; + margin-left: 0 !important; +} + +.oasis-id-copy { + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; + font-family: monospace !important; + font-size: 12px !important; + text-align: center !important; + padding: 8px !important; + border: 1px solid #FFA500 !important; + border-radius: 8px !important; + background: rgba(255, 165, 0, 0.08) !important; + color: inherit !important; + -webkit-user-select: all !important; + user-select: all !important; +} +.peer-key, +.user-link.peer-key { + -webkit-user-select: all !important; + user-select: all !important; + word-break: break-all !important; +} + +.create-button, +button.create-button, +a.create-button { + background-color: #ffcc00 !important; + border-color: #e6b800 !important; + color: #000 !important; +} + +.peers-list .block-info-table, +.peers-technical-block .block-info-table, +.gov-overview { + display: block !important; + width: 100% !important; + max-width: 100% !important; + border: 0 !important; + background: transparent !important; + box-shadow: none !important; +} +.peers-list .block-info-table tbody, +.peers-technical-block .block-info-table tbody, +.gov-overview tbody { + display: block !important; + width: 100% !important; +} +.gov-overview thead { display: none !important; } +.peers-list .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, +.gov-overview tbody tr { + display: block !important; + width: 100% !important; + box-sizing: border-box !important; + border: 1px solid rgba(255, 165, 0, 0.35) !important; + border-radius: 8px !important; + margin: 0 0 8px 0 !important; + padding: 6px 10px !important; + background: rgba(255, 165, 0, 0.04) !important; +} +.peers-list .block-info-table td, +.peers-technical-block .block-info-table td, +.gov-overview tbody 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; + word-break: break-all !important; + overflow-wrap: anywhere !important; +} +.peers-list .block-info-table td::before, +.peers-technical-block .block-info-table td::before, +.gov-overview tbody 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; + word-break: normal !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; } diff --git a/src/client/assets/styles/style.css b/src/client/assets/styles/style.css new file mode 100644 index 0000000..eca02f8 --- /dev/null +++ b/src/client/assets/styles/style.css @@ -0,0 +1,6441 @@ +/* General styles*/ +span { + padding: 2px; +} + +.styled-link { + color: #FFA500; + text-decoration: none; +} + +p { + white-space: pre-wrap; +} + +hr { + border: none; + border-top: 1px solid #FFA500; + margin: 10px 0; + width: 100%; +} + +/* Textarea, input, select styles */ +textarea, input, select { + background-color: #444; + color: #FFA500; + border: 1px solid #333; + padding: 10px; + border-radius: 5px; + width: 80%; + font-size: 1em; + margin-top: 10px; +} + +input[type="text"], input[type="email"], input[type="password"], textarea { + margin-bottom: 15px; +} + +/* Body and overall page styles */ +html { + color: #FFA500; +} + +body { + font-family: sans-serif; + background-color: #000; + color: #FFA500; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +/* Link styles */ +a { + color: #FFA500; + text-decoration: underline; +} + +a:hover { + text-decoration: underline; +} + +.header { + display: flex; + justify-content: space-between; + padding: 10px; + align-items: center; +} + +.update-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 16px; + font-size: 0.85em; + letter-spacing: 0.3px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.update-banner-icon { + font-size: 1.15em; + animation: update-spin 2.5s linear infinite; +} + +@keyframes update-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.update-banner-text { + opacity: 0.85; +} + +.update-banner-link { + font-weight: bold; + text-decoration: underline; + text-underline-offset: 2px; +} + +.header-content { + display: flex; + align-items: center; + flex-wrap: nowrap; + overflow: hidden; +} + +.avatar-profile { + width: 40px; + height: 40px; + border-radius: 50%; + margin-right: 10px; +} + +.created-at, +.author-name, +.about-time, +.time { + margin-right: 10px; + white-space: nowrap; +} + +.author-name { + font-weight: bold; +} + +.created-at, +.about-time, +.time { + font-size: 0.9rem; + color: #ccc; +} + +.time { + font-size: 0.8rem; + color: gray; +} + +.time a { + color: inherit; + text-decoration: none; +} + +form-button-group { + display: flex; + gap: 10px; + flex-wrap: nowrap; + justify-content: start; +} + +.wallet-form-button-group-center { + display: inline-flex; + gap: 10px; + justify-content: space-between; + padding: 10px; + align-items: center; +} + +.conn-buttons { + display: inline-flex; + justify-content: space-between; + padding: 10px; + align-items: center; + gap: 10px; +} + +.left-bar { + display: flex; + align-items: center; +} + +.right-bar { + display: flex; + align-items: center; +} + +.search-input { + width: 380px; + height: 38px; +} + +nav ul { + list-style-type: none; + padding: 0; +} + +nav ul li { + margin: 5px 0; +} + +/* Logo and Navigation */ +.logo-icon { + width: 50px; + height: auto; + margin-right: 15px; +} + +nav ul { + display: flex; + list-style: none; + margin: 0; +} + +nav ul a { + color: #FFA500; + text-decoration: underline; + padding: 10px; + display: flex; +} + +nav ul li { + margin-right: 10px; + padding: 2px; +} + +nav ul li a { + color: #FFA500; + text-decoration: none; + padding: 2px; + display: block; +} + +nav ul li a:hover { + color: #FFD700; + padding: 2px; + text-decoration: underline; +} + +/* nav menus */ +.oasis-nav-group { + list-style: none; + margin-bottom: 0.75rem; +} + +.oasis-nav-toggle { + display: none; +} + +.oasis-nav-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #ffb400; + transition: background 0.2s ease, color 0.2s ease; + text-align: left; + border: 1px solid rgba(255, 180, 0, 0.6); + border-radius: 4px; + margin: 0 0.25rem; + box-sizing: border-box; +} + +.oasis-nav-header .emoji { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4em; + flex-shrink: 0; + text-align: center; + line-height: 1; +} + +.oasis-nav-header .nav-text { + flex: 1; +} + +.oasis-nav-header:hover { + background: rgba(255, 255, 255, 0.05); + color: #ffd36a; +} + +.oasis-nav-arrow { + font-size: 0.7rem; + margin-left: auto; + transition: transform 0.2s ease; +} + +.oasis-nav-header + .oasis-nav-list { + margin-top: 0.35rem; +} + +.oasis-nav-list { + max-height: 0; + overflow: hidden; + transition: max-height 0.25s ease-out; + padding: 0; + margin: 0 0.25rem; + box-sizing: border-box; +} + +.oasis-nav-toggle:checked + .oasis-nav-header + .oasis-nav-list { + max-height: 1000px; +} + +.oasis-nav-toggle:checked + .oasis-nav-header .oasis-nav-arrow { + transform: rotate(180deg); +} + +.oasis-nav-list li { + list-style: none; + padding: 0; + margin: 0; +} + +.oasis-nav-list li a { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.85rem; + text-decoration: none; + opacity: 0.85; + transition: opacity 0.15s ease; + box-sizing: border-box; +} + +.oasis-nav-list li a:hover { + opacity: 1; +} + +.oasis-nav-list .emoji { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4em; + flex-shrink: 0; + text-align: center; + line-height: 1; +} + +.oasis-nav-list .nav-text { + display: inline-block; + line-height: 1.2; +} + +.oasis-header-marquee { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + white-space: nowrap; + font-size: 0.78rem; + color: #ffc45a; +} + +.oasis-header-marquee-inner { + display: inline-block; + padding: 0 1rem; + animation: oasis-marquee 30s linear infinite; +} + +.oasis-marquee-item { + margin: 0 0.4rem; + opacity: 0.9; +} + +.oasis-marquee-separator { + margin: 0 0.4rem; + opacity: 0.5; +} + +@keyframes oasis-marquee { + 0% { transform: translateX(100%); } + 100% { transform: translateX(-100%); } +} + +/* Main content area + Menu */ +/* +.main-content { border: 2px solid red !important; } +.sidebar-left { border: 2px solid blue !important; } +.main-column { border: 2px solid green !important; } +.sidebar-right { border: 2px solid orange !important; } +*/ + +.sidebar-left, +.sidebar-right { + background-color: #222; + border: 1px solid #444; + padding: 10px; + min-width: 180px; + max-width: 260px; + flex-shrink: 0; + box-sizing: border-box; +} + +.main-content { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + width: 100%; + box-sizing: border-box; + gap: 10px; + min-width: 0; + max-width: 100vw; +} + +.main-column pre, +.main-column code { + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +.main-column img { + max-width: 100%; + height: auto; + display: block; +} + +.main-column { + background-color: #333; + padding: 20px; + flex-grow: 1; + min-width: 0; + max-width: 100%; + border-radius: 5px; + border: 1px solid #333; + box-sizing: border-box; + word-break: break-word; + overflow-wrap: anywhere; +} + +.main-column > *, +.main-column details, +.main-column section, +.main-column article { + min-width: 0; + max-width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; + word-break: break-word; +} + +.main-column details, +.main-column section { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.sidebar-left nav ul, +.sidebar-right nav ul { + display: block; +} + +.sidebar-left nav ul li, +.sidebar-right nav ul li { + margin-bottom: 10px; +} + +.sidebar-left nav ul li a, +.sidebar-right nav ul li a, +.header nav ul li a { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + background-color: #1d1d1d; + text-decoration: none; + font-size: 15px; + color: #ffa300; + border: 1px solid #333; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.sidebar-left nav ul li a:hover, +.sidebar-right nav ul li a:hover, +.header nav ul li a:hover { + background-color: #2a2a2a; + border-color: #555; +} + +/* Header layout */ +.header { + display: flex; + justify-content: space-between; + padding: 10px; + align-items: center; +} + +.top-bar-left, +.top-bar-mid, +.top-bar-center, +.top-bar-right { + display: flex; + align-items: center; + gap: 10px; + border: 0px; +} + +.top-bar-left { + justify-content: flex-start; +} + +.top-bar-mid { + justify-content: center; +} + +.top-bar-center { + flex: 1; + justify-content: center; + min-width: 0; +} +.top-bar-center-ainav .ai-ask-form { max-width: 100%; } +.top-bar-center-ainav .ai-ask-input { height: 44px; font-size: 1rem; padding: 0 1rem; } +.top-bar-center-ainav .ai-ask-btn { height: 44px; font-size: 1.1rem; padding: 0 1.1rem; } +.ainav-only.main-content { min-height: 60vh; } +.ainav-landing, +.ainav-landing .ainav-landing-topbar, +.ainav-landing .ainav-landing-center, +.ainav-landing .ainav-landing-myid, +.ainav-landing .top-bar-left, +.ainav-landing .top-bar-right, +.ainav-landing nav, +.ainav-landing ul, +.ainav-landing li { background: transparent !important; border: 0 !important; box-shadow: none !important; } +.ainav-landing { display: flex; flex-direction: column; align-items: stretch; min-height: 100vh; padding: 0 24px 24px; box-sizing: border-box; } +.ainav-landing-topbar { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 12px 0; gap: 16px; } +.ainav-landing-topbar .top-bar-left, .ainav-landing-topbar .top-bar-right { display: flex; align-items: center; gap: 10px; } +.ainav-landing-topbar nav ul { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; } +.ainav-landing-topbar nav ul li { display: inline-flex; } +.ainav-landing-center { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding-top: 15vh; gap: 20px; } +.ainav-landing-myid { text-align: center; word-break: break-all; margin: -8px 0 -4px !important; padding: 0 !important; box-shadow: none !important; line-height: 1.2 !important; font-size: 0.85rem; } +.ainav-landing-topbar nav ul li a, +.header.ainav-only nav ul li a { background: transparent !important; border: 0 !important; box-shadow: none !important; } +.ainav-landing-topbar nav ul li a:hover, +.header.ainav-only nav ul li a:hover { background: transparent !important; color: inherit !important; box-shadow: none !important; filter: none !important; text-decoration: underline !important; } +.ainav-landing-tags { width: 100%; max-width: 720px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; border: 0 !important; background: transparent !important; padding: 0; margin: 0; box-sizing: border-box; } +.ainav-landing-logo { display: inline-flex; } +.ainav-landing-logo img { width: 96px; height: 96px; border-radius: 50%; display: block; } +.ainav-landing-form { display: flex; align-items: center; gap: 10px; width: 100%; max-width: 720px; } +.ainav-landing-input { flex: 1; height: 56px; font-size: 1.1rem; padding: 0 1.2rem; border-radius: 6px; } +.ainav-landing-btn { height: 56px; font-size: 1.2rem; padding: 0 1.4rem; border-radius: 6px; } + +.top-bar-right { + justify-content: flex-end; +} + +.ai-ask-form { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + max-width: 720px; +} + +.ai-ask-form .ai-ask-input, +.ai-ask-form .ai-ask-btn { + box-sizing: border-box; + margin: 0; + height: 32px; + border: 1px solid rgba(255, 180, 0, 0.6); + border-radius: 4px; + padding: 0 0.75rem; + font-size: 0.8rem; + font-family: inherit; + font-weight: 600; + line-height: 1; + background: transparent; + color: #ffb400; + text-transform: none; + letter-spacing: normal; +} + +.ai-ask-form .ai-ask-input { + flex: 1; + min-width: 0; + outline: none; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.ai-ask-form .ai-ask-input::placeholder { + color: rgba(255, 180, 0, 0.55); +} + +.ai-ask-form .ai-ask-input:focus { + border-color: #ffa300; + background: rgba(255, 255, 255, 0.05); +} + +.ai-ask-form .ai-ask-btn { + flex-shrink: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + padding: 0 0.85rem; +} + +.ai-ask-form .ai-ask-btn:hover { + background: rgba(255, 180, 0, 0.15); + border-color: #ffa300; + color: #ffa300; +} + +@media (max-width: 768px) { + .top-bar-center { display: none; } +} + +.top-bar-left nav ul, +.top-bar-mid nav ul, +.top-bar-right nav ul { + padding: 0; + margin: 0; + overflow-x: auto; +} + +.logo-icon { + width: 40px; + height: 40px; + margin-right: 10px; +} + +/* Section styles */ +section { + margin-bottom: 20px; + color: #FFA500; +} + +ul { + padding: 1; + margin: 10px 0; +} + +ul li { + margin: 10px 0; +} + +/* Buttons */ +button, input[type="submit"], input[type="button"] { + background-color: #FFA500; + color: #000; + border: 1px solid #FFA500; + padding: 8px 16px; + border-radius: 5px; + cursor: pointer; + font-size: 0.9em; + transition: all 0.3s ease; + margin-top: 4.5px; +} + +button:hover, input[type="submit"]:hover, input[type="button"]:hover { + background-color: #FFD700; + border-color: #FFD700; +} + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + border: 1px solid #333; +} + +table th, table td { + padding: 10px; + border: 1px solid #444; + text-align: left; +} + +table th { + background-color: #222; +} + +table tr:nth-child(even) { + background-color: #444; +} + +/* Media queries */ +@media (max-width: 768px) { + .main-content { + flex-direction: column; + gap: 20px; + } + + .sidebar-left, .sidebar-right { + width: 100%; + display: block; + } + + .main-column { + width: 100%; + } +} + +@media (max-width: 480px) { + body { + font-size: 14px; + } + + h1, h2, h3 { + font-size: 1.5em; + } + + input, select { + font-size: 0.9em; + } +} + +/* general styles */ +div { + margin-bottom: 20px; + padding: 20px; + border-radius: 10px; + background-color: #222; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease-in-out; + overflow: hidden; +} + +div .header-content{ + width: 35%; +} + +h1 { + color: #FFA500; + font-weight: bold; + margin: 0; +} + +.md-mention { + font-size: 1.5em; + font-weight: bold; + margin: 20px 0; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + user-select: text; +} + +article { + font-size: 1.1em; + color: #FFF; + margin-bottom: 20px; +} + +.thread-view { + width: 90%; +} + +thread-container { + width: 95%; +} + +.profile { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: 10px; +} + +.profile-metrics { + display: flex; + flex-direction: column; + gap: 6px; +} + +.profile-metrics .inhabitant-last-activity { + align-self: center; +} + +.inhabitant-last-activity .label { + margin-right: 6px; +} + +.avatar-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + border-radius: 16px; + box-shadow: 0 8px 20px rgba(255, 165, 0, 0.25); + max-width: 300px; + margin: 0 auto 20px auto; + transition: transform 0.3s ease; +} + +.avatar-container:hover { + transform: scale(1.05); +} + +.avatar { + width: 140px; + height: 140px; + border-radius: 50%; + margin-bottom: 12px; + box-shadow: 0 4px 10px rgba(255, 140, 0, 0.4); + object-fit: cover; + transition: border-color 0.3s ease; +} + +.avatar:hover { + border-color: #FFA500; +} + +.name { + font-size: 1.5em; + font-weight: bold; +} + +footer { + clear: both; + margin-top: 20px; + padding: 10px 0; +} + +footer div { + display: flex; + gap: 10px; +} + +footer a { + background-color: #FFA500; + color: #000; + padding: 8px 16px; + border-radius: 5px; + text-decoration: none; + text-align: center; + margin: 5px 0; + transition: background-color 0.3s ease; +} + +footer a:hover { + background-color: #FFD700; + border-color: #FFD700; +} + +footer .btn { + background-color: #FFA500; + color: #000; + padding: 8px 16px; + border-radius: 5px; + text-align: center; + text-decoration: none; +} + +footer .btn:hover { + background-color: #FFD700; + border-color: #FFD700; +} + +footer .btn:hover { + background-color: #007B9F; +} + +/* Post preview */ +.mention { + font-weight: bold; + color: #ffcc00; + text-decoration: none; +} + +.mention:hover { + text-decoration: underline; +} + +.mentions-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mention-item { + padding: 12px; + border: 1px solid #333; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} + +.mention-content { + margin-bottom: 8px; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.post-preview img { + display: block; + margin: 1em 0; + max-width: 100%; + border-radius: 8px; + border: 2px solid #555; +} + +.post-preview audio, +.post-preview video { + display: block; + margin: 1.2em 0; + max-width: 100%; +} + +.thread-reply-item { + margin-bottom: 18px; +} + +.thread-reply-item:last-child { + margin-bottom: 0; +} + +.thread-reply-footer { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 10px; + padding: 0; + border: none !important; + background: transparent !important; + box-shadow: none !important; +} + +.thread-reply-footer .inline-form { + display: inline-flex; + margin: 0; +} + +.thread-reply-footer .inline-form button { + margin: 0; +} + +.publish-textarea { + min-height: 10em; + padding: 0.75em; + border: 1px solid #444; + border-radius: 6px; + font-family: sans-serif; + font-size: 1em; + line-height: 1.4; +} + +.preview-text { + background-color: #444; + color: #FFA500; + border: 1px solid #333; + padding: 10px; + border-radius: 5px; + width: 80%; + font-size: 1em; + white-space: pre-wrap; + word-wrap: break-word; + line-height: 1.4; +} + +/* Section, article, and message word-wrap */ +section, article, .message, .profile { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} + +article img { + max-width: 100%; + height: auto; + object-fit: contain; + display: block; + margin: 0 auto; +} + +.post-image { + max-width: 100%; + height: auto; + display: block; + border-radius: 8px; + margin: 8px 0; +} + +.post-video { + max-width: 100%; + display: block; + border-radius: 8px; + margin: 8px 0; +} + +.post-audio { + width: 100%; + display: block; + margin: 8px 0; +} + +.feed-container .post-image{max-width:100%;width:auto;max-height:480px;height:auto} +.feed-container .post-video,.feed-container .video-container video{width:100%;max-width:720px;max-height:480px;height:auto} +.feed-container .post-audio,.feed-container .audio-container audio{width:100%;max-width:640px} + +.fediverse-network{border-radius:8px;padding:18px 22px;margin-top:10px;display:flex;flex-direction:column;gap:16px} +.fediverse-network h3{margin:0} +.fediverse-profile{display:flex;flex-direction:column;gap:24px} +.fediverse-namerow{display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.fediverse-namerow .fediverse-avatar{width:80px;height:80px} +.fediverse-profile-id{display:flex;flex-direction:column;min-width:0} +.fediverse-name{font-weight:700;font-size:1.3em} +.fediverse-acct{margin-top:4px !important} +.fediverse-namerow .fediverse-compact-actions{margin-left:auto !important;align-self:flex-start} +.fediverse-compact-actions{display:inline-flex;gap:8px;flex-wrap:wrap} +.fediverse-pre{white-space:pre-wrap;word-break:break-word} +.fediverse-preview-head .fediverse-avatar{width:72px;height:72px} +.fediverse-stats{display:flex;flex-wrap:wrap;gap:32px;margin:0} +.fediverse-stat{display:flex;flex-direction:column;gap:4px} +.fediverse-stat strong{white-space:nowrap} +.fediverse-stat-label{font-size:0.8em;opacity:0.65} +.fediverse-bio{margin:0;padding:0;text-indent:0;opacity:0.9;word-break:break-word;white-space:normal} +.fediverse-bio p{margin:0;padding:0;text-indent:0;white-space:normal} +.fediverse-bio p+p{margin-top:6px} +.fediverse-fields{display:flex;flex-direction:column;gap:10px;margin:0;border-radius:8px;padding:14px 16px} +.fediverse-field{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline} +.fediverse-field-name{font-weight:700;opacity:0.75;min-width:110px} +.fediverse-field-value{min-width:0;word-break:break-word} +.fediverse-field-verified{color:#7fff7f} +.fediverse-reach-url{display:block;margin-top:6px;word-break:break-all} +.pm-exposition-fediverse{background:#2a1a3a;color:#c9a0ff;border-color:#6f4fa3} +.fediverse-profile,.fediverse-profile-id,.fediverse-namerow,.fediverse-stat,.fediverse-name,.fediverse-compact-actions,.fediverse-author,.fediverse-head,.fediverse-bio,.fediverse-stats,.fediverse-field,.fediverse-field-value,.fediverse-actions,.fediverse-compose,.fediverse-compose-actions,.fediverse-media,.fediverse-reach,.fediverse-preview-card,.fediverse-preview-head,.fediverse-disconnected{border:none !important;background:none !important;padding:0 !important;margin:0 !important;box-shadow:none !important;border-radius:0 !important} +.fediverse-empty{text-align:center;padding:24px} +.fediverse-error{color:#ff6b6b;margin:8px 0} +.fediverse-compose{display:flex;flex-direction:column;gap:8px;margin-bottom:18px} +.fediverse-compose textarea{width:100%;box-sizing:border-box;resize:vertical} +.fediverse-compose-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.fediverse-attach{display:inline-flex;align-items:center;gap:6px;font-size:0.9em;opacity:0.85} +.fediverse-card{display:flex;flex-direction:column;gap:8px} +.fediverse-boosted{font-size:0.85em;opacity:0.7} +.fediverse-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.fediverse-avatar{width:40px;height:40px;border-radius:8px;object-fit:cover;flex-shrink:0} +.fediverse-author{display:flex;flex-direction:column;min-width:0} +.fediverse-name{font-weight:700} +.fediverse-acct{font-size:0.85em;opacity:0.7;word-break:break-all} +.fediverse-date{margin-left:auto;font-size:0.8em;opacity:0.6;white-space:nowrap} +.fediverse-media{display:flex;flex-direction:column;gap:8px;align-items:flex-start} +.fediverse-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px} +.fediverse-focus,.fediverse-focus .fediverse-card{border:none !important;background:none !important;padding:0 !important;margin:0 !important;box-shadow:none !important;border-radius:0 !important} + +.post-pdf { + color: #FFA500; + text-decoration: none; + border-bottom: 1px dashed #FFA500; +} + +.post-pdf:hover { + text-decoration: underline; +} + +/* Filters */ +button.filter-btn, +a.filter-btn, +button.create-button { + font: inherit; + padding: 10px 20px; + margin-right: 10px; + border-radius: 5px; + border: 1px solid; + cursor: pointer; + transition: background-color .2s, border-color .2s; +} + +button.filter-btn, +a.filter-btn { + background-color: #ff6600; + border-color: #ff3300; + color: #fff; + text-decoration: none; + display: inline-block; +} + +button.filter-btn:hover, +button.filter-btn.active, +a.filter-btn:hover, +a.filter-btn.active { + background-color: #ff9900; + border-color: #e68a00; + color: #000; +} + +button.create-button { + background-color: #ffcc00; + border-color: #e6b800; + color: #000; +} + +button.create-button:hover { + background-color: #e6b800; + border-color: #cca300; +} + +/* Bookmarks */ +.bookmark-form input[type="text"], +.bookmark-form textarea, +.bookmark-form select { + width: 100%; + padding: 6px; + font-size: 14px; + border: 1px solid #333; + border-radius: 4px; + box-sizing: border-box; +} + +.bookmark-form input[type="url"] { + max-width: 800px; +} + +.bookmark-form textarea { + height: 80px; +} + +.bookmark-form select { + width: auto; + max-width: 200px; +} + +.bookmark-form label { + margin-bottom: 15px; + padding: 6px; +} + +.bookmark-tags { + margin-top: 1em; +} + +.bookmark-url { + color: #ff6600; + text-decoration: none; + font-weight: bold; + font-size: 1.2em; + word-wrap: break-word; + margin-bottom: 1em; +} + +.bookmark-url:hover { + text-decoration: underline; +} + +.filter-box { + padding: 16px 18px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.18); +} + +.filter-box__input { + width: 100%; + box-sizing: border-box; + height: 42px; + padding: 10px 12px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.12); + color: #ffd700; + outline: none; +} + +.filter-box__input::placeholder { + color: rgba(255, 215, 0, 0.75); +} + +.filter-box__controls { + display: flex; + gap: 12px; + align-items: center; + margin-top: 14px; + flex-wrap: wrap; +} + +.filter-box__select { + box-sizing: border-box; + height: 36px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.12); + color: inherit; + min-width: 170px; +} + +.filter-box__button { + height: 36px; + padding: 0 14px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.16); + color: #ffd700; +} + +.bookmark-topbar { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + border:0px; +} + +.bookmark-topbar-left { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.bookmark-topbar-left form { + margin: 0; +} + +.bookmark-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + border: 0px; +} + +.tags-header h2 { + color: #ffa300; +} + +.error-page-message{margin:8px 0 16px;white-space:pre-wrap;word-break:break-word} +.error-page-actions{margin-top:16px} + +.tags-container { + display: flex; + flex-wrap: wrap; +} + +.tag-link { + display: inline-block; + color: #ff6600; + padding: 0.2em 0.6em; + font-size: 0.9em; + text-decoration: none; + margin-right: 1em; + margin-bottom: 0.5em; +} + +.tag-checkboxes-table { + margin-top: 1rem; + width: 100%; + border-collapse: collapse; + color: yellow; +} + +.tag-checkboxes-table td { + padding: 0.5rem 1rem; + white-space: nowrap; + vertical-align: middle; +} + +.input-checkbox { + transform: scale(1.2); +} + +/* Inhabitants */ +.inhabitant-card { + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + gap: 1rem; +} + +.inhabitant-left { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + width: 300px; + flex: 0 0 300px; +} +.inhabitant-left > a:first-of-type { margin-bottom: 0 !important; } +.inhabitant-left > .status.you { margin: 0; } +.inhabitant-left .profile-sensors-box { margin: 0; } +.inhabitant-left .profile-sensors-box .profile-reach { margin: 0; } + +.inhabitant-left > a:first-of-type { + display: block; + width: 100%; +} +.inhabitant-left .profile-sensors-box a { + display: inline; + width: auto; +} + +.inhabitant-left h2 { + margin: 4px 0 0; + line-height: 1.1; +} + +.inhabitant-photo { + width: 100% !important; + height: 100% !important; + border-radius: 50%; + border: 3px solid #ffa500; + display: block; + margin: 0 auto; +} + +.inhabitant-photo-details { + width: 100% !important; + height: 100% !important; + max-width: none !important; + object-fit: cover; + border-radius: 50%; + border: 2px solid #ffa500; + display: block; + margin: 0 auto; +} + +.inhabitant-details { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 0; +} + +.inhabitant-activity-group { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 6px; +} + +.inhabitant-last-activity { + margin-top: 0; + display: flex; + border: 0; + align-items: center; + gap: 6px; +} + +.activity-dot { + display: inline-block; + font-size: 1em; + line-height: 1; + vertical-align: middle; +} + +.activity-dot.green { + color: #2ecc71; +} + +.activity-dot.orange { + color: #f39c12; +} + +.activity-dot.red { + color: #e74c3c; +} + +.profile-last-seen, +.profile-device { + display: block; + font-size: 13px; + color: #888; + margin-top: 4px; +} + +.device-kit { + color: green; + font-weight: bold; +} + +.device-desktop { + color: yellow; + font-weight: bold; +} + +.device-mobile { + color: red; + font-weight: bold; +} + +.activity-filters{display:grid;grid-template-columns:repeat(6,1fr);gap:16px;margin-bottom:12px} +.activity-filter-col{display:flex;flex-direction:column;gap:8px} +.activity-search{display:flex;gap:12px;align-items:center;margin:14px 0 18px 0;flex-wrap:wrap} +.activity-search-input{min-width:240px;max-width:520px;width:100%} +.post-text-pre{white-space:pre-wrap} +.activity-image-thumb{width:100%;max-width:720px;height:auto;border-radius:14px;object-fit:cover} +.activity-avatar{width:256px;height:256px;} +.activity-pub{display:flex;flex-direction:column;gap:10px;align-items:flex-start} +.activity-pub-title{margin:0} +.activity-pub-title .user-link{display:inline} +.activity-contact-links{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.activity-contact-arrow{opacity:.85} +.reply-context{margin:0 0 .55rem 0;padding:.5rem .6rem;border-radius:12px;background:rgba(255,255,255,.04)} +.reply-context-author{margin-left:.45rem} +.reply-context-text{margin:.35rem 0 0 0;opacity:.92;font-size:.97em} +.activity-spread-title{margin:.25rem 0 .55rem 0;font-size:1.08em} +.activity-spread-text{line-height:1.45;font-size:1.02em;word-break:break-word;overflow-wrap:anywhere} +.activity-contact { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.activity-contact-avatar-link { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.activity-contact-avatar { + width: 256px; + height: 256px; + object-fit: cover; + border-radius: 12px; +} + +.activity-contact-arrow { + font-weight: 800; + opacity: 0.85; +} + +/* Documents */ +.pdf-viewer-container { + width: 100%; + max-width: 100%; + background: #1e1e1e; + margin-bottom: 1rem; + padding: 1rem; + border-radius: 6px; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); +} + +.pdf-viewer-container canvas { + display: block; + margin: 0 auto; + border: 1px solid #333; + background-color: #fff; +} + +.pdf-viewer-container div { + margin-top: 1rem; +} + +.pdf-viewer-container button { + background-color: #2d2d2d; + color: #eee; + border: none; + padding: 0.4rem 0.8rem; + font-size: 1rem; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s ease; +} + +.pdf-viewer-container button:hover { + background-color: #444; +} + +.pdf-viewer-container span { + color: #ccc; + margin: 0 1rem; + font-family: monospace; +} + +/* CVs */ +.cv-photo { + width: 200px; + height: 200px; + object-fit: cover; + border-radius: 50%; +} + +.cv-item { + padding: 20px; + border: 1px solid #ddd; + margin-bottom: 20px; +} + +.cv-section { + padding: 20px; +} + +.no-cv { + padding: 20px; + text-align: center; +} + +.checkbox-oasisContributor { + display: table-cell; + align-items: center; + gap: 0.5em; +} + +.action-card { + padding: 1em; + border-bottom: 1px solid #333; + margin-bottom: 1em; +} + +.action-meta { + font-size: 0.9em; + color: #FFA500; +} + +.feed-image { + max-width: 100%; + height: auto; + border-radius: 6px; +} + +.trending-image .feed-image, +.opinion-image .feed-image { + max-width: 100%; + width: auto; + max-height: 480px; + height: auto; +} + +.opinions-voting-collapse { margin: 8px 0; } +.opinions-summary { + display: inline-block !important; + width: auto !important; + max-width: max-content; + box-sizing: border-box; + cursor: pointer; + font: inherit; + padding: 10px 20px; + border: 1px solid #ff3300; + border-radius: 5px; + background-color: #ff6600; + color: #fff; + transition: background-color .2s, border-color .2s; + list-style: none; +} +.opinions-summary:hover { background-color: #ff9900; border-color: #e68a00; color: #000; } +.opinions-summary::-webkit-details-marker { display: none; } +.opinions-summary::after { content: " \25BE"; opacity: 0.85; } +details[open] > .opinions-summary::after { content: " \25B4"; } + +.message-banner { + margin: 12px 0; + padding: 12px 16px; + border: 1px solid #ffa300; + border-radius: 6px; + background: rgba(255, 163, 0, 0.12); +} +.message-banner p { margin: 0; } + +.feed-search-form { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.feed-search-input { + flex: 1 1 260px; + min-width: 220px; + height: 40px; + box-sizing: border-box; +} + +.feed-search-btn { + height: 40px; + display: inline-flex; + align-items: center; + box-sizing: border-box; +} + +.type-label { + font-weight: bold; + margin-top: 0.5em; +} + +.content-text { + margin: 0.5em 0; +} + +.filters { +margin-bottom:16px; +} + +.mode-buttons { +display:flex; gap:8px; margin-top:16px; +} + +.mention-avatar { + width: 50px; + height: 50px; + border-radius: 50%; + margin-right: 10px; +} + +.tribe-card-image { + width: 100%; + max-height: 300px; + object-fit: cover; + border-radius: 6px; + margin-bottom: 8px; +} + +.tribe-feed { + background-color: #111; + padding: 16px; + border-radius: 8px; +} + +.tribe-feed-full { + width: 100%; + background-color: #222; + padding: 16px; + border-radius: 8px; + box-sizing: border-box; +} + +.feed-actions { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.vote-actions { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.feed-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.feed-item { + background-color: #1c1c1c; + padding: 12px; + border-radius: 6px; + color: #f0f0f0; +} + +.feed-item p { + margin: 4px 0; +} + +.feed-item form { + margin-top: 8px; +} + +.feed-item button { + background-color: orange; + color: black; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; +} + +.feed-item button:disabled { + background-color: #555; + cursor: not-allowed; +} + +.tribe-feed .feed-row, +.tribe-feed-full .feed-row { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.tribe-feed .refeed-column, +.tribe-feed-full .refeed-column { + flex: 0 0 68px; + min-width: 68px; + max-width: 68px; + padding: 10px 8px; +} + +.tribe-feed .refeed-column h1, +.tribe-feed-full .refeed-column h1 { + margin: 0 0 8px 0; + font-size: 24px; +} + +.tribe-feed .refeed-btn, +.tribe-feed-full .refeed-btn { + padding: 6px 8px; + font-size: 12px; + width: 100%; +} + +.tribe-feed .feed-main, +.tribe-feed-full .feed-main { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +@media (max-width: 720px) { + .tribe-feed .feed-row, + .tribe-feed-full .feed-row { + flex-direction: column; + } + + .tribe-feed .refeed-column, + .tribe-feed-full .refeed-column { + width: 100%; + min-width: 0; + max-width: none; + align-items: flex-start; + } +} + +.tribe-feed .feed-actions, +.tribe-feed-full .feed-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.tribe-feed .feed-actions form, +.tribe-feed-full .feed-actions form, +.tribe-feed .refeed-column form, +.tribe-feed-full .refeed-column form { + margin: 0; +} + +.tribe-feed-compose { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + margin-top: 14px; + align-items: end; +} + +.tribe-feed-compose textarea { + width: 100%; + min-width: 0; + padding: 12px; + border-radius: 10px; + border: 1px solid #444; + background: #222; + color: #eee; + resize: vertical; + min-height: 120px; + line-height: 1.45; + box-sizing: border-box; +} + +.tribe-feed-compose .tribe-feed-send { + height: 40px; + padding: 0 14px; + border-radius: 10px; + white-space: nowrap; +} + +@media (max-width: 720px) { + .tribe-feed-compose { + grid-template-columns: 1fr; + } + + .tribe-feed-compose .tribe-feed-send { + width: 100%; + } +} + +.tribes-container{ + display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:20px; margin-top:16px; + } + +.tribe-card { + background: #2c2f33; + border-radius: 8px; + border: 1px solid #444; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tribe-details { + display: flex; + border-radius: 12px; + padding: 24px; + gap: 24px; +} + +.tribe-main { + display: flex; + flex-direction: column; + gap: 16px; + flex: 1; + min-width: 0; +} + +.tribe-side { + display: flex; + background: #2c2f33; + width: 300px; + flex-shrink: 0; + flex-direction: column; + gap: 16px; +} + +@media (max-width: 768px) { + .tribe-details { + flex-direction: column; + } + .tribe-side { + width: 100%; + } +} + +.filter-btn,.create-button,.edit-btn,.delete-btn,.join-btn,.leave-btn,.buy-btn +{ +padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#ff6600; color:#fff; +} +.filter-btn.active { +background:#ffa500; + } + +.create-button { +background:#ffcc00; color:#000; + } + +.buy-btn { + display: inline-block; + background: #ffcc00; + color: #000; + text-decoration: none; + text-align: center; +} +.buy-btn:hover { background: #ffcc00; color: #000; } + +.gallery { +display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:10px; padding:10px; +} + +.gallery-item img { +width:100%; max-height:200px; object-fit:cover; border-radius:6px; transition:transform .2s; + } + +.gallery-item:hover img { +transform:scale(1.05); + } + +.tribe-detail-image { +width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; border-radius:6px; + } +.report-detail-image { + display: block; + max-width: 100%; + width: auto; + height: auto; + margin: 16px 0; + border-radius: 6px; + object-fit: contain; +} + +.activity-image-preview { + width: 400px; + height: 300px; + object-fit: contain; + border: 0px; +} + +.media-preview { + max-width: 100%; + height: auto; + display: block; +} + +.meme-checkbox { + width: 1%; +} + +.image-detail { + width: 100%; + max-width: 400px; + max-height: 300px; + object-fit: cover; + border-radius: 6px; + margin-bottom: 8px; +} + +.div-center.image-form .image-detail { + max-width: 200px; + max-height: 150px; + object-fit: contain; +} + +.gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px,1fr)); + gap: 10px; + padding: 10px; +} +.gallery-item img { + width: 100%; + max-height: 200px; + object-fit: cover; + border-radius: 6px; + transition: transform .2s; +} +.gallery-item:hover img { + transform: scale(1.05); +} + +.lightbox { + display: none; + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background: rgba(0,0,0,0.85); + justify-content: center; + align-items: center; + z-index: 10000; +} + +.lightbox:target { + display: flex; +} + +.lightbox img { + max-width: 90%; + max-height: 90%; + object-fit: contain; + border-radius: 6px; +} + +.lightbox-close { + position: absolute; + top: 15px; + right: 60px; + width: 42px; + height: 42px; + background: rgba(0, 0, 0, 0.6); + border-radius: 50%; + color: #fff; + font-size: 1.5em; + text-align: center; + line-height: 32px; + text-decoration: none; +} + +/* Opinions */ +.voting-buttons { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.opinion-card { + background-color: #1c1c1c; + border: 1px solid #333; + border-radius: 10px; + padding: 16px; + margin-bottom: 24px; + color: #FFA500; +} + +.opinion-card h3 { + margin-bottom: 8px; +} + +.opinion-card p { + margin: 4px 0; + color: #FFD700; +} + +/* Events */ +.event-tags { + margin-top: 12px; +} + +.event-tags .tag { + display: inline-block; + background-color: #2a2a2a; + color: #FFA500; + padding: 4px 10px; + margin: 4px 4px 0 0; + border-radius: 6px; + font-size: 13px; +} + +/* Tasks */ +.task-id { + color: #FFA500; + font-weight: bold; + margin: 0; +} + +/* Governance */ +.vote-buttons-row { + display: flex; + gap: 10px; + flex-wrap: nowrap; + justify-content: start; +} + +.conn-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.inhabitant-action { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.transfer-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.transfer-topbar-left { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.transfer-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.transfer-voting-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.confirmations-block { + margin-top: 6px; +} + +.confirmations-progress { + width: 260px; + max-width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.12); + appearance: none; +} + +.confirmations-progress::-webkit-progress-bar { + background: rgba(255, 255, 255, 0.12); +} + +.confirmations-progress::-webkit-progress-value { + background: rgba(255, 255, 255, 0.65); +} + +.confirmations-progress::-moz-progress-bar { + background: rgba(255, 255, 255, 0.65); +} + +.transfer-range { + display: flex; + gap: 8px; + align-items: center; +} + +.transfer-amount-input { + width: 120px; +} + +.market-item-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.job-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.tribe-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.audio-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.video-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.document-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.event-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.feed-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.image-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.task-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.task-actions > form { + margin: 0; +} + +.task-actions .project-control-form, +.task-actions .project-control-form--status { + margin: 0; + padding: 0; +} + +.task-actions .project-control-form--status { + display: flex; + align-items: center; + gap: 10px; +} + +.task-actions .project-control-select, +.task-actions .project-control-btn { + margin: 0; +} + +.task-actions button { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.report-actions{ + display:flex; + align-items:center; + gap:10px; + flex-wrap:wrap; +} +.report-actions form{ margin:0; } +.report-actions button, +.report-actions select{ + height:40px; +} + +.cv-filter-form { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: flex-end; +} + +.cv-filter-submit { + width: 100%; + margin-top: 4px; +} + +.cv-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +.card-salary { + display: inline-block; + font-weight: 700; + font-size: 1.2em; + letter-spacing: 0.02em; +} + +.mode-buttons { + display: flex; + gap: 10px; + margin-top: 10px; +} + +/* Tags */ +.tag-cloud-wrap { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + padding: 1em; + width: 97%; + height: auto; + background: #111; + border: 1px solid #333; + border-radius: 8px; + margin: 1em auto; + gap: 0.8em; +} + +.tag-cloud-item { + display: inline-block; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + transition: transform 0.2s ease, color 0.2s ease; +} + +.tag-cloud-item:hover { + transform: scale(1.3); + color: #FFA500 !important; + z-index: 10; +} + +/* Feed */ +.feed-card { + border: 1px solid #444; + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; + background-color: #1c1c1c; + +} + +.feed-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 20px; +} + +.refeed-column { + text-align: center; + border-right: 1px solid #555; + padding-right: 12px; +} + +.refeed-column h1 { + font-size: 28px; + margin: 0; + color: #FFA500; +} + +.refeed-btn { + margin-top: 10px; + background-color: #FFA500; + border: none; + padding: 6px 12px; + border-radius: 6px; + font-weight: bold; + color: #000; + cursor: pointer; + transition: background-color 0.2s ease-in-out; +} + +.refeed-btn:hover { + background-color: #cc8500; +} + +.feed-main { + flex: 1; +} + +.feed-text { + background-color: #2a2a2a; + padding: 10px; + border-radius: 6px; + color: #FFD700; + margin-bottom: 8px; + margin-right: 15px; + word-wrap: break-word; +} + +.feed-main a { + text-decoration: underline; +} + +.feed-main a:hover { + text-decoration: underline; +} + +.votes-wrapper { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #444; +} + +.votes { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.vote-category { + background-color: #333; + color: #FFA500; + padding: 6px 10px; + border-radius: 6px; + font-size: 14px; +} + +.voting-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.vote-btn { + background-color: #FFA500; + border: none; + color: #000; + padding: 6px 10px; + border-radius: 6px; + font-weight: bold; + cursor: pointer; +} + +.vote-btn:hover { + background-color: #cc8500; +} + +/* Pixelia Grid */ +.pixelia-form-wrap { + display: flex; + justify-content: flex-start; + margin-bottom: 12px; +} + +.pixel-color-000000 { background-color: #000000; } +.pixel-color-ffffff { background-color: #ffffff; } +.pixel-color-17f018 { background-color: #17f018; } +.pixel-color-ffbb00 { background-color: #ffbb00; } +.pixel-color-ff0000 { background-color: #ff0000; } +.pixel-color-0000ff { background-color: #0000ff; } +.pixel-color-ffff00 { background-color: #ffff00; } +.pixel-color-00ff00 { background-color: #00ff00; } +.pixel-color-00ffff { background-color: #00ffff; } +.pixel-color-ff00ff { background-color: #ff00ff; } +.pixel-color-a52a2a { background-color: #a52a2a; } +.pixel-color-800080 { background-color: #800080; } +.pixel-color-808000 { background-color: #808000; } +.pixel-color-00bfff { background-color: #00bfff; } +.pixel-color-d3d3d3 { background-color: #d3d3d3; } +.pixel-color-ff6347 { background-color: #ff6347; } + +.pixel-empty { background-color: #1a1a1a; } + +.pixel-cell { + width: 0.1px; + height: 0.1px; +} + +/* Market */ +.market-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + justify-content: center; +} + +.market-tribe-card .market-card-kind{display:inline-block;font-size:10px;letter-spacing:2px;color:#888;text-transform:uppercase;font-weight:600;margin-bottom:4px} +.market-tribe-card .market-card-price{font-size:16px;color:#FFD700;font-weight:600;margin-top:6px} +.market-tribe-card .market-card-seller{font-size:13px;color:#bbb;margin-top:4px;word-break:break-all;border:none;background:transparent;padding:0} +.market-tribe-card .market-card-seller-label{color:#ff9800;font-weight:600} + +.market-card { + background-color: #2c2c2c; + border-radius: 16px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + display: flex; + flex-direction: column; + justify-content: space-between; + text-align: center; +} + +.market-comments { + background: #2f2f2f; + border-radius: 12px; + padding: 12px; +} + +.market-comments .votations-comment-card { + background: #3a3a3a; +} + +.countdown-strong { + font-weight: 700; +} + +.market-card-image { + width: 100%; + max-height: 150px; + object-fit: contain; + border-radius: 8px; + margin-bottom: 15px; +} + +.market-card-title { + font-size: 1.3em; + font-weight: bold; + color: #ffd600; + margin-bottom: 10px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.market-card-description { + font-size: 0.9em; + color: #ccc; + margin-bottom: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-card-footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 1.1em; + color: #ffd600; +} + +.market-card-footer .condition, +.market-card-footer .stock { + font-size: 1em; + color: #ff9900; +} + +.market-card-footer .stock { + font-weight: bold; + color: #ff0000; +} + +.market-card.price { + font-size: 1.3em; + font-weight: bold; +} + +/*avatar relationships*/ +.relationship-status { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + justify-content: flex-start; + margin: 0.8rem 0 0.5rem 0; + background: transparent !important; + border: 0 !important; + padding: 0 !important; +} + +.relationship-status .relationship-actions { + display: flex; + flex-basis: 100%; + gap: 0.5rem; + margin-top: 0.4rem; + background: transparent !important; + border: 0 !important; + padding: 0 !important; +} + +.relationship-status .relationship-actions form { + background: transparent !important; + border: 0 !important; + padding: 0 !important; +} + +.inhabitant-left .cv-actions, +.inhabitant-details .cv-actions { + background: transparent !important; + border: 0 !important; + padding: 0 !important; + width: 100%; +} + +.inhabitant-left .cv-actions form, +.inhabitant-details .cv-actions form { + background: transparent !important; + border: 0 !important; + padding: 0 !important; + width: 100%; +} + +.inhabitant-left .cv-actions button.btn { + width: 100%; +} + +.inhabitant-left .cv-actions p { + text-align: center; + margin: 0; +} + +.profile-side-relationship .relationship-status { + justify-content: center; + margin: 0; +} + +.profile-side-relationship .relationship-actions { + justify-content: center; +} + +.profile-side-relationship, +.profile-side-actions { + background: transparent !important; + border: 0 !important; + padding: 0 !important; +} + +.profile-side-relationship form, +.profile-side-actions form { + background: transparent !important; + border: 0 !important; + padding: 0 !important; + display: inline-block; +} + +.profile-side-actions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.status { + padding: 0.2rem 0.6rem; + border: 1px solid #555; + border-radius: 4px; + background-color: #2a2a2a; + font-size: 0.9rem; + color: #e0e0e0; +} + +.status.mutual { + border-color: #557d3b; + color: #8bc34a; +} + +.status.supporting { + border-color: #997f2e; + color: #f0c674; +} + +.status.supported-by { + border-color: #3a6f9c; + color: #6ab0f3; +} + +.status.blocked { + color: #ff5555; + border-color: #802020; +} + +.status.blocked-by { + color: #ff8888; + border-color: #a04040; +} + +.status.you { + padding: 0.2rem 0.6rem; + border: 1px solid #555; + border-radius: 4px; + background-color: #2a2a2a; + font-size: 0.9rem; + color: #FFDD44; + border-color: #FF6A00; + font-weight: bold; +} + +/*forums*/ +.forum-list { + display: flex; + flex-direction: column; + gap: 0.5em; +} +.forum-card { + display: flex; + flex-direction: row; + align-items: stretch; + border-bottom: 1px solid #262626; + padding: 12px 0 16px 0; + gap: 1.1em; + background: none; +} +.visit-forum-form { + display: flex; + justify-content: center; + margin-top: 10px; +} +.forum-score-col { + min-width: 78px; + display: flex; + align-items: center; + justify-content: center; +} +.forum-score-box { + background: #22201a; + border-radius: 12px; + box-shadow: 0 0 0.5em #111b; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 56px; + min-height: 134px; + padding: 8px 0; +} +.forum-score-form { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + width: 100%; +} +.score-btn { + background: #282818; + color: #ffd740; + border: none; + width: 38px; + height: 38px; + font-size: 1.25em; + border-radius: 8px; + cursor: pointer; + transition: background 0.13s; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} +.score-btn:hover { + background: #39391c; +} +.score-total { + font-size: 2em; + font-weight: bold; + color: #ffd740; + text-align: center; + margin: 0; + line-height: 1.1; + min-height: 38px; + display: flex; + align-items: center; + justify-content: center; +} + +.forum-main-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.22em; +} + +.forum-header-row { + display: flex; + align-items: center; + gap: 0.7em; + margin-bottom: 2px; + flex-wrap: wrap; +} +.forum-header-row .forum-invite-form, +.forum-header-row .forum-delete-form { + margin: 0; +} +.forum-category { + color: #ff7300; + background: #282818; + font-weight: bold; + font-size: 1em; + text-decoration: underline; + padding: 2px 7px; + border-radius: 5px; + margin-right: 0.4em; +} +.forum-title { + color: #ff7300; + font-size: 1.13em; + font-weight: bold; + text-decoration: underline; + transition: color 0.15s; +} +.forum-title:hover { + color: #ffd740; +} +.forum-body { + margin: 0.15em 0 0.2em 0; + font-size: 1em; + color: #ffd740; + line-height: 1.6; +} +.forum-body { + margin: 0.15em 0 0.2em 0; + font-size: 1em; + color: #ffd740; + line-height: 1.6; + word-break: break-word; +} + +.forum-body h1 { + font-size: 1.6em; + margin: 10px 0 6px 0; + color: #ff9d00; +} + +.forum-body h2 { + font-size: 1.4em; + margin: 8px 0 5px 0; + color: #ff9d00; +} + +.forum-body h3 { + font-size: 1.25em; + margin: 6px 0 4px 0; + color: #ff9d00; +} + +.forum-body ul, +.forum-body ol { + padding-left: 22px; + margin: 6px 0; +} + +.forum-body li { + margin: 3px 0; +} + +.forum-body blockquote { + border-left: 3px solid #ff7300; + padding-left: 10px; + margin: 8px 0; + color: #ffb347; +} + +.forum-body code { + background: #1a1a1a; + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; +} + +.forum-body pre { + background: #111; + padding: 10px; + border-radius: 6px; + overflow-x: auto; + margin: 8px 0; +} + +.forum-body hr { + border: none; + border-top: 1px solid #333; + margin: 10px 0; +} + +.forum-body img { + max-width: 100%; + border-radius: 6px; + margin: 8px 0; +} + +.forum-body video, +.forum-body audio { + max-width: 100%; + margin: 8px 0; +} + +.forum-score-box { + position: sticky; + top: 80px; +} + +.forum-body.truncate { + max-height: 220px; + overflow: hidden; + position: relative; +} + +.forum-body.truncate::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 60px; + background: linear-gradient(to bottom, rgba(0,0,0,0), #0f0f0f); +} +.forum-meta { + font-size: 1em; + align-items: center; + color: #ffd740; + margin: 0.1em 0 0 0; + display: flex; + gap: 1.3em; +} +.forum-footer { + font-size: 0.97em; + margin-top: 6px; + display: flex; + gap: 0.5em; + align-items: center; +} +.date-link { + color: #ffd740; + font-family: monospace; + font-size: 1em; +} +.user-link { + color: #ffd740; + font-weight: bold; + text-decoration: underline dotted; +} +.card-section .user-link, +.card-field .user-link { + display: inline-block; + max-width: 220px; + vertical-align: bottom; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mode-buttons-row { + display: flex; + flex-direction: row; + align-items: flex-start; + margin-bottom: 1.5em; +} +.mode-buttons-cols { + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 0.6em; +} + +.forum-comment { + display: flex; + flex-direction: column; + gap: 12px; + background: #282828; + padding: 12px; + margin-bottom: 8px; + border-radius: 8px; + transition: background 0.3s ease; +} + +.comment-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.comment-body-row { + display: flex; + flex-direction: row; + gap: 16px; +} + +.comment-vote-col { + min-width: 78px; + display: flex; + align-items: flex-start; + justify-content: center; +} + +.comment-text-col { + flex: 1; + display: flex; + flex-direction: column; +} + +.comment-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.comment-textarea { + resize: vertical; + width: 100%; + padding: 8px; + background: #1e1e1e; + color: #ffd740; + border: 1px solid #444; + border-radius: 6px; + font-size: 1em; +} + +.comment-votes { + font-size: 0.9em; + color: #ffd740; +} + +.comment-footer { + font-size: 0.85em; + display: flex; + justify-content: space-between; + color: #ffd740; +} + +.date-link { + color: #ffd740; + font-size: 0.95em; +} + +.user-link { + color: #ffd740; + font-weight: bold; + font-size: 1em; +} + +.forum-send-btn { + background: #ff7300; + color: #fff; + border: none; + padding: 8px 16px; + font-size: 1em; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; +} + +.forum-send-btn:hover { + background: #ffd740; +} + +.forum-owner-actions { + margin-top: 8px; + text-align: left; +} + +.comment-votes .votes-count { + margin-right: 12px; +} + +.new-message-wrapper { + margin-top: 16px; + padding: 16px; + border-radius: 8px; +} + +.highlighted-reply { + background-color: #ffe5b4 !important; +} + +.forum-comment.highlighted-reply { + background-color: #ffa400 !important; + transition: background 0.3s ease; +} + +.forum-comment.level-0 { margin-left: 0; } +.forum-comment.level-1 { margin-left: 20px; } +.forum-comment.level-2 { margin-left: 40px; } +.forum-comment.level-3 { margin-left: 60px; } + +.filter-group { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 6px; +} + +.forum-create-col { + margin-top: 8px; +} + +.forum-title-form { + display: inline; +} + +.forum-title-form .forum-title { + cursor: pointer; +} + +.forum-positive-votes { + color: #4CAF50; + font-weight: bold; +} + +.forum-messages { + font-weight: 500; +} + +.forum-thread-header { + margin-bottom: 16px; +} + +.tribe-forum-filters { + margin-bottom: 16px; +} + +.tribe-forum-reply-form { + margin-top: 12px; + margin-bottom: 16px; +} +.forum-comment.level-4 { margin-left: 80px; } + +/*blockexplorer*/ +.blockchain-view { + font-family: 'Inter', 'Roboto', 'Segoe UI', Arial, sans-serif; + background-color: #191b20; + padding: 32px 16px; + border-radius: 16px; + box-shadow: 0 8px 36px rgba(0,0,0,0.16); + color: #e7e7e7; + max-width: 900px; + margin: 0 auto; +} + +.blockexplorer-search{ + margin:16px 0 20px; +} +.blockexplorer-search-form{ + display:flex; + flex-direction:column; + gap:12px; +} +.blockexplorer-search-row{ + display:flex; + flex-direction:column; + gap:10px; +} +.blockexplorer-search-pair{ + display:flex; + flex-wrap:wrap; + gap:6px; +} +.blockexplorer-search-dates{ + display:flex; + flex-wrap:wrap; + gap:12px; +} +.blockexplorer-search-input{ + min-width:220px; +} +.blockexplorer-search-actions{ + display:flex; + justify-content:flex-start; +} + +.block { + background: #23242a; + border-radius: 16px; + box-shadow: 0 2px 12px rgba(32,35,42,0.11); + padding: 22px 24px; + margin-bottom: 26px; + transition: box-shadow 0.22s; + position: relative; +} + +.block-buttons { + display: flex; + justify-content: right; + align-items: center; + gap: 12px; +} + +.button-container { + display: flex; + gap: 12px; + justify-content: right; + align-items: center; +} + +.block:hover { + box-shadow: 0 8px 32px rgba(35,40,50,0.18); +} + +.block-row { + display: flex; + align-items: center; + gap: 6px; +} + +.blockchain-card-label { + color: #ffa300; + font-weight: bold; + letter-spacing: 1.5px; + line-height: 1.2; + margin-bottom: 0; +} + +.blockchain-card-value { + color: #ffd740; +} + +.block-info-table .card-label { + color: #ffa300; + font-weight: bold; +} + +.block-info-table .card-value { + color: #ffd740; +} + +.block-row--content { + margin: 10px 0 0 0; +} + +.block-info-table td { + padding: 12px 15px; + font-size: 14px; + border: 1px solid #444; +} + +.block-author { + font-weight: 600; + font-size: 14px; + color: #ffd740; + background: rgba(255,163,0,0.08); + padding: 3px 10px; + border-radius: 8px; + text-decoration: none; + transition: color 0.12s; +} + +.block-author:hover { + color: #ffa300; +} + +.block-timestamp { + color: #ffd740; + font-size: 12px; + margin-right: 6px; +} + +.block-content-preview, +.block-content { + background: #222326; + border-radius: 10px; + padding: 14px 18px; + font-size: 14px; + line-height: 1.7; + color: #ffd740; + font-family: 'JetBrains Mono', 'Courier New', monospace; + overflow-x: auto; +} + +.block-content-preview pre { + margin: 0; +} + +.block-info-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; +} + +.block-info-table td { + padding: 10px; + border: 1px solid #444; + font-size: 14px; +} + +.json-content { + white-space: pre-wrap; + word-break: break-word; + color: #ffd740; +} + +.block-row--details { + margin-top: 14px; + position: relative; +} + +.block-url { + color: #ffd740; + font-size: 14px; + text-decoration: none; + font-weight: 500; + transition: color 0.13s, border-color 0.13s; +} + +.block-row--details .block-url { + position: absolute; + top: 0; + right: 0; + background: #1f2023; + padding: 6px 12px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + border: none; + transition: background 0.12s, color 0.12s; +} + +.block-row--details .block-url:hover { + background: #292b36; + color: #ffa300; +} + +.btn-singleview { + background: #1e1f23; + border: none; + border-radius: 50%; + padding: 7px 12px; + cursor: pointer; + font-size: 1.14em; + color: #ffa300; + box-shadow: none; + transition: background 0.14s, color 0.14s; + text-decoration: none; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-singleview:hover { + background: #2d2e34; + color: #ffd740; +} + +.btn-datagram { + font-size: 1.2em; + font-weight: bold; +} + +.block-row--meta-spaced { margin-top: 8px; } +.filter-column { display: flex; flex-direction: column; gap: 8px; } +.deleted-label { color: #b00; font-weight: bold; margin-top: 8px; } +.block-row--back { + margin-top: 15px; +} + +.block-content-label { + color: #ffa300; + font-size: 1.02em; + margin-bottom: 8px; + font-weight: 600; +} + +.btn-back { + background: #21232b; + color: #ffa300; + padding: 7px 22px; + border-radius: 8px; + text-decoration: none; + border: none; + font-size: 1.01rem; + font-weight: 500; + margin-top: 12px; + transition: background 0.12s, color 0.13s; + display: inline-block; +} + +.btn-back:hover { + background: #292b36; + color: #ffd740; +} + +/* block diagram (IP header style) */ +.block-diagram-section{margin:16px 0;background:transparent !important;border:none !important} +.block-diagram-title{font-size:16px;margin:0 0 12px;background:transparent !important;border:none !important} +.block-diagram-link{text-decoration:none;color:inherit;display:block;margin-bottom:8px} +.block-diagram-link *{text-decoration:none} +.block-diagram-link:hover,.block-diagram-link:hover *{text-decoration:none} +.block-diagram{border:2px solid #555;border-radius:0;overflow:hidden;margin-bottom:8px;background:#191b20 !important} +.block-diagram-ruler{display:grid;grid-template-columns:0fr 4fr 4fr 8fr 8fr 7fr;font-size:10px;color:#ffa300;padding:2px 0;background:#111 !important;border:none !important;border-bottom:2px solid #555 !important} +.block-diagram-ruler span{background:transparent !important;border:none !important;text-align:left;padding-left:2px;color:#ffa300} +.block-diagram-grid{display:grid;grid-template-columns:repeat(32,1fr)} +.block-diagram-cell{border:1px solid #555;padding:6px 8px;text-align:center;background:#1e1f23 !important;min-height:38px;display:flex;flex-direction:row;align-items:center;justify-content:center;gap:6px} +.bd-label{font-size:10px;color:#ffa300;font-weight:bold;text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;background:transparent !important;border:none !important} +.bd-value{font-size:12px;font-family:'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#ffd740;text-decoration:none;background:transparent !important;border:none !important} +.bd-seq{grid-column:1/9} +.bd-type{grid-column:9/19} +.bd-ts{grid-column:19/33} +.bd-id{grid-column:1/33} +.bd-author{grid-column:1/33} +.bd-flags{grid-column:1/17} +.bd-ctype{grid-column:17/33} +.bd-data{grid-column:1/33} +.block-diagram:hover{border-color:inherit;box-shadow:none} + +@media (max-width: 700px) { + .blockchain-view { + padding: 9vw 2vw; + } + + .block, .block-single, .block--single { + padding: 15px 5vw; + } + + .mode-buttons-row { + flex-direction: column; + gap: 18px; + } +} + +/*inbox/pm*/ +.pm-card.normal-pm { padding: 10px 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #ffa300; background: transparent } +.pm-btn { padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 700; letter-spacing: 0.5px; cursor: pointer; border: 1px solid #ffa300; background: transparent; color: #ffa300; transition: background 0.15s, color 0.15s } +.pm-btn:hover { background: #ffa300; color: #000 } +.pm-thread { margin-bottom: 12px } +.pm-thread-details { margin-top: 0 } +.pm-thread-details[open] .pm-thread-icon { display: inline-block; transform: rotate(90deg) } +.pm-thread-toggle { cursor: pointer; padding: 6px 12px; font-size: 13px; font-weight: 600; list-style: none; display: flex; align-items: center; gap: 6px } +.pm-thread-toggle::-webkit-details-marker { display: none } +.pm-thread-icon { font-size: 10px; transition: transform 0.15s } +.pm-thread-replies { padding-left: 16px; border-left: 3px solid #ffa300; margin-left: 8px; margin-top: 4px } +.pm-info-table { width: 100%; border-collapse: collapse; margin-bottom: 8px } +.pm-info-table td { padding: 10px; border: 1px solid #444; font-size: 14px } +.pm-spacer { flex: 1 } +.pm-chars { font-size: 12px; opacity: .7 } +.pm-subject { margin: 0 0 6px 0; font-size: 14px; font-weight: 600; line-height: 1.2; white-space: pre-wrap } +.pm-bot { opacity: .85 } +.pm-body { margin-top: 6px } +.pm-action-form { display: inline-block; margin: 0 } +.pm-actions-block { + width: 100%; + max-width: 720px; + border: 0; +} +.pm-actions { + display: flex; + gap: 10px; +} +.pm-crypter-row { + border: none; + background: transparent; + padding: 0; + margin: 8px 0; + display: flex; + justify-content: flex-start; + align-items: center; +} +.pm-crypter-row label { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + cursor: pointer; +} +.pm-crypter-row input[type="checkbox"] { + width: auto; + min-width: 0; + margin: 0; + padding: 0; + accent-color: currentColor; +} +.pm-crypter-decrypt-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + border: none; + background: transparent; + padding: 0; + margin: 8px 0; +} +.pm-crypter-decrypt-form > input.pm-crypter-key-input, +.pm-crypter-decrypt-form > button { + margin: 0; + align-self: center; +} +.pm-crypter-key-input { + width: auto; + min-width: 22em; +} +.pm-btn.danger-btn { + border-color: #d9534f; + color: #d9534f; + background: transparent; +} +.pm-btn.danger-btn:hover { + background: #d9534f; + color: #000; +} +.pm-encryption-cell { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.pm-reply-badge { display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 1px; padding: 2px 8px; border-radius: 4px; margin-bottom: 6px; background: rgba(255,163,0,0.15); color: #ffa300 } +.thread-level-0 { margin-left: 0 } +.thread-level-1 { margin-left: 16px; border-left: 3px solid #ffa300; padding-left: 12px } +.thread-level-2 { margin-left: 32px; border-left: 3px solid #ffa300; padding-left: 12px } +.thread-level-3 { margin-left: 48px; border-left: 3px solid #ffa300; padding-left: 12px } +.thread-level-4 { margin-left: 64px; border-left: 3px solid #ffa300; padding-left: 12px } +.thread-level-5 { margin-left: 80px; border-left: 3px solid #ffa300; padding-left: 12px } +.thread-level-6 { margin-left: 96px; border-left: 3px solid #ffa300; padding-left: 12px } +.thread-level-7 { margin-left: 112px; border-left: 3px solid #ffa300; padding-left: 12px } +.thread-level-8 { margin-left: 128px; border-left: 3px solid #ffa300; padding-left: 12px } +.pm-quote { border-left: 3px solid #ffa300; margin: 8px 0; padding: 8px 12px; opacity: 0.75; font-size: 13px; border-radius: 0 6px 6px 0 } + +/*projects*/ +.project-actions { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 10px; +} + +.project-card.status-completed .badge{ background:rgba(59,130,246,.12); color:#93c5fd; border-color:rgba(59,130,246,.25); } + +.bookmark-actions.project-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 14px; +} + +.bookmark-actions.project-actions form { + margin: 0; +} + +.project-control-form { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; +} + +.project-control-select, +.project-control-input, +.project-control-btn { + height: 42px; +} + +.project-control-select, +.project-control-input { + min-width: 220px; +} + +.project-control-btn { + white-space: nowrap; +} + +.project-progress-input { + max-width: 140px; + min-width: 120px; +} + +@media (max-width: 820px) { + .project-control-select, + .project-control-input { + min-width: 180px; + } + + .project-progress-input { + min-width: 110px; + } +} + +.badge{ + position:absolute; + top:14px; right:14px; + font-size:.75rem; + letter-spacing:.02em; + padding:6px 8px; + border-radius:8px; + border:1px solid var(--line); + color:var(--muted); +} + +.milestones, .bounties{ display:flex; flex-direction:column; gap:8px; } +.milestone-item, .bounty-item{ + padding:12px; + border:1px solid var(--line); + border-radius:12px; + background:#121620; +} + +.milestone-row{ + display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between; + margin-bottom:6px; +} + +.milestone-title{ font-weight:600; } +.milestone-percent{ padding:4px 8px; border-radius:8px; border:1px solid var(--line); font-size:.8rem; color:#b7c2d6; } +.milestone-due{ color:#c9a86a; font-size:.85rem; } + +.bounty-main{ + display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; +} + +.bounty-title{ font-weight:600; } +.bounty-amount{ + font-variant-numeric: tabular-nums; + padding:4px 8px; border-radius:8px; background:#18202f; border:1px solid var(--line); +} + +.pledge-box{ + margin-top:6px; + padding:14px; + border:1px solid var(--line); + border-radius:12px; + background:#121620; + display:grid; + gap:10px; +} + +.pledge-box form{ display:flex; gap:10px; flex-wrap:wrap; } +.pledge-box input[type="number"]{ + background:#0f1420; border:1px solid #2b3240; color:var(--text); + padding:10px 12px; border-radius:10px; outline:none; +} + +.pledge-box input[type="number"]:focus{ border-color:#3a475c; } + +.card-field{display:flex;align-items:baseline;gap:6px;margin-bottom:4px;flex-wrap:wrap} +.card-field>.card-label{white-space:nowrap;flex-shrink:0} +.card-field>.card-value{min-width:0;word-break:break-word;overflow-wrap:anywhere;flex:1 1 auto} +.card-field-stacked{flex-direction:column;align-items:flex-start;gap:2px} +.card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word} +.card-field-rich{flex-direction:column;align-items:flex-start} +.card-field-rich .card-value{white-space:pre-wrap;word-break:break-word;margin-top:4px} +.report-template{margin-top:12px;padding:12px;background:rgba(255,255,255,0.05);border-radius:8px} +.report-template .card-label{white-space:nowrap} + +@media (max-width: 420px){ + .card-field{ flex-direction:column; } + .milestone-row{ flex-direction:column; align-items:flex-start; gap:6px; } +} + +/*banking*/ +.addr-form-card, .addr-list-card { + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + background: rgba(255,255,255,0.03); + padding: 20px; +} + +.addr-form-card.wide { + padding: 22px; +} + +.addr-form .form-row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 10px; + align-items: center; + margin-bottom: 12px; +} + +.addr-form .form-input { + padding: 0 14px; +} + +.addr-form .form-input.xl { + height: 50px; + font-size: 16px; +} + +.addr-form .form-actions { + display: flex; + margin-top: 8px; +} + +.addr-list-card .form-row { + display: grid; + grid-template-columns: 150px 1fr; + gap: 10px; + align-items: center; +} + +.addr-list-card .form-input { + padding: 0 14px; +} + +.addr-search input[type="text"]:focus, +.addr-form .form-input:focus { + outline: none; + border-color: #ffa300; +} + +.addr-form .form-actions .mini-btn { + background: #ffa300; + border: none; + color: #fff; + padding: 9px 14px; + border-radius: 8px; + cursor: pointer; +} + +.addr-form .form-actions .mini-btn:hover { + background: #ff7f00; +} + +.bank-summary .bank-info-table tbody tr .synced { + color: green; + font-weight: bold; +} + +.bank-summary .bank-info-table tbody tr .outdated { + color: red; + font-weight: bold; +} + +.bank-claim-ubi{margin:16px 0} +.bank-claim-card{border:1px solid #FFA500;border-radius:8px;padding:16px;margin:8px 0} +.bank-claim-card p{margin:4px 0} +.bank-claim-btn{font-size:16px;padding:10px 24px;margin-top:8px} + +.bank-eco-chart-block{margin:24px 0;border:1px solid #2a2a2a;border-radius:8px;padding:16px;background:#111} +.bank-eco-chart-title{font-size:16px;margin:0 0 12px 0;color:#FFA500} +.bank-eco-chart-canvas{width:100%;overflow:hidden} +.bank-eco-chart-svg{width:100%;height:auto;display:block} +.bank-eco-chart-bg{fill:#0e0e0e} +.bank-eco-chart-grid{stroke:#1f1f1f;stroke-width:1} +.bank-eco-chart-axis{fill:#FFD700;font-size:10px;font-family:monospace} +.bank-eco-chart-empty{fill:#888;font-size:14px;font-family:monospace} +.bank-eco-chart-line-value{fill:none;stroke:#FFA500;stroke-width:2} +.bank-eco-chart-line-supply{fill:none;stroke:#4aa3ff;stroke-width:1.5;stroke-dasharray:4,3} +.bank-eco-chart-line-inflation{fill:none;stroke:#e74c3c;stroke-width:1.5;stroke-dasharray:2,3} +.bank-eco-chart-line-value-legend{fill:#FFA500} +.bank-eco-chart-line-supply-legend{fill:#4aa3ff} +.bank-eco-chart-line-inflation-legend{fill:#e74c3c} +.bank-eco-chart-legend-text{fill:#bbb;font-size:11px;font-family:monospace} + +.transfer-block-card{margin-top:16px;border-top:2px solid #2a2a2a} +.transfer-block-card h2{font-size:16px;color:#FFA500;margin:0 0 8px 0} + +.melody-player-card{border:1px solid #2a2a2a;border-radius:8px;padding:16px;background:#111;margin:16px 0} +.melody-meta{margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.melody-meta-sep{color:#555} +.melody-audio{width:100%;max-width:520px} +.melody-section-title{font-size:16px;color:#FFA500;margin:16px 0 8px 0} +.melody-help{color:#888;font-size:13px;margin:0 0 12px 0} +.melody-notes-grid{display:flex;flex-wrap:wrap;gap:6px} +.melody-note-chip{display:inline-flex;flex-direction:column;align-items:center;border:1px solid #333;border-radius:6px;padding:6px 10px;background:#161616;min-width:54px} +.melody-note-chip-link{text-decoration:none;transition:border-color 0.15s,background 0.15s} +.melody-note-chip-link:hover{border-color:#FFA500;background:#1f1500} +.melody-note-chip-unavailable{opacity:0.45;cursor:not-allowed;border-style:dashed} +.eco-tax-chip{display:inline-flex;align-items:baseline;gap:4px;font-size:12px;border:1px solid;border-radius:10px;padding:2px 8px;text-decoration:none} +.eco-tax-chip-label{text-transform:uppercase;letter-spacing:.5px;font-weight:600} +.eco-tax-chip-value{font-family:monospace} +.eco-tax-chip-low{background:#0e2a16;border-color:#3a7f4c;color:#9aff9a} +.eco-tax-chip-low .eco-tax-chip-label{color:#bfeac0} +.eco-tax-chip-low .eco-tax-chip-value{color:#9aff9a} +.eco-tax-chip-low:hover{background:#143820;border-color:#5aa066} +.eco-tax-chip-mid{background:#2a2410;border-color:#a39134;color:#ffd95a} +.eco-tax-chip-mid .eco-tax-chip-label{color:#ffe9a0} +.eco-tax-chip-mid .eco-tax-chip-value{color:#ffd95a} +.eco-tax-chip-mid:hover{background:#3a3416;border-color:#c5b14e} +.eco-tax-chip-high{background:#3a0e0e;border-color:#b04040;color:#ff8a8a} +.eco-tax-chip-high .eco-tax-chip-label{color:#ffbdbd} +.eco-tax-chip-high .eco-tax-chip-value{color:#ff8a8a} +.eco-tax-chip-high:hover{background:#511414;border-color:#e06060} +.bank-taxes-example-chips{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:8px 0 12px 0} +.bank-taxes-example-label{color:#bbb;font-size:13px;font-weight:600} +.bank-taxes-summary-note{color:#bbb;font-size:13px;line-height:1.5;margin:0 0 10px 0} +.bank-taxes-types-form{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin:0 0 10px 0;background:transparent !important;border:0 !important;padding:0 !important} +.bank-taxes-types-label{color:#FFD700;font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap} +.bank-taxes-type-toggle{color:#FFD700;font-size:13px;font-weight:600;display:inline-flex;flex-direction:row;align-items:center;gap:8px;cursor:pointer;white-space:nowrap} +.bank-taxes-type-toggle input[type=checkbox]{accent-color:#FFA500;width:16px;height:16px;flex:none;margin:0} +.bank-taxes-type-toggle-locked{cursor:not-allowed;opacity:0.85} +.bank-taxes-type-toggle-locked input[type=checkbox]{cursor:not-allowed} +.melody-note-name{color:#FFA500;font-family:monospace;font-size:13px;font-weight:600} +.melody-note-type{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.5px} +.melody-composition{margin:16px 0} +.melody-stats{margin:16px 0} +.melody-regen-form{margin-top:10px} +.melody-upload-block{margin-top:18px;background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:14px 16px} +.melody-upload-hint{color:#bbb;font-size:13px;line-height:1.5;margin:0 0 12px 0} +.melody-upload-name-note{color:#FFD700;font-size:13px;line-height:1.5;margin:0 0 12px 0} +.melody-upload-name-value{color:#FFD700;font-family:monospace;font-weight:700;word-break:break-all} +.melody-upload-form{background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-direction:column;gap:6px} +.melody-upload-form textarea,.melody-upload-form input[type=text]{width:100%;max-width:560px;font-family:inherit} +.melody-upload-submit{width:auto;display:inline-block;padding:6px 18px;align-self:flex-start} +.melody-upload-label{color:#FFD700;font-size:13px;font-weight:600;display:inline-block;margin-top:6px} +.melody-bcs-list{display:flex;flex-direction:column;gap:14px;margin-top:14px;background:transparent !important;border:0 !important;padding:0 !important} +.audio-container{display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important} +.audio-transcode-form{display:inline-block;background:transparent !important;border:0 !important;padding:0 !important;margin:0} +.melody-bcs-card{background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:12px 16px} +.melody-bcs-head{display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important;padding:0 !important} +.melody-bcs-title{color:#FFA500;margin:0;font-size:17px} +.melody-bcs-meta{color:#bbb;font-size:13px;background:transparent !important;border:0 !important;padding:0 !important} +.melody-bcs-player{margin:10px 0;background:transparent !important;border:0 !important;padding:0 !important} +.melody-bcs-desc{color:#ccc;font-size:13px;margin:6px 0 0 0;line-height:1.5} +.melody-bcs-actions{margin-top:10px;background:transparent !important;border:0 !important;padding:0 !important} +.transcode-meta{margin:8px 0 0 0;color:#bbb;font-size:13px} +.transcode-stego{margin-top:14px;background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 14px} +.transcode-stego-fields{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important} +.transcode-stego-field{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;font-size:13px} +.transcode-stego-field .card-label{color:#FFD700;text-transform:uppercase;letter-spacing:.5px} +.transcode-stego-field .card-value{color:#ddd;font-family:monospace} +.transcode-stego-msg{flex-direction:column;align-items:flex-start;gap:4px} +.transcode-stego-text{color:#FFD700;font-family:monospace;line-height:1.5;margin:0;white-space:pre-wrap;word-break:break-word;background:#0e0e0e;border:1px solid #333;border-radius:4px;padding:8px 10px;width:100%} +.transcode-composition{margin-top:14px} + +.pub-item{border:1;border-radius:10px;background:none} +.snh-invite-box{border:1px solid currentColor;border-radius:8px;padding:16px;margin:12px 0;font-family:monospace} +.snh-invite-name{margin:0 0 8px 0} +.snh-invite-date{font-size:13px;opacity:0.7;margin:0 0 10px 0} +.snh-invite-btn{display:block;word-break:break-all;font-size:14px;margin:0;cursor:pointer;background:none;border:1px solid #FFA500;color:#FFA500;padding:10px;border-radius:5px;width:auto;text-align:left;font-family:monospace} +.error-box{background:#222326;border:none;color:#f5c242;padding:12px;border-radius:8px;margin-top:8px} +.error-title{margin:0 0 6px 0;font-weight:600} +.error-pre{margin:0;white-space:pre-wrap;font-family:monospace} + +.shop-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin:8px 0} +.shop-card{border:1px solid #333;border-radius:8px;overflow:hidden;display:flex;flex-direction:column} +.shop-card-media{max-height:200px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#111} +.shop-card-media img,.shop-media{width:100%;max-height:200px;object-fit:cover} +.shop-card-body{padding:12px;display:flex;flex-direction:column;gap:6px} +.shop-title-link{color:#FFA500;text-decoration:none} +.shop-card-desc{font-size:14px;opacity:0.85;margin:0} +.shop-card-meta{display:flex;flex-wrap:wrap;gap:8px;font-size:13px} +.shop-open{color:#0f0;font-weight:bold} +.shop-closed{color:#f44;font-weight:bold} +.shop-product-count{color:#FFA500} +.shop-location{opacity:0.7} +.shop-card-tags{display:flex;flex-wrap:wrap;gap:4px} +.shop-card-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px} +.shop-detail{padding:8px 0} +.shop-detail-media{max-width:100%;margin:12px 0} +.shop-detail-media img{max-width:100%;border-radius:6px} +.shop-detail-desc{margin:8px 0} +.shop-share{margin:8px 0;display:flex;align-items:center;gap:8px} +.shop-share-input{flex:1;background:#222;border:1px solid #555;color:#ccc;padding:6px 10px;border-radius:4px;font-family:monospace;font-size:13px} +.shop-products-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;margin:12px 0} +.shop-product-card{border:1px solid #333;border-radius:8px;overflow:hidden;display:flex;flex-direction:column} +.shop-product-media{max-height:180px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#111} +.shop-product-media img{width:100%;max-height:180px;object-fit:cover} +.shop-product-body{padding:10px;display:flex;flex-direction:column;gap:6px} +.shop-product-price{font-size:18px;font-weight:bold;color:#FFA500} +.shop-product-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px} +.shop-add-product{border:1px dashed #555;border-radius:8px;padding:16px;margin:12px 0} +.shop-featured-products{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0} +.shop-featured-item{display:flex;flex-direction:column;align-items:center;width:72px;text-decoration:none;border:1px solid #333;border-radius:6px;overflow:hidden;background:#111} +.shop-featured-item:hover{border-color:#FFA500} +.shop-featured-thumb{width:72px;height:72px;object-fit:cover} +.shop-featured-price{font-size:11px;color:#FFA500;padding:4px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%} +.shop-product-stars{color:#FFA500;font-size:14px;letter-spacing:1px} +.card-field-mb{margin-bottom:10px} +.card-field-mt{margin-top:10px} +.card-tags-mt{margin-top:10px} +.card-label-bold{font-weight:800} +.card-value-bold{margin-left:10px;font-weight:800} +.contact-bold{font-weight:bold} +.textarea-full{width:100%} +.shop-form{padding:8px 0} +.shop-form input[type="text"],.shop-form input[type="number"],.shop-form textarea,.shop-form select{width:100%;max-width:480px;padding:8px;margin:4px 0 8px 0;background:#222;border:1px solid #555;color:#ccc;border-radius:4px;font-family:monospace} + +/* carbon footprint chart */ +.carbon-section-title{font-size:18px;color:#555;margin:8px 0;font-weight:600} +.carbon-chart{margin:12px 0;background:transparent !important;border:none !important} +.carbon-bar-label{display:flex;justify-content:space-between;font-size:14px;margin:4px 0 2px;background:transparent !important;border:none !important} +.carbon-bar-track{background:#3a3a3a !important;border-radius:6px;height:22px;overflow:hidden;border:none !important} +.carbon-bar-fill{height:100%;border-radius:6px;min-width:2px;transition:width 0.4s ease;border:none !important} +.carbon-bar-network{background:#2ecc71 !important} +.carbon-bar-mine{background:#3498db !important} +.carbon-bar-max{background:#555 !important;border:none !important} +.carbon-bar-note{font-size:13px;color:#ffd700;margin:6px 0 2px} +.carbon-bar-formula{font-size:12px;color:#ffd700;margin:2px 0 10px} + +/* parliament */ +.cycle-info { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .75rem; + margin-bottom: 1rem; +} +.kpi { + border: 1px solid rgba(0,0,0,.08); + border-radius: 8px; + padding: .75rem; +} +.kpi__label { + display: block; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--parl-accent, #ff9f1a); + opacity: 1; +} +.kpi__value { + font-weight: 600; +} +.method-badge, +.method-hero { + display: inline-flex; + align-items: center; + gap: .5rem; +} +.method-cell { + text-align: center; +} +.leader-cell { + text-align: center; +} +.mt-2 { margin-top: .5rem; } + +.parliament-actor-table { + table-layout: auto; + width: 100%; +} +.parliament-actor-table th, +.parliament-actor-table td { + vertical-align: top; +} + +.parliament-actor-col { + white-space: nowrap; + width: 1%; + text-align: left; +} +.parliament-actor-col .user-link { + display: inline-block; +} + +.parliament-description-col { + white-space: normal; + text-align: left; +} + +.parliament-members-row td { + padding-top: .5rem; +} +.parliament-members-list { + margin: .25rem 0 0; + padding-left: 1.25rem; +} +.parliament-members-list li { + margin: .125rem 0; +} + +.oasis-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 2px 10px; + border-top: 1px solid rgba(255,255,255,0.08); + line-height: 1; + min-height: 0; + margin-top: 5px; +} + +.oasis-footer-center { + margin-top: 15px; + text-align: center; + line-height: 1.6; + box-shadow: 0 2px 20px 0 #FFD60024; +} + +.oasis-footer-pulse-row{display:inline-block} +.oasis-footer-pulse-label{color:inherit} + +.oasis-footer-logo-link { + display: block; + margin: 0 auto; +} + +.oasis-footer-logo { + width: 48px; + height: 48px; + border-radius: 10px; + object-fit: cover; + display: block; + margin: 0 auto; +} + +.oasis-footer-package-link, +.oasis-footer-package-name, +.oasis-footer-version, +.oasis-footer-sep { + line-height: 1; +} + +.oasis-footer-package-link { + text-decoration: none; +} + +.oasis-footer-package-link, +.oasis-footer-center a, +.oasis-footer-center span, +.oasis-footer-center strong { + padding-top: 0; + padding-bottom: 0; + margin: 0; +} + +.oasis-footer-center a { + background-color: transparent !important; + padding: 0 !important; + border-radius: 0 !important; + text-decoration: none !important; + font-weight: normal !important; + box-shadow: none !important; +} + +.oasis-footer-carbon { + font-size: inherit; +} + +.inbox-badge { + display: inline-block; + background: #e74c3c; + color: #fff; + font-size: 0.7em; + font-weight: bold; + padding: 1px 5px; + border-radius: 10px; + margin-left: 4px; + min-width: 16px; + text-align: center; + line-height: 1.4; + vertical-align: middle; +} + +.reply-context { + border-left: 3px solid #666; + padding-left: 10px; + margin-bottom: 8px; + opacity: 0.85; +} + +.carbon-footprint-bar { + height: 18px; + border-radius: 4px; + transition: width 0.3s; +} + +.block-canvas-container { + border: 1px solid #555; + border-radius: 8px; + overflow: hidden; + margin: 16px 0; +} + +.dominant-opinion { + font-weight: bold; + margin-left: 6px; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; +} + +.feed-success-msg { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + border: 1px solid #2ecc71; + background: rgba(46,204,113,0.1); +} +.pm-form-error-msg { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + border: 1px solid #e74c3c; + background: rgba(231,76,60,0.1); +} + +.preset-group { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + padding: 12px; + margin-bottom: 12px; +} + +.preset-group-title { + font-weight: bold; + margin-bottom: 8px; +} + +.activity-tribe-meta{display:flex;gap:.6em;flex-wrap:wrap} + +.tribe-card:hover { + border-color: #ffa500; +} + +.tribe-card-body { + padding: 12px 16px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.tribe-card-title { + margin: 0; + font-size: 1.15em; +} + +.tribe-card-footer { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 4px; +} + +.tribe-card-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.tribe-side h2 { + margin: 0; +} + +.tribe-side-meta { + display: flex; + flex-direction: column; + gap: 4px; +} + +.tribe-side-description { + color: #cfd3e1; + font-size: 14px; + line-height: 1.5; +} + +.tribe-side-tags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tribe-side-footer { + border-top: 1px solid #444; + padding-top: 8px; +} + +.tribe-side-footer p { + margin: 2px 0; + font-size: 13px; +} + +.tribe-side-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin: 8px 0; +} + +.tribe-side-actions form { + margin: 0; + display: inline-flex; + align-items: center; + gap: 6px; +} +.tribe-side-actions form > * { margin: 0 } +.tribe-side-actions select, +.tribe-side-actions input[type="number"], +.tribe-side-actions input[type="text"] { height: 36px; box-sizing: border-box } +.tribe-side-actions button { height: 36px; box-sizing: border-box; display: inline-flex; align-items: center } + +.tribe-side-subtribes { + margin: 8px 0; +} + +.tribe-subtribes-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.tribe-subtribe-link { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + background: #1c1c1c; + border: 1px solid #333; + border-radius: 6px; + color: #ffa500; + cursor: pointer; + font-size: 14px; +} + +.tribe-subtribe-link:hover { + background: #333; +} + +.tribe-members-count { + color: #ffa500; + font-size: 18px; +} + +.tribe-mode-buttons { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.tribe-card-image-wrapper { + position: relative; + overflow: hidden; + padding: 0 !important; + margin: 0 !important; +} + +.tribe-card-hero-image { + display: block; + width: 100%; + height: 200px; + object-fit: cover; +} + +.tribe-card-description { + font-size: 14px; + line-height: 1.45; + color: #cfd3e1; + margin: 0; +} + +.tribe-info-table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; +} +.tribe-info-table td { + padding: 6px 8px; + border: 1px solid #555; + font-size: 12px; +} +.tribe-info-label { + font-size: 10px; + color: #ffa300; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + background: #1e1f23; +} +.tribe-info-value { + font-size: 12px; + font-family: 'JetBrains Mono', monospace; + color: #ffd740; + background: #1e1f23; +} +.tribe-info-empty { + color: #9aa3b2; + font-size: 12px; + font-style: italic; +} + +.tribe-card-members { + border: none; + border-radius: 4px; + padding: 8px; + margin: 6px 0; + background: #1e1f23; + text-align: center; +} + +.tribe-open-invite { + border: none; + border-radius: 4px; + padding: 8px; + margin: 6px 0; + background: #1e1f23; + text-align: center; +} +.tribe-open-invite-code { + font-family: monospace; + word-break: break-all; + font-weight: bold; + margin-left: 6px; +} +.tribe-open-invite-panel { margin: 6px 0; } +.profile-side-version { margin: 4px 0 8px; font-size: 0.9em; text-align: center; } +.larp-house-link { + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + margin: 0 0 6px; +} +.larp-house-mini { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 8px; + flex: 0 0 auto; +} + +.invite-qr-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 4px; + margin: 8px 0; +} +.invite-qr-img { + width: 200px; + height: 200px; + background: #fff; + padding: 8px; + border-radius: 8px; + image-rendering: pixelated; +} + +.tribe-card-actions { + border: 1px solid #555; + border-radius: 4px; + padding: 8px; + margin: 6px 0; + background: #1e1f23; + display: flex; + gap: 8px; + justify-content: center; +} +.tribe-action-btn { + padding: 6px 16px; + border-radius: 6px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + border: 1px solid #ffa300; + background: transparent; + color: #ffa300; + transition: background 0.15s, color 0.15s; +} +.tribe-action-btn, +.tribe-side-actions button, +.tribe-side-actions a, +.map-detail-actions button, +.map-detail-actions a { + display: inline-block; + padding: 6px 16px; + border-radius: 6px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + line-height: 1.2; + text-transform: uppercase; + text-decoration: none; + text-align: center; + cursor: pointer; + border: 1px solid #ffa300; + background: transparent; + color: #ffa300; + transition: background 0.15s, color 0.15s; +} +.tribe-side-actions button:hover, +.tribe-side-actions a:hover, +.map-detail-actions button:hover, +.map-detail-actions a:hover { + background: #ffa300; + color: #000; +} +.tribe-side-actions .tribe-action-btn.danger-btn, +.tribe-side-actions .delete-btn, +.map-detail-actions .delete-btn { + border-color: #d9534f; + color: #d9534f; +} +.tribe-side-actions .tribe-action-btn.danger-btn:hover, +.tribe-side-actions .delete-btn:hover, +.map-detail-actions .delete-btn:hover { + background: #d9534f; + color: #000; +} +.tribe-action-btn.danger-btn, +.tribe-card-actions .tribe-action-btn.danger-btn, +.tribe-side-actions .tribe-action-btn.danger-btn { + border: 1px solid #d9534f !important; + color: #d9534f !important; + background: transparent !important; +} +.tribe-action-btn.danger-btn:hover, +.tribe-card-actions .tribe-action-btn.danger-btn:hover, +.tribe-side-actions .tribe-action-btn.danger-btn:hover { + background: #d9534f !important; + color: #000 !important; +} +.tribe-action-btn:hover { + background: #ffa300; + color: #000; +} +.tribe-action-btn.danger-btn { border-color: #d9534f; color: #d9534f; } +.tribe-action-btn.danger-btn:hover { background: #d9534f; color: #000; } + +.calendar-interval-block { margin: 8px 0 16px; } +.calendar-interval-label { display: block; margin-bottom: 6px; } +.calendar-interval-until { margin-top: 10px; } +.calendar-interval-row { display: flex; gap: 16px; flex-wrap: wrap; } +.calendar-interval-option { display: flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; } + +.tribe-thumb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; +} +.tribe-thumb-link { + display: block; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + border-radius: 6px; + border: 1px solid #555; + transition: border-color 0.15s; +} +.tribe-thumb-link:hover { + border-color: #ffa300; +} +.tribe-thumb-img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.tribe-feed-compose { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.tribe-feed-compose textarea { + width: 100%; + box-sizing: border-box; +} + +.tribe-feed-compose .tribe-feed-send { + align-self: flex-end; +} + +.tribe-parent-box { + margin-bottom: 12px; + text-align: center; +} + +.tribe-parent-box h2 { + margin: 0 0 8px 0; +} + +.tribe-parent-link { + display: block; +} + +.tribe-parent-image { + width: 100%; + max-width: 200px; + border-radius: 8px; + border: 2px solid #444; +} + +.tribe-card-meta-row { + display: flex; + gap: .5em; + flex-wrap: wrap; + font-size: 12px; +} + +.tribe-meta-label { + color: #9aa3b2; + font-size: 12px; +} + +.tribe-tag-link { + margin-right: 0.8em; + margin-bottom: 0.5em; +} + +.tribe-member-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 4px; +} + +.tribe-visit-btn-wrapper { + position: absolute; + bottom: 8px; + right: 8px; +} + +.tribe-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.tribe-section-nav { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 16px; + padding-bottom: 8px; +} + +.tribe-section-nav form { + margin: 0; +} + +.tribe-section-group { + display: flex; + gap: 4px; + padding: 0 8px; + flex-wrap: wrap; + align-items: center; + border: 0px; +} + +.tribe-section-group-label { + font-size: 10px; + text-transform: uppercase; + color: #ffcc00; + letter-spacing: 1px; + width: 100%; + padding: 0 4px 2px; + font-weight: bold; +} + +.tribe-section-group:last-child { + border-right: none; +} + +.tribe-section-nav .filter-btn { + padding: 8px 12px; + text-transform: uppercase; +} + +.tribe-section-nav .filter-btn.active { + background: #ffcc00; + color: #000; +} + +.tribe-content-card { + background: #1c1c1c; + border: 1px solid #333; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +.tribe-content-card h2 { + margin-top: 0; +} + +.tribe-content-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; +} + +.tribe-content-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.tribe-content-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} +.tribe-search-form{display:flex;gap:10px;align-items:center} + +.tribe-content-filters { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.tribe-content-status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; +} + +.tribe-content-status-open { + background: #2d5a27; + color: #7cc576; +} + +.tribe-content-status-closed { + background: #5a2727; + color: #c57676; +} + +.tribe-content-status-in-progress { + background: #5a4e27; + color: #c5b576; +} + +.tribe-content-meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin: 8px 0; + color: #9aa3b2; + font-size: 14px; +} + +.tribe-content-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.tribe-content-actions form { + margin: 0; +} + +.tribe-inhabitants-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.tribe-inhabitant-card { + background: #1c1c1c; + border-radius: 8px; + padding: 12px; + text-align: center; +} + +.tribe-inhabitant-avatar { + width: 64px; + height: 64px; + border-radius: 50%; + object-fit: cover; +} + +.tribe-votation-options { + display: flex; + flex-direction: column; + gap: 8px; + margin: 12px 0; +} + +.tribe-votation-option { + display: flex; + align-items: center; + gap: 12px; +} + +.tribe-votation-bar { + background: #333; + border-radius: 4px; + height: 24px; + position: relative; + overflow: hidden; + flex: 1; +} + +.tribe-votation-fill { + background: #ff6600; + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.tribe-fill-0 { width: 0%; } +.tribe-fill-5 { width: 5%; } +.tribe-fill-10 { width: 10%; } +.tribe-fill-15 { width: 15%; } +.tribe-fill-20 { width: 20%; } +.tribe-fill-25 { width: 25%; } +.tribe-fill-30 { width: 30%; } +.tribe-fill-35 { width: 35%; } +.tribe-fill-40 { width: 40%; } +.tribe-fill-45 { width: 45%; } +.tribe-fill-50 { width: 50%; } +.tribe-fill-55 { width: 55%; } +.tribe-fill-60 { width: 60%; } +.tribe-fill-65 { width: 65%; } +.tribe-fill-70 { width: 70%; } +.tribe-fill-75 { width: 75%; } +.tribe-fill-80 { width: 80%; } +.tribe-fill-85 { width: 85%; } +.tribe-fill-90 { width: 90%; } +.tribe-fill-95 { width: 95%; } +.tribe-fill-100 { width: 100%; } + +.tribe-votation-label { + min-width: 120px; + color: #ccc; +} + +.tribe-votation-count { + min-width: 40px; + text-align: right; + color: #9aa3b2; +} + +.tribe-forum-thread { + background: #1c1c1c; + border: 1px solid #333; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +.tribe-forum-reply { + background: #222; + border-left: 3px solid #ff6600; + padding: 12px; + margin: 8px 0 8px 24px; + border-radius: 0 8px 8px 0; +} + +.tribe-forum-reply-form { + margin-top: 16px; +} + +.tribe-media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 18px; +} + +.tribe-media-item { + background: #1c1c1c; + border-radius: 8px; + overflow: hidden; + border: 1px solid #333; + transition: border-color .2s; + padding-bottom: 8px; +} + +.tribe-media-item:hover { + border-color: #ffa500; +} + +.tribe-media-item a { + display: block; +} + +.tribe-media-thumb { + width: 100%; + height: 160px; + object-fit: cover; + display: block; + cursor: pointer; +} + +.tribe-media-audio { + width: 100%; + padding: 8px; + box-sizing: border-box; +} + +.tribe-media-item img { + width: 100%; + height: 160px; + object-fit: cover; +} + +.tribe-media-item video { + width: 100%; + height: 160px; + object-fit: cover; +} + +.tribe-media-title-link { + color: #ffa500; + font-weight: bold; + text-decoration: none; + display: block; + margin-bottom: 4px; +} + +.tribe-media-title-link:hover { + text-decoration: underline; +} + +.tribe-media-item-info { + padding: 12px; +} + +.tribe-media-date { + margin: 6px 0 2px 0; + font-size: 0.85em; +} + +.tribe-media-author { + margin: 0 0 6px 0; + font-size: 0.85em; +} + +.tribe-media-filters { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.tribe-overview-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.tribe-overview-section { + background: #1c1c1c; + border-radius: 8px; + padding: 16px; +} + +.tribe-overview-section h2 { + margin-top: 0; + font-size: 16px; + border-bottom: 1px solid #333; + padding-bottom: 8px; +} + +.tribe-priority-critical { + color: #ff4444; + font-weight: bold; +} + +.tribe-priority-high { + color: #ff8800; + font-weight: bold; +} + +.tribe-priority-medium { + color: #ffcc00; +} + +.tribe-priority-low { + color: #88cc88; +} + +.tribe-feed-compose { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.tribe-feed-compose textarea { + width: 100%; + box-sizing: border-box; +} + +.tribe-feed-compose .tribe-feed-send { + align-self: flex-end; +} + +.comment-submit-btn { + width: auto; + max-width: 200px; +} + +.market-owner-actions-inline { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.project-goal-highlight { + font-size: 1.4em; + font-weight: 700; +} + +.project-control-form--progress { + max-width: 300px; +} + +.project-progress-input { + max-width: 80px; +} + +.applicants-under { + color: #4caf50; +} + +.applicants-at { + color: #ffeb3b; +} + +.applicants-over { + color: #f44336; +} + +.transfer-amount-highlight { + font-size: 1.3em; + font-weight: 700; +} + +.pixelia-paint-form { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.pixelia-paint-form label, +.pixelia-paint-form input, +.pixelia-paint-form select, +.pixelia-paint-form button { + width: auto; + margin: 0; +} + +.pixelia-paint-form input[type="number"] { + max-width: 80px; +} + +.method-image-centered { + text-align: center; + margin: 12px 0; +} + +.method-image-centered img { + height: auto; + margin: 0 auto; +} + +.tag-cloud-wrap { + display: flex; + flex-wrap: wrap; + max-width: 90%; + gap: 14px; + justify-content: center; + align-items: center; + min-height: 220px; + padding: 36px; + border-radius: 16px; +} + +.tag-cloud-item { + position: relative; + padding: 6px 14px; + border-radius: 999px; + font-weight: 500; + font-size: 0.95rem; + letter-spacing: 0.3px; + text-decoration: none; + transition: all 0.25s ease; +} + +.tag-cloud-item:hover { + background: rgba(255,255,255,0.1); + border-color: rgba(255,255,255,0.2); + transform: translateY(-3px); + color: #ffffff; + box-shadow: 0 6px 18px rgba(0,0,0,0.5); +} + +.tag-cloud-item[data-weight="1"] { + font-size: 0.85rem; + opacity: 0.7; +} + +.tag-cloud-item[data-weight="2"] { + font-size: 0.95rem; + opacity: 0.8; +} + +.tag-cloud-item[data-weight="3"] { + font-size: 1.05rem; + opacity: 0.9; +} + +.tag-cloud-item[data-weight="4"] { + font-size: 1.15rem; + font-weight: 600; +} + +.stats-karma-block { + background-color: #222; + padding: 24px; + border-radius: 8px; + border: 1px solid #444; + margin-bottom: 24px; +} + +.feed-detail-card { + background-color: #222; + border: 1px solid #444; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.map-card { + background-color: #222; + border: 1px solid #444; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.map-card-info { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 8px; + border: 0px; +} + +.map-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; + background-color: #FFA500; + color: #000; +} + +.map-coords { + font-family: monospace; + font-size: 0.85rem; + color: white; +} + +.map-coords-detail { + font-family: monospace; + font-size: 0.9rem; + color: white; +} + +.map-marker-count { + font-size: 0.8rem; + color: #888; +} + +.map-description { + margin: 20px 0; +} + +.map-url-container { + margin: 8px 0; +} + +.map-url-link { + color: #FFA500; + word-break: break-all; +} + +.map-detail-info { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.map-wrap { + margin-right: 30px; + position: relative; + border: 0px; +} + +.map-img { + display: block; + width: 100% !important; + height: 100% !important; + max-width: none !important; + border: none !important; +} + +.map-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + background-color: #1a1a1a; + border-top: 1px solid #333; +} + +.map-bar-info { + font-size: 0.75rem; + color: #aaa; +} + +.map-attr { + font-size: 0.6rem; + color: #555; +} + +.map-coord-preview { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background-color: #222; + border-radius: 6px; + margin: 8px 0; + font-family: monospace; + font-size: 0.9rem; + color: #FFA500; +} + +.map-coord-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: monospace; + font-size: 0.9rem; + color: #FFA500; + margin: 4px 0; +} + +.map-coord-pin { + font-size: 1.2rem; +} + +.map-create-layout { + width: 100%; +} + +.map-form { + margin-top: 12px; +} + +.map-form label { + display: block; + font-weight: 600; + color: #FFA500; + margin-top: 8px; + margin-bottom: 4px; +} + +.map-form input[type="text"], +.map-form textarea, +.map-form select { + width: 100%; + box-sizing: border-box; +} + +.map-form-row { + display: flex; + border: 0px; + gap: 12px; +} + +.map-form-field { + flex: 1; +} + +.map-card { + background-color: #222; + border: 1px solid #444; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 12px; + display: flex; + gap: 16px; + align-items: flex-start; +} + +.map-card-thumb-link { + flex-shrink: 0; +} + +.map-card-thumb { + width: 120px !important; + height: 120px !important; + max-width: none !important; + border-radius: 6px !important; + border: 1px solid #555 !important; + object-fit: cover; +} + +.map-card-body { + flex: 1; + min-width: 0; +} + +.map-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.map-card-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; +} + +.map-click-hint { + color: #FFA500; + font-size: 0.85rem; + padding: 6px 0; +} + +.map-detail { + background-color: #222; + border: 1px solid #444; + border-radius: 8px; + padding: 16px; +} + +.map-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.map-detail-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; +} + +.map-marker-form { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid #444; +} + +.map-markers-list { + margin-top: 16px; +} + +.map-marker-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid #333; +} + +.map-marker-dot { + font-size: 1.2rem; + line-height: 1; +} + +.map-marker-info { + display: flex; + flex-direction: column; + gap: 2px; + border: 0px; +} + +.map-marker-label { + font-weight: 600; + color: #ffa300; +} + +.map-marker-coords { + font-family: monospace; + font-size: 0.8rem; + color: white; +} + +.map-marker-meta { + font-size: 0.75rem; +} + +.map-empty { + padding: 24px; + text-align: center; + color: #666; + font-size: 0.9rem; +} + +.map-form { + width: 100%; +} + +.map-form label { + font-weight: 600; + color: #FFA500; +} + +.map-form input[type="text"], +.map-form textarea, +.map-form select { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.map-marker-form { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #444; +} + +.map-marker-form label { + font-weight: 600; + color: #FFA500; +} + +.map-marker-form input[type="text"] { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.map-form-map-slot { + margin: 12px 0; + border: none; + overflow: hidden; +} + +.map-markers-list { + margin-top: 16px; + text-align: left; +} + +.map-marker-item { + padding: 8px 0; + border-bottom: 1px solid #333; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.map-marker-dot { + color: #4a9eff; + font-size: 0.9rem; +} + +.search-result-image { + max-width: 100%; + max-height: 300px; + border-radius: 4px; + display: block; + margin: 6px 0; +} + +.map-embed-section { + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 0; +} + +.map-location-inline { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.map-location-icon { + color: #FFA500; +} + +.map-location-link { + color: #FFA500; + font-size: 0.85rem; +} + +.map-location-embed { + margin: 16px 0; +} + +.maps-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.maps-search { + margin-bottom: 16px; +} + +.inhabitant-karma-ubi { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.inhabitant-karma-ubi .karma-line, +.inhabitant-karma-ubi .ubi-line { + display: block; + font-size: 0.9em; +} + +.shop-featured-item, +.shop-featured-item:hover, +.shop-featured-item:visited, +.shop-featured-item:active { + text-decoration: none; +} + +.shop-detail { + border: 1px solid #333; + border-radius: 8px; + padding: 16px; +} + +.map-form-map-slot { + margin: 12px 0 0 0; + border: none; + outline: none; + box-shadow: none; + overflow: hidden; +} + +.map-marker-item { + border: none; + padding: 4px 0; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + text-align: left; +} +.map-marker-info { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + padding: 4px 0; + width: 100%; + overflow: hidden; + text-align: left; + justify-content: flex-start; +} +.map-markers-list h3 { + margin-bottom: 4px; +} +.map-form-full { + width: 100%; +} +.map-form-full form { + width: 100%; +} +.map-osm-embed { + width: 100%; + height: 450px; + border: 1px solid #444; + border-radius: 8px; + margin: 12px 0; +} +.map-viewer { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + border: 0px; +} +.map-form-full, .map-form-full form { + box-sizing: border-box; + overflow: hidden; +} +.map-popup { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.65); + z-index: 1000; + align-items: center; + justify-content: center; +} +.map-popup:target { + display: flex; +} +.map-popup-box { + background: #222; + border: 1px solid #555; + border-radius: 10px; + padding: 20px 24px; + max-width: 360px; + width: 90%; + color: #ddd; + position: relative; +} +.map-popup-close { + position: absolute; + top: 10px; right: 14px; + color: #FFA500; + text-decoration: none; + font-size: 1.2rem; + line-height: 1; +} +.map-popup-label { + font-weight: 600; + font-size: 1rem; + color: #eee; + margin-bottom: 6px; +} +.map-popup-coords { + font-family: monospace; + font-size: 0.85rem; + color: white; +} +.map-popup-link { + color: #FFA500; + text-decoration: underline; + word-break: break-all; +} +.map-popup-img { + display: block; + max-width: 180px; + max-height: 120px; + object-fit: cover; + border-radius: 4px; + margin: 6px auto; +} +.map-marker-img { + display: block; + max-width: 120px; + max-height: 80px; + object-fit: cover; + border-radius: 4px; + margin: 4px 0; +} +.map-zoom-info { + display: block; + font-size: 0.8rem; + color: #888; + margin-bottom: 4px; +} +.map-embed-url { + margin-top: 6px; +} +.map-embed-url .map-location-link { + font-size: 0.8rem; +} +.map-zone { + display: block; +} +.map-popup-container { height: 0; overflow: hidden; border: 0px; } +.project-maploc { + margin: 6px 0; +} +.project-maploc .map-location-link { + font-size: 0.85rem; + word-break: break-all; +} +.shop-product-actions:empty { + display: none; +} +.ubi-available{color:#4CAF50;font-weight:bold} +.ubi-unavailable{color:#F44336;font-weight:bold} +.banking-ubi .card-field{margin:4px 0} +.pad-status-open{font-weight:bold} +.pad-status-invite{color:#FFA500;font-weight:bold} +.pad-status-closed{font-weight:bold} +.pad-deadline{color:#FFA500;font-size:0.9rem} +.pad-editor-container{margin-top:12px} +.pad-editor-area{display:flex;flex-direction:column;gap:8px;} +.pad-editor-area textarea{width:100%;min-height:200px;background:#1a1a1a;color:#eee;border:1px solid #444;border-radius:4px;padding:10px;font-family:monospace;font-size:0.95rem;resize:vertical;box-sizing:border-box} +.pad-members-list{flex:1;min-width:160px;background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:10px} +.pad-members-list h4{margin:0 0 8px 0;color:#aaa;font-size:0.85rem;text-transform:uppercase} +.pad-member-item{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.85rem} +.pad-color-indicator{width:10px;height:10px;border-radius:50%;flex-shrink:0} +.pad-entries{margin-top:16px;display:flex;flex-direction:column;gap:8px} +.pad-entry{padding:8px 12px;border-left:4px solid #444;background:#1a1a1a;border-radius:0 4px 4px 0} +.pad-entry-header{font-size:0.8rem;color:#888;margin-bottom:4px} +.pad-entry-text{white-space:pre-wrap;word-break:break-word;font-family:monospace;font-size:0.9rem} +.pad-invite-section{margin-top:12px;padding:12px;background:#1a1a1a;border:1px solid #333;border-radius:4px} +.pad-invite-section input[type="text"]{width:100%;background:#111;color:#eee;border:1px solid #555;border-radius:4px;padding:8px;box-sizing:border-box;margin-bottom:8px} +.chat-main-split{display:flex;gap:16px;width:100%} +.chat-messages-column{flex:2;min-width:0;display:flex;flex-direction:column;gap:8px} +.chat-participants-column{flex:1;min-width:140px;background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:12px} +.chat-participants-column h2{margin:0 0 8px 0;color:#aaa;font-size:0.85rem;text-transform:uppercase;font-weight:normal} +.chat-participants-list{list-style:none;padding:0;margin:0} +.chat-participants-list li{padding:4px 0;font-size:0.85rem} +.chat-message-form{background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:10px} +.chat-message-form textarea{width:100%;box-sizing:border-box;background:#111;color:#eee;border:1px solid #555;border-radius:4px;padding:8px;resize:vertical;font-family:inherit} +.chat-messages-list{display:flex;flex-direction:column;gap:4px} +.chat-message{padding:6px 10px;border-radius:4px;background:#1a1a1a;word-break:break-word;border:none} +.chat-message-author{border-left-color:#FFA500;background:#1f1800} +.chat-message-self{border-left-color:#888;background:#181818} +.chat-message-meta{font-size:0.8rem;color:#888;border:none} +.chat-message-date{color:#666} +.chat-message-sender a{color:#aaa} +.chat-message-text{display:block;margin-top:2px;font-size:0.9rem;white-space:pre-wrap;word-break:break-word} +.calendar-note-card{position:relative;padding:10px 80px 10px 10px;min-height:56px} +.calendar-note-delete{position:absolute;top:8px;right:8px;margin:0} +.calendar-note-delete button{padding:4px 10px;font-size:0.8rem} +.chat-no-messages{color:#666;font-style:italic;padding:12px 0} +.chat-join-section{margin-top:12px;padding:12px;background:#1a1a1a;border:1px solid #333;border-radius:4px} +.chat-invite-form input[type="text"]{width:100%;background:#111;color:#eee;border:1px solid #555;border-radius:4px;padding:8px;box-sizing:border-box;margin-bottom:8px} +.games-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;margin-top:16px} +.game-card{border:1px solid #333;border-radius:8px;overflow:hidden;display:flex;flex-direction:column;background:#111} +.game-card-media{width:100%;max-height:180px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#000} +.game-card-media img{width:100%;max-height:180px;object-fit:cover} +.game-card-body{padding:14px;display:flex;flex-direction:column;gap:8px;flex:1} +.game-card-title{margin:0;font-size:1.1rem;color:#FFA500} +.game-card-desc{margin:0;font-size:0.9rem;color:#ccc;flex:1} +.game-card-actions{display:flex;justify-content:center;margin-top:8px} +.hall-of-fame-table{width:100%;border-collapse:collapse;font-size:0.9rem} +.hall-of-fame-table th{color:#FFA500;text-align:left;padding:4px 8px;border-bottom:1px solid #444} +.hall-of-fame-table td{padding:4px 8px;color:#ccc;border-bottom:1px solid #222} +.hall-of-fame-table tr:hover td{background:#1a1a1a} + +.activity-filter-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:16px;margin-bottom:24px} +.activity-filter-col{display:flex;flex-direction:column;gap:8px} + +.visit-btn-centered{display:flex;margin-top:10px;border:0px} + +.pad-editor-white{width:100%;box-sizing:border-box;background:#fff;color:#111;border-radius:4px;padding:10px;resize:vertical;font-family:inherit;font-size:0.95rem} +.pad-readonly-text{background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;white-space:pre-wrap;word-break:break-word;font-size:0.95rem} +.pad-version-list{margin-top:12px} +.pad-version-item{display:flex;flex-direction:column;gap:4px;padding:8px;border-bottom:1px solid #333;font-size:0.85rem} +.pad-version-date{color:#888} +.pad-version-author a{color:#FFA500} +.pad-version-link{color:#aaa;text-decoration:underline;cursor:pointer} +.pad-author-color-0{background-color:#e74c3c} +.pad-author-color-1{background-color:#3498db} +.pad-author-color-2{background-color:#2ecc71} +.pad-author-color-3{background-color:#f39c12} +.pad-author-color-4{background-color:#9b59b6} +.pad-author-color-5{background-color:#1abc9c} +.pad-author-color-6{background-color:#e67e22} +.pad-author-color-7{background-color:#e91e63} +.pad-author-color-8{background-color:#00bcd4} +.pad-author-color-9{background-color:#8bc34a} +.pad-author-color-none{background-color:#888} +.pad-entry-author{display:block;font-size:.75rem;margin-bottom:4px;opacity:.75} +.pad-author-swatch{display:inline-block;width:12px;height:12px;border-radius:2px;margin-right:6px;vertical-align:middle;border:1px solid #000} +.pad-readonly-colored{background:#fff;color:#111;padding:12px;border-radius:4px;min-height:120px;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:.95rem;border:1px solid #aaa;margin-bottom:10px} +.pad-author-span{padding:1px 2px;border-radius:2px} +.pad-members-section{margin-top:12px;border:none} +.pad-members-below-tags{margin-top:12px;border:none} +.pad-member-card{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.85rem} +.pad-members-grid{display:flex;flex-direction:column;gap:4px;margin-bottom:12px} +.pad-version-section{margin-top:16px;border-top:1px solid #333;padding-top:12px} + +.chat-full-width{flex:1;min-width:0} +.chat-participants-section{margin-top:12px;padding:12px;background:#1a1a1a;border:none;border-radius:4px} +.chat-participants-below{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px} +.chat-participants-below a{font-size:0.85rem;color:#aaa} +.chat-message-image-wrap{margin:4px 0} +.chat-message-image{max-width:100%;max-height:300px;border-radius:4px} + +.games-single-col{display:flex;flex-direction:column;gap:16px;margin-top:16px} +.game-row{display:flex;gap:16px;border:1px solid #333;border-radius:8px;overflow:hidden;background:#111;padding:12px;align-items:flex-start} +.game-row-media{flex-shrink:0;width:120px} +.game-row-media img{width:100%;border-radius:4px} +.game-row-body{flex:1;display:flex;flex-direction:column;gap:6px} +.game-top-score{font-size:0.85rem;color:#aaa} +.score-first{color:#2ecc71;font-weight:bold} +.games-scoring-list{display:flex;flex-direction:column;gap:24px;margin-top:16px} +.game-scoring-section{border:1px solid #333;border-radius:8px;padding:16px;background:#111} +.game-scoring-header{display:flex;gap:16px;align-items:flex-start;margin-bottom:12px} +.game-scoring-thumb{width:80px;border-radius:4px;flex-shrink:0} +.game-scoring-info{flex:1} +.game-shell-section{padding:0!important;margin:0} +.game-iframe{width:100%;height:82vh;border:none;background:#000;display:block} +.game-iframe-ecoinflow{height:95vh} +.game-iframe-neoninfiltrator{height:95vh} +.game-iframe-audiopendulum{height:95vh} +.game-iframe-flipflop{height:720px} +.game-iframe-rockpaperscissors{height:580px} +.game-iframe-tiktaktoe{height:580px} +.game-desc-yellow{color:yellow} +.game-new-record-label{color:#FFA500;font-weight:bold} +.game-row-actions{display:flex;align-items:center;text-align:center;flex-shrink:0;border:0px;} +.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin:12px 0} +.calendar-day-header{font-weight:bold;padding:8px;text-align:center;color:#FFA500} +.calendar-day{padding:10px 4px;text-align:center;background:#222;border-radius:4px;min-height:36px} +.calendar-day-empty{background:#111;opacity:.4} +.calendar-day-marked{background:#FFA500;color:#000;font-weight:bold} +.calendar-day-marked a{color:#000;text-decoration:none;display:block} +.calendar-nav{display:flex;justify-content:space-between;align-items:center;margin:8px 0} +.calendar-date-list{margin-top:16px} +.calendar-date-item{background:#333;padding:18px 108px 18px 18px;border-radius:6px;margin-bottom:14px;position:relative;min-height:64px} +.calendar-date-delete{position:absolute;top:12px;right:12px;margin:0} +.calendar-date-delete button{padding:4px 10px;font-size:0.8rem} +.calendar-date-item-header{color:#FFA500;font-weight:bold;margin-bottom:6px;width:max-content} +.calendar-participants-count{color:#FFA500;font-weight:bold} +.calendar-day-notes{margin-top:16px} +.pad-viewer-back{margin-bottom:12px} + +.access-denied-msg{margin:12px 0;padding:12px;background:#1a1a1a;border:1px solid #555;border-radius:4px;color:#ccc;font-style:italic} + +.torrent-table{width:100%;border-collapse:collapse;margin-top:12px} +.torrent-table th,.torrent-table td{padding:8px 12px;text-align:left} +.torrent-table th{background:#1a1a1a;color:#FFA500;font-weight:bold} +.torrent-download{margin:12px 0} +.torrent-download .filter-btn{border:none} + +.logs-table{width:100%;border-collapse:collapse;margin-top:12px} +.logs-table th,.logs-table td{padding:8px 12px;text-align:left;vertical-align:middle;border-bottom:1px solid #222} +.logs-col-actions{vertical-align:middle;text-align:center;white-space:nowrap;background:transparent} +.logs-col-actions form{margin:0;display:inline-block} +.logs-col-actions .filter-btn{min-width:110px} +.logs-table th{background:#1a1a1a;color:#FFA500;font-weight:bold} +.logs-col-date{white-space:nowrap;text-align:center;font-family:monospace;background:transparent} +.logs-date-day{color:#FFA500;font-weight:bold;font-size:0.9rem} +.logs-date-time{color:#ddd;font-size:0.85rem} +.logs-col-log{max-width:560px;vertical-align:middle;background:transparent;padding:8px 12px} +.logs-col-log>*{background:transparent;border:0;padding:0;margin:0} +.logs-entry-label{color:#FFA500;font-weight:bold;margin-bottom:4px !important;background:transparent;border:0} +.logs-entry-meta{color:#888;font-size:0.85rem;margin-bottom:6px} +.logs-entry-mode{color:#FFA500;font-weight:bold} +.logs-entry-text{white-space:pre-wrap;word-break:break-word;color:#ddd;background:transparent;border:0;padding:0;margin:0;display:block} +.logs-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:12px 0} +.logs-toolbar-inline{display:inline-flex;gap:10px;margin:0} +.logs-mode-form{margin:12px 0} +.logs-mode-group{display:flex;gap:10px;align-items:center;flex-wrap:wrap} +.logs-ai-disabled{color:#e74c3c;font-style:italic;margin:8px 0} +.logs-col-type{white-space:nowrap;text-align:center;font-family:monospace;background:transparent} +.logs-type-text{font-weight:bold;letter-spacing:0.5px;text-transform:uppercase;font-family:monospace;font-size:0.85rem} +.logs-type-ai{color:#00b4ff} +.logs-type-manual{color:#FFA500} +.logs-toolbar-wrap{display:flex;flex-direction:column;gap:12px;margin:12px 0} +.logs-search{margin:0} +.logs-detail{padding:16px} +.logs-detail-header{color:#aaa;font-family:monospace;margin-bottom:12px;word-break:break-all} +.logs-detail-text{white-space:pre-wrap;word-break:break-word;color:#ddd;margin:12px 0} +.logs-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px} +.no-border{border:none} +.tribe-card-padded{padding:12px 16px} +.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px} +.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center} +.bold{font-weight:bold} +.inline-form{display:inline} +.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db} +.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6} +.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6} +.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c} +.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c} +.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085} +.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71} +.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71} +.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22} +.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22} +.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c} +.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c} +.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12} +.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12} +.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b} +.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b} +.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db} +.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db} +.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad} +.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400} +.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60} +.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f} +.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6} +.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6} +.stats-link{color:#ffa500;text-decoration:none} +.stats-link-break{color:#ffa500;text-decoration:none;word-break:break-all} +.stats-muted{color:#aaa} +.stats-strong{color:#ffd700;font-weight:600} +.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px} +.stats-mode-row{justify-content:flex-start;margin-bottom:24px} +.stats-section-h{font-size:18px;color:#ffa500;margin:8px 0;font-weight:600} +.stats-list-reset{list-style-type:none;padding:0;margin:0} +.stats-mb-16{margin-bottom:16px} +.stats-w-100{width:100%} +.stats-table{width:100%;border-collapse:collapse} +.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px} +.stats-h-row{font-size:15px;color:#ddd;margin:6px 0;display:flex;gap:8px;align-items:baseline} +.stats-card{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px;box-shadow:none} +.stats-card .block-info-table{table-layout:fixed} +.stats-card .block-info-table .card-label{width:70%} +.stats-card .block-info-table .card-value{width:30%;text-align:right;word-break:break-word} +.invites-pubs-table{table-layout:fixed} +.invites-pubs-table col.invites-col-host,.invites-pubs-table td:nth-child(1){width:22%} +.invites-pubs-table td:nth-child(2){width:8%} +.invites-pubs-table td:nth-child(3){width:40%;word-break:break-all} +.invites-pubs-table td:nth-child(4){width:30%} +.invites-pubs-table tr:first-child td{white-space:nowrap} +.stats-block{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px} +.stats-block h2{margin:0 0 10px;font-size:16px;color:#ffa500;font-weight:600} +.stats-block ul{margin:6px 0 0;padding-left:18px} +.stats-block ul li{margin:3px 0;color:#ddd;font-size:14px} +.stats-block table.stats-table th,.stats-block table.stats-table-mt8 th{background:#272727;color:#FFDD44;text-align:left;padding:6px 8px;font-size:13px} +.stats-block table.stats-table td,.stats-block table.stats-table-mt8 td{padding:5px 8px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:13px} +.stats-block table.stats-table tr:last-child td,.stats-block table.stats-table-mt8 tr:last-child td{border-bottom:none} +.stats-pill{display:inline-block;padding:2px 8px;border-radius:10px;background:#2a2a2a;border:1px solid #3a3a3a;color:#ffd700;font-size:12px;margin:2px 4px 2px 0} +.stats-toplist{margin:0;padding:0;list-style:none} +.stats-toplist li{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid #2a2a2a} +.stats-toplist li:last-child{border-bottom:none} +.stats-toplist .stats-bar-track{flex:1;background:#2a2a2a;border-radius:4px;height:8px;overflow:hidden} +.stats-toplist .stats-bar-fill{background:#ffa500;height:100%} +.stats-toplist .stats-toplist-name{flex:0 0 auto;color:#FFDD44;font-size:13px;min-width:120px} +.stats-toplist .stats-toplist-num{flex:0 0 auto;color:#FFDD44;font-size:13px;min-width:36px;text-align:right} +.peer-key{word-break:break-all;font-size:12px} +.peers-technical-block{margin-top:16px} +.peers-conn-actions{margin-bottom:12px} +.invites-pubs-actions{margin:12px 0} +.larp-cycle-banner{display:flex;flex-wrap:wrap;align-items:center;gap:8px;background:#1A1A1A;border:1px solid #FFA500;border-radius:6px;padding:8px 14px;margin:12px 0;font-family:monospace} +.larp-post-submit{width:auto;display:inline-block;padding:6px 18px;align-self:flex-start} +.larp-cycle-label,.larp-governing-label{color:#FFD700;font-size:12px;text-transform:uppercase;letter-spacing:1px} +.larp-cycle-value{color:#FFA500;font-weight:700;font-size:14px} +.larp-cycle-sep{color:#555} +.larp-governing-house{color:#FFD700;font-weight:700;text-decoration:underline} +.larp-my-house{display:flex;align-items:center;gap:8px;margin:8px 0 16px 0;background:transparent !important;border:0 !important;padding:0 !important} +.larp-my-house-label{color:#FFD700;font-size:12px;text-transform:uppercase;letter-spacing:1px} +.larp-my-house-link{color:#FFA500;font-weight:700} +.larp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px;margin-top:12px} +.larp-card{background:#1A1A1A;border:1px solid #333;border-radius:8px;overflow:hidden;display:flex;flex-direction:column} +.larp-card-mine{border:3px solid #FFA500} +.larp-card-ruling{border:3px solid #ff6b6b} +.larp-card-image-link{display:block} +.larp-card-image{width:100%;height:160px;object-fit:cover;display:block;background:#0a0a0a} +.larp-card-body{padding:10px 12px;display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important} +.larp-card-title-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-card-title{color:#FFA500;font-weight:700;font-size:16px;letter-spacing:0.5px} +.larp-card-motto{color:#FFDD44;font-style:italic;font-size:13px;margin:0} +.larp-card-roles{color:#bbb;font-size:12px;margin:0} +.larp-card-count{color:#888;font-size:12px;margin:0} +.larp-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px} +.larp-badge-mine{background:#0e3320;color:#7fff7f} +.larp-badge-ruling{background:#3a0000;color:#ff6b6b} +.larp-detail-header{display:flex;flex-wrap:wrap;align-items:center;gap:10px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-detail-motto{color:#FFDD44;font-style:italic;font-size:15px;margin:6px 0 0 0} +.larp-detail{display:flex;flex-wrap:wrap;gap:18px;margin-top:14px} +.larp-detail-image-col{flex:0 0 240px} +.larp-detail-image{width:100%;height:auto;border-radius:8px;border:2px solid #FFA500;display:block} +.larp-detail-body{flex:1 1 320px;display:flex;flex-direction:column;gap:10px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-info-table{width:100%;border-collapse:collapse} +.larp-info-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top} +.larp-detail-description{color:#ccc;line-height:1.5;margin:0} +.larp-actions{margin-top:10px;background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.larp-members-block{margin-top:24px} +.larp-members-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:6px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-member-row{padding:4px 8px;background:#1A1A1A;border:1px solid #333;border-radius:4px} +.larp-back{margin-right:6px} +.larp-house-badges{display:flex;flex-wrap:wrap;gap:14px;margin:8px 0 12px 0;background:transparent !important;border:0 !important;padding:0 !important} +.larp-house-nav{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:10px 0;background:transparent !important;border:0 !important;padding:0 !important} +.larp-house-nav-label{color:#FFD700;font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-right:6px} +.larp-house-nav-chip{display:inline-block;padding:4px 10px;border:1px solid #555;border-radius:14px;color:#ccc;font-size:12px;font-weight:600;text-decoration:none;background:#1A1A1A} +.larp-house-nav-chip:hover{border-color:#FFA500;color:#FFA500} +.larp-house-nav-chip.active{background:#FFA500;border-color:#FFA500;color:#000} +.larp-detail-name{color:#FFA500;font-size:22px;margin:0 0 4px 0} +.larp-posts-block{margin-top:24px} +.larp-post-form{margin:12px 0;background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-direction:column;gap:6px} +.larp-post-form textarea{width:100%;max-width:720px;font-family:inherit} +.larp-post-label{color:#FFD700;font-size:13px;font-weight:600} +.larp-post-public-label{color:#bbb;font-size:13px;display:inline-flex;align-items:center;gap:6px} +.larp-posts-list{display:flex;flex-direction:column;gap:10px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-post{background:#1A1A1A;border:1px solid #2a2a2a;border-radius:6px;padding:10px 14px} +.larp-post-private{border-left:3px solid #FFA500} +.larp-post-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:13px;background:transparent !important;border:0 !important;padding:0 !important;margin-bottom:6px} +.larp-post-time{color:#888;font-size:12px} +.larp-post-badge{display:inline-block;padding:2px 8px;border-radius:10px;background:#3a2300;color:#FFD700;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px} +.larp-post-text{color:#ddd;line-height:1.5;margin:0;white-space:pre-wrap;word-break:break-word} +.larp-rules{margin-top:14px;display:flex;flex-direction:column;gap:18px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-rules-section{background:#1A1A1A;border:1px solid #2a2a2a;border-radius:6px;padding:12px 16px} +.larp-rules-section h3{color:#FFA500;margin:0 0 6px 0;font-size:15px} +.larp-rules-section p{color:#ccc;line-height:1.55;margin:6px 0} +.larp-rules-list{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-rules-list strong{color:#FFD700} +.lan-checkbox-label{display:inline-flex;align-items:center;gap:10px;color:#FFD700;font-size:14px;margin:10px 0;background:transparent !important;border:0 !important;padding:0 !important;cursor:pointer} +.lan-checkbox-input{width:18px;height:18px;margin:0;flex:0 0 auto;cursor:pointer;accent-color:#FFA500} +.lan-checkbox-text{line-height:1} +.larp-sign-block{display:block;line-height:0;margin:0 auto;background:transparent !important;border:0 !important;padding:0 !important;width:180px} +.profile-sensors-box .larp-sign-block{margin-top:14px} +.larp-sign-large{width:180px;height:180px;object-fit:cover;border-radius:8px;border:2px solid #FFA500;display:block} +.larp-sign-mini-block{display:inline-block;line-height:0;margin:6px 0;background:transparent !important;border:0 !important;padding:0 !important;width:64px} +.larp-sign-mini{width:64px;height:64px;object-fit:cover;border-radius:50%;border:2px solid #FFA500;display:block} +.larp-academia-join{margin-top:18px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-academia-join>h2{color:#FFA500;margin:0 0 8px 0;font-size:18px;text-transform:uppercase;letter-spacing:1px} +.larp-academia-join-hint{color:#ccc;font-size:13px;margin:0 0 10px 0} +.larp-test-cooldown-banner{color:#FFD700;background:#3a2300;border:1px solid #FFA500;border-radius:4px;padding:6px 10px;font-size:13px;margin:0 0 10px 0;display:inline-block} +.larp-last-attempt{margin:8px 0 14px 0;background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 14px} +.larp-last-attempt-title{color:#FFA500;font-size:15px;margin:0 0 8px 0} +.larp-last-attempt-pass{color:#7fff7f;font-weight:700} +.larp-last-attempt-fail{color:#ff8a8a;font-weight:700} +.larp-test-form-compact{margin:10px 0 0 0;gap:8px} +.larp-test-form-compact .larp-test-question{padding:8px 12px} +.larp-test-form-compact .larp-test-q-text{font-size:13px;margin:0 0 6px 0} +.larp-test-form-compact .larp-test-option{font-size:12px;padding:2px 6px} +.larp-will-test-hint{color:#bbb;font-size:13px;line-height:1.4;margin:4px 0 10px 0} +.larp-academia-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-invite-redeem-form,.larp-invite-create-form,.larp-card-invite-form{display:inline-flex;gap:6px;align-items:center;background:transparent !important;border:0 !important;padding:0 !important;margin:0} +.larp-invite-redeem-form input[type=text],.larp-card-invite-form input[type=text]{padding:4px 8px;font-family:monospace;font-size:12px;min-width:280px;width:100%;max-width:360px;box-sizing:border-box} +.larp-card-invite-form{margin-top:8px;flex-wrap:wrap} +.larp-invite-banner{background:#161616;border:1px solid #FFA500;border-radius:6px;padding:10px 14px;margin:10px 0} +.larp-invite-banner-title{color:#FFD700;margin:0 0 4px 0;font-size:14px} +.larp-invite-banner-hint{color:#bbb;font-size:13px;margin:0 0 8px 0} +.larp-invite-banner-code{color:#9aff9a;font-family:monospace;font-size:15px;letter-spacing:1px;margin:0;word-break:break-all} +.larp-test-ranking{margin-top:14px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-test-ranking-title{color:#FFA500;font-size:15px;margin:0 0 8px 0} +.larp-academia-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-academia-thumb{display:flex;flex-direction:column;align-items:center;gap:6px;text-decoration:none;background:#1A1A1A;border:1px solid #333;border-radius:8px;padding:10px;transition:border-color 0.2s} +.larp-academia-thumb:hover{border-color:#FFA500} +.larp-academia-thumb-mine{border:3px solid #FFA500} +.larp-academia-thumb-image{width:100%;max-width:128px;height:128px;object-fit:cover;border-radius:6px;border:1px solid #FFA500;display:block} +.larp-academia-thumb-name{color:#FFA500;font-weight:700;font-size:14px;text-transform:uppercase;letter-spacing:0.5px;text-align:center} +.larp-test-header{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;margin:14px 0;background:transparent !important;border:0 !important;padding:0 !important} +.larp-test-image{width:180px;height:180px;object-fit:cover;border-radius:8px;border:2px solid #FFA500;display:block;flex:0 0 180px} +.larp-test-info{flex:1 1 320px;background:transparent !important;border:0 !important;padding:0 !important} +.larp-test-intro{color:#ccc;line-height:1.5;margin:0 0 8px 0} +.larp-test-meta{color:#bbb;font-size:13px;margin:0} +.larp-test-meta strong{color:#FFD700} +.larp-test-cooldown{background:#1A1A1A;border:1px solid #FFA500;border-radius:6px;padding:14px 18px;margin:14px 0} +.larp-test-cooldown p{color:#FFD700;margin:0 0 8px 0;font-size:14px} +.larp-test-form{margin:14px 0;background:transparent !important;border:0 !important;padding:0 !important;display:flex;flex-direction:column;gap:14px} +.larp-test-question{background:#1A1A1A;border:1px solid #2a2a2a;border-radius:6px;padding:12px 16px} +.larp-test-q-text{color:#ddd;line-height:1.5;margin:0 0 10px 0;font-size:14px} +.larp-test-q-text strong{color:#FFA500} +.larp-test-options{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;align-items:flex-start} +.larp-test-option{color:#ccc;font-size:13px;display:inline-flex;flex-direction:row;align-items:center;justify-content:flex-start;gap:8px;cursor:pointer;padding:4px 6px;border-radius:3px;width:auto;max-width:100%} +.larp-test-option:hover{background:#222} +.larp-test-option input[type=radio]{accent-color:#FFA500;margin:0;flex:none;width:16px;height:16px} +.larp-test-result{background:#1A1A1A;border:1px solid #FFA500;border-radius:8px;padding:18px 22px;margin:14px 0} +.larp-test-result.passed{border-color:#FFA500} +.larp-test-result.failed{border-color:#FFA500} +.larp-test-result h2{margin:0 0 8px 0;font-size:18px;color:#FFA500} +.larp-test-result.passed h2{color:#FFA500} +.larp-test-result.failed h2{color:#FFA500} +.larp-test-score{color:#FFD700;font-size:16px;font-weight:700;margin:0 0 4px 0} +.larp-test-next{color:#bbb;font-size:13px;margin:0 0 12px 0} +.suggested-meta{margin-top:8px;display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important;padding:0 !important} +.inbox-exposition{margin:12px 0 4px 0;display:flex;flex-wrap:wrap;align-items:center;gap:8px} +.inbox-filters-label{color:#FFDD44;font-size:13px;font-weight:600;align-self:center} +.inbox-vis-toggle{background:transparent !important;border:0 !important;padding:0 !important;display:inline-block} +.suggested-badge{display:inline-block;background:#FFA500;color:#000;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;width:fit-content} +.peers-import-form{margin-top:16px;display:flex;flex-direction:column;gap:8px;align-items:flex-start} +.peers-import-form textarea{width:100%;max-width:720px;font-family:monospace;font-size:12px} +.peers-import-label{color:#FFDD44;font-size:13px;font-weight:600} +.graphos-canvas{width:100%;max-width:1100px;margin:12px auto;padding:8px;background:transparent} +.graphos-svg{width:100%;height:auto;min-height:480px;display:block} +.graphos-edge{stroke:#666;stroke-width:1;opacity:.55} +.graphos-edge-online{stroke:#2ecc71;stroke-width:2;opacity:.9} +.graphos-edge-discovered{stroke:#ffd700;stroke-width:1.5;opacity:.75} +.graphos-edge-unknown{stroke:#888;stroke-width:1;stroke-dasharray:4 4;opacity:.55} +.graphos-node-circle{stroke:#1a1a1a;stroke-width:2;transition:stroke-width .15s,filter .15s} +.graphos-node-circle-me{fill:#ffa500;stroke:#cc7700;stroke-width:3} +.graphos-node-circle-online{fill:#2ecc71;stroke:#1e8449} +.graphos-node-circle-discovered{fill:#ffd700;stroke:#b8860b} +.graphos-node-circle-unknown{fill:#888;stroke:#555} +.graphos-node-link{cursor:pointer;outline:none} +.graphos-node-link:hover .graphos-node-circle{stroke-width:4;filter:brightness(1.15)} +.graphos-node-link:hover .graphos-node-label{font-weight:600} +.graphos-node-label{font-size:12px;fill:#ddd;text-anchor:middle;pointer-events:none} +.graphos-node-label-me{font-weight:600;fill:#ffa500} +.graphos-legend{display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:13px;color:#ddd;margin:0 0 8px} +.graphos-legend-item{display:flex;align-items:center;gap:6px} +.graphos-legend-dot{display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid #1a1a1a} +.graphos-legend-dot.graphos-node-circle-me{background:#ffa500;border-color:#cc7700} +.graphos-legend-dot.graphos-node-circle-online{background:#2ecc71;border-color:#1e8449} +.graphos-legend-dot.graphos-node-circle-discovered{background:#ffd700;border-color:#b8860b} +.graphos-legend-dot.graphos-node-circle-unknown{background:#888;border-color:#555} +.stats-kpi{padding:8px 4px;display:flex;flex-direction:column;gap:4px;background:transparent;border:none} +.stats-kpi-label{color:#ffa500;font-size:12px;font-weight:600;letter-spacing:.3px} +.stats-kpi-value{color:#ffd700;font-weight:600;font-size:22px;line-height:1.1;word-break:break-word} +.stats-kpi-bar{margin-top:6px} +.stats-activity-totals{display:flex;gap:24px;flex-wrap:wrap;color:#ffa500;font-size:13px;margin-top:8px} +.stats-activity-totals strong{color:#ffd700} +.stats-w-0{width:0%}.stats-w-5{width:5%}.stats-w-10{width:10%}.stats-w-15{width:15%}.stats-w-20{width:20%}.stats-w-25{width:25%}.stats-w-30{width:30%}.stats-w-35{width:35%}.stats-w-40{width:40%}.stats-w-45{width:45%}.stats-w-50{width:50%}.stats-w-55{width:55%}.stats-w-60{width:60%}.stats-w-65{width:65%}.stats-w-70{width:70%}.stats-w-75{width:75%}.stats-w-80{width:80%}.stats-w-85{width:85%}.stats-w-90{width:90%}.stats-w-95{width:95%}.stats-w-100{width:100%} +.profile-qr{display:flex;justify-content:center;align-items:center;margin:16px 0} +.profile-qr-img{display:block;max-width:240px;height:auto} +.inhabitant-qr-link{display:inline-block;margin:6px 0 8px;line-height:0} +.inhabitant-qr-small{display:block;width:96px;height:96px;background:#fff;padding:4px;border-radius:4px;image-rendering:pixelated} +.job-candidates{margin-top:24px;padding-top:16px;border-top:1px solid #333} +.job-candidates>h2{color:#ffa500;margin-bottom:4px} +.job-exchange-hint{color:#ffa500;font-style:italic;margin:4px 0 12px;font-size:13px} +.profile-visibility-table{margin:8px 0;border-collapse:collapse;width:auto;max-width:320px} +.profile-visibility-table td{padding:6px 8px;text-align:left;vertical-align:middle;border:none;background:transparent} +.profile-visibility-table tr, +.profile-visibility-table tr:nth-child(even), +.profile-visibility-table tr:nth-child(odd){background:transparent} +.profile-visibility-table label{cursor:pointer;background:transparent;color:inherit;font-size:inherit;font-weight:inherit;font-family:inherit} + +.prefs-card{border:1px solid #2a2a2a;padding:14px 16px;margin:12px 0;background:#111} +.prefs-card .tags-header{margin:0 0 10px 0} +.prefs-card .tags-header h2{font-size:16px;color:#FFA500;margin:0 0 4px 0} +.prefs-help{color:#FFD700;font-size:12px;margin:0} +.pref-pill-row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px} +.pref-pill{display:inline-flex;align-items:center;border:1px solid #333;padding:6px 12px;cursor:pointer;background:#161616;color:#888;font-size:13px;user-select:none} +.pref-pill:hover{border-color:#555;color:#bbb} +.pref-pill-input{position:absolute;opacity:0;width:0;height:0;pointer-events:none} +.pref-pill-label{display:inline-block} +.pref-pill:has(.pref-pill-input:checked){background:#FFA500;border-color:#FFA500;color:#000;font-weight:600} +.pref-pill:has(.pref-pill-input:checked):hover{background:#FFD700;border-color:#FFD700;color:#000} +.pref-pill-forced,.pref-pill-forced:hover,.pref-pill:has(.pref-pill-input:checked).pref-pill-forced,.pref-pill:has(.pref-pill-input:checked).pref-pill-forced:hover{background:#c0392b !important;border-color:#c0392b !important;color:#fff !important;font-weight:600;cursor:not-allowed} + +.profile-layout{align-items:flex-start} +.profile-layout .tribe-side, +.profile-layout .tribe-main{background:transparent} +.profile-layout-single{display:flex;justify-content:center;padding:24px 0;gap:24px} +.profile-layout-single .profile-side{width:560px;max-width:100%;flex:0 0 auto} +.profile-side{align-items:center;text-align:center} +.profile-side-header{display:flex;flex-direction:column;align-items:center;gap:6px;width:100%} +.profile-side .inhabitant-photo-details{width:160px !important;height:160px !important;max-width:160px !important;object-fit:cover;border:2px solid #ff9800;background:transparent} +.profile-side-name{font-size:20px;margin:0;color:#FFD700;word-break:break-word} +.profile-side-mention{font-family:monospace;font-size:12px;color:#FFD700;word-break:break-all;overflow-wrap:anywhere;margin:6px 0 8px 0;line-height:1.5;align-self:stretch;width:100%;max-width:100%;box-sizing:border-box;text-align:center} +.profile-side-mention a{color:#FFD700;text-decoration:none} +.profile-side-mention strong{color:#FFD700;font-weight:700} +.profile-side-description{color:#ddd;font-size:13px;line-height:1.5;margin:0;word-break:break-word;background:transparent;padding:0;border:none;text-align:left} +.profile-side-description p{margin:0 0 6px 0} +.profile-side-description ul,.profile-side-description ol{margin:6px 0;padding-left:20px} +.profile-side-description li{margin:2px 0} +.profile-side-qr{width:160px;height:160px;background:#fff;padding:6px;display:block;margin:0 auto} +.profile-side-relationship{margin:0} +.profile-side-relationship .status{display:inline-block;margin:0 2px} +.profile-side-actions, +.profile-sensors-box{ + display:flex; + flex-direction:column; + gap:6px; + align-items:stretch; + margin:12px 0; + background:transparent; + border:none; + padding:0; + width:100%; +} +.inhabitant-stats-box{ + margin:12px 0; + width:100%; + border:none !important; + background:none !important; + padding:0 !important; + box-shadow:none !important; +} +.inhabitant-stats-title{ + display:block; + font-weight:700; + text-transform:uppercase; + letter-spacing:.04em; + font-size:13px; + opacity:.85; + margin-bottom:8px; +} +.inhabitant-stats-grid{ + display:grid; + grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); + gap:6px; +} +.inhabitant-stat{ + display:flex; + justify-content:space-between; + align-items:center; + gap:8px; + padding:5px 10px; + border:1px solid rgba(128,128,128,.35); + border-radius:6px; +} +.inhabitant-stat-label{ + font-size:12px; + opacity:.85; +} +.inhabitant-stat-value{ + font-weight:700; +} +.profile-side-actions .btn{ + background-color:#FFA500; + color:#000; + padding:8px 16px; + text-align:center; + text-decoration:none; + border:none; + cursor:pointer; + font-family:inherit; + font-size:inherit; + display:inline-block; +} +.profile-side-actions .btn:hover{background-color:#FFD700;color:#000;text-decoration:none} +.profile-sensors-box .inhabitant-activity-group, +.inhabitant-left .inhabitant-activity-group, +.inhabitant-left .inhabitant-karma-ubi{display:flex;flex-direction:column;gap:6px;margin-top:0;align-items:stretch;border:none;background:transparent;padding:0} +.inhabitant-left>a:first-child{margin-bottom:14px} +.profile-sensors-box .inhabitant-last-activity, +.profile-sensors-box .karma-line, +.profile-sensors-box .ubi-line, +.profile-sensors-box .gpg-line, +.inhabitant-left .inhabitant-last-activity, +.inhabitant-left .karma-line, +.inhabitant-left .ubi-line, +.inhabitant-left .gpg-line{ + display:flex; + gap:6px; + align-items:center; + justify-content:center; + padding:0.2rem 0.6rem; + border:1px solid #555; + border-radius:4px; + background-color:#1A1A1A; + font-size:0.9rem; + color:#FFDD44; + margin:0; +} +.profile-sensors-box .inhabitant-last-activity strong, +.profile-sensors-box .karma-line strong, +.profile-sensors-box .ubi-line strong, +.profile-sensors-box .gpg-line strong, +.inhabitant-left .inhabitant-last-activity strong, +.inhabitant-left .karma-line strong, +.inhabitant-left .ubi-line strong, +.inhabitant-left .gpg-line strong{color:#FFDD44} +.profile-sensors-box .inhabitant-last-activity a, +.profile-sensors-box .karma-line a, +.profile-sensors-box .ubi-line a, +.profile-sensors-box .gpg-line a, +.inhabitant-left .inhabitant-last-activity a, +.inhabitant-left .karma-line a, +.inhabitant-left .ubi-line a, +.inhabitant-left .gpg-line a{color:#FFDD44;text-decoration:none} +a.chip-link{text-decoration:none;display:inline-block} +a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)} +.gpg-edit-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px} +.gpg-edit-row .gpg-remove-btn{background:transparent;border:1px solid #FF6B6B;color:#FF6B6B;cursor:pointer;padding:4px 10px;border-radius:4px;font-family:inherit;font-size:0.9rem} +.gpg-edit-row .gpg-remove-btn:hover{background:#FF6B6B;color:#000} +.profile-sensors-box .inhabitant-last-activity a:hover, +.profile-sensors-box .karma-line a:hover, +.profile-sensors-box .ubi-line a:hover, +.profile-sensors-box .gpg-line a:hover, +.inhabitant-left .inhabitant-last-activity a:hover, +.inhabitant-left .karma-line a:hover, +.inhabitant-left .ubi-line a:hover, +.inhabitant-left .gpg-line a:hover{color:#ffa500;text-decoration:underline} +.profile-main{gap:12px} +.profile-module-section{display:flex;flex-direction:column;gap:12px;margin:0 0 12px 0} +.tribe-invite-code-input{width:100%;max-width:680px;padding:10px 12px;font-family:monospace;font-size:14px;letter-spacing:1px;background:#1a1a1a;color:#ffd700;border:1px solid #555;border-radius:4px;margin:12px 0} +.pad-author-cell,.tribe-author-cell{width:100%} +.pad-author-cell a.user-link,.tribe-author-cell a.user-link{display:block;width:100%;box-sizing:border-box;text-align:center;padding:8px 12px} + +.search-filters-table{margin:12px 0;border-collapse:collapse;width:auto;max-width:680px} +.search-filters-table tr, +.search-filters-table tr:nth-child(even), +.search-filters-table tr:nth-child(odd){background:transparent} +.search-filters-table td{padding:6px 10px;border:none;vertical-align:middle;background:transparent} +.search-filters-table td.card-label{white-space:nowrap;font-weight:600;color:#ffa500} +.search-filters-table input[type="datetime-local"], +.search-filters-table input.search-oasis-id{min-width:280px} +.transfer-category-select{width:auto;min-width:160px;max-width:200px;padding:6px 10px} +.spread-row{margin:8px 0;display:flex;justify-content:flex-start} +.spread-form{display:inline-block} +.spread-btn{cursor:pointer;font:inherit;padding:4px 10px;border:1px solid #555;border-radius:4px;background:#1a1a1a;color:#ffa500;font-size:14px} +.spread-btn:hover{background:#2a2a2a;border-color:#ffa500} +.spread-btn-on{background:#ffa500;color:#1a1a1a;border-color:#ff7300} +.spread-btn-on:hover{background:#ffb733;color:#000} +.welcome-header{margin-bottom:18px} +.welcome-chat{max-width:760px;margin:0 auto;padding:8px} +.welcome-ai-header{display:flex;align-items:center;gap:10px;margin-bottom:10px;color:#ffa500;font-weight:600} +.welcome-ai-avatar{font-size:24px} +.welcome-ai-name{font-size:16px} +.welcome-ai-step{margin-left:auto;font-size:12px;color:#888} +.welcome-bubble{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:14px 18px;margin-bottom:14px;line-height:1.5} +.welcome-bubble-ai{border-left:3px solid #ffa500} +.welcome-bubble-title{margin:0 0 8px;color:#ffd700;font-size:18px} +.welcome-bubble p{margin:6px 0;color:#ddd} +.welcome-actions{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0;justify-content:flex-end} +.welcome-action-primary{background:#ff6600;color:#fff;border-color:#ff3300} +.welcome-progress{display:flex;justify-content:center;gap:10px;margin:18px 0 6px} +.welcome-dot{font-size:14px;color:#444;text-decoration:none} +.welcome-dot-active{color:#ffa500} +.welcome-skip{text-align:center;margin-top:6px} +.welcome-skip-link{color:#666;font-size:12px} +.indexing-progress-block{max-width:640px;margin:24px auto;text-align:center} +.indexing-progress{width:100%;height:18px} +.indexing-percent{margin:8px 0 4px;font-size:22px;color:#ffd700} +.indexing-note{color:#888;font-size:12px;margin:0} +.indexing-detail{max-width:720px;margin:24px auto;color:#ddd;line-height:1.55} +.indexing-detail p{margin:10px 0} +.indexing-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin:24px 0} +.search-filters-row{display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start} +.search-filters-row .search-filters-table{flex:1 1 320px} +.search-submit-cell{padding-top:8px} +.search-submit-cell button{padding:6px 16px} +.search-submit-row{margin-top:12px;display:flex;justify-content:flex-start} +.search-submit-row button{padding:8px 18px} +.pm-exposition{display:flex;border:0;} +.pm-exposition-label{color:#ffa500;font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:0.5px} +.pm-exposition-chip{display:inline-flex;align-items:center;gap:4px;border-radius:10px;font-size:12px;font-weight:600;border:1px solid;padding:2px 8px} +.pm-exposition-whole{background:#3a2300;color:#ffd700;border-color:#a39134} +.pm-exposition-mutuals{background:#1a3a1a;color:#7fff7f;border-color:#3a7f4c} +.pm-exposition-encrypted{background:#0e3320;color:#7fff7f;border-color:#3a7f4c} +.pm-double-enc-chip{background:#2a1633;border-color:#7a4b9c} +.pm-double-enc-chip,.pm-double-enc-chip .pm-exposition-icon,.pm-double-enc-chip .pm-exposition-text{color:#d59bff} +.pm-exposition-closed{background:#3a0e0e;color:#ff8a8a;border-color:#b04040} +.pm-exposition-hidden{background:#1f2533;color:#9fb3d1;border-color:#46557a} +.shop-orders-badge{color:#2ecc71;font-weight:700;margin-left:4px} +.pm-exposition-lifespan-green{background:#0e2a16;color:#9aff9a;border-color:#3a7f4c} +.pm-exposition-lifespan-orange{background:#2a1f10;color:#ffb84d;border-color:#a37a34} +.pm-exposition-lifespan-red{background:#2a0e0e;color:#ff7f7f;border-color:#b04040} +.jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:20px} +.jobs-grid .tribe-card,.tribe-grid .tribe-card{margin-bottom:0} +.tribe-card .card-comments-summary{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px} +.tribe-card .card-comments-summary .inline-form{margin:0} +.jobs-info-table{table-layout:auto} +.jobs-info-table .tribe-info-label{width:1%;white-space:nowrap} +.jobs-info-table .tribe-info-value{white-space:normal;word-break:normal;overflow-wrap:anywhere} +.job-section{margin-bottom:12px;padding:0} +.job-section-title{margin:0 0 8px 0;color:#ffa500;font-size:1.05em} +.job-card .tribe-card-members{background:transparent;padding:4px 0;text-align:left} +.job-card .card-field{border-bottom:none} +.job-card .confirmations-block{margin:6px 0} +.job-meta-line{margin:0;color:#9aa3b2;font-size:12px;letter-spacing:1px;text-transform:uppercase} +.job-price-line{margin:0;font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace} +.job-card-view-btn{margin-top:8px} +.job-card-view-btn form{margin:0} +.vote-comments-section,.comments-count,.comment-form-wrapper,.comments-list,.vote-buttons-block,.vote-buttons-row,.voting-buttons,.vote-table{border:none;background:transparent;padding:0;margin:0} +.vote-buttons-block{display:flex;flex-direction:column;gap:8px} +.vote-buttons-row{display:flex;gap:8px;flex-wrap:wrap} +.voting-buttons{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap} +.vote-comments-section{margin-top:16px} +.comment-form-wrapper{margin-top:12px} +.comments-list{margin-top:12px} +.comments-count{margin-bottom:8px} +.title-with-chip{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important} +.title-with-chip h2{margin:0} +.pm-exposition-icon{font-size:14px;line-height:1} +.pm-exposition-text{line-height:1;} +.shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;padding:0;background:transparent;border-radius:0;box-shadow:none} +.shop-title-row h2{margin:0} +.card-chips-row{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px 0;padding:0;border:none;background:transparent;border-radius:0;box-shadow:none} +.tribe-card-body .job-price-line,.tribe-card-body .job-meta-line,.tribe-card-body .job-card-view-btn,.tribe-card-body .card-comments-summary,.tribe-card-body .tribe-card-members{padding:0;background:transparent;border-radius:0;box-shadow:none;border:none} +.tribe-card-body .job-price-line{margin:4px 0} +.tribe-card-body .job-card-view-btn{margin-top:8px;margin-bottom:0} +.transfer-block-card{border:none;background:transparent;padding:0;margin:0 0 12px 0} +.tribe-main .job-section{border:none;background:transparent} +.profile-reach{display:flex;flex-direction:column;align-items:center;gap:8px;margin:12px 0;text-align:center;border:none !important;background:none !important} +.profile-reach .shop-clearnet-url{margin:0;text-align:center;border:none !important;background:none !important} +.profile-reach-toggle{margin:0} +.profile-reach-toggle .btn{ + background-color:#FFA500; + color:#000; + padding:8px 16px; + border-radius:5px; + text-align:center; + text-decoration:none; + border:none; + cursor:pointer; + font-family:inherit; + font-size:inherit; +} +.profile-reach-toggle .btn:hover{background-color:#FFD700} + +/* Unified item card aesthetic — applied to per-module containers that previously had no card styling. Existing rules (.market-card, .feed-card, .tribe-card, etc.) keep their own look via cascade. */ +.card, +.card-section, +.event-card, +.job-card, +.task-card, +.transfer-card, +.project-card, +.report-card, +.vote-card, +.ai-nav-result-card, +.forum-card, +.calendar-card, +.game-card, +.game-score-card, +.bookmark-card, +.audio-card, +.video-card, +.image-card, +.document-card, +.torrent-card, +.chat-card, +.opinions-card, +.post-card, +.map-card, +.mention-card, +.trending-card, +.bank-claim-card, +.addr-list-card, +.shop-product-card, +.cv-item, +.transfer-item, +.bounty-item, +.calendar-date-item, +.evidence-item, +.settlement-item, +.thread-reply-item { + background: #1c1c1c; + border: 1px solid #444; + border-radius: 12px; + padding: 16px 18px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.35); +} +.card-section .card-section, +.event-card .card-section, +.job-card .card-section, +.transfer-card .card-section, +.project-card .card-section { + margin-bottom: 8px; + padding: 10px 12px; + background: #232323; + border-color: #3a3a3a; +} +.card>.card-footer, +.card-section>.card-footer, +.event-card>.card-footer, +.job-card>.card-footer, +.task-card>.card-footer, +.transfer-card>.card-footer, +.project-card>.card-footer, +.vote-card>.card-footer { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid #333; + font-size: 13px; + color: #aaa; +} +.tribe-details .card-footer, +.tribe-main>.card-footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} +.tribe-details .card-footer .date-link, +.tribe-main>.card-footer .date-link { margin-left: 0; } + +.rules-points { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } +.rules-point { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border: 1px solid #333; + border-left: 3px solid #ff6600; + border-radius: 6px; + line-height: 1.5; +} +.rules-point-num { + flex: 0 0 auto; + min-width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: #ff6600; + color: #fff; + font-weight: bold; + font-size: 0.85em; +} +.rules-point-text { flex: 1 1 auto; } + +.parliament-leader-avatar, +.leader-table__avatar, +.avatar--lg { + width: 120px; + height: 120px; + object-fit: cover; + border-radius: 8px; + display: block; +} +.candidature-voters { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; } +.candidature-voter { font-size: 0.85em; } +.candidature-votes-count { font-weight: bold; } +.parliament-method-img { width: 160px; height: 160px; object-fit: contain; display: block; margin: -12px auto 0; vertical-align: middle; } +.parliament-method-col { vertical-align: middle !important; } +.parliament-avatar-cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + background: none !important; + border: none !important; + box-shadow: none !important; +} +.parliament-avatar-name { + font-size: 0.9em; + background: none !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; +} +.parliament-avatar-cell a, +.parliament-avatar-name a { + background: none !important; + border: none !important; + box-shadow: none !important; +} +.parliament-gov-table td, .parliament-gov-table th, +.parliament-actor-table td, .parliament-actor-table th, +.table--centered td, .table--centered th, +.gov-overview td, .gov-overview th { text-align: center !important; vertical-align: middle; } +.parliament-gov-table td, .parliament-gov-table th, +.parliament-gov-table td:first-child, +.gov-overview td:first-child { background: none !important; border: none !important; } + +.graphos-backbar { margin: 24px 0; } +.graphos-focus-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin: 0; +} +.graphos-focus-label { font-weight: bold; } +.graphos-cap-note { opacity: 0.75; font-size: 0.9em; margin: 4px 0 0; } +.graphos-node-link { cursor: pointer; } +.card-fields-container { display:flex; flex-direction:column; gap:0; } + +/* Table-like layout for label/value pairs inside item cards. Two-column grid: label fixed, value flexible, subtle row separator. */ +.card .card-field, +.card-section .card-field, +.card-fields-container .card-field, +.event-card .card-field, +.job-card .card-field, +.task-card .card-field, +.transfer-card .card-field, +.project-card .card-field, +.report-card .card-field, +.vote-card .card-field, +.forum-card .card-field, +.tribe-card .card-field, +.tribe-content-card .card-field, +.calendar-card .card-field, +.game-card .card-field, +.game-score-card .card-field, +.bookmark-card .card-field, +.audio-card .card-field, +.video-card .card-field, +.image-card .card-field, +.document-card .card-field, +.torrent-card .card-field, +.chat-card .card-field, +.opinions-card .card-field, +.post-card .card-field, +.map-card .card-field, +.mention-card .card-field, +.trending-card .card-field, +.bank-claim-card .card-field, +.shop-product-card .card-field, +.cv-item .card-field, +.transfer-item .card-field { + display: grid; + grid-template-columns: max-content 1fr; + gap: 10px; + padding: 4px 0; + margin: 0; + border-bottom: 1px solid #2a2a2a; + align-items: baseline; +} +.card .card-field:last-child, +.card-section .card-field:last-child, +.card-fields-container .card-field:last-child, +.event-card .card-field:last-child, +.job-card .card-field:last-child, +.task-card .card-field:last-child, +.transfer-card .card-field:last-child, +.project-card .card-field:last-child, +.report-card .card-field:last-child, +.vote-card .card-field:last-child, +.forum-card .card-field:last-child, +.tribe-card .card-field:last-child, +.calendar-card .card-field:last-child, +.bookmark-card .card-field:last-child, +.audio-card .card-field:last-child, +.video-card .card-field:last-child, +.image-card .card-field:last-child, +.document-card .card-field:last-child, +.torrent-card .card-field:last-child, +.chat-card .card-field:last-child, +.cv-item .card-field:last-child, +.transfer-item .card-field:last-child { + border-bottom: none; +} +.card .card-field-stacked, +.card-section .card-field-stacked, +.event-card .card-field-stacked, +.job-card .card-field-stacked, +.project-card .card-field-stacked, +.report-card .card-field-stacked, +.tribe-card .card-field-stacked { + grid-template-columns: 1fr; + gap: 4px; +} + +.ai-approve-block{display:flex;flex-direction:column;gap:8px;width:100%} +.ai-approve-form{display:flex;flex-direction:column;gap:6px;background:transparent !important;border:0 !important;padding:0 !important} +.ai-approve-meta{display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.ai-approve-meta-label{color:#FFA500;font-size:12px} +.ai-approve-meta input[type="text"]{flex:1 1 240px;padding:4px 8px} +.ai-approve-meta select{padding:4px 8px} +.ai-approve-custom{width:100%;font-family:inherit} +.ai-approve-actions{display:flex;justify-content:flex-start} +.ai-approve-reject{background:transparent !important;border:0 !important;padding:0 !important;margin:0} +.approve-btn{background:#1e7e34;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer} +.reject-btn{background:#a71d2a;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer} +.ai-exchange-tag{display:inline-block;padding:1px 6px;margin-right:4px;background:#222;color:#FFD700;border-radius:8px;font-size:11px} +.ai-exchange-vote-form{display:inline-block;margin-left:8px;background:transparent !important;border:0 !important;padding:0 !important} +.shop-title-row,.card-chips-row,.card-tags,.tribe-card-image-wrapper,.tribe-visit-btn-wrapper,.bookmark-topbar,.bookmark-topbar-left,.bookmark-topbar-right,.bookmark-actions{border:0 !important;background:transparent !important} +.bookmark-topbar-right{display:flex;align-items:center;gap:10px} +.trending-card .bookmark-topbar div,.trending-card .bookmark-topbar form{border:0 !important;background:transparent !important;box-shadow:none !important;margin:0;padding:0} +.trending-card .bookmark-topbar{align-items:center} +.trending-card .bookmark-topbar button{margin:0;padding:8px 14px;font-size:14px;line-height:1.2} +.bookmark-actions .delete-btn{border:1px solid #d9534f;color:#d9534f;background:transparent} +.bookmark-actions .delete-btn:hover{background:#d9534f;color:#000} +.card-date-highlight{color:#ffd740;font-size:18px;font-weight:700;margin:6px 0;letter-spacing:.5px;background:transparent !important;border:0 !important;padding:0 !important} +.vote-buttons-row-single{display:flex;flex-wrap:wrap;gap:6px;background:transparent !important;border:0 !important;padding:0 !important} +.vote-buttons-row-single form{margin:0;display:inline-block} +.card-visit-btn-centered{display:flex;justify-content:center;margin-top:10px;background:transparent !important;border:0 !important;padding:0 !important} +.card-visit-btn-centered form{margin:0} +.tribe-card-body .voting-buttons{padding:0;background:transparent !important;border:0 !important;margin:8px 0 0 0} +.card-assigned-list{display:flex;flex-direction:column;gap:4px;background:transparent !important;border:0 !important;padding:0 !important;margin:6px 0} +.card-assigned-list a{display:block} +.audio-detail-chip-row{display:flex;justify-content:flex-start;align-items:center;margin:0 0 6px 0;background:transparent !important;border:0 !important;padding:0 !important} +.audio-detail-tags{background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0} +.audio-detail-replicate{background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0} +.card-spread-centered,.spread-row{display:flex;justify-content:center;background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0} +.card-spread-centered form,.spread-row form{margin:0} +.card-spread-left{display:flex;justify-content:flex-start;background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0} +.card-spread-left form{margin:0} +.progress-block,.confirmations-block{border:0 !important;background:transparent !important;padding:0 !important;margin:8px 0} +.trending-card{background:#2c2f33;border-radius:8px;padding:16px 16px 24px;border:1px solid #444;margin-bottom:16px} +.trending-card>.card-footer{margin-top:14px;padding-top:10px;border-top:1px solid #333;font-size:13px;color:#aaa;margin-bottom:0} +.trending-card>.card-section{padding:16px 18px 22px;margin-bottom:14px} +.trending-card>.card-section>:last-child{margin-bottom:0} +.activity-card-header{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;border:none;background:transparent;padding:0} +.activity-card-header .card-chips-row{margin:0} +.content-actions{display:flex;justify-content:right;align-items:center;gap:12px;flex-wrap:wrap;border:none;background:transparent;padding:0} +.trending-card.own-content{box-shadow:inset 3px 0 0 #ffa300} +.trending-dominant-sep{margin:0 8px;opacity:0.5} +.trending-dominant-text,.trending-total-count{font-weight:700} +.comment-file-upload{display:flex;flex-direction:column;align-items:flex-start;gap:6px;background:transparent !important;border:0 !important;padding:0 !important;margin:8px 0} +.comment-file-upload label{display:block} +.comment-file-upload input[type=file]{display:block} + +.post-preview .image-container{max-width:100%;overflow:hidden;display:block;margin:0 auto} +div .header-content{width:100%} +::-webkit-scrollbar{width:10px} +::-webkit-scrollbar-thumb{border-radius:10px} +.action-container{padding:10px;border-radius:8px;border-width:1px;border-style:solid} +.card{border-radius:16px;padding:0 24px 10px 24px;margin-bottom:16px;font-family:inherit} +.card-section{border:none;padding:10px 0 0 16px} +.card-header{display:flex;justify-content:space-between;align-items:center;padding-bottom:0;margin-top:8px;padding-top:0;border:none} +.card-label{font-weight:bold;letter-spacing:1.5px;line-height:1.2;margin-bottom:0} +.card-footer{margin-top:6px;font-weight:500;font-size:1.07em;display:flex;align-items:center;gap:8px;background:none;border:none;padding-top:0} +.card-body{margin-top:0;padding:0} +.card-field{display:flex;align-items:baseline;padding:0;margin-bottom:0;border:none;background:none} +.card-tags{margin:5px 0 3px 0;display:flex;flex-wrap:wrap;gap:9px} +.card-tags a.tag-link{text-decoration:none;padding:5px 13px 4px 13px;border-radius:7px;font-size:.98em;border:none;font-weight:bold} +a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bold;text-decoration:none;display:inline-block;border:2px solid transparent;font-size:.8em} +.date-link{padding:8px 16px;border-radius:5px;margin-left:8px} +.activitySpreadInhabitant2,.activityVotePost{padding:8px 16px;border-radius:5px;font-weight:bold;text-decoration:none;display:inline-block;border:2px solid transparent} +.danger-btn,.delete-btn{border:1px solid #d9534f;color:#d9534f;background:transparent} +.danger-btn:hover,.delete-btn:hover{background:#d9534f;color:#000} diff --git a/src/client/assets/themes/OasisMobile.css b/src/client/assets/themes/OasisMobile.css new file mode 100644 index 0000000..7726265 --- /dev/null +++ b/src/client/assets/themes/OasisMobile.css @@ -0,0 +1,1005 @@ +body { + background-color: #121212; + color: #FFD700; +} + +header, footer { + background-color: #1F1F1F; +} + +.sidebar-left, .sidebar-right { + background-color: #1A1A1A; + border: 1px solid #333; +} + +.main-column { + background-color: #1C1C1C; +} + +button, input[type="submit"], input[type="button"] { + background-color: #444; + color: #FFD700; + border: 1px solid #444; +} + +button:hover, input[type="submit"]:hover, input[type="button"]:hover { + background-color: #333; + border-color: #666; +} + +input, textarea, select { + background-color: #333; + color: #FFD700; + border: 1px solid #555; +} + +a { + color: #FFD700; +} + +a:hover { + color: #FFDD44; +} + +table { + background-color: #222; + color: #FFD700; +} + +table th { + background-color: #333; +} + +table tr:nth-child(even) { + background-color: #2A2A2A; +} + +nav ul li a:hover { + color: #FFDD44; + text-decoration: underline; +} + +.profile { + background-color: #222; + padding: 15px; + border-radius: 8px; +} + +.profile .name { + color: #FFD700; +} + +.avatar { + border: 3px solid #FFD700; +} + +article, section { + background-color: #1C1C1C; + color: #FFD700; +} + +.article img { + border: 3px solid #FFD700; +} + +.post-preview img { + border: 3px solid #FFD700; +} + +.post-preview .image-container { + max-width: 100%; + overflow: hidden; + display: block; + margin: 0 auto; +} + +div { + background-color: #1A1A1A; + border: 1px solid #333; +} + +div .header-content { + width: 100%; +} + +::-webkit-scrollbar { + width: 10px; +} + +::-webkit-scrollbar-thumb { + background-color: #444; + border-radius: 10px; +} + +::-webkit-scrollbar-track { + background-color: #222; +} + +.action-container { + background-color: #1A1A1A; + border: 1px solid #333; + padding: 10px; + border-radius: 8px; + color: #FFD700; +} + +footer { + background-color: #1F1F1F; + padding: 10px 0; +} + +footer a { + background-color: #444; + color: #FFD700; + text-align: center; + padding: 8px 16px; + border-radius: 5px; + text-decoration: none; +} + +footer a:hover { + background-color: #333; + color: #FFDD44; +} + +.card { + border-radius: 16px; + padding: 0px 24px 10px 24px; + margin-bottom: 16px; + color: #FFD600; + font-family: inherit; + box-shadow: 0 2px 20px 0 #FFD60024; +} + +.card-section { + border:none; + padding: 10px 0 0 16px; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0px; + margin-top: 8px; + padding-top: 0px; + border: none; +} + +.card-label { + color: #ffa300; + font-weight: bold; + letter-spacing: 1.5px; + line-height: 1.2; + margin-bottom: 0; +} + +.card-footer { + margin-top: 6px; + font-weight: 500; + color: #ff9900; + font-size: 1.07em; + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + padding-top: 0; + margin-bottom: 6; +} + +.card-body { + margin-top: 0; + margin-bottom: 4; + padding: 0; +} + +.card-field { + display: flex; + align-items: baseline; + padding: 0; + margin-bottom: 0; + border: none; + background: none; +} + +.card-tags { + margin: 5px 0 3px 0; + display: flex; + flex-wrap: wrap; + gap: 9px; +} + +.card-tags a.tag-link { + text-decoration: none; + color: #181818; + background: #FFD600; + padding: 5px 13px 4px 13px; + border-radius: 7px; + font-size: .98em; + border: none; + font-weight: bold; +} + +.card-tags a.tag-link:hover { + background: #ffe86a; + color: #111; + cursor: pointer; +} + +a.user-link { + background-color: #FFA500; + color: #000; + padding: 8px 16px; + border-radius: 5px; + text-align: center; + font-weight: bold; + text-decoration: none; + display: inline-block; + border: 2px solid transparent; + transition: background-color 0.3s, color 0.3s, border-color 0.3s; + font-size: 0.8em; +} + +a.user-link:hover { + background-color: #FFD700; + border-color: #FFD700; + color: #000; + cursor: pointer; +} + +a.user-link:focus { + background-color: #007B9F; + border-color: #007B9F; + color: #fff; +} + +.date-link { + background-color: #444; + color: #FFD600; + padding: 8px 16px; + border-radius: 5px; + margin-left: 8px; +} + +.date-link:hover { + background-color: #555; + color: #FFD700; +} + +.activitySpreadInhabitant2 { + background-color: #007B9F; + color: #fff; + padding: 8px 16px; + border-radius: 5px; + font-weight: bold; + text-decoration: none; + display: inline-block; + border: 2px solid transparent; +} + +.activityVotePost { + background-color: #557d3b; + color: #fff; + padding: 8px 16px; + border-radius: 5px; + font-weight: bold; + text-decoration: none; + display: inline-block; + border: 2px solid transparent; +} +html { + -webkit-text-size-adjust: 100%; + box-sizing: border-box; + overflow-x: hidden !important; +} + +*, *::before, *::after { + box-sizing: inherit; +} + +body { + font-size: 15px; + overflow-x: hidden; + max-width: 100vw; +} + +img, +video, +canvas, +iframe, +table { + max-width: 100% !important; +} + +pre, +code { + white-space: pre-wrap !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; +} + +.header { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + padding: 4px !important; + gap: 4px !important; + overflow: visible !important; +} + +.header-content { + display: flex !important; + flex-wrap: wrap !important; + width: 100% !important; + max-width: 100% !important; + padding: 2px !important; + gap: 4px !important; + overflow: visible !important; +} + +.top-bar-left, +.top-bar-mid, +.top-bar-right { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + align-items: stretch !important; + gap: 6px !important; +} + +.top-bar-left nav ul, +.top-bar-mid nav ul, +.top-bar-right nav ul { + display: flex !important; + flex-wrap: wrap !important; + gap: 6px !important; + padding: 0 !important; + margin: 0 !important; + overflow: visible !important; +} + +.top-bar-left nav ul li, +.top-bar-mid nav ul li, +.top-bar-right nav ul li { + margin: 0 !important; +} + +.top-bar-left nav ul li a, +.top-bar-mid nav ul li a, +.top-bar-right nav ul li a { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + padding: 6px 8px !important; + font-size: 0.9rem !important; + white-space: nowrap !important; +} + +.search-input, +.feed-search-input, +.activity-search-input { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + height: 40px !important; + font-size: 16px !important; +} + +.main-content { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + gap: 12px !important; +} + +.sidebar-left, +.sidebar-right, +.main-column { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + padding: 10px !important; + border-left: none !important; + border-right: none !important; +} + +.sidebar-left { + order: 1 !important; +} + +.main-column { + order: 3 !important; +} + +.sidebar-right { + order: 2 !important; +} + +.sidebar-left nav ul, +.sidebar-right nav ul { + display: flex !important; + flex-direction: column !important; +} + +.oasis-nav-header { + font-size: 0.85rem !important; +} + +.oasis-nav-list li a { + font-size: 0.9rem !important; + padding: 8px 12px !important; +} + +button, +input[type="submit"], +input[type="button"], +.filter-btn, +.create-button, +.edit-btn, +.delete-btn, +.join-btn, +.leave-btn, +.buy-btn { + min-height: 44px !important; + font-size: 16px !important; + white-space: normal !important; + text-align: center !important; +} + +.feed-row, +.comment-body-row, +table { + display: block !important; + width: 100% !important; + overflow-x: auto !important; +} + +textarea, +input, +select { + width: 100% !important; + max-width: 100% !important; + font-size: 16px !important; +} + +.gallery { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + gap: 8px !important; +} + +footer, +.footer { + display: block !important; + width: 100% !important; + max-width: 100% !important; + padding: 12px !important; + overflow-x: auto !important; +} + +footer div { + display: flex !important; + flex-direction: column !important; + gap: 8px !important; +} + +h1 { font-size: 1.35em !important; } +h2 { font-size: 1.2em !important; } +h3 { font-size: 1em !important; } + +.small, +.time { + font-size: 0.8rem !important; +} + +.created-at { + display: block !important; + width: 100% !important; + white-space: normal !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; + line-height: 1.5 !important; + font-size: 0.8rem !important; +} + +.header-content .created-at { + display: block !important; + flex: 1 1 100% !important; + min-width: 0 !important; + max-width: 100% !important; +} + +.post-meta, +.feed-post-meta, +.feed-row .small, +.feed-row .time, +.feed-row .created-at { + display: block !important; + width: 100% !important; + white-space: normal !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; + line-height: 1.4 !important; +} + +.post-meta, +.feed-post-meta { + flex-direction: column !important; + gap: 4px !important; +} + +.mode-buttons, +.mode-buttons-cols, +.mode-buttons-row, +.filter-group, +.activity-filter-grid, +.activity-sub-filter, +.filters .ui-toolbar { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + justify-content: center !important; + width: 100% !important; + max-width: 100% !important; + gap: 6px !important; + overflow-x: hidden !important; + overflow-y: visible !important; + grid-template-columns: none !important; + box-sizing: border-box !important; +} + +.mode-buttons .column, +.mode-buttons > div, +.activity-filter-col { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + justify-content: center !important; + width: auto !important; + gap: 6px !important; + flex: 1 1 auto !important; +} + +.mode-buttons form, +.filter-group form, +.activity-filter-col form { + width: auto !important; + flex: 0 0 auto !important; + scroll-snap-align: start !important; +} + +.mode-buttons .filter-btn, +.mode-buttons button, +.filter-group .filter-btn, +.filter-group button, +.activity-filter-grid .filter-btn, +.activity-filter-grid button, +.activity-sub-filter .filter-btn, +.filters .filter-btn, +.filters button { + width: auto !important; + flex: 0 0 auto !important; + white-space: nowrap !important; + min-width: 0 !important; +} + +.inhabitant-card { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + overflow: hidden !important; +} + +.inhabitant-left { + width: 100% !important; + text-align: center !important; +} + +.inhabitant-details { + width: 100% !important; +} + +.inhabitant-photo, +.inhabitant-photo-details { + max-width: 200px !important; + margin: 0 auto !important; +} + +.inhabitants-list, +.inhabitants-grid { + display: flex !important; + flex-direction: column !important; + gap: 10px !important; +} + +.tribe-card { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + max-width: 100% !important; + overflow: hidden !important; +} + +.tribe-grid { + display: flex !important; + flex-direction: column !important; + gap: 10px !important; + grid-template-columns: 1fr !important; +} + +.tribes-list, +.tribes-grid { + display: flex !important; + flex-direction: column !important; + gap: 10px !important; +} + +.tribe-card-image { + width: 100% !important; + max-width: 100% !important; +} + +.forum-card { + flex-direction: column !important; +} + +.forum-score-col, +.forum-main-col, +.root-vote-col { + width: 100% !important; +} + +.forum-header-row { + flex-direction: column !important; + gap: 4px !important; +} + +.forum-meta { + flex-wrap: wrap !important; + gap: 8px !important; +} + +.forum-thread-header { + flex-direction: column !important; +} + +.forum-comment { + margin-left: 0 !important; + padding-left: 8px !important; +} + +.comment-body-row { + flex-direction: column !important; +} + +.comment-vote-col, +.comment-text-col { + width: 100% !important; +} + +.forum-score-box, +.forum-score-form { + flex-direction: row !important; + justify-content: center !important; +} + +.new-message-form textarea, +.comment-textarea { + width: 100% !important; +} + +[style*="grid-template-columns: repeat(6"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns: repeat(3"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns:repeat(6"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns:repeat(3"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns: repeat(auto-fit"] { + grid-template-columns: 1fr !important; +} + +[style*="grid-template-columns:repeat(auto-fit"] { + grid-template-columns: 1fr !important; +} + +[style*="width:50%"] { + width: 100% !important; +} + +.update-banner { + background-color: #1a1400; + border-bottom-color: #3a2e00; + color: #FFD700; +} + +.update-banner-link { + color: #FFD700; +} + +.snh-invite-code { + color: #FFA500 !important; +} + +.stats-kpi-label { + color: #ffa300 !important; +} + +.stats-kpi-value { + color: #FFD700 !important; +} + +.carbon-bar-note, +.carbon-bar-formula { + color: #FFD700 !important; +} + +.graphos-node-label { + fill: #ddd !important; +} + +.graphos-node-label-me { + fill: #ffa500 !important; +} + +.graphos-legend { + color: #ddd !important; +} + +.oasis-nav-side-toggle { + position: absolute !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; + pointer-events: none !important; +} + +.header { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + align-items: center !important; + justify-content: center !important; + gap: 8px !important; + padding: 8px !important; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; +} + +.header .top-bar-left, +.header .top-bar-right, +.header .top-bar-mid, +.header .top-bar-center { + display: contents !important; +} + +.header .top-bar-left a.logo-icon { + order: 1 !important; + flex: 1 1 100% !important; + display: flex !important; + justify-content: center !important; + margin: 0 0 4px 0 !important; +} + +.oasis-mobile-menus { + order: 2 !important; + display: flex !important; + flex: 1 1 auto !important; + flex-wrap: wrap !important; + justify-content: center !important; + align-items: center !important; + gap: 8px !important; +} + +.oasis-menu-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 4px !important; + min-height: 42px !important; + padding: 6px 12px !important; + border: 1px solid #FFD700 !important; + border-radius: 8px !important; + background: #161616 !important; + color: #FFD700 !important; + font-weight: 600 !important; + font-size: 0.85rem !important; + text-decoration: none !important; + white-space: nowrap !important; + cursor: pointer !important; +} +.oasis-menu-btn .oasis-menu-ico { font-size: 1.05rem !important; } + +.header .top-bar-left nav { + order: 3 !important; + flex: 1 1 100% !important; + width: 100% !important; +} +.header .top-bar-right nav { + order: 4 !important; + flex: 1 1 100% !important; + width: 100% !important; +} +.header .top-bar-left nav ul, +.header .top-bar-right nav ul, +.header .top-bar-mid nav ul { + display: grid !important; + grid-template-columns: repeat(3, 1fr) !important; + gap: 6px !important; + overflow: visible !important; + list-style: none !important; + padding: 0 !important; + margin: 0 !important; + width: 100% !important; + box-sizing: border-box !important; +} +.header .top-bar-left nav ul li, +.header .top-bar-right nav ul li, +.header .top-bar-mid nav ul li { + width: 100% !important; + margin: 0 !important; +} +.header .top-bar-left nav ul li, +.header .top-bar-right nav ul li { margin: 0 !important; } +.header .top-bar-left nav ul li a, +.header .top-bar-right nav ul li a { + display: flex !important; + align-items: center !important; + justify-content: center !important; + gap: 4px !important; + width: 100% !important; + min-height: 42px !important; + box-sizing: border-box !important; + padding: 6px 8px !important; + font-size: 0.85rem !important; + white-space: nowrap !important; + border: 1px solid #FFD700 !important; + border-radius: 8px !important; + background: #161616 !important; + color: #FFD700 !important; + text-decoration: none !important; + font-weight: 600 !important; +} +.header a, +.header a:link, +.header a:visited { + text-decoration: none !important; +} +.header .top-bar-left nav ul li a .inbox-badge { + color: #fff !important; +} + +.main-content { + display: flex !important; + flex-direction: column !important; + gap: 10px !important; +} +.main-content .main-column { order: 0 !important; } +.main-content .sidebar-left, +.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; + background: #0d0d0d !important; + border: 1px solid #FFD700 !important; + border-radius: 10px !important; + padding: 8px !important; + margin: 0 0 8px 0 !important; +} +.sidebar-left nav ul, +.sidebar-right nav ul { + display: flex !important; + flex-direction: column !important; + gap: 4px !important; + list-style: none !important; + padding: 0 !important; + margin: 0 !important; +} + +.activity-filter-grid, +.activity-filters, +.activity-sub-filter, +.filters .ui-toolbar { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + justify-content: center !important; + align-items: flex-start !important; + gap: 6px !important; + overflow-x: hidden !important; + grid-template-columns: none !important; + scroll-snap-type: none !important; + width: 100% !important; + max-width: 100% !important; +} +.activity-filter-col { + display: flex !important; + flex: 1 1 100% !important; + flex-direction: row !important; + flex-wrap: wrap !important; + justify-content: center !important; + gap: 6px !important; + width: 100% !important; + max-width: 100% !important; +} +.activity-filter-grid form, +.activity-filter-col form, +.activity-sub-filter form { + flex: 0 1 auto !important; + scroll-snap-align: none !important; + width: auto !important; +} +.activity-filter-grid .filter-btn, +.activity-filter-col .filter-btn { + width: auto !important; + min-width: 0 !important; + white-space: nowrap !important; +} + +.oasis-footer, +.oasis-footer-center { + overflow-x: hidden !important; + max-width: 100% !important; +} +.oasis-footer-center span, +.oasis-footer-center a { + overflow-wrap: anywhere !important; + word-break: break-all !important; + max-width: 100% !important; +} + +a:not([class]), +a.mention, +a.hashtag { + color: #FFD700 !important; + text-decoration: underline !important; +} + +.module-locked { opacity: 0.45 !important; } +.module-locked .input-checkbox { cursor: not-allowed !important; } + +.game-row { + flex-direction: column !important; + align-items: stretch !important; +} +.game-row-media, +.game-row-media img { + width: 100% !important; + max-width: 100% !important; +} +.game-row-body { width: 100% !important; min-width: 0 !important; } +.game-row-actions { justify-content: center !important; width: 100% !important; } +.games-grid { grid-template-columns: 1fr !important; } + +.profile-sensors-box { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 6px !important; max-width: 100% !important; } +.profile-sensors-box span { display: block !important; max-width: 100% !important; overflow-wrap: anywhere !important; } +.larp-sign-block { display: block !important; max-width: 100% !important; overflow: hidden !important; text-align: center !important; } +.larp-sign-large { max-width: 100% !important; height: auto !important; display: block !important; margin: 0 auto !important; } +.profile-side-mention { max-width: 100% !important; overflow-wrap: anywhere !important; word-break: break-all !important; } +.profile-side-mention a, .profile-side-mention strong { max-width: 100% !important; overflow-wrap: anywhere !important; word-break: break-all !important; display: inline-block !important; } +.inhabitant-stats-box { max-width: 100% !important; overflow: hidden !important; } +.inhabitant-stats-grid { display: flex !important; flex-wrap: wrap !important; justify-content: center !important; gap: 6px !important; max-width: 100% !important; } +.inhabitant-stat { max-width: 100% !important; overflow-wrap: anywhere !important; } +.profile-reach { max-width: 100% !important; overflow: hidden !important; text-align: center !important; } +.feed-card .card-footer { display: flex !important; flex-wrap: wrap !important; align-items: center !important; gap: 6px !important; row-gap: 4px !important; } +.feed-card .date-link { max-width: 100% !important; overflow-wrap: anywhere !important; font-size: 0.85em !important; padding: 6px 10px !important; } diff --git a/src/client/assets/translations/i18n.js b/src/client/assets/translations/i18n.js new file mode 100644 index 0000000..129e820 --- /dev/null +++ b/src/client/assets/translations/i18n.js @@ -0,0 +1,15 @@ +const path = require('path'); +let i18n = {}; +const languages = ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt', 'zh', 'ar', 'hi', 'ru']; + +languages.forEach(language => { + try { + const languagePath = path.join(__dirname, `oasis_${language}.js`); + const languageData = require(languagePath); + i18n[language] = languageData[language]; + } catch (error) { + console.error(`Failed to load language file for ${language}:`, error); + } +}); + +module.exports = i18n; diff --git a/src/client/assets/translations/oasis_ar.js b/src/client/assets/translations/oasis_ar.js new file mode 100644 index 0000000..4e6d76b --- /dev/null +++ b/src/client/assets/translations/oasis_ar.js @@ -0,0 +1,3872 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + ar: { + languageName: "العربية", + shopOrderStatusPaid: "تم استلام الدفع", + shopOrderStatusReceived: "تم الاستلام", + shopOrderMarkPaid: "تأكيد استلام الدفع", + shopOrderConfirmReceived: "تأكيد الاستلام", + shopMyOrdersTitle: "طلباتي", + shopPurchasesButton: "المشتريات", + shopMyOrdersDescription: "طلبات الشراء الخاصة بك.", + shopMyOrdersEmpty: "ليس لديك مشتريات بعد.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "البائع", + shopOrderPaymentConcept: "دفعة", + shopOrderInvoice: "فاتورة", + shopOrderInvoiceConcept: "فاتورة", + shopOrderStatusPending: "قيد الانتظار", + shopOrderStatusAccepted: "مقبول", + shopOrderStatusRejected: "مرفوض", + shopOrderStatusShipped: "تم الشحن", + shopOrderStatusDelivered: "تم التسليم", + shopOrderAccept: "قبول", + shopOrderReject: "رفض", + shopOrderMarkShipped: "وضع علامة شُحن", + shopOrderMarkDelivered: "وضع علامة سُلّم", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "عقد ذكي", + shopOrderContractConcept: "طلب متجر", + shopOrdersPending: "طلبات معلّقة", + all: "الكل", + mine: "الخاصة بي", + recent: "الأحدث", + anonymous: "مجهول", + unnamed: "مجهول", + authorLabel: "المؤلف", + delete: "حذف", + save: "حفظ", + vote: "تصويت", + url: "رابط", + price: "السعر", + priority: "الأولوية", + severity: "الخطورة", + organizer: "المنظم", + edited: "تم التعديل", + isPublic: "عام", + searchIsPublicLabel: "عام", + privacyPrivate: "خاص", + privacyPublic: "عام", + notFound: "غير موجود", + no_results: "لا توجد بيانات", + searchButton: "بحث", + mapZoomLabel: "تكبير", + modulesTitle: "الوحدات", + viewJson: "عرض JSON", + matchScore: "درجة التطابق", + preferencesLabel: "التفضيلات", + inhabitantProfileTitle: "ملف الساكن", + contentWarningLabel: "تحذير المحتوى", + invalidPost: "محتوى غير صالح", + invalidPostHint: "هذه الرسالة تحتوي على نص فارغ/غير صالح.", + spreadBy: "بواسطة", + spreadChron: "نشر", + spreaded: "تم النشر", + spreadMore: "المزيد", + spreadContentUnavailable: "المحتوى غير متاح بعد (في انتظار النسخ)", + filteredByTag: "مُصفّى حسب الوسم", + filteredByTagTitle: "مُصفّى حسب الوسم", + feedPublishedSuccess: "تم نشر التغذية بنجاح!", + tribeFeedSent: "تم إرسال الرسالة بنجاح!", + tribeContentEncrypted: "محتوى مشفّر", + tribeTorrentsEmpty: "لا توجد تورنتات بعد.", + torrentDescription: "الوصف", + torrentDownload: "تنزيل", + chatInviteMode: "دعوة", + padInviteMode: "دعوة", + noInviteMode: "لا يوجد وضع دعوة", + noDeadline: "بدون موعد نهائي", + noStatus: "بدون حالة", + noSeverity: "بدون خطورة", + calendarDeleteDate: "حذف التاريخ", + calendarIntervalUntil: "حتى", + bookmarkCategory: "الفئة", + bookmarkLastVisit: "آخر زيارة", + profileClearnetBookmarksLabel: "الإشارات المرجعية", + forumFilterHot: "رائج", + invitesPort: "المنفذ", + currentlyUnrecheable: "خطأ!", + peerHostValidation: "عنوان IPv4 صالح (مثل 192.168.1.100) أو اسم مضيف (مثل pub.example.com)", + peerPortValidation: "المنفذ 1-65535", + peerKeyValidation: "مفتاح SSB ed25519 العام (@<44 حرف base64>=.ed25519)", + inboxJobUnsubscribedTitle: "إلغاء الاشتراk في عرض عملك", + inboxProjectFollowedTitle: "متابع جديد لمشروعك", + inboxProjectUnfollowedTitle: "ألغى متابعة مشروعك", + pmHasFollowedYourProject: "تابع مشروعك", + pmHasUnfollowedYourProject: "ألغى متابعة مشروعك", + pmHasUnsubscribedFromYourJobOffer: "ألغى الاشتراك في عرض عملك", + marketDeleteButton: "حذف", + marketNoBids: "لا توجد عروض بعد", + marketAuctionBidAmount: "مبلغ العرض", + marketAuctionBidTime: "وقت العرض", + marketAuctionUser: "مقدّم العرض", + parliamentActorInPower: "القبيلة الحاكمة", + parliamentWinningCandidature: "الترشيح الفائز", + projectBounty: "مكافأة", + projectFollowing: "متابَع", + projectBackerAuthor: "داعم", + projectBackerDate: "التاريخ", + projectConfirmTransferButton: "تأكيد التحويل", + projectPendingTransfersTitle: "تحويلات معلّقة", + transfersUpdateSectionTitle: "تحديث التحويل", + taskUnassignedFrom: "أُلغي تعيينه من", + taskDescriptionPlaceholder: "وصف المهمة", + exportPasswordLabel: "كلمة مرور التصدير", + importPasswordPlaceholder: "أدخل كلمة المرور", + searchCasesPlaceholder: "البحث عن القضايا…", + shopBuyDeliveryAddressPlaceholder: "عنوان التسليم", + shopBuyNotesPlaceholder: "ملاحظات", + bankTaxesUserNoteBold: "تخفيض ضريبة ECO مباشرة", + bankTaxesUserNoteIntro: "تُخصم ضريبة ECO الخاصة بك من الفائض الذي تولّده الشبكة فوق دخلك الأساسي الشامل؛ وتُحدَّد القيمة الأساسية للدخل كحد أدنى ثابت. لا تُخصم الضريبة أبداً من المبلغ الثابت المخصص لكل ساكن. تُغذّي الأموال المخصومة خوارزمية إعادة توزيع الثروة وتُوجَّه إلى سكان آخرين عبر مطالباتهم. هؤلاء السكان الذين يتلقون دخلاً أكبر لمشاريعهم سيكون لديهم على الأرجح مهمة مخصصة لمساعدتك على تقليل ضريبة ECO. أو قد يكرّسون أنفسهم لـ ", + bankTaxesUserNoteOutro: "، وقد يتطلب توافق الشبكة مساهمتهم المستمرة.", + courtsViewDetails: "عرض", + courtsViewDetailsShort: "التفاصيل", + courtsThDetails: "التفاصيل", + courtsDetailsAnswersTitle: "الإجابات", + courtsDetailsEvidenceTitle: "الأدلة", + courtsDetailsSettlementsTitle: "التسويات", + courtsDetailsVerdictTitle: "الحكم", + courtsDetailsNotPublic: "غير عام", + courtsEvidenceSideAccuser: "أدلة المدّعي", + courtsEvidenceSideRespondent: "أدلة الدفاع", + courtsEvidenceSideUnknown: "غير معروف", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "دعم القضية", + courtsSettlementAcceptBtn: "قبول التسوية", + courtsSupportCount: "المؤيدون", + courtsVerdictVoteTitle: "التصويت على الحكم", + courtsVerdictVoteLabel: "تصويت الحكم", + courtsVerdictVoteAccept: "قبول", + courtsVerdictVoteReject: "رفض", + courtsVerdictVoteSubmit: "إرسال التصويت", + reportsSetStatus: "تعيين الحالة", + reportsStatusClosed: "مغلق", + statsAvgPerInhabitant: "المتوسط لكل ساكن", + statsCarbonMaxAnnual: "أقصى تقدير سنوي", + statsCarbonNetwork: "إجمالي الشبكة", + statsCarbonOfEstMax: "من السعة القصوى المقدّرة", + statsCarbonOfNetwork: "من إجمالي الشبكة", + statsCarbonUser: "بصمتك", + statsContentCountColumn: "العدد", + statsContentTypeColumn: "النوع", + statsMsgsPerDay: "رسائل/يوم (إجمالي)", + statsMyShare: "حصتك من الشبكة", + statsNetworkSpan: "مدى الشبكة", + statsTombstoneRatioLabel: "نسبة المحذوفات", + statsTopTagsTitle: "أبرز الوسوم", + statsTopTypesTitle: "أبرز أنواع المحتوى", + statsTotalMsgs: "إجمالي الرسائل", + feedViewDetails: "عرض التفاصيل", + eventFileLabel: "إرفاق صورة", + taskFileLabel: "إرفاق صورة", + courtsRespondentRequired: "معرّف المدعى عليه مطلوب", + extended: "الكون المتعدد", + extendedDescription: [ + "عندما تدعم شخصًا ما، قد تقوم بتنزيل منشورات من السكان الذين يدعمونهم، وتظهر تلك المنشورات هنا، مرتبة حسب الأحدث.", + ], + popular: "أبرز المنشورات", + popularDescription: [ + "منشورات من سكان شبكتك، ", + strong("مرتبة حسب الانتشار"), + ". اختر الفترة الزمنية للحصول على القائمة.", + ], + week: "أسبوع", + month: "شهر", + year: "سنة", + latest: "الأحدث", + latestDescription: [ + strong("منشورات"), + " منك ومن السكان الذين تدعمهم، مرتبة حسب الأحدث.", + ], + topics: "المواضيع", + topicsDescription: [ + strong("مواضيع"), + " منك ومن السكان الذين تدعمهم، مرتبة حسب الأحدث.", + ], + summaries: "الملخصات", + summariesDescription: [ + strong("مواضيع تحتوي على تعليقات"), + " منك ومن السكان الذين تدعمهم، مرتبة حسب الأحدث.", + ], + threads: "المحادثات", + threadsDescription: [ + strong("منشورات تحتوي على تعليقات"), + " من السكان الذين تدعمهم (بما في ذلك من الكون المتعدد)، مرتبة حسب الأحدث.", + ], + profile: "الصورة الرمزية", + inhabitants: "السكان", + peersReplicatedFeeds: "الموجزات المنسوخة", + graphos: "غرافوس", + graphosDescription: "خريطة تفاعلية للشبكة من حولك.", + graphosViewingGraphOf: "عرض", + graphosBackToMine: "← شبكتي", + graphosShowing: "عرض", + graphosYou: "أنت", + graphosTotalNodes: "إجمالي العقد", + manualMode: "الوضع اليدوي", + mentions: "الإشارات", + mentionsDescription: [ + strong("منشورات تُشير إليك @"), + "، مرتبة حسب الأحدث.", + ], + nextPage: "التالي", + previousPage: "السابق", + noMentions: "لم تتلقَّ إشارات @ بعد.", + privateReminders: "تذكيرات", + inboxFiltersLabel: "المرشحات:", + inboxToggleToMutuals: "التبديل إلى المتبادلين", + inboxToggleToWhole: "التبديل إلى الجميع", + privateFrom: "من", + privateTo: "إلى", + privateDate: "التاريخ", + pmReply: "رد", + pmReplies: "ردود", + pmNew: "جديد", + pmMarkRead: "تعيين كمقروء", + inReplyTo: "ردًا على", + pmPreview: "معاينة", + pmPreviewTitle: "معاينة الرسالة", + peers: "الأقران", + searchDescription: "الوصف", + imageSearch: "بحث عن الصور", + searchFromLabel: "من", + searchToLabel: "إلى", + searchMinOpinionsLabel: "أدنى الآراء", + searchInhabitantLabel: "ساكن (Oasis ID)", + searchQueryLabel: "استعلام", + searchPerPageLabel: "نتائج لكل صفحة", + settings: "الإعدادات", + continueReading: "متابعة القراءة", + moreComments: "تعليق إضافي", + readThread: "اقرأ بقية المحادثة", + pixeliaTitle: 'بكسيليا', + pixeliaDescription: 'ارسم بكسلات على الشبكة وتعاون فنيًا مع الآخرين في شبكتك.', + melodyTitle: 'Melody', + melodyDescription: 'شغّل لحن سلسلة الكتل خاصتك — كل كتلة تصبح نغمة.', + melodyEmpty: 'لا توجد نغمات بعد — لم تنتج سلسلة الكتل خاصتك أي كتلة.', + melodyCompositionTitle: 'التأليف', + melodyCompositionHelp: 'كل كتلة في سلسلة الكتل خاصتك تصبح نغمة موسيقية حسب نوعها وحجمها.', + melodyStatsTitle: 'التوزيع حسب النوع', + melodyInhabitantLabel: 'الساكن', + melodyTotalBlocks: 'نغمات', + melodyType: 'النوع', + melodyCount: 'كتل', + melodyFilterMine: 'لي', + melodyFilterAll: 'الكل', + melodyFilterShare: 'تحميل لحن', + melodyShareTitle: 'شارك لحنك', + melodyShareHelp: 'انشر لقطة من لحنك الحالي ليتمكن الآخرون من الاستماع وإعادة المزج.', + melodyShareTitleLabel: 'العنوان (اختياري)', + melodyShareTitlePlaceholder: 'شبح بلوكتشين', + melodyShareSubmit: 'نشر اللحن', + melodyNoShared: 'لا توجد ألحان بلوكتشين بعد.', + melodyRegenerate: 'إعادة التوليد', + melodyDownload: "تنزيل BCS", + profileActivityTitle: 'النشاط', + profileActivityMore: 'عرض كل النشاط', + profileContentSectionTitle: 'محتوى الصورة الرمزية', + profileContentHelp: 'اختر أي وحدات تُعرض في صورتك الرمزية.', + profileSensorsHelp: 'مقاييس اختيارية معروضة في صورتك الرمزية.', + profileClearnetHelp: 'الوحدات التي يمكن الوصول إليها من خارج أوازيس.', + profileHubAll: 'الكل', + profileHubTitle: 'المحتوى العام', + footerPulseTitle: 'النبض', + footerPulsePeers: 'الأقران', + footerPulseInbox: 'الوارد', + footerPulseSync: 'آخر مزامنة', + footerPulseEcoValue: 'ECO/ساعة', + footerPulseLastActivity: 'آخر نشاط', + footerLicenseLabel: 'الترخيص', + profileSwitchToOasis: 'العودة إلى Oasis', + profileSwitchToClearnet: 'الغوص في Clearnet', + profileVisibilityFediverse: "فيديفيرس", + profileSwitchToFediverse: "انغمس في الفيديفيرس", + coordLabel: 'الإحداثي (مثال: A3)', + coordPlaceholder: 'أدخل الإحداثي', + contributorsTitle: "المساهمون", + pixeliaBy: "بواسطة", + colorLabel: 'اختر لونًا', + paintButton: 'ارسم!', + invalidCoordinate: 'إحداثي غير صحيح', + goToMuralButton: "عرض الجدارية", + totalPixels: 'إجمالي البكسلات', + modules: "الوحدات", + modulesViewTitle: "الوحدات", + modulesViewDescription: "اضبط بيئتك بتفعيل أو تعطيل الوحدات.", + inbox: "صندوق الوارد", + multiverse: "الكون المتعدد", + fediverse: "فيديفيرس", + fediverseDescription: "أدر حساباتك الأخرى في الفيديفيرس، بما في ذلك إرسال واستقبال المحتوى.", + fediverseStatus: "الحالة", + fediverseDisconnected: "الفيديفيرس غير متصل. تحقق من %LINK% أو حالة الاتصال.", + fediverseSettingsTitle: "فيديفيرس", + fediverseInstanceLabel: "العنوان", + fediverseTokenLabel: "رمز الوصول", + fediverseTokenHelp: "اضبط رمز الوصول الخاص بك. سيُستخدم لإدارة حساب ماستودون من داخل أوازيس.", + fediverseConnect: "ربطه", + fediverseConnectedAs: "متصل باسم", + fediverseDisconnect: "قطع الاتصال", + fediverseEmpty: "عند ربط حسابات أخرى في الفيديفيرس من %LINK%، يمكنك إدارتها من هنا.", + fediverseEmptyLink: "الإعدادات", + fediverseComposePlaceholder: "بماذا تفكر؟", + fediverseReplyPlaceholder: "اكتب ردك…", + fediverseAttach: "إرفاق وسائط", + fediversePublish: "نشر", + fediverseReply: "رد", + fediverseBoosted: "قام بالترقية", + fediverseNoPosts: "خطك الزمني فارغ.", + fediverseThread: "سلسلة", + fediverseTimeline: "الخطوط الزمنية", + fediverseManageTimeline: "إدارة الخط الزمني", + fediverseFollowers: "المتابعون", + fediverseFollowing: "يُتابِع", + fediversePosts: "المنشورات", + fediverseJoined: "انضم", + fediverseOverview: "نظرة عامة", + fediverseRefresh: "تحديث", + fediverseWrite: "اكتب", + fediversePreview: "معاينة", + fediverseEdit: "تعديل", + fediverseOpenFeed: "زيارة الخلاصة", + fediverseManage: "إدارة", + fediverseStatusNotConnected: "غير متصل", + fediverseError: "تعذّر الاتصال بماستودون.", + fediverseErrInstance: "رابط خادم غير صالح.", + fediverseErrAuth: "رمز غير صالح أو منتهٍ الصلاحية.", + fediverseErrConnect: "تعذّر الاتصال بالخادم.", + fediverseErrToken: "الرمز مطلوب.", + fediverseErrPublic: "غير متاح في الوضع العام.", + fediverseErrFetch: "تعذّر تحميل الخط الزمني.", + fediverseErrPost: "تعذّر النشر.", + fediverseErrMedia: "تعذّر رفع الملف.", + fediverseErrEmpty: "اكتب شيئاً أو أرفق ملفاً.", + popularLabel: "أبرز المنشورات", + topicsLabel: "المواضيع", + latestLabel: "الأحدث", + summariesLabel: "الملخصات", + threadsLabel: "المحادثات", + multiverseLabel: "الكون المتعدد", + inboxLabel: "صندوق الوارد", + invitesLabel: "الدعوات", + walletLabel: "المحفظة", + legacyLabel: "المفاتيح", + cipherLabel: "التشفير", + bookmarksLabel: "العلامات المرجعية", + videosLabel: "الفيديوهات", + torrentsLabel: "التورنت", + docsLabel: "المستندات", + audiosLabel: "الملفات الصوتية", + tagsLabel: "الوسوم", + imagesLabel: "الصور", + inhabitantsLabel: "السكان", + trendingLabel: "الرائج", + eventsLabel: "الأحداث", + tasksLabel: "المهام", + apply: "تطبيق", + menuPersonal: "شخصي", + menuContent: "المحتوى", + menuBlogs: "مدونات", + menuGovernance: "الحوكمة", + menuOffice: "المكتب", + menuMultiverse: "الكون المتعدد", + menuNetwork: "الشبكة", + menuCreative: "إبداعي", + menuEconomy: "الاقتصاد", + menuMedia: "الوسائط", + menuTools: "الأدوات", + comment: "تعليق", + subtopic: "موضوع فرعي", + json: "JSON", + createdBy: "بواسطة", + unfollow: "إلغاء الدعم", + follow: "دعم", + block: "حظر", + unblock: "إلغاء الحظر", + newerPosts: "منشورات أحدث", + olderPosts: "منشورات أقدم", + feedRangeEmpty: "النطاق المحدد فارغ لهذا التغذية. جرّب عرض ", + seeFullFeed: "التغذية الكاملة", + feedEmpty: "لم تشهد شبكة Oasis أي منشورات من هذا الحساب.", + beginningOfFeed: "هذه بداية التغذية", + noNewerPosts: "لم يتم استقبال منشورات أحدث بعد.", + relationshipNotFollowing: "أنت غير مدعوم", + relationshipTheyFollow: "يدعمك", + relationshipMutuals: "دعم متبادل", + relationshipFollowing: "أنت تدعم", + relationshipYou: "أنت", + relationshipBlocking: "أنت تحظر", + relationshipBlockedBy: "أنت محظور", + relationshipMutualBlock: "حظر متبادل", + relationshipNone: "أنت لا تدعم", + relationshipConflict: "تعارض", + relationshipBlockingPost: "منشور محظور", + viewLikes: "عرض الانتشارات", + spreadedDescription: "قائمة المنشورات التي نشرها الساكن.", + totalspreads: "إجمالي الانتشارات", + attachFiles: "إرفاق ملفات", + preview: "معاينة", + publish: "كتابة", + contentWarningPlaceholder: "أضف موضوعًا للمنشور (اختياري)", + privateWarningPlaceholder: "أضف سكانًا لإرسال منشور خاص (اختياري)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "تذكّر: بسبب تقنية البلوكتشين، بمجرد نشر المنشور لا يمكن تعديله أو حذفه.", + ], + commentWarning: [ + "تذكّر: بسبب تقنية البلوكتشين، بمجرد نشر المنشور لا يمكن تعديله أو حذفه.", + ], + commentPublic: "عام", + commentPrivate: "خاص", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "تذكّر: بسبب تقنية البلوكتشين، بمجرد نشر المنشور لا يمكن تعديله أو حذفه.", + ], + replyLabel: ({ markdownUrl }) => [ + "تذكّر: بسبب تقنية البلوكتشين، بمجرد نشر المنشور لا يمكن تعديله أو حذفه.", + ], + publishCustomInfo: ({ href }) => [ + "إذا كانت لديك خبرة، يمكنك أيضًا ", + a({ href }, "كتابة منشور متقدم"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "إذا لم تكن لديك خبرة، يجب عليك ", + a({ href }, "كتابة منشور"), + ".", + ], + publishCustom: "كتابة منشور متقدم", + subtopicLabel: "إنشاء موضوع فرعي من هذا المنشور", + messagePreview: "معاينة المنشور", + mentionsMatching: "الإشارات المطابقة", + mentionsName: "الاسم", + mentionsRelationship: "العلاقة", + updateit: "احصل على التحديثات!", + updateBannerText: "يتوفر إصدار جديد من Oasis.", + updateBannerAction: "حدّث الآن →", + info: "معلومات", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "مضت", + sendTime: "حوالي ", + theme: "السمة", + legacy: "المفاتيح", + legacyTitle: "المفاتيح", + legacyDescription: "إدارة مفتاحك السري (المفتاح الخاص) بسرعة وأمان.", + legacyExportButton: "تصدير", + legacyImportButton: "استيراد", + ssbLogStream: "البلوكتشين", + ssbLogStreamDescription: "تكوين حد الرسائل لتدفقات البلوكتشين.", + saveSettings: "حفظ الإعدادات", + exportTitle: "تصدير البيانات", + exportDescription: "عيّن كلمة مرور (32 حرفًا على الأقل) لتشفير مفتاحك", + exportDataTitle: "نسخة احتياطية", + exportDataDescription: "تنزيل بياناتك (بدون المفتاح السري!)", + exportDataButton: "تنزيل قاعدة البيانات", + pubWallet: "محفظة PUB", + pubWalletDescription: "عيّن عنوان محفظة PUB. سيُستخدم لمعاملات PUB (بما في ذلك الدخل الأساسي الشامل).", + pubWalletConfiguration: "حفظ الإعدادات", + importTitle: "استيراد البيانات", + importDescription: "استورد مفتاحك السري المشفر (المفتاح الخاص) لتفعيل صورتك الرمزية", + importAttach: "إرفاق ملف مشفر (.enc)", + passwordLengthInfo: "يجب أن تكون كلمة المرور 32 حرفًا على الأقل.", + passwordImport: "اكتب كلمة المرور لفك تشفير البيانات التي سيتم حفظها في مجلد النظام الرئيسي (الاسم: secret)", + exportPasswordPlaceholder: "استخدم أحرفًا صغيرة وكبيرة وأرقامًا ورموزًا", + fileInfo: "سيتم حفظ مفتاحك السري المشفر في مجلد النظام الرئيسي (الاسم: oasis.enc)", + themeIntro: + "اختر سمة.", + setTheme: "تعيين السمة", + language: "اللغة", + languageDescription: + "إذا كنت ترغب في استخدام لغة أخرى، اخترها هنا.", + setLanguage: "تعيين اللغة", + peerConnections: "الأقران", + peerConnectionsIntro: "إدارة جميع اتصالاتك مع الأقران الآخرين.", + peerConnectionsTitle: "الاتصالات", + online: "متصل", + offline: "غير متصل", + discovered: 'مُكتشَف', + unknown: 'غير معروف', + lanBroadcastLabel: "بث LAN", + lanBroadcastActive: "نشط (بث UDP المتعدد عبر LAN)", + lanBroadcastDisabled: "معطل (وضع pub)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "حديث", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "مدعوم", + recommended: "مُوصى به", + blocked: "محظور", + noConnections: "لا يوجد أقران متصلون.", + noDiscovered: "لم يتم اكتشاف أقران.", + noUnknownPeers: "لا توجد أقران غير معروفة في رسم بياني الأصدقاء.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "تحديث", + peerPruneIdle: "إزالة الخاملة", + peerExport: "تصدير", + peerImport: "استيراد", + peerImportTitle: "استيراد قائمة الأقران", + peerImportPlaceholder: "ألصق عنوان multiserver واحدًا لكل سطر، أو حمّل ملف .txt…", + noSupportedConnections: "لا يوجد أقران مدعومون.", + noBlockedConnections: "لا يوجد أقران محظورون.", + noRecommendedConnections: "لا يوجد أقران مُوصى بهم.", + connectionActionIntro: + "", + startNetworking: "بدء الشبكة", + stopNetworking: "إيقاف الشبكة", + restartNetworking: "إعادة تشغيل الشبكة", + sync: "مزامنة الشبكة", + indexes: "الفهارس", + indexesDescription: + "إعادة بناء الفهارس آمنة، وقد تصلح بعض أنواع الأخطاء.", + homePageTitle: "الصفحة الرئيسية", + homePageDescription: "اختر الوحدة التي تريدها كصفحتك الرئيسية.", + saveHomePage: "تعيين الرئيسية", + invites: "الدعوات", + invitesTitle: "الدعوات", + invitesInvites: "الدعوات", + invitesDescription: "إدارة وتطبيق رموز الدعوة في شبكتك.", + invitesTribesTitle: "القبائل", + invitesTribeInviteCodePlaceholder: "أدخل رمز دعوة القبيلة", + invitesTribeJoinButton: "انضم إلى القبيلة", + invitesChatsTitle: "المحادثات", + invitesChatInviteCodePlaceholder: "أدخل رمز دعوة المحادثة", + invitesChatJoinButton: "انضم إلى المحادثة", + invitesPadsTitle: "الوسادات", + invitesPadInviteCodePlaceholder: "أدخل رمز دعوة الوسادة", + invitesPadJoinButton: "انضم إلى الوسادة", + invitesCalendarsTitle: "التقاويم", + invitesCalendarInviteCodePlaceholder: "أدخل رمز دعوة التقويم", + invitesCalendarJoinButton: "انضم إلى التقويم", + invitesEventsTitle: "الأحداث", + invitesEventInviteCodePlaceholder: "أدخل رمز دعوة الحدث", + invitesEventJoinButton: "انضم إلى الحدث", + invitesForumsTitle: "المنتديات", + invitesForumInviteCodePlaceholder: "أدخل رمز دعوة المنتدى", + invitesForumJoinButton: "انضم إلى المنتدى", + invitesMapsTitle: "خرائط", + invitesMapInviteCodePlaceholder: "أدخل رمز دعوة الخريطة", + invitesMapJoinButton: "انضم إلى الخريطة", + invitesShopsTitle: "المتاجر", + invitesShopInviteCodePlaceholder: "أدخل رمز دعوة المتجر", + invitesShopJoinButton: "الانضمام إلى المتجر", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "أدخل رمز دعوة PUB", + invitesAcceptInvite: "انضم إلى PUB", + invitesAlreadyFederated: "أنت متحد بالفعل مع هذا الخادم.", + invitesFederationsTitle: "الاتحادات", + invitesAcceptedInvites: "متحدة", + invitesNoInvites: "لم يتم قبول أي دعوات بعد.", + invitesUnfollow: "إلغاء المتابعة", + invitesFollow: "متابعة", + invitesUnfollowedInvites: "غير متحدة", + invitesNoFederatedPubs: "لا توجد pubs متحدة.", + invitesNoUnfollowed: "لا توجد pubs غير متحدة.", + invitesUnreachablePubs: "يتعذر الوصول", + invitesNoUnreachablePubs: "لا توجد pubs يتعذر الوصول إليها.", + invitesPubsRefresh: "تحديث", + invitesPubsClearUnreachable: "إزالة المتعذر الوصول إليها", + invitesPubsExport: "تصدير", + invitesPubsImport: "استيراد", + invitesPubsImportTitle: "استيراد pubs", + invitesPubsImportPlaceholder: "ألصق عنوان multiserver أو رمز دعوة لكل سطر، أو حمّل ملف .txt…", + currentlyUnreachable: "خطأ!", + errorDetails: "تفاصيل الخطأ", + genericError: "حدث خطأ.", + panicMode: "وضع الطوارئ!", + encryptData: "عيّن كلمة مرور (32 حرفًا على الأقل) لتشفير البلوكتشين الخاص بك", + decryptData: "أدخل كلمة المرور لفك تشفير البلوكتشين الخاص بك", + panicModeDescription: "تشفير/فك تشفير أو حذف البلوكتشين الخاص بك", + removeDataDescription: "تحذير: لا يمكن التراجع عن هذه العملية.", + encryptPanicButton: "تشفير البلوكتشين", + decryptPanicButton: "فك تشفير البلوكتشين", + removePanicButton: "حذف جميع بياناتك!", + searchTitle: "بحث", + searchDescriptionLabel: "ابحث عن محتوى في شبكتك.", + searchLanguagesLabel: "اللغات", + searchSkillsLabel: "المهارات", + searchPlaceholder:"ابحث عن محتوى...", + searchDateLabel:"التاريخ", + searchLocationLabel:"الموقع", + searchPriceLabel:"السعر", + searchUrlLabel:"الرابط", + searchCategoryLabel:"الفئة", + searchStartLabel:"وقت البدء", + searchEndLabel:"وقت الانتهاء", + searchPriorityLabel:"الأولوية", + searchStatusLabel:"الحالة", + statusLabel:"الحالة", + totalVotesLabel:"إجمالي الأصوات", + noResultsFound:"لم يتم العثور على نتائج.", + votesOption:"خيارات التصويت", + voteYesLabel:"نعم", + voteNoLabel:"لا", + allTypesLabel: "غير محدود", + ABSTENTIONLabel:"امتناع", + YESLabel:"نعم", + NOLabel:"لا", + FOLLOW_MAJORITYLabel: "اتباع الأغلبية", + CONFUSEDLabel: "مرتبك", + NOT_INTERESTEDLabel: "غير مهتم", + StatusLabel:"الحالة", + votesOptionYesLabel:"نعم", + votesOptionNoLabel:"لا", + votesOptionAbstentionLabel:"امتناع", + votesQuestionLabel:"السؤال", + votesCreatedByLabel:"أنشأه", + voteStatusOpen:"مفتوح", + voteStatusClosed:"مغلق", + imageSearchLabel: "أدخل كلمات للبحث عن صور مُعلَّمة بها.", + commentDescription: ({ parentUrl }) => [ + " علّق على ", + a({ href: parentUrl }, " المحادثة"), + ], + commentTitle: ({ authorName }) => [`تعليق على منشور @${authorName}`], + subtopicDescription: ({ parentUrl }) => [ + " أنشأ موضوعًا فرعيًا من ", + a({ href: parentUrl }, " منشور"), + ], + subtopicTitle: ({ authorName }) => [`موضوع فرعي على منشور @${authorName}`], + mysteryDescription: "نشر منشورًا غامضًا", + oasisDescription: "شبكة مشروع OASIS", + searchSubmit: "لنبحث...", + postLabel: "المنشورات", + aboutLabel: "السكان", + feedLabel: "التغذيات", + votesLabel: "التصويتات", + reportLabel: "التقارير", + imageLabel: "الصور", + videoLabel: "الفيديوهات", + audioLabel: "الملفات الصوتية", + documentLabel: "المستندات", + torrentLabel: "التورنت", + pdfFallbackLabel: "مستند PDF", + eventLabel: "الأحداث", + taskLabel: "المهام", + transferLabel: "التحويلات", + curriculumLabel: "السيرة الذاتية", + bookmarkLabel: "العلامات المرجعية", + tribeLabel: "القبائل", + marketLabel: "السوق", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "المحافظ", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "إرسال", + subjectLabel: "الموضوع", + editProfile: "تعديل الصورة الرمزية", + profileQrAlt: "رمز QR للملف الشخصي", + profileQrHint: "امسح ضوئيًا لنسخ معرّف Oasis هذا.", + oasisIdLabel: "OasisID", + spreadLabel: "نشر", + spreadHint: "نشر هذا لداعميك (يتم النسخ عبر تغذيتك).", + welcomePmSubject: "Hello World!", + welcomePmBody: "مرحبًا بك في أوازيس — شبكة اجتماعية حرة، نِدّ لِنِد، اتحادية ومشفّرة، ذات بنية موزّعة بالدعوة فقط.\n\nبعض الأماكن المثيرة للاهتمام للبدء:\n\n- /profile — عدّل صورتك الرمزية واسمك ووصفك والوحدات التي تظهر في جانبك العام.\n- /invites — أضف pub للاتحاد مع بقية الشبكة.\n- /peers — اطّلع على من هو متصل، أعد مسح شبكتك المحلية، صدّر أو استورد قوائم الأقران.\n- /inhabitants — اكتشف الصور الرمزية للآخرين وزرها.\n- /tribes — أنشئ مجموعات خاصة بتشفير من طرف إلى طرف أو انضم إليها.\n- /inbox — اقرأ وأرسل الرسائل الخاصة.\n- /activity — اطّلع على النشاط الأخير، نشاطك ونشاط شبكتك.\n- /publish — اكتب منشورًا أو شارك صورة أو صوتًا أو فيديو أو مستندًا.\n\nبعض الأماكن المثيرة للاهتمام للاتصال:\n\n- /fediverse — أدر حساباتك الأخرى في الفيديفيرس، بما في ذلك إرسال واستقبال المحتوى.\n\nأُرسلت هذه الرسالة بشكل خاص منك إلى نفسك، لذا لم يلزم أي اتصال لاستلامها.", + profileVisibilityTitle: "ظهور الملف الشخصي العام", + profileVisibilityHint: "اختر الحقول التي يراها السكان الآخرون في ملفك الشخصي. افتراضيًا، يظهر Karma فقط.", + profileSensorsSectionTitle: "المستشعرات", + profileVisibilityActivity: "مستوى النشاط", + profileVisibilityDevice: "الجهاز", + profileDeviceLockedHint: "هذا المستشعر مفعّل دائمًا: يتيح للآخرين معرفة الجهاز الذي تتصل منه ولا يمكن تعطيله.", + profileVisibilityKarma: "نقاط KARMA", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "محفظة ECOIN", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "مفتاح GPG", + profileVisibilityClearnet: "نشر ملفي الشخصي", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "المتاجر", + profileClearnetJobsLabel: "الوظائف", + profileClearnetEventsLabel: "الفعاليات", + profileClearnetProjectsLabel: "المشاريع", + profileClearnetPostsLabel: "المدونات", + profileClearnetAudiosLabel: "الصوتيات", + profileClearnetVideosLabel: "الفيديوهات", + profileClearnetImagesLabel: "الصور", + profileClearnetDocumentsLabel: "المستندات", + profileClearnetTorrentsLabel: "التورنت", + editProfileDescription: + "", + profileName: "الاسم", + profileImage: "صورة الصورة الرمزية", + profileGpgKey: "مفتاح GPG العام (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "تنزيل", + profileGpgRemove: "إزالة", + profileDescription: "الوصف", + hashtagDescription: + "منشورات من سكان شبكتك تشير إلى هذا #الوسم، مرتبة حسب الأحدث.", + rebuildName: "إعادة بناء قاعدة البيانات", + wallet: "المحفظة", + walletAddress: "العنوان", + walletAmount: "المبلغ", + walletAddressLine: ({ address }) => `العنوان: ${address}`, + walletAmountLine: ({ amount }) => `المبلغ: ${amount} ECO`, + walletBack: "رجوع", + walletBalanceTitle: "الرصيد", + walletWalletSendTitle: "إرسال", + walletReceiveTitle: "استقبال", + walletHistoryTitle: "السجل", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "تأكيدات", + walletConfirm: "تأكيد", + walletDescription: "إدارة أصولك الرقمية، بما في ذلك إرسال واستقبال ECOin، وعرض رصيدك، والوصول إلى سجل معاملاتك.", + walletDate: "التاريخ", + walletFee: "الرسوم (كلما ارتفعت الرسوم، زادت سرعة معالجة معاملتك)", + walletFeeLine: ({ fee }) => `الرسوم: ECO ${fee}`, + walletHistory: "السجل", + walletReceive: "استقبال", + walletReset: "إعادة تعيين", + walletSend: "إرسال", + walletStatus: "الحالة", + walletDisconnected: [ + "محفظة ECOin ", + strong("غير متصلة"), + ". تحقق من ", + a({ href: '/settings#wallet' }, "إعداداتك"), + " أو حالة الاتصال.", + ], + walletSentToLine: ({ destination, amount }) => `تم إرسال ECO ${amount} إلى ${destination}`, + walletSettingsTitle: "المحفظة", + walletSettingsDescription: "دمج Oasis مع محفظة ECOin الخاصة بك.", + walletSettingsDocLink: "دليل تثبيت ECOin", + walletStatusMessages: { + invalid_amount: "مبلغ غير صالح", + invalid_dest: "عنوان وجهة غير صالح", + invalid_fee: "رسوم غير صالحة", + validation_errors: "أخطاء في التحقق", + send_tx_success: "تمت المعاملة بنجاح", + }, + walletTitle: "المحفظة", + walletTotalCostLine: ({ totalCost }) => `التكلفة الإجمالية: ECO ${totalCost}`, + walletTransactionId: "معرّف المعاملة", + walletTxId: "معرف المعاملة", + walletType: "النوع", + walletUser: "اسم المستخدم", + walletPass: "كلمة المرور", + walletConfiguration: "تعيين المحفظة", + cipher: "التشفير", + cipherTitle: "التشفير", + cipherDescription: "تشفير وفك تشفير نصك بشكل متماثل (باستخدام كلمة مرور مشتركة).", + randomPassword: "كلمة مرور عشوائية", + cipherEncryptTitle: "تشفير النص", + cipherEncryptDescription: "أدخل النص للتشفير", + cipherTextLabel: "النص للتشفير", + cipherTextPlaceholder: "أدخل النص للتشفير...", + cipherPasswordLabel: "عيّن كلمة مرور (32 حرفًا على الأقل) لتشفير نصك", + cipherPasswordDecryptLabel: "عيّن كلمة مرور (32 حرفًا على الأقل) لفك تشفير نصك", + cipherPasswordPlaceholder: "أدخل كلمة مرور...", + cipherEncryptButton: "تشفير", + cipherDecryptTitle: "فك تشفير النص", + cipherDecryptDescription: "أدخل النص لفك التشفير", + cipherEncryptedMessageLabel: "النص المشفر", + cipherDecryptedMessageLabel: "النص المفكوك", + cipherPasswordUsedLabel: "كلمة المرور المستخدمة للتشفير (احتفظ بها!)", + cipherEncryptedTextPlaceholder: "أدخل النص المشفر...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "أدخل متجه التهيئة...", + cipherDecryptButton: "فك التشفير", + password: "كلمة المرور", + text: "النص", + encryptedText: "النص المشفر", + iv: "متجه التهيئة (IV)", + encryptTitle: "تشفير نصك", + encryptDescription: "أدخل النص الذي تريد تشفيره وقدّم كلمة مرور.", + encryptButton: "تشفير", + decryptTitle: "فك تشفير نصك", + decryptDescription: "أدخل النص المشفر وقدّم نفس كلمة المرور المستخدمة للتشفير.", + decryptButton: "فك التشفير", + passwordLengthError: "يجب أن تكون كلمة المرور 32 حرفًا على الأقل.", + missingFieldsError: "لم يتم تقديم النص أو كلمة المرور أو IV.", + encryptionError: "خطأ في تشفير النص.", + decryptionError: "خطأ في فك تشفير النص.", + bookmarkTitle: "العلامات المرجعية", + bookmarkDescription: "اكتشف وأدِر العلامات المرجعية في شبكتك.", + bookmarkAllSectionTitle: "العلامات المرجعية", + bookmarkMineSectionTitle: "علاماتك المرجعية", + bookmarkRecentSectionTitle: "العلامات المرجعية الأخيرة", + bookmarkTopSectionTitle: "أفضل العلامات المرجعية", + bookmarkFavoritesSectionTitle: "المفضلة", + bookmarkCreateSectionTitle: "إنشاء علامة مرجعية", + bookmarkUpdateSectionTitle: "تحديث علامة مرجعية", + bookmarkFilterAll: "الكل", + bookmarkFilterMine: "خاصتي", + bookmarkFilterTop: "الأفضل", + bookmarkFilterFavorites: "المفضلة", + bookmarkFilterRecent: "الأخيرة", + bookmarkCreateButton: "إنشاء علامة مرجعية", + bookmarkUpdateButton: "تحديث", + bookmarkDeleteButton: "حذف", + bookmarkAddFavoriteButton: "إضافة للمفضلة", + bookmarkRemoveFavoriteButton: "إزالة من المفضلة", + bookmarkUrlLabel: "الرابط", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "الوصف", + bookmarkDescriptionPlaceholder: "اختياري", + bookmarkTagsLabel: "الوسوم", + bookmarkTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + bookmarkCategoryLabel: "الفئة", + bookmarkCategoryPlaceholder: "اختياري", + bookmarkLastVisitLabel: "آخر زيارة", + bookmarkSearchPlaceholder: "ابحث عن رابط، وسوم، فئة، مؤلف...", + bookmarkSortRecent: "الأحدث", + bookmarkSortOldest: "الأقدم", + bookmarkSortTop: "الأكثر تصويتًا", + bookmarkSearchButton: "بحث", + bookmarkUpdatedAt: "تم التحديث", + bookmarkNoMatch: "لا توجد علامات مرجعية تطابق بحثك.", + noBookmarks: "لا توجد علامات مرجعية متاحة.", + noUrl: "لا يوجد رابط", + noCategory: "لا توجد فئة", + noLastVisit: "لا توجد زيارة أخيرة", + videoTitle: "الفيديوهات", + videoDescription: "استكشف وأدِر محتوى الفيديو في شبكتك.", + videoPluginTitle: "العنوان", + videoPluginDescription: "الوصف", + videoMineSectionTitle: "فيديوهاتك", + videoCreateSectionTitle: "رفع فيديو", + videoUpdateSectionTitle: "تحديث الفيديو", + videoAllSectionTitle: "الفيديوهات", + videoRecentSectionTitle: "الفيديوهات الأخيرة", + videoTopSectionTitle: "أفضل الفيديوهات", + videoFavoritesSectionTitle: "المفضلة", + videoFilterAll: "الكل", + videoFilterMine: "خاصتي", + videoFilterRecent: "الأخيرة", + videoFilterTop: "الأفضل", + videoFilterFavorites: "المفضلة", + videoCreateButton: "رفع فيديو", + videoUpdateButton: "تحديث", + videoDeleteButton: "حذف", + videoAddFavoriteButton: "إضافة للمفضلة", + videoRemoveFavoriteButton: "إزالة من المفضلة", + videoFileLabel: "اختر ملف فيديو (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "الوسوم", + videoTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + videoTitleLabel: "العنوان", + videoTitlePlaceholder: "اختياري", + videoDescriptionLabel: "الوصف", + videoDescriptionPlaceholder: "اختياري", + videoNoFile: "لم يتم تقديم ملف فيديو", + noVideos: "لا توجد فيديوهات متاحة.", + videoSearchPlaceholder: "ابحث عن عنوان، وسوم، مؤلف...", + videoSortRecent: "الأحدث", + videoSortOldest: "الأقدم", + videoSortTop: "الأكثر تصويتًا", + videoSearchButton: "بحث", + videoMessageAuthorButton: "رسالة خاصة", + videoUpdatedAt: "تم التحديث", + videoNoMatch: "لا توجد فيديوهات تطابق بحثك.", + documentTitle: "المستندات", + documentDescription: "اكتشف وأدِر المستندات في شبكتك.", + documentAllSectionTitle: "المستندات", + documentMineSectionTitle: "مستنداتك", + documentRecentSectionTitle: "المستندات الأخيرة", + documentTopSectionTitle: "أفضل المستندات", + documentFavoritesSectionTitle: "المفضلة", + documentCreateSectionTitle: "رفع مستند", + documentUpdateSectionTitle: "تعديل المستند", + documentFilterAll: "الكل", + documentFilterMine: "خاصتي", + documentFilterRecent: "الأخيرة", + documentFilterTop: "الأفضل", + documentFilterFavorites: "المفضلة", + documentCreateButton: "رفع مستند", + documentUpdateButton: "تحديث", + documentDeleteButton: "حذف", + documentAddFavoriteButton: "إضافة للمفضلة", + documentRemoveFavoriteButton: "إزالة من المفضلة", + documentMessageAuthorButton: "رسالة خاصة", + documentFileLabel: "رفع مستند (.pdf)", + documentTagsLabel: "الوسوم", + documentTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + documentTitleLabel: "العنوان", + documentTitlePlaceholder: "اختياري", + documentDescriptionLabel: "الوصف", + documentDescriptionPlaceholder: "اختياري", + documentNoFile: "لا يوجد ملف.", + noDocuments: "لا توجد مستندات متاحة.", + documentSearchPlaceholder: "ابحث عن عنوان، وسوم، وصف، مؤلف...", + documentSortRecent: "الأحدث", + documentSortOldest: "الأقدم", + documentSortTop: "الأكثر تصويتًا", + documentSearchButton: "بحث", + documentNoMatch: "لا توجد مستندات تطابق بحثك.", + documentUpdatedAt: "تم التحديث", + audioTitle: "الملفات الصوتية", + audioDescription: "استكشف وأدِر المحتوى الصوتي في شبكتك.", + audioPluginTitle: "العنوان", + audioPluginDescription: "الوصف", + audioMineSectionTitle: "ملفاتك الصوتية", + audioCreateSectionTitle: "رفع ملف صوتي", + audioUpdateSectionTitle: "تحديث الملف الصوتي", + audioAllSectionTitle: "الملفات الصوتية", + audioRecentSectionTitle: "الملفات الصوتية الأخيرة", + audioTopSectionTitle: "أفضل الملفات الصوتية", + audioFilterAll: "الكل", + audioFilterMine: "خاصتي", + audioFilterRecent: "الأخيرة", + audioFilterTop: "الأفضل", + audioFilterBlockchain: "بلوكتشين", + audioCreateButton: "رفع ملف صوتي", + audioUpdateButton: "تحديث", + audioDeleteButton: "حذف", + audioAddFavoriteButton: "إضافة للمفضلة", + audioRemoveFavoriteButton: "إزالة من المفضلة", + audioFileLabel: "اختر ملفًا صوتيًا (.mp3, .wav, .ogg)", + audioTagsLabel: "الوسوم", + audioTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + audioTitleLabel: "العنوان", + audioTitlePlaceholder: "اختياري", + audioDescriptionLabel: "الوصف", + audioDescriptionPlaceholder: "اختياري", + audioNoFile: "لم يتم تقديم ملف صوتي", + noAudios: "لا توجد ملفات صوتية متاحة.", + audioSearchPlaceholder: "ابحث عن عنوان، وسوم، مؤلف...", + audioSortRecent: "الأحدث", + audioSortOldest: "الأقدم", + audioSortTop: "الأكثر تصويتًا", + audioSearchButton: "بحث", + audioMessageAuthorButton: "رسالة خاصة", + audioUpdatedAt: "تم التحديث", + audioNoMatch: "لا توجد ملفات صوتية تطابق بحثك.", + audioFavoritesSectionTitle: "المفضلة", + audioFilterFavorites: "المفضلة", + favoritesTitle: "المفضلة", + favoritesDescription: "كل المحتوى المفضل لديك في مكان واحد.", + favoritesFilterAll: "الكل", + favoritesFilterRecent: "الأخيرة", + favoritesFilterAudios: "الملفات الصوتية", + favoritesFilterBookmarks: "العلامات المرجعية", + favoritesFilterDocuments: "المستندات", + favoritesFilterImages: "الصور", + favoritesFilterMaps: "خرائط", + favoritesFilterPads: "اللوحات", + favoritesFilterChats: "الدردشات", + favoritesFilterCalendars: "التقويمات", + favoritesFilterVideos: "الفيديوهات", + favoritesRemoveButton: "إزالة من المفضلة", + favoritesNoItems: "لا توجد مفضلات بعد.", + yourContacts: "جهات اتصالك", + allInhabitants: "السكان", + allCVs: "جميع السير الذاتية", + discoverPeople: "اكتشف السكان في شبكتك.", + allInhabitantsButton: "الكل", + contactsButton: "المدعومون", + CVsButton: "السير الذاتية", + matchSkills: "مطابقة المهارات", + matchSkillsButton: "مطابقة المهارات", + suggestedButton: "المقترحون", + searchInhabitantsPlaceholder: "تصفية السكان حسب الاسم …", + filterLocation: "تصفية السكان حسب الموقع …", + filterLanguage: "تصفية السكان حسب اللغة …", + filterSkills: "تصفية السكان حسب المهارات …", + applyFilters: "تطبيق الفلاتر", + locationLabel: "الموقع", + languagesLabel: "اللغات", + skillsLabel: "المهارات", + commonSkills: "المهارات المشتركة", + mutualFollowers: "المتابعون المتبادلون", + suggestedFollowsYou: "يتابعك", + latestInteractions: "آخر التفاعلات", + viewAvatar: "عرض الصورة الرمزية", + viewCV: "عرض السيرة الذاتية", + suggestedSectionTitle: "المقترحون", + topkarmaSectionTitle: "أفضل كارما", + topecoSectionTitle: "أفضل إيكو", + topactivitySectionTitle: "الأكثر نشاطًا", + blockedSectionTitle: "المحظورون", + gallerySectionTitle: "المعرض", + blockedButton: "المحظورون", + blockedLabel: "مستخدم محظور", + inhabitantviewDetails: "عرض التفاصيل", + keepReading: "متابعة القراءة...", + oasisId: "المعرّف", + noInhabitantsFound: "لم يتم العثور على سكان بعد.", + inhabitantActivityLevel: "مستوى النشاط", + lifespanLabel: "العمر الافتراضي", + deviceLabel: "الجهاز", + parliamentTitle: "البرلمان", + parliamentDescription: "استكشف أشكال الحكم وقوانين الإدارة الجماعية.", + parliamentFilterGovernment: "الحكومة", + parliamentFilterCandidatures: "الترشيحات", + parliamentFilterProposals: "المقترحات", + parliamentFilterLaws: "القوانين", + parliamentFilterHistorical: "السجل التاريخي", + parliamentFilterLeaders: "القادة", + parliamentFilterRules: "القواعد", + parliamentGovernmentCard: "الحكومة الحالية", + parliamentActorInPowerInhabitant: 'الساكن الحاكم', + parliamentActorInPowerTribe: 'القبيلة الحاكمة', + parliamentHistoricalGovernmentsTitle: 'الحكومات', + parliamentHistoricalElectionsTitle: 'دورات الانتخاب', + parliamentHistoricalLawsTitle: 'السجل التاريخي', + parliamentHistoricalLeadersTitle: 'القادة', + parliamentThCycles: 'الدورات', + parliamentThTimesInPower: 'فترات الحكم', + parliamentThTotalCandidatures: 'الترشيحات', + parliamentThProposed: 'القوانين المقترحة', + parliamentThApproved: 'القوانين المعتمدة', + parliamentThDeclined: 'القوانين المرفوضة', + parliamentThDiscarded: 'القوانين الملغاة', + parliamentMembers: "الأعضاء", + parliamentLegSince: "بداية الدورة", + parliamentLegEnd: "نهاية الدورة", + parliamentPoliciesProposal: "مقترحات القوانين", + parliamentPoliciesApproved: "القوانين المعتمدة", + parliamentPoliciesDeclined: "القوانين المرفوضة", + parliamentPoliciesDiscarded: "القوانين الملغاة", + parliamentEfficiency: "% الكفاءة", + parliamentFilterRevocations: 'الإلغاءات', + parliamentRevocationFormTitle: 'إلغاء قانون', + parliamentRevocationLaw: 'القانون', + parliamentRevocationTitle: 'العنوان', + parliamentRevocationReasons: 'الأسباب', + parliamentRevocationPublish: 'نشر الإلغاء', + parliamentCurrentRevocationsTitle: 'الإلغاءات الحالية', + parliamentFutureRevocationsTitle: 'الإلغاءات المستقبلية', + parliamentPoliciesRevocated: 'القوانين الملغاة', + parliamentPoliciesTitle: 'السجل التاريخي', + parliamentLawsTitle: 'القوانين المعتمدة', + parliamentRulesRevocations: 'يمكن إلغاء أي قانون معتمد باستخدام طريقة الحكم الحالية.', + parliamentNoStableGov: "لم يتم اختيار حكومة بعد.", + parliamentNoGovernments: "لا توجد حكومات بعد.", + parliamentCandidatureFormTitle: "اقتراح ترشيح", + parliamentCandidatureIdPh: "معرّف Oasis (@...) أو اسم القبيلة", + parliamentCandidatureSlogan: "الشعار (140 حرفًا كحد أقصى)", + parliamentCandidatureSloganPh: "شعار قصير", + parliamentCandidatureMethod: "الطريقة", + parliamentCandidatureProposeBtn: "نشر الترشيح", + parliamentThDate: "تاريخ الاقتراح", + parliamentThSlogan: "الشعار", + parliamentThSince: "تاريخ الملف الشخصي", + parliamentThVotes: "الأصوات المُستلَمة", + parliamentThVoteAction: "تصويت", + parliamentTypeUser: "ساكن", + parliamentTypeTribe: "قبيلة", + parliamentVoteBtn: "تصويت", + parliamentProposalFormTitle: "اقتراح قانون", + parliamentProposalDescription: "الوصف (≤1000)", + parliamentProposalPublish: "نشر الاقتراح", + parliamentFinalize: "إنهاء", + parliamentDeadline: "الموعد النهائي", + parliamentNoProposals: "لا توجد مقترحات قوانين بعد.", + parliamentNoLaws: "لا توجد قوانين معتمدة بعد.", + parliamentMethodDEMOCRACY: "ديمقراطية", + parliamentMethodMAJORITY: "أغلبية (80%)", + parliamentMethodMINORITY: "أقلية (20%)", + parliamentMethodDICTATORSHIP: "ديكتاتورية", + parliamentMethodKARMATOCRACY: "كارماتوقراطية", + parliamentThId: "المعرّف", + parliamentThProposalDate: "تاريخ الاقتراح", + parliamentThMethod: "الطريقة", + parliamentThKarma: "الكارما", + parliamentThSupports: "الداعمون", + parliamentThVote: "تصويت", + parliamentCurrentProposalsTitle: "المقترحات الحالية", + parliamentVotesSlashTotal: "الأصوات/الإجمالي", + parliamentVotesNeeded: "الأصوات المطلوبة", + parliamentFutureLawsTitle: "القوانين المستقبلية", + parliamentNoFutureLaws: "لا توجد قوانين مستقبلية بعد.", + parliamentVoteAction: "تصويت", + parliamentLeadersTitle: "القادة", + parliamentThLeader: "الصورة الرمزية", + parliamentPopulation: "عدد السكان", + parliamentThType: "النوع", + parliamentThInPower: "في السلطة", + parliamentThPresented: "الترشيحات", + parliamentNoLeaders: "لا يوجد قادة بعد.", + parliamentCandidaturesListTitle: "قائمة الترشيحات", + parliamentTimeRemaining: "الوقت المتبقي", + parliamentNoLeader: "لا يوجد ترشيح فائز بعد.", + parliamentElectionsStatusTitle: "الحكومة القادمة", + parliamentProposalDeadlineLabel: "الموعد النهائي", + parliamentProposalTimeLeft: "الوقت المتبقي", + parliamentProposalOnTrack: "في طريقه للتمرير", + parliamentProposalOffTrack: "لا يوجد دعم كافٍ بعد", + parliamentRulesTitle: "كيف يعمل البرلمان", + parliamentRulesIntro: "تُحسم الانتخابات كل شهرين؛ الترشيحات مستمرة وتُعاد عند اختيار حكومة.", + parliamentRulesCandidates: "يمكن لأي ساكن ترشيح نفسه أو ساكن آخر أو أي قبيلة. يمكن لكل ساكن تقديم 3 ترشيحات كحد أقصى لكل دورة؛ تُرفض التكرارات في نفس الدورة.", + parliamentRulesElection: "الفائز هو الترشيح الذي يحصل على أكبر عدد من الأصوات وقت الحسم.", + parliamentRulesTies: "ترتيب كسر التعادل: أعلى كارما للساكن؛ إذا تعادلوا، أقدم ملف شخصي؛ إذا لا يزال متعادلاً، أقدم ترشيح؛ ثم ترتيب أبجدي حسب المعرّف.", + parliamentRulesFallback: "إذا لم يصوّت أحد، يفوز آخر ترشيح مقدّم. إذا لم تكن هناك ترشيحات، تُختار قبيلة عشوائية.", + parliamentRulesTerm: "يعرض تبويب الحكومة الحكومة الحالية والإحصائيات.", + parliamentRulesMethods: "الأشكال: فوضوية (أغلبية بسيطة)، ديمقراطية (50%+1)، أغلبية (80%)، أقلية (20%)، كارماتوقراطية (مقترحات أعلى كارما)، ديكتاتورية (موافقة فورية).", + parliamentRulesAnarchy: "الفوضوية هي الوضع الافتراضي: إذا لم يُنتخب أي ترشيح عند الحسم، تُعلن الفوضوية. في ظل الفوضوية، يمكن لأي ساكن اقتراح قوانين.", + parliamentRulesProposals: "إذا كنت الساكن الحاكم أو عضوًا في القبيلة الحاكمة، يمكنك نشر مقترحات قوانين. الطرق غير الديكتاتورية تنشئ تصويتًا عامًا.", + parliamentRulesLimit: "يمكن لكل ساكن نشر 3 مقترحات قوانين كحد أقصى لكل دورة.", + parliamentRulesLaws: "عندما يصل اقتراح إلى حده الأدنى، يصبح قانونًا ويظهر في تبويب القوانين مع تاريخ سنّه.", + parliamentRulesHistorical: "في تبويب السجل التاريخي يمكنك رؤية كل دورة حكومية حدثت وبيانات عن إدارتها.", + parliamentRulesLeaders: "في تبويب القادة يمكنك رؤية ترتيب السكان/القبائل التي حكمت (أو ترشحت)، مرتبة حسب الكفاءة.", + parliamentProposalVoteStatusLabel: "حالة التصويت", + parliamentProposalOnTrackYes: "تم الوصول للحد الأدنى", + parliamentProposalOnTrackNo: "دون الحد الأدنى", + courtsTitle: "المحاكم", + courtsDescription: "استكشف أشكال حل النزاعات وإدارة العدالة الجماعية.", + courtsFilterCases: "القضايا", + courtsFilterMyCases: "خاصتي", + courtsFilterJudges: "القضاة", + courtsFilterHistory: "السجل", + courtsFilterRules: "القواعد", + courtsCaseFormTitle: "فتح قضية", + courtsCaseRespondent: "المتهم / المدعى عليه", + courtsCaseRespondentPh: "معرّف Oasis (@...) أو اسم القبيلة", + courtsCaseMediatorsAccuser: "الوسطاء (المدعي)", + courtsCaseMethod: "طريقة الحل", + courtsCaseDescription: "الوصف (1000 حرف كحد أقصى)", + courtsCaseEvidenceTitle: "أدلة القضية", + courtsCaseEvidenceHelp: "أرفق صورًا أو ملفات صوتية أو مستندات (PDF) أو فيديوهات تدعم قضيتك.", + courtsCaseSubmit: "رفع القضية", + courtsNominateJudge: "ترشيح قاضٍ", + courtsJudgeId: "القاضي", + courtsJudgeIdPh: "معرّف Oasis (@...) أو اسم الساكن", + courtsNominateBtn: "ترشيح", + courtsAddEvidence: "إضافة دليل", + courtsEvidenceText: "نص", + courtsEvidenceLink: "رابط", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "إرفاق", + courtsAnswerText: "مذكرة الرد", + courtsAnswerSubmit: "إرسال الرد", + courtsVerdictTitle: "إصدار الحكم", + courtsVerdictOrdersPh: "إجراءات، مواعيد نهائية، خطوات تصحيحية ...", + courtsIssueVerdict: "إصدار الحكم", + courtsMediationPropose: "اقتراح تسوية", + courtsSettlementProposeBtn: "اقتراح", + courtsNominationsTitle: "ترشيحات القضاء", + courtsThJudge: "القاضي", + courtsThDate: "التاريخ", + courtsThVote: "تصويت", + courtsNoNominations: "لا توجد ترشيحات بعد.", + courtsCaseMediatorsRespondentTitle: "إضافة وسطاء الدفاع", + courtsCaseMediatorsRespondent: "الوسطاء (الدفاع)", + courtsMediatorsAccuserLabel: "الوسطاء (المدعي)", + courtsMediatorsRespondentLabel: "الوسطاء (الدفاع)", + courtsMediatorsSubmit: "حفظ الوسطاء", + courtsNoMyCases: "ليس لديك نزاعات بعد.", + courtsNoHistory: "لا توجد محاكمات مسجلة بعد.", + courtsMethodDICTATOR: "ديكتاتور", + courtsMethodPOPULAR: "شعبي", + courtsMethodKARMATOCRACY: "كارماتوقراطية", + courtsRulesTitle: "كيف تعمل المحاكم", + courtsRulesIntro: "المحاكم هي عملية يديرها المجتمع لحل النزاعات وتعزيز العدالة التصالحية. يُعطى الأولوية للحوار والأدلة الواضحة والعلاجات المتناسبة.", + courtsRulesLifecycle: "العملية: 1) فتح قضية 2) اختيار الطريقة 3) تقديم الأدلة 4) الاستماع والمداولة 5) الحكم والعلاج 6) الامتثال والإغلاق 7) الاستئناف (إن وُجد).", + courtsRulesRoles: "المدعي: يفتح القضية. الدفاع: الشخص أو القبيلة المتهمة. الطريقة: الآلية التي يختارها المجتمع لتسهيل وتقييم الأدلة وإصدار الحكم. الشاهد: يقدم شهادة أو دليلاً. الوسطاء: أشخاص محايدون يدعوهم المدعي و/أو الدفاع، لديهم حق الوصول لجميع التفاصيل، يساعدون في تهدئة النزاع والمشاركة في صنع الاتفاقيات.", + courtsRulesEvidence: "الوصف حتى 1000 حرف. أرفق صورًا وملفات صوتية وفيديوهات ومستندات PDF ذات صلة وقانونية. لا تشارك بيانات خاصة حساسة دون موافقة.", + courtsRulesDeliberation: "قد تكون الجلسات علنية أو خاصة. يضمن القضاة الاحترام ويطلبون التوضيحات وقد يتجاهلون المواد غير ذات الصلة أو غير القانونية.", + courtsRulesVerdict: "يُعطى الأولوية للتصالح: الاعتذارات، اتفاقيات الوساطة، إدارة المحتوى، القيود المؤقتة، أو تدابير متناسبة أخرى. يجب تسجيل الأسباب.", + courtsRulesAppeals: "الاستئناف: مسموح عند وجود أدلة جديدة أو خطأ إجرائي واضح. يجب تقديمه خلال 7 أيام ما لم يُذكر خلاف ذلك.", + courtsRulesPrivacy: "احترم الخصوصية والأمان. يتم إزالة التشهير والكراهية والتهديدات. قد يعدل القضاة أو يختمون أجزاء من السجل لحماية الأشخاص المعرضين للخطر.", + courtsRulesMisconduct: "قد يؤدي التحرش أو التلاعب أو تلفيق الأدلة إلى حكم سلبي فوري.", + courtsRulesGlossary: "القضية: سجل نزاع. الدليل: مواد تدعم الادعاءات. الحكم: قرار مع علاج. الاستئناف: طلب مراجعة الحكم.", + courtsFilterActions: "الإجراءات", + courtsNoActions: "لا توجد إجراءات معلقة لدورك.", + courtsCaseTitlePlaceholder: "وصف مختصر للنزاع", + courtsCaseSeverity: "الخطورة", + courtsCaseSeverityNone: "بدون تصنيف خطورة", + courtsCaseSeverityLOW: "منخفضة", + courtsCaseSeverityMEDIUM: "متوسطة", + courtsCaseSeverityHIGH: "عالية", + courtsCaseSeverityCRITICAL: "حرجة", + courtsCaseSubject: "الموضوع", + courtsCaseSubjectNone: "بدون تصنيف موضوع", + courtsCaseSubjectBEHAVIOUR: "سلوك", + courtsCaseSubjectCONTENT: "محتوى", + courtsCaseSubjectGOVERNANCE: "حوكمة / قواعد", + courtsCaseSubjectFINANCIAL: "مالي / موارد", + courtsCaseSubjectOTHER: "أخرى", + courtsHiddenRespondent: "مخفي (مرئي فقط للأدوار المعنية).", + courtsThRole: "الدور", + courtsRoleAccuser: "المدعي", + courtsRoleDefence: "الدفاع", + courtsRoleMediator: "الوسيط", + courtsRoleJudge: "القاضي", + courtsRoleDictator: "الديكتاتور", + courtsAssignJudgeTitle: "اختيار قاضٍ", + courtsAssignJudgeBtn: "اختيار قاضٍ", + trendingTitle: "الرائج", + exploreTrending: "استكشف المحتوى الأكثر شعبية في شبكتك.", + bookmarkButton: "العلامات المرجعية", + transferButton: "التحويلات", + eventButton: "الأحداث", + taskButton: "المهام", + votesButton: "التصويتات", + reportButton: "التقارير", + feedButton: "التغذية", + marketButton: "السوق", + imageButton: "الصور", + audioButton: "الملفات الصوتية", + videoButton: "الفيديوهات", + documentButton: "المستندات", + torrentButton: "التورنت", + noTrendingFound: "لم يتم العثور على محتوى رائج.", + noContentMessage: "لا يتوفر محتوى رائج بعد.", + trendingDescription: "الوصف", + trendingDate: "التاريخ", + trendingLocation: "الموقع", + trendingPrice: "السعر", + trendingUrl: "الرابط", + trendingCategory: "الفئة", + trendingStart: "البداية", + trendingEnd: "النهاية", + trendingPriority: "الأولوية", + trendingStatus: "الحالة", + trendingFrom: "من", + trendingTo: "إلى", + trendingConcept: "المفهوم", + trendingAmount: "المبلغ", + trendingDeadline: "الموعد النهائي", + trendingItemStatus: "حالة العنصر", + trendingTotalVotes: "إجمالي الأصوات", + trendingTotalOpinions: "إجمالي الآراء", + trendingNoContentMessage: "لا يتوفر محتوى رائج بعد.", + trendingAuthor: "بواسطة", + trendingCreatedAtLabel: "أُنشئ في", + trendingTotalCount: "العدد الإجمالي", + tasksTitle: "المهام", + tasksDescription: "اكتشف وأدِر المهام في شبكتك.", + taskTitleLabel: "العنوان", + taskDescriptionLabel: "الوصف", + taskStartTimeLabel: "وقت البدء", + taskEndTimeLabel: "وقت الانتهاء", + taskPriorityLabel: "الأولوية", + taskPrioritySelect: "اختر الأولوية", + taskPriorityUrgent: "عاجل", + taskPriorityHigh: "عالية", + taskPriorityMedium: "متوسطة", + taskPriorityLow: "منخفضة", + taskLocationLabel: "الموقع", + taskTagsLabel: "الوسوم", + taskVisibilityLabel: "الظهور", + taskPublic: "عام", + taskPrivate: "خاص", + taskCreatedAt: "أُنشئ في", + taskBy: "بواسطة", + taskStatus: "الحالة", + taskStatusOpen: "مفتوح", + taskStatusInProgress: "قيد التنفيذ", + taskStatusClosed: "مغلق", + taskAssignedTo: "مُسنَد إلى", + taskAssignees: "المُكلَّفون", + taskAssignButton: "إسناد لي", + taskUnassignButton: "إلغاء الإسناد", + taskCreateButton: "إنشاء مهمة", + taskUpdateButton: "تحديث", + taskDeleteButton: "حذف", + taskFilterAll: "الكل", + taskFilterMine: "خاصتي", + taskFilterOpen: "مفتوح", + taskFilterInProgress: "قيد التنفيذ", + taskFilterClosed: "مغلق", + taskFilterAssigned: "مُسنَد", + taskFilterArchived: "مؤرشف", + taskFilterUrgent: "عاجل", + taskFilterHigh: "عالية", + taskFilterMedium: "متوسطة", + taskFilterLow: "منخفضة", + taskAllSectionTitle: "المهام", + taskMineSectionTitle: "مهامك", + taskCreateSectionTitle: "إنشاء مهمة", + taskUpdateSectionTitle: "تحديث", + taskOpenTitle: "المهام المفتوحة", + taskInProgressTitle: "المهام قيد التنفيذ", + taskClosedTitle: "المهام المغلقة", + taskAssignedTitle: "المهام المُسنَدة", + taskArchivedTitle: "المهام المؤرشفة", + taskPublicTitle: "المهام العامة", + taskPrivateTitle: "المهام الخاصة", + notasks: "لا توجد مهام متاحة.", + noLocation: "لم يتم تحديد الموقع", + taskSetStatus: "تعيين الحالة", + eventTitle: "الأحداث", + eventDateLabel: "التاريخ", + eventsTitle: "الأحداث", + eventsDescription: "اكتشف وأدِر الأحداث في شبكتك.", + eventDescription: "الوصف", + eventPrice: "السعر", + eventStatus: "الحالة", + eventOrganizer: "المنظم", + eventAllSectionTitle: "الأحداث", + eventMineSectionTitle: "أحداثك", + eventArchivedTitle: "الأحداث المؤرشفة", + eventCreateSectionTitle: "إنشاء حدث", + eventUpdateSectionTitle: "تحديث الحدث", + eventDeleteButton: "حذف", + eventCreateButton: "إنشاء حدث", + eventAddToCalendar: "إضافة إلى التقويم", + eventVisitCalendar: "عرض التقويم", + viewTask: "عرض المهمة", + viewReport: "عرض التقرير", + viewTransfer: "عرض التحويل", + viewItem: "عرض العنصر", + viewShop: "عرض المتجر", + viewProject: "عرض المشروع", + viewVotation: "عرض التصويت", + voteCastTitle: "أدلِ بصوتك", + voteResults: "النتائج", + eventTitleLabel: "العنوان", + eventDescriptionLabel: "الوصف", + eventDescriptionPlaceholder: "أدخل وصف الحدث...", + eventUpdateButton: "تحديث", + eventAttendeesLabel: "الحاضرون", + eventAttendeesPlaceholder: "أدخل الحاضرين، مفصولين بفواصل...", + eventTagsLabel: "الوسوم", + eventTagsPlaceholder: "أدخل وسوم الحدث، مفصولة بفواصل...", + eventTags: "الوسوم", + eventPriceLabel: "السعر", + eventUrlLabel: "الرابط", + eventAttendees: "الحاضرون", + noAttendees: "لا يوجد حاضرون بعد", + eventCreatedAt: "أُنشئ في", + eventLocation: "الموقع", + eventLocationLabel: "الموقع", + eventNoLocation: "لم يتم تحديد الموقع", + eventNoURL: "لم يتم تحديد رابط", + eventBy: "بواسطة", + noevents: "لا توجد أحداث متاحة.", + eventDate: "التاريخ", + eventDateFormat: "DD:MM:YYYY HH:mm", + eventAttendButton: "حضور الحدث", + eventUnattendButton: "إلغاء الحضور", + eventCreatedBy: "أُنشئ بواسطة", + eventAttendeesCount: "عدد الحاضرين", + eventCreatedByYou: "أنت أنشأت هذا الحدث", + eventFilterAll: "الكل", + eventFilterMine: "خاصتي", + eventFilterToday: "اليوم", + eventFilterWeek: "هذا الأسبوع", + eventFilterMonth: "هذا الشهر", + eventFilterYear: "هذه السنة", + eventFilterArchived: "مؤرشف", + eventTodayTitle: "أحداث اليوم", + eventThisWeekTitle: "أحداث هذا الأسبوع", + eventThisMonthTitle: "أحداث هذا الشهر", + eventThisYearTitle: "أحداث هذه السنة", + eventPrivacyLabel: "الظهور", + eventPublic: "عام", + eventPrivate: "خاص", + eventPublicTitle: "الأحداث العامة", + eventNoPrice: "حدث مجاني", + eventNoImage: "لم يتم رفع صورة", + eventAttendConfirmation: "أنت الآن تحضر هذا الحدث", + eventUnattendConfirmation: "لم تعد تحضر هذا الحدث", + eventAttended: "حضر", + eventUnattended: "لم يحضر", + eventStatusOpen: "مفتوح", + eventStatusClosed: "مغلق", + tagsTitle: "الوسوم", + tagsDescription: "اكتشف واستكشف أنماط التصنيف في شبكتك.", + tagsAllSectionTitle: "الوسوم", + tagsTopSectionTitle: "أفضل الوسوم", + tagsCloudSectionTitle: "سحابة الوسوم", + tagsFilterAll: "الكل", + tagsFilterTop: "الأفضل", + tagsFilterCloud: "السحابة", + tagsNoItems: "لا توجد وسوم متاحة.", + tagsTableHeaderTag: "الوسم/الرابط:", + tagsTableHeaderCount: "العداد:", + transfersTitle: "التحويلات", + transfersDescription: "اكتشف وأدِر التحويلات في شبكتك.", + transfersFrom: "من", + transfersTo: "إلى", + transfersFilterAll: "الكل", + transfersFilterMine: "خاصتي", + transfersFilterUBI: "UBI", + transfersFilterMarket: "السوق", + transfersFilterTop: "الأفضل", + transfersFilterPending: "معلّق", + transfersFilterUnconfirmed: "غير مؤكد", + transfersFilterClosed: "مغلق", + transfersFilterDiscarded: "ملغى", + transfersFilterEconomic: "اقتصادي", + transfersFilterTime: "وقت", + transfersFilterTrust: "ثقة", + transfersCategory: "الفئة", + transfersCategoryEconomic: "اقتصادي", + transfersCategoryTime: "وقت", + transfersCategoryTrust: "ثقة", + transfersCategoryApply: "تطبيق", + transfersUnitEco: "ECO", + transfersUnitHours: "س", + transfersUnitTrust: "ثقة", + transfersEconomicSectionTitle: "التحويلات الاقتصادية", + transfersTimeSectionTitle: "تحويلات الوقت", + transfersTrustSectionTitle: "تحويلات الثقة", + transfersCreateButton: "إنشاء تحويل", + transfersUpdateButton: "تحديث", + transfersDeleteButton: "حذف", + transfersToUser: "معرف Oasis", + transfersToUserValidation: "معرّف Oasis صالح، مثال: @…=.ed25519", + transfersConcept: "المفهوم", + transfersAmount: "المبلغ", + transfersDeadline: "الموعد النهائي", + transfersTags: "الوسوم", + transfersStatus: "الحالة", + transfersStatusUnconfirmed: "غير مؤكد", + transfersStatusClosed: "مغلق", + transfersStatusDiscarded: "ملغى", + transfersCreatedAt: "أُنشئ في", + transfersConfirmations: "التأكيدات", + transfersContainingBlock: "الكتلة المحتوية", + transfersExportContract: "إنشاء العقد", + transfersConfirmButton: "تأكيد التحويل", + transfersNoItems: "لم يتم العثور على تحويلات.", + transfersMineSectionTitle: "تحويلاتك", + transfersUBISectionTitle: "تحويلات UBI", + transfersMarketSectionTitle: "تحويلات السوق", + transfersTopSectionTitle: "أفضل التحويلات", + transfersPendingSectionTitle: "التحويلات المعلّقة", + transfersUnconfirmedSectionTitle: "التحويلات غير المؤكدة", + transfersClosedSectionTitle: "التحويلات المغلقة", + transfersDiscardedSectionTitle: "التحويلات الملغاة", + transfersCreateSectionTitle: "إنشاء عملية نقل", + transfersAllSectionTitle: "التحويلات", + transfersFilterFavs: "المفضلة", + transfersFavsSectionTitle: "التحويلات المفضلة", + transfersSearchLabel: "بحث", + transfersSearchPlaceholder: "ابحث عن مفهوم، وسوم، مستخدمين...", + transfersMinAmountLabel: "الحد الأدنى للمبلغ", + transfersMaxAmountLabel: "الحد الأقصى للمبلغ", + transfersSortLabel: "ترتيب حسب", + transfersSortRecent: "الأحدث", + transfersSortAmount: "الأعلى مبلغًا", + transfersSortDeadline: "الأقرب موعدًا", + transfersSearchButton: "بحث", + transfersFavoriteButton: "إضافة للمفضلة", + transfersUnfavoriteButton: "إزالة من المفضلة", + transfersMessageUserButton: "رسالة", + transfersExpiringSoonBadge: "على وشك الانتهاء", + transfersExpiredBadge: "منتهي الصلاحية", + transfersUpdatedAt: "تم التحديث", + transfersNoMatch: "لا توجد تحويلات تطابق بحثك.", + votationsTitle: "التصويتات", + votationsDescription: "اكتشف وأدِر التصويتات في شبكتك.", + voteMineSectionTitle: "تصويتاتك", + voteCreateSectionTitle: "إنشاء تصويت", + voteUpdateSectionTitle: "تحديث", + voteOpenTitle: "التصويتات المفتوحة", + voteClosedTitle: "التصويتات المغلقة", + voteAllSectionTitle: "التصويتات", + voteCreateButton: "إنشاء تصويت", + voteUpdateButton: "تحديث", + voteDeleteButton: "حذف", + voteOptionYes: "نعم", + voteOptionNo: "لا", + voteOptionAbstention: "امتناع", + voteConfused: "مرتبك", + voteFollowMajority: "اتباع الأغلبية", + voteNotInterested: "غير مهتم", + voteFilterAll: "الكل", + voteFilterMine: "خاصتي", + voteFilterOpen: "مفتوح", + voteFilterClosed: "مغلق", + voteQuestionLabel: "السؤال", + voteDeadlineLabel: "الموعد النهائي", + voteOptionsLabel: "تصويتك", + voteBreakdown: "التفصيل", + voteFinalResult: "النتيجة", + voteNoQuorum: "لا يوجد نصاب", + voteTagsLabel: "الوسوم", + voteDeadline: "الموعد النهائي", + voteStatus: "الحالة", + voteTags: "الوسوم", + voteOpinions: "الآراء", + novotes: "لا توجد مقترحات تصويت متاحة.", + voteBy: "بواسطة", + voteCreatedAt: "أُنشئ في", + voteNoQuestion: "لم يتم تقديم سؤال", + voteUnknownCreator: "مُنشئ غير معروف", + voteUnknownDate: "تاريخ غير معروف", + errorVoteNotFound: "لم يتم العثور على التصويت", + errorAlreadyVoted: "لقد أبديت رأيك بالفعل.", + errorVoteClosedCannotEdit: "لا يمكنك تعديل تصويت مغلق", + errorVoteDeadlinePassed: "انتهى الموعد النهائي لهذا التصويت", + errorRetrievingVote: "خطأ في استرجاع التصويت", + errorCreatingVote: "خطأ في إنشاء التصويت", + errorVoteAlreadyVoted: "لا يمكن التعديل بعد الإدلاء بالرأي", + errorDeletingOldVote: "خطأ في حذف الرأي القديم", + errorCreatingUpdatedVote: "خطأ في إنشاء الرأي المحدّث", + errorCreatingTombstone: "خطأ في إنشاء شاهد القبر", + voteDetailSectionTitle: 'تفاصيل التصويت', + voteCommentsLabel: 'التعليقات', + voteCommentsForumButton: 'فتح النقاش', + voteCommentsSectionTitle: 'فتح النقاش', + voteNoCommentsYet: 'لا توجد تعليقات بعد. كن أول من يرد.', + voteNewCommentPlaceholder: 'اكتب تعليقك هنا…', + voteNewCommentButton: 'نشر التعليق', + voteNewCommentLabel: 'إضافة تعليق', + cvTitle: "السيرة الذاتية", + cvLabel: "السيرة الذاتية (CV)", + cvEditSectionTitle: "تعديل السيرة الذاتية", + cvCreateSectionTitle: "إنشاء سيرة ذاتية", + cvDescription: "إدارة ومشاركة مهاراتك ومعلوماتك المهنية.", + cvNameLabel: "الاسم الكامل", + cvDescriptionLabel: "الملخص", + cvPhotoLabel: "الصورة", + cvPersonalExperiencesLabel: "الخبرات الشخصية", + cvPersonalSkillsLabel: "المهارات الشخصية (مفصولة بفواصل)", + cvOasisExperiencesLabel: "خبرات المساهمة في Oasis", + cvOasisSkillsLabel: "مهارات المساهمة في Oasis (مفصولة بفواصل)", + cvEducationExperiencesLabel: "الخبرات التعليمية", + cvEducationalSkillsLabel: "المهارات التعليمية (مفصولة بفواصل)", + cvProfessionalExperiencesLabel: "الخبرات المهنية", + cvProfessionalSkillsLabel: "المهارات المهنية (مفصولة بفواصل)", + cvLanguagesLabel: "اللغات", + cvLocationLabel: "الموقع", + cvStatusLabel: "الحالة", + cvPreferencesLabel: "التفضيلات", + cvOasisContributorLabel: "مساهم في Oasis", + cvPersonal: "شخصي", + cvOasis: "مساهم في Oasis (اختياري)", + cvOasisContributorView: "مساهمة في Oasis", + cvEducational: "تعليمي (اختياري)", + cvEducationalView: "تعليمي", + cvProfessional: "مهني (اختياري)", + cvProfessionalView: "مهني", + cvAvailability: "التوفر (اختياري)", + cvAvailabilityView: "التوفر", + cvUpdateButton: "تحديث", + cvCreateButton: "إنشاء سيرة ذاتية", + cvContactLabel: "التواصل", + cvCreatedAt: "أُنشئ في", + cvUpdatedAt: "تم التحديث في", + cvEditButton: "تحديث", + cvDeleteButton: "حذف", + cvNoCV: "لم يتم العثور على سيرة ذاتية.", + blogSubject: "الموضوع", + blogMessage: "النص محدود بـ 8096 حرفًا.", + blogImage: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", + blogPublish: "معاينة", + noPopularMessages: "لم تُنشر رسائل شائعة بعد", + forumTitle: "المنتديات", + forumCategoryLabel: "الفئة", + forumTitleLabel: "العنوان", + forumTitlePlaceholder: "عنوان المنتدى...", + forumCreateButton: "إنشاء منتدى", + forumCreateSectionTitle: "إنشاء منتدى", + forumDescription: "تحدث بصراحة مع السكان الآخرين في شبكتك.", + forumFilterAll: "الكل", + forumFilterMine: "خاصتي", + forumFilterRecent: "الأخيرة", + forumFilterTop: "الأفضل", + forumMineSectionTitle: "منتدياتك", + forumRecentSectionTitle: "المنتديات الأخيرة", + forumAllSectionTitle: "المنتديات", + forumDeleteButton: "حذف", + forumParticipants: "المشاركون", + forumMessages: "الرسائل", + forumLastMessage: "آخر رسالة", + forumMessageLabel: "الرسالة", + forumMessagePlaceholder: "اكتب رسالتك...", + forumSendButton: "إرسال", + forumVisitForum: "زيارة المنتدى", + noForums: "لم يتم العثور على منتديات.", + forumVisitButton: "زيارة المنتدى", + forumCatGENERAL: "عام", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "سياسة", + forumCatTECH: "تقنية", + forumCatSCIENCE: "علوم", + forumCatMUSIC: "موسيقى", + forumCatART: "فن", + forumCatGAMING: "ألعاب", + forumCatBOOKS: "كتب", + forumCatFILMS: "أفلام", + forumCatPHILOSOPHY: "فلسفة", + forumCatSOCIETY: "مجتمع", + forumCatPRIVACY: "خصوصية", + forumCatCYBERWARFARE: "حرب سيبرانية", + forumCatSURVIVALISM: "البقائية", + imageTitle: "الصور", + imageDescription: "استكشف وأدِر محتوى الصور في شبكتك.", + imagePluginTitle: "العنوان", + imagePluginDescription: "الوصف", + imageMineSectionTitle: "صورك", + imageCreateSectionTitle: "رفع صورة", + imageUpdateSectionTitle: "تحديث الصورة", + imageAllSectionTitle: "الصور", + imageRecentSectionTitle: "الصور الأخيرة", + imageTopSectionTitle: "أفضل الصور", + imageFavoritesSectionTitle: "المفضلة", + imageGallerySectionTitle: "المعرض", + imageMemeSectionTitle: "ميمز", + imageFilterAll: "الكل", + imageFilterMine: "خاصتي", + imageFilterRecent: "الأخيرة", + imageFilterTop: "الأفضل", + imageFilterFavorites: "المفضلة", + imageFilterGallery: "المعرض", + imageFilterMeme: "ميمز", + imageCreateButton: "رفع صورة", + imageUpdateButton: "تحديث", + imageDeleteButton: "حذف", + imageAddFavoriteButton: "إضافة للمفضلة", + imageRemoveFavoriteButton: "إزالة من المفضلة", + imageFileLabel: "اختر ملف صورة (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "الوسوم", + imageTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + imageTitleLabel: "العنوان", + imageTitlePlaceholder: "اختياري", + imageDescriptionLabel: "الوصف", + imageDescriptionPlaceholder: "اختياري", + imageMemeLabel: "تعيين كميم", + imageNoFile: "لم يتم تقديم ملف صورة", + noImages: "لا توجد صور متاحة.", + imageSearchPlaceholder: "ابحث عن عنوان، وسوم، وصف، مؤلف...", + imageSortRecent: "الأحدث", + imageSortOldest: "الأقدم", + imageSortTop: "الأكثر تصويتًا", + imageSearchButton: "بحث", + imageMessageAuthorButton: "رسالة", + imageUpdatedAt: "تم التحديث", + imageNoMatch: "لا توجد صور تطابق بحثك.", + feedTitle: "التغذية", + createFeedTitle: "إنشاء تغذية", + createFeedButton: "أرسل التغذية!", + feedPlaceholder: "ماذا يحدث؟ (280 حرفًا كحد أقصى)", + TODAYButton: "اليوم", + CREATEButton: "إنشاء تغذية", + totalOpinions: "إجمالي الآراء", + moreVoted: "الأكثر تصويتًا", + noFeedsFound: "لم يتم العثور على تغذيات.", + createdAtLabel: "أُنشئ في", + FeedshareYourOpinions: "اكتشف وشارك نصوصًا قصيرة في شبكتك.", + refeedButton: "إعادة نشر", + alreadyRefeeded: "لقد أعدت نشر هذا بالفعل.", + activityTitle: "النشاط", + yourActivity: "نشاطك", + globalActivity: "النشاط العام", + activityList: "النشاط", + activityDesc: "شاهد آخر نشاط في شبكتك.", + allButton: "الكل", + mineButton: "خاصتي", + noActions: "لا يتوفر نشاط.", + performed: "→", + from: "من", + to: "إلى", + amount: "المبلغ", + concept: "المفهوم", + description: "الوصف", + meme: "ميم", + activityContact: "جهة اتصال", + activityBy: "الاسم", + activityPixelia: "تمت إضافة بكسل جديد", + viewImage: "عرض الصورة", + playAudio: "تشغيل الصوت", + playVideo: "تشغيل الفيديو", + typeRecent: "الأخيرة", + errorActivity: "خطأ في استرجاع النشاط", + typePost: "مدونات", + recentButton: "الأحدث", + typeTribe: "القبائل", + typeLarp: "L.A.R.P.", + typeInhabitants: "السكان", + activityProfileUpdated: "حدّث ملفه الشخصي", + typeAbout: "السكان", + typeCurriculum: "السير الذاتية", + typeImage: "الصور", + typeBookmark: "العلامات المرجعية", + typeDocument: "المستندات", + typeVotes: "التصويتات", + typeAudio: "الملفات الصوتية", + typeMarket: "السوق", + typeJob: "الوظائف", + typeProject: "المشاريع", + typeVideo: "الفيديوهات", + typeVote: "انتشار", + typeEvent: "الأحداث", + typeTransfer: "تحويلات", + typeTask: "المهام", + typePixelia: "بيكسيليا", + typeForum: "المنتديات", + typeReport: "التقارير", + typeFeed: "الخلاصات", + typeContact: "جهة اتصال", + typePub: "PUB", + typeTombstone: "شاهد قبر", + typeBanking: "المصرفية", + typeBankWallet: "المصرفية/المحفظة", + typeBankClaim: "المصرفية/الدخل الأساسي", + typeKarmaScore: "كارما", + typeParliament: "البرلمان", + typeSpread: "الانتشارات", + typeParliamentCandidature: "البرلمان · ترشيح", + typeParliamentTerm: "البرلمان · فترة", + typeParliamentProposal:"البرلمان · اقتراح", + typeParliamentRevocation:"البرلمان · إلغاء", + typeParliamentLaw: "البرلمان · قانون جديد", + typeCourts: "المحاكم", + typeCourtsCase: "المحاكم · قضية", + typeCourtsEvidence: "المحاكم · دليل", + typeCourtsAnswer: "المحاكم · رد", + typeCourtsVerdict: "المحاكم · حكم", + typeCourtsSettlement: "المحاكم · تسوية", + typeCourtsSettlementProposal: "المحاكم · اقتراح تسوية", + typeCourtsSettlementAccepted: "المحاكم · تسوية مقبولة", + typeCourtsNomination: "المحاكم · ترشيح", + typeCourtsNominationVote: "المحاكم · تصويت ترشيح", + activitySupport: "تحالف جديد أُقيم", + activityJoin: "تم الانضمام إلى PUB جديد", + question: "السؤال", + deadline: "الموعد النهائي", + status: "الحالة", + votes: "الأصوات", + totalVotes: "إجمالي الأصوات", + voteTotalVotes: "إجمالي الأصوات", + name: "الاسم", + skills: "المهارات", + tags: "الوسوم", + title: "العنوان", + date: "التاريخ", + category: "الفئة", + attendees: "الحاضرون", + activitySpread: "->", + visitLink: "زيارة الرابط", + viewDocument: "عرض المستند", + location: "الموقع", + contentWarning: "الموضوع", + personName: "اسم الساكن", + bankWalletConnected: "محفظة ECOin", + bankUbiReceived: "تم استلام الدخل الأساسي", + bankTx: "معاملة", + bankEpochShort: "الحقبة", + bankingUserEngagementScore: "تسجيل الكارما", + viewDetails: "عرض التفاصيل", + link: "رابط", + activityProjectFollow: "%OASIS% الآن %ACTION% هذا المشروع %PROJECT%", + activityProjectUnfollow: "%OASIS% الآن %ACTION% هذا المشروع %PROJECT%", + activityProjectPledged: "%OASIS% قد %ACTION% %AMOUNT% للمشروع %PROJECT%", + following: "يتابع", + unfollowing: "ألغى المتابعة", + pledged: "تبرع", + parliamentCandidatureId: "الترشيح", + parliamentGovMethod: "الطريقة", + parliamentVotesReceived: "الأصوات المُستلَمة", + parliamentMethodANARCHY: "فوضوية", + parliamentMethodVOTE: "تصويت مجتمعي", + parliamentMethodRANKED: "اختيار تفضيلي", + parliamentMethodPLURALITY: "تعددية", + parliamentMethodCOUNCIL: "مجلس", + parliamentMethodJURY: "هيئة محلفين", + parliamentAnarchy: "فوضوية", + parliamentElectionsStart: "بداية الانتخابات", + parliamentElectionsEnd: "نهاية الانتخابات", + parliamentCurrentLeader: "الترشيح الفائز", + parliamentProposalTitle: "العنوان", + parliamentOpenVote: "تصويت مفتوح", + parliamentStatus: "الحالة", + parliamentLawQuestion: "السؤال", + parliamentLawMethod: "الطريقة", + parliamentLawProposer: "المقترح", + parliamentLawEnacted: "تم سنّه في", + parliamentLawVotes: "الأصوات", + createdAt: "أُنشئ في", + courtsCaseTitle: "القضية", + courtsMethod: "الطريقة", + courtsMethodJUDGE: "قاضٍ", + courtsMethodJUDGES: "هيئة قضاة", + courtsMethodSINGLE_JUDGE: "قاضٍ منفرد", + courtsMethodJURY: "هيئة محلفين", + courtsMethodCOUNCIL: "مجلس", + courtsMethodCOMMUNITY: "مجتمع", + courtsMethodARBITRATION: "تحكيم", + courtsMethodVOTE: "تصويت مجتمعي", + courtsAccuser: "المدعي", + courtsRespondent: "المدعى عليه", + courtsThStatus: "الحالة", + courtsThAnswerBy: "الرد بواسطة", + courtsThEvidenceBy: "الدليل بواسطة", + courtsThDecisionBy: "القرار بواسطة", + courtsVotesNeeded: "الأصوات المطلوبة", + courtsVotesSlashTotal: "نعم/الإجمالي", + courtsOpenVote: "تصويت مفتوح", + courtsAnswerTitle: "الرد", + courtsStanceADMIT: "اعتراف", + courtsStanceDENY: "إنكار", + courtsStancePARTIAL: "جزئي", + courtsStanceCOUNTERCLAIM: "دعوى مضادة", + courtsStanceNEUTRAL: "محايد", + courtsVerdictResult: "النتيجة", + courtsVerdictOrders: "الأوامر", + courtsSettlementText: "التسوية", + courtsSettlementAccepted: "مقبولة", + courtsSettlementPending: "معلّقة", + courtsJudge: "القاضي", + courtsThSupports: "الداعمون", + courtsFilterOpenCase: 'فتح قضية', + courtsEvidenceFileLabel: 'ملف الدليل (صورة، صوت، فيديو أو PDF)', + courtsCaseMediators: 'الوسطاء', + courtsCaseMediatorsPh: 'معرّفات Oasis للوسطاء، مفصولة بفاصلة', + courtsMediatorsLabel: 'الوسطاء', + courtsThCase: 'القضية', + courtsThCreatedAt: 'تاريخ البدء', + courtsThActions: 'الإجراءات', + courtsPublicPrefLabel: 'الظهور بعد الحل', + courtsPublicPrefYes: 'أوافق على أن تكون هذه القضية علنية بالكامل', + courtsPublicPrefNo: 'أفضل إبقاء التفاصيل خاصة', + courtsPublicPrefSubmit: 'حفظ تفضيل الظهور', + courtsMethodMEDIATION: 'وساطة', + courtsNoCases: 'لا توجد قضايا.', + reportsTitle: "التقارير", + reportsDescription: "إدارة وتتبع التقارير المتعلقة بالمشاكل والأخطاء والإساءات وتحذيرات المحتوى في شبكتك.", + reportsFilterAll: "الكل", + reportsFilterMine: "خاصتي", + reportsFilterFeatures: "الميزات", + reportsFilterBugs: "الأخطاء", + reportsFilterAbuse: "الإساءة", + reportsFilterContent: "المحتوى", + reportsFilterConfirmed: "مؤكد", + reportsFilterResolved: "محلول", + reportsFilterOpen: "مفتوح", + reportsFilterUnderReview: "قيد المراجعة", + reportsFilterInvalid: "غير صالح", + reportsCreateButton: "إنشاء تقرير", + reportsTitleLabel: "العنوان", + reportsDescriptionLabel: "الوصف", + reportsDescriptionPlaceholder: "يرجى تقديم وصف مفصّل.", + reportsCategory: "الفئة", + reportsCategoryLabel: "الفئة", + reportsCategoryFeatures: "الميزات", + reportsCategoryBugs: "الأخطاء", + reportsCategoryAbuse: "الإساءة", + reportsCategoryContent: "مشاكل المحتوى", + reportsUpdateButton: "تحديث", + reportsDeleteButton: "حذف", + reportsDateLabel: "التاريخ", + reportsUploadFile: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", + reportsMineSectionTitle: "تقاريرك", + reportsFeaturesSectionTitle: "طلبات الميزات", + reportsBugsSectionTitle: "الأخطاء", + reportsAbuseSectionTitle: "تقارير الإساءة", + reportsContentSectionTitle: "مشاكل المحتوى", + reportsAllSectionTitle: "التقارير", + reportsNoItems: "لا توجد تقارير متاحة.", + reportsValidationTitle: "يرجى إدخال عنوان صالح.", + reportsValidationDescription: "لا يمكن أن يكون الوصف فارغًا.", + reportsValidationCategory: "يرجى اختيار فئة.", + reportsCreatedAt: "أُنشئ في", + reportsCreatedBy: "بواسطة", + reportsSeverity: "الخطورة", + reportsSeverityLow: "منخفضة", + reportsSeverityMedium: "متوسطة", + reportsSeverityHigh: "عالية", + reportsSeverityCritical: "حرجة", + reportsStatus: "الحالة", + reportsStatusOpen: "مفتوح", + reportsStatusUnderReview: "قيد المراجعة", + reportsStatusResolved: "محلول", + reportsStatusInvalid: "غير صالح", + reportsUpdateStatusButton: "تحديث الحالة", + reportsAnonymityOption: "إرسال بشكل مجهول", + reportsAnonymousAuthor: "مجهول", + reportsConfirmButton: "تأكيد التقرير!", + reportsConfirmations: "التأكيدات", + reportsConfirmedSectionTitle: "التقارير المؤكدة", + reportsCreateTaskButton: "إنشاء مهمة", + reportsOpenSectionTitle: "التقارير المفتوحة", + reportsUnderReviewSectionTitle: "التقارير قيد المراجعة", + reportsResolvedSectionTitle: "التقارير المحلولة", + reportsInvalidSectionTitle: "التقارير غير الصالحة", + reportsTemplateSectionTitle: 'قالب التقرير', + reportsBugTemplateTitle: 'تفاصيل إعادة الإنتاج (الأخطاء)', + reportsFeatureTemplateTitle: 'التفاصيل (الميزات)', + reportsAbuseTemplateTitle: 'التفاصيل (الإساءة)', + reportsContentTemplateTitle: 'التفاصيل (مشاكل المحتوى)', + reportsStepsToReproduceLabel: 'خطوات إعادة الإنتاج', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'النتيجة المتوقعة', + reportsExpectedBehaviorPlaceholder: 'ماذا يجب أن يحدث؟', + reportsActualBehaviorLabel: 'النتيجة الفعلية', + reportsActualBehaviorPlaceholder: 'ماذا يحدث فعلاً؟', + reportsEnvironmentLabel: 'البيئة', + reportsEnvironmentPlaceholder: 'الإصدار، الجهاز، نظام التشغيل، المتصفح، الإعدادات، السجلات، إلخ.', + reportsReproduceRateLabel: 'معدل إعادة الإنتاج', + reportsReproduceRateAlways: 'دائمًا', + reportsReproduceRateOften: 'غالبًا', + reportsReproduceRateSometimes: 'أحيانًا', + reportsReproduceRateRarely: 'نادرًا', + reportsReproduceRateUnable: 'غير قابل لإعادة الإنتاج', + reportsReproduceRateUnknown: 'غير محدد', + reportsProblemStatementLabel: 'المشكلة / الحاجة', + reportsProblemStatementPlaceholder: 'ما المشكلة التي تحلها هذه الميزة؟', + reportsUserStoryLabel: 'قصة الساكن', + reportsUserStoryPlaceholder: 'بصفتي <ساكن>، أريد <إجراء>، لكي <فائدة>.', + reportsAcceptanceCriteriaLabel: 'معايير القبول', + reportsAcceptanceCriteriaPlaceholder: '- بفرض...\n- عندما...\n- عندها...', + reportsWhatHappenedLabel: 'ماذا حدث؟', + reportsWhatHappenedPlaceholder: 'صف الحادثة مع السياق والتواريخ التقريبية.', + reportsReportedUserLabel: 'الساكن / الكيان المُبلَّغ عنه', + reportsReportedUserPlaceholder: '@ساكن، تغذية، معرّف، رابط، إلخ.', + reportsEvidenceLinksLabel: 'الأدلة / الروابط', + reportsEvidenceLinksPlaceholder: 'الصق الروابط، معرّفات الرسائل، لقطات الشاشة (إن وُجدت)، إلخ.', + reportsContentLocationLabel: 'مكان المحتوى', + reportsContentLocationPlaceholder: 'رابط، معرّف، قناة، محادثة، مؤلف، إلخ.', + reportsWhyInappropriateLabel: "لماذا هو غير مناسب", + reportsWhyInappropriatePlaceholder: 'اشرح السبب والتأثير.', + reportsRequestedActionLabel: 'الإجراء المطلوب', + reportsRequestedActionPlaceholder: 'إزالة، إخفاء، وسم، تحذير، إلخ.', + tribesTitle: "القبائل", + tribeAllSectionTitle: "القبائل", + tribeMineSectionTitle: "قبائلك", + tribeCreateSectionTitle: "إنشاء قبيلة", + tribeUpdateSectionTitle: "تحديث القبيلة", + tribeGallerySectionTitle: "معرض القبائل", + tribeRecentSectionTitle: "القبائل الأخيرة", + tribeTopSectionTitle: "القبائل الشائعة", + tribeviewTribeButton: "زيارة القبيلة", + tribeviewSubTribeButton: "زيارة القبيلة الفرعية", + tribeRootLabel: "الجذر", + tribeDescription: "استكشف أو أنشئ قبائل في شبكتك.", + tribeFilterAll: "الكل", + tribeFilterMine: "خاصتي", + tribeFilterMembership: "العضوية", + tribeFilterRecent: "الأخيرة", + tribeFilterTop: "الأفضل", + tribeFilterSubtribes: "القبائل الفرعية", + tribeFilterGallery: "المعرض", + tribeMainTribeLabel: "القبيلة الرئيسية", + tribeCreateButton: "إنشاء قبيلة", + tribeUpdateButton: "تحديث", + tribeDeleteButton: "حذف", + tribeImageLabel: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", + tribeTitleLabel: "العنوان", + searchTribesPlaceholder: "تصفية القبائل حسب الاسم …", + tribeTitlePlaceholder: "اسم القبيلة", + tribeDescriptionLabel: "الوصف", + tribeDescriptionPlaceholder: "صف هذه القبيلة", + tribeLocationLabel: "الموقع", + tribeLocationPlaceholder: "أين تقع هذه القبيلة؟", + tribeTagsLabel: "الوسوم", + tribeTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + tribeInviteMode: "وضع الدعوة", + tribeModeLabel: "الوضع", + tribeIsAnonymousLabel: "الحالة", + tribeMembersCount: "الأعضاء", + tribeInviteCodePlaceholder: "أدخل رمز الدعوة", + tribeJoinByCodeButton: "الانضمام بالرمز", + tribeJoinButton: "انضمام", + tribeEnterInvite: "إدخال الرمز", + tribeLeaveButton: "مغادرة", + tribeYes: "نعم", + tribeNo: "لا", + tribePublic: "عامة", + tribePrivate: "خاصة", + tribeGenerateInvite: "دعوة فردية", + tribeOpenInvitation: "دعوة مفتوحة", + tribeJoinOpen: "الانضمام إلى القبيلة", + tribeRemoveInvitation: "إزالة الدعوة", + tribeCreatedAt: "أُنشئت في", + tribeAuthor: "بواسطة", + tribeAuthorLabel: "المؤلف", + tribeStrict: "صارم", + tribeOpen: "مفتوح", + tribeFeedFilterRECENT: "الأخيرة", + tribeFeedFilterMINE: "خاصتي", + tribeFeedFilterALL: "الكل", + tribeFeedFilterTOP: "الأفضل", + tribeFeedRefeeds: "إعادات النشر", + tribeFeedRefeed: "إعادة نشر", + tribeFeedMessagePlaceholder: "اكتب تغذية…", + tribeFeedSend: "إرسال", + tribeFeedEmpty: "لا توجد رسائل تغذية متاحة بعد.", + noTribes: "لم يتم العثور على قبائل بعد.", + tribeNotFound: "لم يتم العثور على القبيلة!", + createTribeTitle: "إنشاء قبيلة", + updateTribeTitle: "تحديث القبيلة", + tribeSectionOverview: "نظرة عامة", + tribeSectionInhabitants: "السكان", + tribeSectionVotations: "التصويتات", + tribeSectionEvents: "الأحداث", + tribeSectionReports: "التقارير", + tribeSectionTasks: "المهام", + tribeSectionFeed: "التغذية", + tribeSectionForum: "المنتدى", + tribeSectionMarket: "السوق", + tribeSectionJobs: "الوظائف", + tribeSectionProjects: "المشاريع", + tribeSectionMedia: "الوسائط", + tribeSectionImages: "الصور", + tribeSectionAudios: "الملفات الصوتية", + tribeSectionVideos: "الفيديوهات", + tribeSectionDocuments: "المستندات", + tribeSectionBookmarks: "العلامات المرجعية", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "لا يوجد سكان في هذه القبيلة بعد.", + tribeEventCreate: "إنشاء حدث", + tribeEventsEmpty: "لا توجد أحداث بعد.", + tribeEventTitle: "العنوان", + tribeEventDescription: "الوصف", + tribeEventDate: "التاريخ", + tribeEventLocation: "الموقع", + tribeEventAttend: "حضور", + tribeEventUnattend: "مغادرة", + tribeEventAttendees: "الحاضرون", + tribeTaskCreate: "إنشاء مهمة", + tribeTasksEmpty: "لا توجد مهام بعد.", + tribeTaskTitle: "العنوان", + tribeTaskDescription: "الوصف", + tribeTaskPriority: "الأولوية", + tribeTaskDeadline: "الموعد النهائي", + tribeTaskAssignees: "المُكلَّفون", + tribeTaskStatusInProgress: "قيد التنفيذ", + tribeTaskStatusClosed: "إغلاق", + tribeTaskAssign: "إسناد", + tribeTaskUnassign: "إلغاء الإسناد", + tribeReportCreate: "إنشاء تقرير", + tribeReportsEmpty: "لا توجد تقارير بعد.", + tribeReportTitle: "العنوان", + tribeReportDescription: "الوصف", + tribeReportCategory: "الفئة", + tribeVotationCreate: "إنشاء تصويت", + tribeVotationsEmpty: "لا توجد تصويتات بعد.", + tribeVotationTitle: "العنوان", + tribeVotationDescription: "الوصف", + tribeVotationOptions: "الخيارات", + tribeVotationDeadline: "الموعد النهائي", + tribeVotationVote: "تصويت", + tribeVotationResults: "الأصوات", + tribeVotationClose: "إغلاق التصويت", + tribeVotationOptionPlaceholder: "خيار", + tribeForumCreate: "إنشاء منتدى", + tribeForumEmpty: "لا توجد محادثات بعد.", + tribeForumTitle: "العنوان", + tribeForumText: "الرسالة", + tribeForumCategory: "الفئة", + tribeForumReply: "رد", + tribeForumReplies: "الردود", + tribeMarketCreate: "إنشاء عرض", + tribeMarketEmpty: "لا توجد عروض بعد.", + tribeMarketTitle: "العنوان", + tribeMarketDescription: "الوصف", + tribeMarketPrice: "السعر", + tribeMarketImage: "الصورة", + tribeMarketCategory: "الفئة", + tribeJobCreate: "إنشاء وظيفة", + tribeJobsEmpty: "لا توجد وظائف بعد.", + tribeJobTitle: "العنوان", + tribeJobDescription: "الوصف", + tribeJobLocation: "الموقع", + tribeJobSalary: "الراتب", + tribeJobDeadline: "الموعد النهائي", + tribeProjectCreate: "إنشاء مشروع", + tribeProjectsEmpty: "لا توجد مشاريع بعد.", + tribeProjectTitle: "العنوان", + tribeProjectDescription: "الوصف", + tribeProjectGoal: "الهدف", + tribeProjectFunded: "مُموَّل", + tribeProjectDeadline: "الموعد النهائي", + tribeMediaUpload: "رفع وسائط", + readDocument: "قراءة المستند", + tribeCreateImage: "إنشاء صورة", + tribeCreateAudio: "إنشاء ملف صوتي", + tribeCreateVideo: "إنشاء فيديو", + tribeCreateDocument: "إنشاء مستند", + tribeCreateBookmark: "إنشاء علامة مرجعية", + tribeMediaEmpty: "لا توجد وسائط بعد.", + tribeMediaTitle: "العنوان", + tribeMediaDescription: "الوصف", + tribeMediaType: "النوع", + tribeMediaTypeImage: "صورة", + tribeMediaTypeVideo: "فيديو", + tribeMediaTypeAudio: "صوت", + tribeMediaTypeDocument: "مستند", + tribeMediaTypeBookmark: "علامة مرجعية", + tribeContentDelete: "حذف", + tribeInviteCodeText: "رمز الدعوة: ", + oasisVersionLabel: "إصدار Oasis", + tribeInviteCodeHint: "شارك هذا الرمز مع من تريد دعوته. يمكنه الانضمام من /invites → Tribes.", + tribeGroupTribe: "القبيلة", + tribeGroupOffice: "المكتب", + tribeGroupNetwork: "الشبكة", + tribeGroupEconomy: "الاقتصاد", + tribeGroupMedia: "الوسائط", + tribeStatusOpen: "مفتوح", + tribeStatusClosed: "مغلق", + tribeStatusInProgress: "قيد التنفيذ", + tribePriorityLow: "منخفضة", + tribePriorityMedium: "متوسطة", + tribePriorityHigh: "عالية", + tribePriorityCritical: "حرجة", + tribeTaskFilterAll: "الكل", + tribeMediaFilterAll: "الكل", + tribeReportCatBug: "خطأ", + tribeReportCatAbuse: "إساءة", + tribeReportCatContent: "محتوى", + tribeReportCatOther: "أخرى", + tribeForumCatGeneral: "عام", + tribeForumCatProposal: "اقتراح", + tribeForumCatQuestion: "سؤال", + tribeForumCatAnnouncement: "إعلان", + tribeMarketCatGoods: "سلع", + tribeMarketCatServices: "خدمات", + tribeMarketCatFood: "طعام", + tribeMarketCatOther: "أخرى", + tribeStatusLabel: "الحالة", + tribeSubTribes: "القبائل الفرعية", + tribeSubTribesCreate: "إنشاء قبيلة فرعية", + tribeSubTribesStrictDenied: "الوضع الصارم للقبيلة لا يسمح لك بإنشاء قبائل فرعية جديدة. يرجى الاتصال بالمسؤول.", + tribeSubTribesEmpty: "لم يتم إنشاء قبائل فرعية بعد.", + tribeActivityJoined: "انضم", + tribeActivityLeft: "غادر", + tribeActivityFeed: "تغذية", + tribeActivityRefeed: "إعادة نشر", + tribeGroupAnalytics: "التحليلات", + tribeGroupCreative: "إبداعي", + tribeSectionActivity: "النشاط", + tribeSectionTrending: "الرائج", + tribeSectionOpinions: "الآراء", + tribeSectionPixelia: "بكسيليا", + tribeSectionTags: "الوسوم", + tribeSectionSearch: "بحث", + tribeActivityEmpty: "لا يوجد نشاط بعد.", + tribeActivityCreated: "أنشأ", + tribeActivityPosted: "نشر", + tribeActivityReplied: "ردّ", + tribeTrendingEmpty: "لا يوجد محتوى رائج بعد.", + tribeTrendingPeriodDay: "اليوم", + tribeTrendingPeriodWeek: "هذا الأسبوع", + tribeTrendingPeriodAll: "كل الأوقات", + tribeTrendingEngagement: "التفاعل", + tribeOpinionsEmpty: "لا توجد آراء بعد.", + tribeOpinionsCast: "تصويت", + tribeOpinionsRankings: "الترتيب", + tribeOpinionsAlreadyVoted: "تم التصويت بالفعل", + tribeTopCategory: "الأكثر تصويتًا", + tribePixeliaTitle: "بكسيليا", + tribePixeliaDescription: "لوحة فن بكسل تعاونية.", + tribePixeliaPaint: "رسم", + tribePixeliaContributors: "المساهمون", + tribePixeliaTotalPixels: "البكسلات المرسومة", + tribeTagsEmpty: "لم يتم العثور على وسوم.", + tribeTagsCloud: "سحابة الوسوم", + tribeTagsContentWith: "محتوى بالوسم", + tribeSearchPlaceholder: "ابحث في محتوى القبيلة...", + tribeSearchEmpty: "لم يتم العثور على نتائج.", + tribeSearchResults: "النتائج", + tribeSearchMinChars: "أدخل حرفين على الأقل للبحث.", + agendaTitle: "الأجندة", + agendaDescription: "هنا يمكنك العثور على جميع العناصر المسندة إليك.", + agendaFilterAll: "الكل", + agendaFilterToday: "اليوم", + agendaFilterUpcoming: "القادمة", + agendaFilterOverdue: "متأخرة", + agendaFilterOpen: "مفتوح", + agendaFilterClosed: "مغلق", + agendaFilterTasks: "المهام", + agendaFilterMarket: "السوق", + agendaFilterTribes: "القبائل", + agendaFilterEvents: "الأحداث", + agendaFilterReports: "التقارير", + agendaFilterTransfers: "التحويلات", + agendaFilterJobs: "الوظائف", + agendaFilterProjects: "المشاريع", + agendaFilterCalendars: "التقويمات", + agendaNoItems: "لم يتم العثور على إسنادات.", + agendaAuthor: "بواسطة", + agendaDiscardButton: "تجاهل", + agendaRestoreButton: "استعادة", + agendaCreatedAt: "أُنشئ في", + agendaTitleLabel: "العنوان", + agendaMembersCount: "الأعضاء", + agendaDescriptionLabel: "الوصف", + agendaStatus: "الحالة", + agendaVisibility: "الظهور", + agendaEventDate: "تاريخ الحدث", + agendaEventLocation: "الموقع", + agendaEventPrice: "السعر", + agendaEventUrl: "الرابط", + agendaTaskStart: "وقت البدء", + agendaLocationLabel: "الموقع", + agendaYes: "نعم", + agendaNo: "لا", + agendaInviteModeLabel: "الحالة", + agendaAnonymousLabel: "مجهول", + agendaMembersLabel: "الأعضاء", + agendareportCategory: "الفئة", + agendareportSeverity: "الخطورة", + agendareportStatus: "الحالة", + agendareportDescription: "الوصف", + agendaTaskEnd: "وقت الانتهاء", + agendaTaskPriority: "الأولوية", + agendaTransferFrom: "من", + agendaTransferTo: "إلى", + agendaTransferConcept: "المفهوم", + agendaTransferAmount: "المبلغ", + agendaTransferDeadline: "الموعد النهائي", + opinionsTitle: "الآراء", + shareYourOpinions: "اكتشف وصوّت للآراء في شبكتك.", + author: "بواسطة", + voteNow: "صوّت الآن", + alreadyVoted: "لقد أبديت رأيك بالفعل.", + noOpinionsFound: "لم يتم العثور على آراء.", + RECENTButton: "الأخيرة", + TOPButton: "الأفضل", + interestingButton: "مثير للاهتمام", + necessaryButton: "ضروري", + funnyButton: "مضحك", + disgustingButton: "مقزز", + sensibleButton: "حساس", + propagandaButton: "دعاية", + adultOnlyButton: "للبالغين فقط", + boringButton: "ممل", + confusingButton: "مربك", + usefulButton: "مفيد", + informativeButton: "إعلامي", + wellResearchedButton: "بحث جيد", + accurateButton: "دقيق", + needsSourcesButton: "يحتاج مصادر", + wrongButton: "خاطئ", + lowQualityButton: "جودة منخفضة", + creativeButton: "إبداعي", + insightfulButton: "ثاقب", + actionableButton: "قابل للتنفيذ", + inspiringButton: "ملهم", + loveButton: "حب", + clearButton: "واضح", + upliftingButton: "مُحفّز", + unnecessaryButton: "غير ضروري", + rejectedButton: "مرفوض", + misleadingButton: "مضلل", + offTopicButton: "خارج الموضوع", + duplicateButton: "مكرر", + clickbaitButton: "طُعم نقرات", + spamButton: "رسائل مزعجة", + trollButton: "تصيّد", + nsfwButton: "NSFW", + violentButton: "عنيف", + toxicButton: "سام", + harassmentButton: "تحرش", + hateButton: "كراهية", + scamButton: "احتيال", + triggeringButton: "مُحفّز سلبيًا", + opinionsCreatedAt: "أُنشئ في", + opinionsTotalCount: "إجمالي الآراء", + voteInteresting: "مثير للاهتمام", + voteNecessary: "ضروري", + voteUseful: "مفيد", + voteInformative: "إعلامي", + voteWellResearched: "بحث جيد", + voteNeedsSources: "يحتاج مصادر", + voteWrong: "خاطئ", + voteLowQuality: "جودة منخفضة", + voteLove: "حب", + voteClear: "واضح", + voteMisleading: "مضلل", + voteOffTopic: "خارج الموضوع", + voteDuplicate: "مكرر", + voteClickbait: "طُعم نقرات", + votePropaganda: "دعاية", + voteFunny: "مضحك", + voteInspiring: "ملهم", + voteUplifting: "مُحفّز", + voteUnnecessary: "غير ضروري", + voteRejected: "مرفوض", + voteConfusing: "مربك", + voteTroll: "تصيّد", + voteNsfw: "NSFW", + voteViolent: "عنيف", + voteToxic: "سام", + voteHarassment: "تحرش", + voteHate: "كراهية", + voteScam: "احتيال", + voteTriggering: "مُحفّز سلبيًا", + voteInsightful: "ثاقب", + voteAccurate: "دقيق", + voteActionable: "قابل للتنفيذ", + voteCreative: "إبداعي", + voteSpam: "رسائل مزعجة", + voteAdultOnly: "للبالغين فقط", + publishBlog: "نشر مدونة", + privateMessage: "رسالة خاصة", + pmSendTitle: "الرسائل الخاصة", + pmSend: "إرسال!", + pmDescription: "استخدم هذا النموذج لإرسال رسالة مشفرة إلى سكان آخرين.", + pmRecipients: "المستلمون", + pmRecipientsHint: "أدخل معرّفات Oasis مفصولة بفواصل", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "الموضوع", + pmSubjectHint: "أدخل موضوع الرسالة", + pmText: "الرسالة", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "فك التشفير", + pmCrypterBadKey: "المفتاح المشترك غير صحيح!", + pmCrypterTooLong: "الرسالة طويلة جدًا للتشفير باستخدام Crypter. يُرجى اختصارها والمحاولة مرة أخرى.", + pmCrypterCipherLabel: "الرسالة المعاد تشفيرها", + pmCrypterCharsLabel: "حرفًا", + pmInvalidRecipients: "معرّف Oasis غير صالح (يجب أن يكون بالشكل @…=.ed25519).", + pmEncryptionLabel: "التشفير", + pmFile: "المرفق", + private: "خاص", + privateDescription: "رسائلك المشفرة.", + privateInbox: "الوارد", + privateSent: "المُرسَل", + privateDelete: "حذف", + pmCreateButton: "كتابة رسالة خاصة", + noPrivateMessages: "لا توجد رسائل خاصة.", + pmFromLabel: "من:", + pmToLabel: "إلى:", + pmInvalidMessage: "رسالة غير صالحة", + pmNoSubject: "(بدون موضوع)", + pmSubjectLabel: "الموضوع:", + pmBodyLabel: "النص", + pmBotJobs: "42-JobsBOT", + pmBotProjects: "42-ProjectsBOT", + pmBotMarket: "42-MarketBOT", + inboxJobSubscribedTitle: "اشتراك جديد في عرض عملك", + pmInhabitantWithId: "ساكن بمعرّف OASIS:", + pmHasSubscribedToYourJobOffer: "اشترك في عرض عملك", + inboxProjectCreatedTitle: "تم إنشاء مشروع جديد", + pmHasCreatedAProject: "أنشأ مشروعًا", + inboxMarketItemSoldTitle: "تم بيع عنصر", + pmYourItem: "عنصرك", + pmHasBeenSoldTo: "تم بيعه إلى", + pmFor: "مقابل", + inboxProjectPledgedTitle: "تبرع جديد لمشروعك", + pmHasPledged: "تبرع بـ", + pmToYourProject: "لمشروعك", + blockchain: 'مستكشف الكتل', + blockchainTitle: 'مستكشف الكتل', + blockchainDescription: 'استكشف وتصوّر الكتل في البلوكتشين.', + blockchainNoBlocks: 'لم يتم العثور على كتل في البلوكتشين.', + blockchainStatsTitle: 'الإحصاءات', + blockchainStatsTypeBreakdown: 'التوزيع حسب النوع', + blockchainStatsTopAuthors: 'أبرز المؤلفين', + blockchainStatsCount: 'كتل', + blockchainBlockID: 'معرّف الكتلة', + blockchainBlockAuthor: 'المؤلف', + blockchainBlockCarbon: 'البصمة الكربونية', + blockchainBlockType: 'النوع', + blockchainBlockTimestamp: 'الطابع الزمني', + blockchainBlockContent: 'الكتلة', + blockchainBlockURL: 'الرابط:', + blockchainContent: 'الكتلة', + blockchainContentPreview: 'معاينة محتوى الكتلة', + blockchainLatestDatagram: 'آخر بيانات', + blockchainDatagram: 'البيانات', + blockchainDetails: 'عرض تفاصيل الكتلة', + blockchainViewBlockexplorer: "عرض في مستكشف الكتل", + blockchainBlockInfo: 'معلومات الكتلة', + blockchainBlockDetails: 'تفاصيل الكتلة المحددة', + blockchainBack: 'العودة إلى مستكشف الكتل', + blockchainContentDeleted: "تم حذف هذا المحتوى", + visitContent: "زيارة المحتوى", + banking: 'المصرفية', + bankingTitle: 'المصرفية', + bankingDescription: 'استكشف القيمة الحالية لـ ECOin وتخصيص الدخل الأساسي الشامل المقابل، الموزّع لكل حقبة بناءً على المشاركة والثقة.', + bankOverview: 'نظرة عامة', + bankEpochs: 'الحقب', + bankRules: 'القواعد', + pending: 'معلّق', + closed: 'مغلق', + bankBack: 'العودة إلى المصرفية', + bankViewTx: 'عرض المعاملة', + bankClaimNow: 'طالب الآن', + bankClaimUBI: 'طالب بـ UBI!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'لا توجد تخصيصات UBI معلقة لهذه الحقبة.', + bankEpoch: 'الحقبة', + bankPool: 'المجمع (هذه الحقبة)', + bankWeightsSum: 'مجموع الأوزان', + bankAllocations: 'التخصيصات', + bankNoAllocations: 'لم يتم العثور على تخصيصات.', + bankNoEpochs: 'لم يتم العثور على حقب.', + bankEpochAllocations: 'تخصيصات الحقبة', + bankAllocId: 'معرّف التخصيص', + bankAllocDate: 'التاريخ', + bankAllocConcept: 'المفهوم', + bankAllocFrom: 'من', + bankAllocTo: 'إلى', + bankAllocAmount: 'المبلغ', + bankAllocStatus: 'الحالة', + bankEpochId: 'معرّف الحقبة', + bankRuleHash: 'بصمة لقطة القواعد', + bankViewEpoch: 'عرض الحقبة', + bankUserBalance: 'رصيدك', + ecoWalletNotConfigured: 'محفظة ECOin غير مُعدّة', + editWallet: 'تعديل المحفظة', + addWallet: 'إضافة محفظة', + bankAddresses: 'العناوين', + bankNoAddresses: 'لم يتم العثور على عناوين.', + bankUser: 'معرّف Oasis', + bankAddress: 'العنوان', + bankAddAddressTitle: 'إضافة عنوان ECOIN', + bankAddAddressUser: 'معرّف Oasis', + bankAddAddressAddress: 'عنوان ECOIN', + bankAddAddressSave: 'حفظ', + bankAddressAdded: 'تمت إضافة العنوان', + bankAddressUpdated: 'تم تحديث العنوان', + bankAddressExists: 'العنوان موجود بالفعل', + bankAddressInvalid: 'عنوان غير صالح', + bankAddressDeleted: 'تم حذف العنوان', + bankAddressNotFound: 'لم يتم العثور على العنوان', + bankAddressForbidden: 'يمكنك فقط تعيين عنوان الدفع الخاص بك', + bankAddressTotal: 'إجمالي العناوين', + bankAddressSearch: 'ابحث عن @ساكن أو عنوان', + bankAddressActions: 'الإجراءات', + bankAddressDelete: 'حذف', + bankAddressSource: 'المصدر', + bankAddressDeleteConfirm: 'حذف هذا العنوان؟', + search: 'ابحث!', + bankLocal: 'محلي', + bankFromOasis: 'Oasis', + bankMyAddress: 'عنوانك', + bankRemoveMyAddress: 'إزالة عنواني', + bankNotRemovableOasis: 'لا يمكن إزالة العناوين محليًا', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "لا توجد أموال!", + bankUbiAvailableOk: "متوفر!", + bankUbiAvailability: "توفر UBI", + bankAlreadyClaimedThisMonth: "تم المطالبة بالفعل هذا الشهر", + bankUbiThisMonth: "الدخل الأساسي (هذا الشهر)", + bankUbiLastClaimed: "الدخل الأساسي (آخر مطالبة)", + bankUbiNeverClaimed: "لم يتم المطالبة قط", + bankUbiTotalClaimed: "الدخل الأساسي (إجمالي المطالبات)", + bankUbiPub: "PUB", + bankUbiInhabitant: "ساكن", + bankUbiClaimedAmount: "المطالب به (ECO)", + typeBankUbiResult: "البنك - الدخل الأساسي", + bankNoPubConfigured: "لم يتم تكوين PUB. قم بتعيين معرف PUB في الإعدادات.", + shopsTitle: "متاجر", + shopDescription: "اكتشف وأدر المتاجر في الشبكة.", + shopTitle: "متجر", + shopFilterAll: "الكل", + shopFilterMine: "متاجري", + shopFilterRecent: "الأحدث", + shopFilterTop: "الأفضل", + shopFilterProducts: "المنتجات", + shopFilterPrices: "الأسعار", + shopFilterFavorites: "المفضلة", + shopUpload: "إنشاء متجر", + shopAllSectionTitle: "جميع المتاجر", + shopMineSectionTitle: "متاجري", + shopRecentSectionTitle: "متاجر حديثة", + shopTopSectionTitle: "أفضل المتاجر", + shopProductsSectionTitle: "أفضل المنتجات", + shopPricesSectionTitle: "المنتجات حسب السعر", + shopFavoritesSectionTitle: "المفضلة", + shopCreateSectionTitle: "إنشاء متجر", + shopUpdateSectionTitle: "تحديث متجر", + shopCreate: "إنشاء", + shopUpdate: "تحديث", + shopDelete: "حذف", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "النشر على Clearnet", + shopClearnetUnpublish: "إلغاء النشر من Clearnet", + shopClearnetUrlLabel: "رابط Clearnet", + shopClearnetWarning: "النشر على Clearnet يجعل هذا المتجر قابلاً للفهرسة بواسطة محركات البحث والزواحف الخارجية.", + shopAddFavorite: "إضافة مفضلة", + shopRemoveFavorite: "إزالة مفضلة", + shopOpen: "مفتوح", + shopClosed: "مغلق", + shopOpenShop: "فتح المتجر", + shopCloseShop: "إغلاق المتجر", + shopMakePrivate: "جعلها خاصة (E2E)", + shopMakePublic: "جعلها عامة", + shopProducts: "المنتجات", + shopProductAdd: "إضافة منتج", + shopProductTitle: "منتج", + shopProductUpdate: "تحديث منتج", + shopProductPrice: "السعر (ECO)", + shopProductStock: "المخزون", + shopProductUntitled: "منتج بلا عنوان", + shopUntitled: "متجر بلا عنوان", + shopNoItems: "لم يتم العثور على متاجر.", + shopNoProducts: "لا توجد منتجات بعد.", + shopOutOfStock: "نفد المخزون", + shopBuy: "شراء", + shopBuyEncryptedNote: "بيانات التوصيل الخاصة بك مشفّرة ولا يراها سوى صاحب المتجر.", + shopBuyDeliveryAddress: "عنوان التوصيل", + shopBuyContact: "جهة الاتصال", + shopBuyContactPlaceholder: "البريد، الهاتف، إلخ.", + shopBuyNotes: "ملاحظات", + shopBuyDeliveryRequired: "عنوان التوصيل مطلوب.", + shopOrdersTitle: "الطلبات", + shopOrdersDescription: "الطلبات المستلمة من هذا المتجر.", + shopOrdersEmpty: "لا توجد طلبات بعد.", + shopOrderProduct: "المنتج", + shopOrderPrice: "السعر", + shopOrderBuyer: "المشتري", + shopBackToShop: "العودة إلى المتجر", + shopShareUrl: "رابط المشاركة", + shopVisitShop: "زيارة المتجر", + marketVisitItem: "زيارة العنصر", + shopStatus: "الحالة", + shopCreatedAt: "تاريخ الإنشاء", + shopSearchPlaceholder: "البحث عن متاجر...", + shopUrl: "الرابط", + shopLocation: "الموقع", + shopTags: "الوسوم", + shopVisibility: "الرؤية", + shopImage: "صورة", + shopShortDescription: "وصف قصير", + shopShortDescriptionPlaceholder: "وصف مختصر لبطاقات المتجر (160 حرف كحد أقصى)", + shopTitlePlaceholder: "اسم متجرك", + shopDescriptionPlaceholder: "وصف تفصيلي لمتجرك", + shopUrlPlaceholder: "https://رابط-متجرك.com", + shopLocationPlaceholder: "المدينة، البلد", + shopTagsPlaceholder: "وسم1، وسم2، وسم3", + mapTitlePlaceholder: "عنوان الخريطة", + shopProductFeatured: "منتج مميز", + shopSendToMarket: "Send to Market", + typeShop: "متاجر", + typeShopProduct: "منتج متجر", + bankExchange: 'الصرف', + bankExchangeCurrentValue: 'قيمة ECOin (1 ساعة)', + bankTotalSupply: 'إجمالي معروض ECOin', + bankEcoinHours: "معادلة ECOin بالوقت", + bankHoursOfWork: 'ساعات', + bankUnitMs: "مللي ثانية", + bankUnitSeconds: "ثواني", + bankUnitMinutes: "دقائق", + bankUnitDays: "أيام", + bankExchangeNoData: 'لا توجد بيانات متاحة', + bankExchangeIndex: 'قيمة ECOin (1 ساعة)', + bankInflation: 'تضخم ECOin (anual)', + bankInflationMonthly: 'تضخم ECOin (mensual)', + bankExchangeChartTitle: 'تطور قيمة ECOin عبر الزمن', + bankExchangeChartValue: 'القيمة (ECO/ساعة)', + bankExchangeChartSupply: 'العرض', + bankExchangeChartInflation: 'التضخم %', + bankExchangeChartEmpty: 'لا توجد عينات كافية بعد — ارجع لاحقًا', + bankCurrentSupply: 'المعروض الحالي من ECOin', + bankingSyncStatus: 'حالة ECOin', + bankingSyncStatusSynced: 'متزامن', + bankingSyncStatusOutdated: 'قديم', + statsTitle: 'الإحصائيات', + statistics: "الإحصائيات", + statsInhabitant: "إحصائيات الساكن", + statsDescription: "اكتشف إحصائيات عن شبكتك.", + ALLButton: "الكل", + MINEButton: "خاصتي", + TOMBSTONEButton: "شواهد القبور", + statsYou: "أنت", + statsUserId: "معرّف Oasis", + statsCreatedAt: "أُنشئ في", + statsYourContent: "المحتوى", + statsYourOpinions: "الآراء", + statsYourTombstone: "شواهد القبور", + statsNetwork: "الشبكة", + statsTotalInhabitants: "السكان", + statsDiscoveredTribes: "القبائل (العامة)", + statsPrivateDiscoveredTribes: "القبائل (الخاصة)", + statsNetworkContent: "المحتوى", + statsYourMarket: "السوق", + statsYourJob: "الوظائف", + statsYourProject: "المشاريع", + statsYourTransfer: "التحويلات", + statsYourForum: "المنتديات", + statsNetworkOpinions: "الآراء", + statsDiscoveredMarket: "السوق", + statsDiscoveredJob: "الوظائف", + statsDiscoveredProject: "المشاريع", + statsBankingTitle: "المصرفية", + statsEcoWalletLabel: "محفظة ECOIN", + statsEcoWalletNotConfigured: "غير مُعدّة!", + statsTotalEcoAddresses: "إجمالي العناوين", + statsDiscoveredTransfer: "التحويلات", + statsDiscoveredForum: "المنتديات", + statsNetworkTombstone: "شواهد القبور", + statsBookmark: "العلامات المرجعية", + statsEvent: "الأحداث", + statsTask: "المهام", + statsVotes: "الأصوات", + statsMarket: "السوق", + statsForum: "المنتديات", + statsJob: "الوظائف", + statsReport: "التقارير", + statsFeed: "التغذيات", + statsTribe: "القبائل", + statsImage: "الصور", + statsAudio: "الملفات الصوتية", + statsVideo: "الفيديوهات", + statsDocument: "المستندات", + statsMap: "الخرائط", + statsShop: "المتاجر", + statsShopProduct: "منتجات المتجر", + statsTransfer: "التحويلات", + statsAiExchange: "الذكاء الاصطناعي", + statsPUBs: 'PUBs', + statsPost: "المنشورات", + statsOasisID: "معرّف Oasis", + statsSize: "الإجمالي (الحجم)", + statsBlockchainSize: "البلوكتشين (الحجم)", + statsBlobsSize: "الملفات (الحجم)", + statsActivity7d: "النشاط (آخر 7 أيام)", + statsActivity7dTotal: "إجمالي 7 أيام", + statsActivity30dTotal: "إجمالي 30 يومًا", + statsKarmaScore: "نقاط الكارما", + statsPublic: "عام", + statsPrivate: "خاص", + day: "يوم", + messages: "الرسائل", + statsProject: "المشاريع", + statsProjectsTitle: "المشاريع", + statsProjectsTotal: "إجمالي المشاريع", + statsProjectsActive: "نشط", + statsProjectsCompleted: "مكتمل", + statsProjectsPaused: "متوقف", + statsProjectsCancelled: "ملغى", + statsProjectsGoalTotal: "الهدف الإجمالي", + statsProjectsPledgedTotal: "إجمالي التبرعات", + statsProjectsSuccessRate: "نسبة النجاح", + statsProjectsAvgProgress: "متوسط التقدم", + statsProjectsMedianProgress: "متوسط التقدم (الوسيط)", + statsProjectsActiveFundingAvg: "متوسط التمويل النشط", + statsJobsTitle: "الوظائف", + statsJobsTotal: "إجمالي الوظائف", + statsJobsOpen: "مفتوح", + statsJobsClosed: "مغلق", + statsJobsOpenVacants: "الشواغر المفتوحة", + statsJobsSubscribersTotal: "إجمالي المشتركين", + statsJobsAvgSalary: "متوسط الراتب", + statsJobsMedianSalary: "متوسط الراتب (الوسيط)", + statsMarketTitle: "السوق", + statsMarketTotal: "إجمالي العناصر", + statsMarketForSale: "للبيع", + statsMarketReserved: "محجوز", + statsMarketClosed: "مغلق", + statsMarketSold: "مُباع", + statsMarketRevenue: "الإيرادات", + statsMarketAvgSoldPrice: "متوسط سعر البيع", + statsUsersTitle: "السكان", + user: "ساكن", + statsTombstoneTitle: "شواهد القبور", + statsNetworkTombstones: "شواهد قبور الشبكة", + statsTombstoneRatio: "نسبة شواهد القبور (%)", + statsParliamentCandidature: "ترشيحات البرلمان", + statsParliamentTerm: "فترات البرلمان", + statsParliamentProposal: "مقترحات البرلمان", + statsParliamentRevocation: "إلغاءات البرلمان", + statsParliamentLaw: "قوانين البرلمان", + statsCourtsCase: "قضايا المحاكم", + statsCourtsEvidence: "أدلة المحاكم", + statsCourtsAnswer: "ردود المحاكم", + statsCourtsVerdict: "أحكام المحاكم", + statsCourtsSettlement: "تسويات المحاكم", + statsCourtsSettlementProposal: "مقترحات التسوية", + statsCourtsSettlementAccepted: "التسويات المقبولة", + statsCourtsNomination: "ترشيحات القضاة", + statsCourtsNominationVote: "أصوات الترشيح", + ai: "الذكاء الاصطناعي", + aiTitle: "الذكاء الاصطناعي", + aiDescription: "ذكاء اصطناعي جماعي (CAI) يُدعى '42' يتعلم من شبكتك.", + aiUserQuestion: "السؤال", + aiResponseTitle: "الرد", + aiSubmitButton: "إرسال!", + aiSettingsDescription: "عيّن التوجيه (128 حرفًا كحد أقصى) لنموذج الذكاء الاصطناعي.", + aiPrompt: "قدّم ردًا إعلاميًا ودقيقًا.", + aiConfiguration: "تعيين التوجيه", + aiPromptUsed: "التوجيه", + aiClearHistory: "مسح سجل المحادثة", + aiSharePrompt: "إضافة هذه الإجابة للتدريب الجماعي؟", + aiShareYes: "نعم", + aiShareNo: "لا", + aiSharedLabel: "تمت الإضافة للتدريب", + aiRejectedLabel: "لم تتم الإضافة للتدريب", + aiServerError: "لم يتمكن الذكاء الاصطناعي من الرد. يرجى المحاولة مرة أخرى.", + aiInputPlaceholder: "ما هو Oasis؟", + typeAiExchange: "الذكاء الاصطناعي", + aiApproveTrain: "إضافة للتدريب الجماعي", + aiRejectTrain: "عدم التدريب", + aiTrainPending: "بانتظار الموافقة", + aiTrainApproved: "تمت الموافقة للتدريب", + aiTrainRejected: "تم الرفض للتدريب", + aiSnippetsUsed: "المقتطفات المُستخدَمة", + aiSnippetsLearned: "المقتطفات المُتعلَّمة", + statsAITraining: "تدريب الذكاء الاصطناعي", + aiApproveCustomTrain: "التدريب باستخدام هذه الإجابة المخصصة", + aiCustomAnswerPlaceholder: "اكتب إجابتك المخصصة…", + statsAIExchanges: "تبادلات النموذج", + marketMineSectionTitle: "عناصرك", + marketCreateSectionTitle: "إنشاء عنصر", + marketUpdateSectionTitle: "تحديث", + marketAllSectionTitle: "السوق", + marketRecentSectionTitle: "السوق الأخير", + marketTitle: "السوق", + marketDescription: "سوق لتبادل السلع أو الخدمات في شبكتك.", + marketFilterAll: "الكل", + marketFilterMine: "خاصتي", + marketFilterAuctions: "المزادات", + marketFilterItems: "التبادل", + marketFilterNew: "جديد", + marketFilterUsed: "مستعمل", + marketFilterBroken: "معطّل", + marketFilterForSale: "للبيع", + marketFilterSold: "مُباع", + marketFilterDiscarded: "ملغى", + marketFilterRecent: "الأخيرة", + marketFilterMyBids: "العروض", + marketCreateButton: "إنشاء عنصر", + marketItemType: "النوع", + marketItemTitle: "العنوان", + marketItemAvailable: "الموعد النهائي", + marketItemDescription: "الوصف", + marketItemDescriptionPlaceholder: "صف العنصر الذي تبيعه", + marketItemStatus: "الحالة", + marketShopLabel: "متجر", + marketItemCondition: "الحالة", + marketItemPrice: "السعر", + marketItemTags: "الوسوم", + marketItemTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + marketItemDeadline: "الموعد النهائي", + marketItemIncludesShipping: "يشمل الشحن؟", + marketItemHighestBid: "أعلى عرض", + marketItemHighestBidder: "صاحب أعلى عرض", + marketItemStock: "المخزون", + marketOutOfStock: "نفد المخزون", + marketItemBidTime: "وقت العرض", + marketActionsUpdate: "تحديث", + marketUpdateButton: "تحديث", + marketActionsDelete: "حذف", + marketActionsSold: "تعيين كمُباع", + marketActionsChangeStatus: "تغيير الحالة", + marketActionsBuy: "شراء!", + marketAuctionBids: "العروض الحالية", + marketPlaceBidButton: "تقديم عرض", + marketItemSeller: "البائع", + marketNoItems: "لا توجد عناصر متاحة بعد.", + marketYourBid: "عرضك", + marketCreateFormImageLabel: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", + marketSearchLabel: "بحث", + marketSearchPlaceholder: "ابحث عن عنوان أو وسوم", + marketMinPriceLabel: "الحد الأدنى للسعر", + marketMaxPriceLabel: "الحد الأقصى للسعر", + marketSortLabel: "ترتيب حسب", + marketSortRecent: "الأحدث", + marketSortPrice: "السعر", + marketSortDeadline: "الموعد النهائي", + marketSearchButton: "بحث", + marketAuctionEndsIn: "ينتهي", + marketAuctionEnded: "انتهى", + marketMyBidBadge: "عرضتَ", + marketNoItemsMatch: "لا توجد عناصر تطابق بحثك.", + jobsTitle: "الوظائف", + jobsCandidatesTitle: "المرشحون المقترحون", + jobsCandidatesDescription: "السكان الذين تتطابق مهاراتهم مع هذه الوظيفة. أرسل لهم رسالة خاصة لدعوتهم.", + jobsCandidatesPmBody: "مرحبًا، أعتقد أن ملفك الشخصي يتطابق مع عرض العمل الخاص بي", + jobsDescription: "اكتشف وأدِر الوظائف في شبكتك.", + jobsFilterRecent: "الأخيرة", + jobsFilterMine: "خاصتي", + jobsFilterAll: "الكل", + jobsFilterRemote: "عن بُعد", + jobsFilterOpen: "مفتوح", + jobsFilterClosed: "مغلق", + jobsCV: "السير الذاتية", + jobsCreateJob: "إنشاء وظيفة", + jobsRecentTitle: "الوظائف الأخيرة", + jobsMineTitle: "وظائفك", + jobsAllTitle: "الوظائف", + jobsRemoteTitle: "الوظائف عن بُعد", + jobsOpenTitle: "الوظائف المفتوحة", + jobsClosedTitle: "الوظائف المغلقة", + jobsCVTitle: "السير الذاتية", + jobsFilterPresencial: "حضوري", + jobsFilterFreelancer: "مستقل", + jobsFilterEmployee: "موظف", + jobsPresencialTitle: "الوظائف الحضورية", + jobsFreelancerTitle: "الوظائف المستقلة", + jobsEmployeeTitle: "وظائف الموظفين", + jobTitle: "العنوان", + jobLocation: "الموقع", + jobSalary: "الراتب (ECO/1 ساعة)", + jobVacants: "الشواغر", + jobDescription: "الوصف", + jobRequirements: "المتطلبات", + jobLanguages: "اللغات", + jobStatus: "الحالة", + jobStatusOPEN: "مفتوح", + jobStatusCLOSED: "مغلق", + jobSetClosed: "تعيين كمغلق", + jobSubscribeButton: "انضم لهذا العرض!", + jobUnsubscribeButton: "غادر هذا العرض!", + jobTitlePlaceholder: "أدخل عنوان الوظيفة", + jobDescriptionPlaceholder: "صف الوظيفة", + jobRequirementsPlaceholder: "أدخل المتطلبات", + jobLanguagesPlaceholder: "العربية، الإنجليزية، الفرنسية...", + jobTasksPlaceholder: "قائمة المهام", + jobLocationPresencial: "حضوري", + jobLocationRemote: "عن بُعد", + jobVacantsPlaceholder: "عدد المناصب", + jobSalaryPlaceholder: "الراتب بـ ECO لساعة عمل واحدة", + jobImage: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", + jobTasks: "المهام", + jobType: "نوع الوظيفة", + jobTime: "وقت الوظيفة", + jobSubscribers: "المشتركون", + noSubscribers: "لا يوجد مشتركون", + jobsFilterTop: "الأفضل", + jobsTopTitle: "الوظائف الأعلى راتبًا", + createJobButton: "نشر الوظيفة", + viewDetailsButton: "عرض التفاصيل", + noJobsFound: "لم يتم العثور على عروض وظيفية.", + jobAuthor: "بواسطة", + jobTypeFreelance: "مستقل", + jobTypeSalary: "موظف", + jobTypeExchange: "تبادل ساعات", + jobTypeEXCHANGE: "تبادل ساعات", + jobsFilterExchange: "تبادل", + jobsExchangeTitle: "وظائف تبادل ساعات", + jobsHoursOffered: "الساعات المعروضة", + jobsHoursRequested: "الساعات المطلوبة في المقابل", + jobsExchangeSkill: "المهارة المطلوبة في المقابل", + jobsHoursOfferedPlaceholder: "مثال: 4", + jobsHoursRequestedPlaceholder: "مثال: 4", + jobsExchangeSkillPlaceholder: "مثال: النجارة، التصميم", + jobExchangeHint: "بالنسبة لوظائف تبادل الساعات، املأ الحقول أدناه بدلاً من الراتب.", + jobTimePartial: "دوام جزئي", + jobTimeComplete: "دوام كامل", + jobsDeleteButton: "حذف", + jobsUpdateButton: "تحديث", + jobsFilterApplied: "مُتقدَّم", + jobsAppliedTitle: "طلباتي", + jobsAppliedBadge: "مُتقدَّم", + jobsFilterFavs: "المفضلة", + jobsFavsTitle: "المفضلة", + jobsFilterNeeds: "يحتاج مساعدة", + jobsNeedsTitle: "يحتاج مساعدة", + jobsSearchLabel: "بحث", + jobsSearchPlaceholder: "ابحث عن عنوان، وسوم، وصف...", + jobsMinSalaryLabel: "الحد الأدنى للراتب", + jobsMaxSalaryLabel: "الحد الأقصى للراتب", + jobsSortLabel: "ترتيب حسب", + jobsSortRecent: "الأحدث", + jobsSortSalary: "الأعلى راتبًا", + jobsSortSubscribers: "الأكثر متقدمين", + jobsSearchButton: "بحث", + jobsFavoriteButton: "إضافة للمفضلة", + jobsUnfavoriteButton: "إزالة من المفضلة", + jobsMessageAuthorButton: "رسالة خاصة", + jobsApplicants: "المتقدمون", + jobsUpdatedAt: "تم التحديث", + jobSetOpen: "تعيين كمفتوح", + jobNewBadge: "جديد", + jobsTagsLabel: "الوسوم", + jobsTagsPlaceholder: "وسم1، وسم2، وسم3", + noJobsMatch: "لا توجد وظائف تطابق بحثك.", + projectsTitle: "المشاريع", + projectsDescription: "أنشئ ومُوّل وتابع مشاريع يقودها المجتمع في شبكتك.", + projectCreateProject: "إنشاء مشروع", + projectCreateButton: "إنشاء مشروع", + projectUpdateButton: "تحديث", + projectDeleteButton: "حذف", + projectNoProjectsFound: "لم يتم العثور على مشاريع.", + projectFilterAll: "الكل", + projectFilterMine: "خاصتي", + projectFilterActive: "نشط", + projectFilterPaused: "متوقف", + projectFilterCompleted: "مكتمل", + projectFilterFollowing: "يتابع", + projectFilterRecent: "الأخيرة", + projectFilterTop: "الأفضل", + projectAllTitle: "المشاريع", + projectMineTitle: "مشاريعك", + projectActiveTitle: "المشاريع النشطة", + projectPausedTitle: "المشاريع المتوقفة", + projectCompletedTitle: "المشاريع المكتملة", + projectFollowingTitle: "المشاريع المُتابَعة", + projectRecentTitle: "المشاريع الأخيرة", + projectTopTitle: "الأكثر تمويلاً", + projectTitlePlaceholder: "اسم المشروع", + projectImage: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", + projectDescription: "الوصف", + projectDescriptionPlaceholder: "اسرد القصة والأهداف…", + projectGoal: "الهدف (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "الموعد النهائي", + projectProgress: "نسبة التقدم الأولية (%)", + projectStatus: "الحالة", + projectFunding: "التمويل", + projectPledged: "المتبرَّع به", + projectSetStatus: "تعيين الحالة", + projectSetProgress: "تحديث التقدم", + projectFollowButton: "متابعة", + projectUnfollowButton: "إلغاء المتابعة", + projectStatusACTIVE: "نشط", + projectStatusPAUSED: "متوقف", + projectStatusCOMPLETED: "مكتمل", + projectStatusCANCELLED: "ملغى", + projectPledgeTitle: "ادعم هذا المشروع", + projectPledgePlaceholder: "المبلغ بـ ECO", + projectBounties: "المكافآت", + projectBountiesInputLabel: "المكافآت (واحدة في كل سطر: العنوان|المبلغ [ECO]|الوصف)", + projectBountiesPlaceholder: "إصلاح خطأ واجهة|100|رابط المشكلة\nكتابة التوثيق|250|أمثلة الاستخدام", + projectNoBounties: "لم يتم العثور على مكافآت.", + projectTitle: "العنوان", + projectAddBountyTitle: "مكافأة جديدة", + projectBountyTitle: "عنوان المكافأة", + projectBountyAmount: "المبلغ (ECO)", + projectBountyDescription: "الوصف", + projectMilestoneSelect: "اختيار مرحلة", + projectBountyCreateButton: "إنشاء مكافأة", + projectBountyStatus: "حالة المكافأة", + projectBountyOpen: "مفتوحة", + projectBountyClaimed: "مُطالَب بها", + projectBountyDone: "مكتملة", + projectBountyClaimedBy: "طالب بها", + projectBountyClaimButton: "المطالبة", + projectBountyCompleteButton: "تعيين كمكتملة", + projectMilestones: "المراحل", + projectAddMilestoneTitle: "مرحلة جديدة", + projectMilestoneTitle: "عنوان المرحلة", + projectMilestoneTargetPercent: "النسبة المئوية (%)", + projectMilestoneDueDate: "التاريخ", + projectMilestoneCreateButton: "إنشاء مرحلة", + projectMilestoneStatus: "حالة المرحلة", + projectMilestoneOpen: "مفتوحة", + projectMilestoneDone: "مكتملة", + projectMilestoneDue: "مستحقة", + projectNoMilestones: "لم يتم العثور على مراحل.", + projectMilestoneMarkDone: "تعيين كمنجزة", + projectMilestoneTitlePlaceholder: "أدخل عنوان المرحلة", + projectMilestoneDescriptionPlaceholder: "أدخل وصف هذه المرحلة", + projectMilestoneDescription: "وصف المرحلة", + projectBudgetGoal: "الميزانية (الهدف)", + projectBudgetAssigned: "المخصص للمكافآت", + projectBudgetRemaining: "المتبقي", + projectBudgetOver: "تجاوز الميزانية: المخصص يتجاوز الهدف", + projectFollowers: "المتابعون", + projectFollowersTitle: "المتابعون", + projectFollowersNone: "لا يوجد متابعون بعد.", + projectMore: "المزيد", + projectYouFollowHint: "أنت تتابع هذا المشروع", + projectBackers: "الداعمون", + projectBackersTitle: "الداعمون", + projectBackersTotal: "إجمالي الداعمين", + projectBackersTotalPledged: "إجمالي التبرعات", + projectBackersYourPledge: "تبرعك", + projectBackersNone: "لا توجد تبرعات بعد.", + projectNoRemainingBudget: "لا توجد ميزانية متبقية.", + projectFilterBackers: "الداعمون", + projectFilterApplied: "مُتقدَّم", + projectAppliedTitle: "مُتقدَّم", + projectBackersLeaderboardTitle: "أفضل الداعمين", + projectNoBackersFound: "لم يتم العثور على داعمين.", + projectBackerAmount: "إجمالي المساهمة", + projectBackerPledges: "التبرعات", + projectBackerProjects: "المشاريع", + projectPledgeAmount: "المبلغ", + projectSelectMilestoneOrBounty: "اختر مرحلة أو مكافأة", + projectPledgeButton: "تبرّع", + footerLicense: "GPLv3", + footerPackage: "الحزمة", + footerVersion: "الإصدار", + modulesModuleName: "الاسم", + modulesModuleDescription: "الوصف", + modulesModuleStatus: "الحالة", + modulesTotalModulesLabel: "الوحدات المُحمَّلة", + modulesEnabledModulesLabel: "مُفعَّلة", + modulesDisabledModulesLabel: "مُعطَّلة", + modulesPopularLabel: "أبرز المنشورات", + modulesPopularDescription: "وحدة لاستقبال المنشورات الرائجة أو الأكثر مشاهدة أو الأكثر تعليقًا.", + modulesTopicsLabel: "المواضيع", + modulesTopicsDescription: "وحدة لاستقبال فئات النقاش القائمة على الاهتمامات المشتركة.", + modulesSummariesLabel: "الملخصات", + modulesSummariesDescription: "وحدة لاستقبال ملخصات النقاشات أو المنشورات الطويلة.", + modulesLatestLabel: "الأحدث", + modulesLatestDescription: "وحدة لاستقبال أحدث المنشورات والنقاشات.", + modulesThreadsLabel: "المحادثات", + modulesThreadsDescription: "وحدة لاستقبال المحادثات المجمعة حسب الموضوع أو السؤال.", + modulesMultiverseLabel: "الكون المتعدد", + modulesMultiverseDescription: "وحدة لاستقبال المحتوى من الأقران المتحدين الآخرين.", + modulesInvitesLabel: "الدعوات", + modulesInvitesDescription: "وحدة لإدارة وتطبيق رموز الدعوة.", + modulesWalletLabel: "المحفظة", + modulesWalletDescription: "وحدة لإدارة أصولك الرقمية (ECOin).", + modulesLegacyLabel: "القديم", + modulesLegacyDescription: "وحدة لإدارة مفتاحك السري (المفتاح الخاص) بسرعة وأمان.", + modulesCipherLabel: "التشفير", + modulesCipherDescription: "وحدة لتشفير وفك تشفير نصك بشكل متماثل (باستخدام كلمة مرور مشتركة).", + modulesBookmarksLabel: "العلامات المرجعية", + modulesBookmarksDescription: "وحدة لاكتشاف وإدارة العلامات المرجعية.", + modulesVideosLabel: "الفيديوهات", + modulesVideosDescription: "وحدة لاكتشاف وإدارة الفيديوهات.", + modulesDocsLabel: "المستندات", + modulesDocsDescription: "وحدة لاكتشاف وإدارة المستندات.", + modulesAudiosLabel: "الملفات الصوتية", + modulesAudiosDescription: "وحدة لاكتشاف وإدارة الملفات الصوتية.", + modulesTagsLabel: "الوسوم", + modulesTagsDescription: "وحدة لاكتشاف واستكشاف أنماط التصنيف (الوسوم).", + modulesImagesLabel: "الصور", + modulesImagesDescription: "وحدة لاكتشاف وإدارة الصور.", + modulesTrendingLabel: "الرائج", + modulesTrendingDescription: "وحدة لاستكشاف المحتوى الأكثر شعبية.", + modulesEventsLabel: "الأحداث", + modulesEventsDescription: "وحدة لاكتشاف وإدارة الأحداث.", + modulesTasksLabel: "المهام", + modulesTasksDescription: "وحدة لاكتشاف وإدارة المهام.", + modulesMarketLabel: "السوق", + modulesMarketDescription: "وحدة لتبادل السلع أو الخدمات.", + modulesShopsLabel: "متاجر", + modulesShopsDescription: "وحدة لإدارة واكتشاف المتاجر.", + modulesTribesLabel: "القبائل", + modulesTribesDescription: "وحدة لاستكشاف أو إنشاء قبائل (مجموعات).", + modulesVotationsLabel: "التصويتات", + modulesVotationsDescription: "وحدة لاكتشاف وإدارة التصويتات.", + modulesReportsLabel: "التقارير", + modulesReportsDescription: "وحدة لإدارة وتتبع التقارير المتعلقة بالمشاكل والأخطاء والإساءات وتحذيرات المحتوى.", + modulesOpinionsLabel: "الآراء", + modulesOpinionsDescription: "وحدة لاكتشاف والتصويت على الآراء.", + modulesTransfersLabel: "التحويلات", + modulesTransfersDescription: "وحدة لاكتشاف وإدارة العقود الذكية (التحويلات).", + modulesFediverseLabel: "فيديفيرس", + modulesFediverseDescription: "أدر حساباتك الأخرى في الفيديفيرس، بما في ذلك إرسال واستقبال المحتوى.", + modulesFeedLabel: "التغذية", + modulesFeedDescription: "وحدة لاكتشاف ومشاركة النصوص القصيرة (التغذيات).", + modulesParliamentLabel: "البرلمان", + modulesParliamentDescription: "وحدة لانتخاب الحكومات والتصويت على القوانين.", + modulesCourtsLabel: "المحاكم", + modulesCourtsDescription: "وحدة لحل النزاعات وإصدار الأحكام.", + modulesPixeliaLabel: "بكسيليا", + modulesPixeliaDescription: "وحدة للرسم على شبكة تعاونية.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "وحدة لتوليد ومشاركة \"صوت\" سلسلة الكتل خاصتك.", + modulesAgendaLabel: "الأجندة", + modulesAgendaDescription: "وحدة لإدارة جميع العناصر المسندة إليك.", + modulesAILabel: "الذكاء الاصطناعي", + modulesAIDescription: "وحدة للتحدث مع نموذج لغوي يُدعى '42'.", + modulesForumLabel: "المنتديات", + modulesForumDescription: "وحدة لاكتشاف وإدارة المنتديات.", + modulesJobsLabel: "الوظائف", + modulesJobsDescription: "وحدة لاكتشاف وإدارة الوظائف.", + modulesProjectsLabel: "المشاريع", + modulesProjectsDescription: "وحدة لاستكشاف وتمويل جماعي وإدارة المشاريع.", + modulesBankingLabel: "المصرفية", + modulesBankingDescription: "وحدة لتحديد القيمة الحقيقية لـ ECOIN وتوزيع الدخل الأساسي الشامل باستخدام الخزينة المشتركة.", + modulesFavoritesLabel: "المفضلة", + modulesFavoritesDescription: "وحدة لإدارة المحتوى المفضل لديك.", + fileTooLargeTitle: "الملف كبير جدًا", + fileTooLargeMessage: "يتجاوز الملف الحجم الأقصى المسموح به (50 ميغابايت). يرجى اختيار ملف أصغر.", + goBack: "رجوع", + errorPageTitle: "حدث خطأ", + visibilityLabel: "الرؤية", + visibilityPublic: "عام", + visibilityHidden: "مخفي", + visibilityMakePublic: "جعله عامًا", + visibilityMakeHidden: "إخفاؤه", + invitesInhabitantsTitle: "السكان", + invitesInhabitantsFollow: "متابعة", + aiNavPlaceholder: "إلى أين تريد الذهاب؟", + aiNavDisabled: "تعطيل التنقل بالذكاء الاصطناعي.", + aiNavResultsTitle: "اقتراحات التنقل بالذكاء الاصطناعي", + aiNavQueryLabel: "الاستفسار", + aiNavResultsDescription: "اختر المسار الأنسب لما تبحث عنه.", + aiNavResultPath: "المسار", + aiNavResultDescription: "ما يمكنك فعله", + aiNavResultMatch: "التطابق", + aiNavResultsEmpty: "لا توجد مسارات مطابقة. جرّب /search.", + aiNavSearchInstead: "ابحث بدلًا من ذلك", + modulesAINavLabel: "AINav", + modulesAINavDescription: "وحدة لإجراء استعلامات بلغة طبيعية حول محتوى الشبكة.", + directConnect: "اتصال مباشر", + directConnectDescription: "اتصل مباشرة بقرين عن طريق إدخال عنوان IP والمنفذ والمفتاح العام. سيتم إضافة القرين كاتصال مُتابَع.", + peerHost: "IP", + peerPort: "المنفذ (افتراضي: 8008)", + peerPublicKey: "المفتاح العام (@...ed25519)", + connectAndFollow: "اتصال", + deviceSourceLabel: "مصدر الجهاز", + modulesPresetTitle: "الإعدادات الشائعة", + modulesPreset_minimal: "الحد الأدنى", + modulesPreset_basic: "أساسي", + modulesPreset_social: "اجتماعي", + modulesPreset_economy: "اقتصادي", + modulesPreset_full: "كامل", + statsCarbonFootprintTitle: "البصمة الكربونية", + statsCarbonFootprintNetwork: "البصمة الكربونية للشبكة", + statsCarbonFootprintYours: "بصمتك الكربونية", + statsCarbonTombstone: "بصمة الحذف", + feedSuccessMsg: "تم نشر التغذية بنجاح!", + dominantOpinionLabel: "الرأي السائد", + uploadMedia: "رفع وسائط (الحد الأقصى: 50 ميغابايت)", + feedOpenDiscussion: "فتح النقاش", + feedDetailTitle: "التغذية", + feedPostComment: "نشر تعليق", + courtsRespondentInvalid: "يجب أن يكون معرّف SSB صالحًا (@...ed25519)", + noComments: "لا توجد تعليقات بعد", + mapsLabel: "خرائط", + mapTitle: "خرائط", + mapDescription: "استكشف وأدر الخرائط غير المتصلة في شبكتك.", + mapMineSectionTitle: "خرائطك", + mapCreateSectionTitle: "إنشاء خريطة", + mapUpdateSectionTitle: "تحديث الخريطة", + mapAllSectionTitle: "خرائط", + mapRecentSectionTitle: "خرائط حديثة", + mapFavoritesSectionTitle: "المفضلة", + mapFilterAll: "الكل", + mapFilterMine: "خرائطي", + mapFilterRecent: "حديثة", + mapFilterFavorites: "المفضلة", + mapUploadButton: "إنشاء خريطة", + mapCreateButton: "إنشاء خريطة", + mapUpdateButton: "تحديث", + mapDeleteButton: "حذف", + mapAddFavoriteButton: "إضافة للمفضلة", + mapRemoveFavoriteButton: "إزالة من المفضلة", + mapLatLabel: "خط العرض", + mapLatPlaceholder: "مثال 24.7136", + mapLngLabel: "خط الطول", + mapLngPlaceholder: "مثال 46.6753", + mapDescriptionLabel: "الوصف", + mapDescriptionPlaceholder: "صف الخريطة أو الموقع...", + mapTypeLabel: "نوع الخريطة", + mapTypeSingle: "فردي (الموقع الأولي فقط)", + mapTypeOpen: "مفتوح (يمكن لأي شخص إضافة علامات)", + mapTypeClosed: "مغلق (المنشئ فقط يضيف علامات)", + mapTagsLabel: "الوسوم", + mapTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + mapUrlLabel: "رابط الخريطة", + mapPickCoordLabel: "أو اختر موقعاً على الشبكة:", + mapMarkersLabel: "علامات", + mapMarkersTitle: "العلامات", + mapMarkerDefault: "علامة", + mapMarkerLatLabel: "خط عرض العلامة", + mapMarkerLngLabel: "خط طول العلامة", + mapMarkerLabelField: "تسمية العلامة", + mapMarkerLabelPlaceholder: "صف هذه العلامة...", + markerImageLabel: "صورة العلامة", + mapAddMarkerTitle: "إضافة علامة", + mapAddMarkerButton: "إضافة علامة", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "تطبيق التكبير", + mapSearchPlaceholder: "البحث في الوصف، الوسوم، المؤلف...", + mapSearchButton: "بحث", + mapUpdatedAt: "محدّث", + mapNoMatch: "لا توجد خرائط مطابقة لبحثك.", + noMaps: "لا توجد خرائط متاحة.", + mapLocationTitle: "الموقع", + mapVisitLabel: "زيارة الخريطة", + typeMap: "خرائط", + typeMapMarker: "علامة خريطة", + modulesMapLabel: "خرائط", + modulesMapDescription: "وحدة لإدارة ومشاركة الخرائط غير المتصلة.", + padsTitle: "الوسادات", + padTitle: "وسادة", + modulesPadsLabel: "الوسادات", + modulesPadsDescription: "وحدة لإدارة محررات النصوص التعاونية.", + padFilterAll: "الكل", + padFilterMine: "خاصتي", + padFilterRecent: "الأخيرة", + padFilterOpen: "مفتوح", + padFilterClosed: "مغلق", + padCreate: "إنشاء وسادة", + padUpdate: "تحديث الوسادة", + padDelete: "حذف الوسادة", + padTitleLabel: "العنوان", + padTitlePlaceholder: "أدخل عنوان الوسادة...", + padStatusLabel: "الحالة", + padStatusOpen: "مفتوح", + padStatusInviteOnly: "بدعوة فقط", + padStatusClosed: "مغلق", + padDeadlineLabel: "الموعد النهائي", + padTagsLabel: "الوسوم", + padTagsPlaceholder: "وسم1، وسم2، ...", + padMembersLabel: "الأعضاء", + padVisitPad: "زيارة الوسادة", + padShareUrl: "مشاركة الرابط", + padCreated: "تاريخ الإنشاء", + padAuthor: "المؤلف", + padGenerateCode: "إنشاء رمز", + padInviteCodeLabel: "رمز الدعوة", + padInviteCodePlaceholder: "أدخل رمز الدعوة...", + padValidateInvite: "تحقق", + padStartEditing: "ابدأ التحرير!", + padEditorPlaceholder: "ابدأ الكتابة...", + padSubmitEntry: "إرسال", + padNoEntries: "لا توجد مدخلات بعد.", + padAllSectionTitle: "جميع الوسادات", + padMineSectionTitle: "وساداتي", + padsDescription: "إدارة محرّرات النصوص التعاونية المشفّرة في شبكتك.", + padRecentSectionTitle: "الوسادات الأخيرة", + padOpenSectionTitle: "الوسادات المفتوحة", + padClosedSectionTitle: "الوسادات المغلقة", + padCreateSectionTitle: "إنشاء وسادة جديدة", + padUpdateSectionTitle: "تحديث الوسادة", + padInviteGenerated: "تم إنشاء رمز الدعوة", + typePad: "وسادات", + padNew: "جديد", + padAddFavorite: "إضافة إلى المفضلة", + padRemoveFavorite: "إزالة من المفضلة", + padClose: "إغلاق اللوحة", + padBackToEditor: "العودة إلى المحرر", + padSearchPlaceholder: "البحث عن الوسادات...", + + modulesChatsLabel: "المحادثات", + modulesChatsDescription: "وحدة لاكتشاف وإدارة المحادثات المشفرة.", + typeChat: "محادثات", + typeChatMessage: "رسالة محادثة", + chatLabel: "المحادثات", + chatMessageLabel: "رسائل المحادثة", + chatsTitle: "المحادثات", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "المفضلة", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "الوصف", + chatCategory: "الفئة", + chatStatus: "الحالة", + chatFilterAll: "الكل", + chatFilterMine: "لي", + chatFilterRecent: "الأخيرة", + chatFilterFavorites: "المفضلة", + chatFilterOpen: "مفتوح", + chatFilterClosed: "مغلق", + chatCreate: "إنشاء محادثة", + chatUpdate: "تحديث المحادثة", + chatDelete: "حذف المحادثة", + chatClose: "إغلاق المحادثة", + chatVisitChat: "زيارة المحادثة", + chatUntitled: "محادثة بدون عنوان", + chatNoItems: "لا توجد محادثات.", + chatParticipants: "المشاركون", + chatStartChatting: "ابدأ المحادثة!", + chatGenerateCode: "إنشاء رمز", + chatShareUrl: "مشاركة الرابط", + chatCreatedAt: "تم الإنشاء", + chatSearchPlaceholder: "بحث في المحادثات...", + chatStatusOpen: "مفتوح", + chatStatusInviteOnly: "بالدعوة فقط", + chatStatusClosed: "مغلق", + chatSendMessage: "إرسال", + chatMessagePlaceholder: "اكتب رسالتك...", + chatNoMessages: "لا توجد رسائل بعد.", + chatLeave: "مغادرة المحادثة", + chatInviteCodeLabel: "أدخل رمز الدعوة", + chatJoinByInvite: "انضم", + chatTitlePlaceholder: "عنوان المحادثة", + chatDescriptionPlaceholder: "وصف المحادثة", + chatTagsPlaceholder: "وسم1، وسم2", + chatImageLabel: "اختر ملف صورة (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "رمز الدعوة", + chatAuthor: "المؤلف", + chatCreated: "تم الإنشاء", + chatAddFavorite: "إضافة للمفضلة", + chatRemoveFavorite: "إزالة من المفضلة", + chatPM: "رسالة", + chatStatusLabel: "الحالة", + chatCategoryLabel: "الفئة", + chatParticipantsLabel: "المشاركون", + gamesTitle: "الألعاب", + gamesDescription: "اكتشف وألعب بعض الألعاب.", + gamesFilterAll: "الكل", + gamesPlayButton: "العب!", + gamesBackToGames: "العودة إلى الألعاب", + modulesGamesLabel: "الألعاب", + modulesGamesDescription: "وحدة لاكتشاف وتشغيل بعض الألعاب.", + modulesGraphosLabel: "غرافوس", + modulesGraphosDescription: "وحدة لاستكشاف الشبكة كخريطة تفاعلية للعقد.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "اربط PUBs بالسكان عبر المدققين والمتاجر والمجمّعات. انجُ من تهديد CBDC!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "تسلل عبر الشبكة، اجمع بيانات سرية، تجنب طائرات الأمن واهرب. كم مستوى تستطيع اجتيازه؟", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "أوقف الغزو الفضائي! أسقط موجات الغزاة قبل أن يصلوا إلى الأرض.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "اكسر جميع الطوب بمضربك وكرتك. تحدي أركيد كلاسيكي.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "بينج بونج كلاسيكي ضد ذكاء اصطناعي. أول من يصل إلى 5 نقاط يفوز.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "سباق ضد الوقت! تجنب السيارات وصل إلى خط النهاية قبل انتهاء الوقت.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "قد مركبتك عبر حقل الكويكبات. دمرها قبل أن تصطدم بك.", + gamesRockPaperScissorsTitle: "حجر ورقة مقص", + gamesRockPaperScissorsDesc: "حجر، ورقة أو مقص ضد ذكاء اصطناعي. الأفضل في ثلاث جولات يفوز.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "تيك-تاك-تو الكلاسيكي ضد الذكاء الاصطناعي. ثلاثة في صف واحد للفوز.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "اقلب عملة معدنية وراهن على الوجه أو الظهر. كم أنت محظوظ?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "التقاويم", + calendarTitle: "التقويم", + modulesCalendarsLabel: "التقاويم", + modulesCalendarsDescription: "وحدة لاكتشاف وإدارة التقاويم.", + typeCalendar: "تقويمات", + calendarFilterAll: "الكل", + calendarFilterMine: "تقاويمي", + calendarFilterOpen: "مفتوح", + calendarFilterClosed: "مغلق", + calendarFilterRecent: "الأحدث", + calendarFilterFavorites: "المفضلة", + calendarCreate: "إنشاء تقويم", + calendarUpdate: "تحديث", + calendarDelete: "حذف", + calendarTitleLabel: "العنوان", + calendarTitlePlaceholder: "عنوان التقويم...", + calendarStatusLabel: "الحالة", + calendarStatusOpen: "مفتوح", + calendarStatusClosed: "مغلق", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "الوسوم", + calendarTagsPlaceholder: "وسم1، وسم2...", + calendarParticipantsLabel: "المشاركون", + calendarParticipantsCount: "المشاركون", + calendarVisitCalendar: "زيارة التقويم", + calendarCreated: "تم الإنشاء", + calendarAuthor: "المؤلف", + calendarJoin: "انضمام", + calendarGenerateInvite: "إنشاء دعوة", + calendarInviteCodePlaceholder: "أدخل رمز الدعوة...", + calendarValidateInvite: "التحقق من الرمز", + calendarJoined: "منضم", + calendarAddDate: "إضافة تاريخ", + calendarAddNote: "إضافة ملاحظة", + calendarDateLabel: "التاريخ", + calendarDatePlaceholder: "وصف هذا التاريخ...", + calendarNoteLabel: "ملاحظة", + calendarNotePlaceholder: "إضافة ملاحظة...", + calendarFirstDateLabel: "التاريخ", + calendarFirstNoteLabel: "ملاحظات", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "الوصف", + calendarNoDates: "لا توجد تواريخ.", + calendarNoNotes: "لا توجد ملاحظات.", + calendarsNoItems: "لا توجد تقاويم.", + calendarsDescription: "اكتشف وأدر التقويمات في شبكتك.", + calendarMonthPrev: "← السابق", + calendarMonthNext: "التالي →", + calendarMonthLabel: "التواريخ", + calendarsShareUrl: "رابط المشاركة", + calendarAllSectionTitle: "التقاويم", + calendarRecentSectionTitle: "التقويمات الأحدث", + calendarFavoritesSectionTitle: "المفضلة", + calendarMineSectionTitle: "تقويماتك", + calendarOpenSectionTitle: "التقاويم المفتوحة", + calendarClosedSectionTitle: "التقاويم المغلقة", + calendarCreateSectionTitle: "إنشاء تقويم جديد", + calendarUpdateSectionTitle: "تحديث التقويم", + calendarAddFavorite: "إضافة إلى المفضلة", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "إزالة من المفضلة", + calendarSearchPlaceholder: "البحث عن تقاويم...", + calendarAddEntry: "إضافة إدخال", + calendarLeave: "مغادرة التقويم", + statsCalendar: "التقاويم", + statsCalendarDate: "تواريخ التقويم", + statsCalendarNote: "ملاحظات التقويم", + chatAccessDenied: "ليس لديك حق الوصول إلى هذه المحادثة. اطلب دعوة للوصول إلى المحتوى.", + padAccessDenied: "ليس لديك حق الوصول إلى هذا الوسادة. اطلب دعوة للوصول إلى المحتوى.", + contentAccessDenied: "ليس لديك حق الوصول إلى هذا المحتوى.", + blockAccessRestricted: "الوصول مقيد", + tribeContentAccessDenied: "تم رفض الوصول", + tribeContentAccessDeniedMsg: "هذا المحتوى ينتمي إلى قبيلة. يجب أن تكون عضواً للوصول إليه.", + tribeViewTribes: "عرض القبائل", + torrentsTitle: "التورنت", + torrentsDescription: "استكشف وأدِر التورنت في شبكتك.", + torrentAllSectionTitle: "التورنت", + torrentMineSectionTitle: "تورنتاتك", + torrentRecentSectionTitle: "تورنتات حديثة", + torrentTopSectionTitle: "أفضل التورنتات", + torrentFavoritesSectionTitle: "المفضلة", + torrentFilterAll: "الكل", + torrentFilterMine: "خاصتي", + torrentFilterRecent: "الحديثة", + torrentFilterTop: "الأفضل", + torrentFilterFavorites: "المفضلة", + torrentCreateSectionTitle: "رفع تورنت", + torrentUpdateSectionTitle: "تحديث التورنت", + torrentCreateButton: "رفع تورنت", + torrentUpdateButton: "تحديث", + torrentDeleteButton: "حذف", + torrentAddFavoriteButton: "إضافة إلى المفضلة", + torrentRemoveFavoriteButton: "إزالة من المفضلة", + torrentFileLabel: "اختر ملف تورنت (.torrent)", + torrentTitleLabel: "العنوان", + torrentTitlePlaceholder: "العنوان", + torrentDescriptionLabel: "الوصف", + torrentDescriptionPlaceholder: "الوصف", + torrentTagsLabel: "الوسوم", + torrentTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل", + torrentSizeLabel: "الحجم", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "لا يوجد ملف تورنت", + noTorrents: "لم يتم العثور على تورنتات.", + torrentSearchPlaceholder: "بحث بالعنوان، الوسوم، المؤلف...", + torrentSearchButton: "بحث", + torrentSortRecent: "الأحدث", + torrentSortOldest: "الأقدم", + torrentSortTop: "الأكثر تصويتاً", + torrentNoMatch: "لا توجد تورنتات مطابقة.", + torrentMessageAuthorButton: "مراسلة المؤلف", + torrentUpdatedAt: "تم التحديث", + statsTorrent: "التورنت", + typeTorrent: "التورنت", + modulesTorrentsLabel: "التورنت", + modulesTorrentsDescription: "وحدة لاكتشاف وإدارة التورنت.", + favoritesFilterTorrents: "التورنت", + favoritesFilterMarket: "السوق", + favoritesFilterShopProducts: "منتجات المتجر", + tribeSectionTorrents: "التورنت", + tribeCreateTorrent: "رفع تورنت", + tribeMediaTypeTorrent: "تورنت", + settingsWishTitle: "الرغبة", + settingsWishDesc: "قم بتكوين رغبة الأفاتار الخاص بك.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "الرسائل الخاصة", + settingsPmVisibilityDesc: "قم بتكوين مستوى التعرض للرسائل الخاصة في الشبكة.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "الإرسال حاجز UX. الاستقبال فلتر انتباه.", + pmBlockedNonMutual: "يمكنك إرسال الرسائل الخاصة فقط للسكان ذوي الدعم المتبادل.", + inhabitantsPendingFollowsTitle: "طلبات الدعم المعلقة", + inhabitantsPendingAccept: "قبول", + inhabitantsPendingReject: "رفض", + bxEncrypted: "مشفر", + bxEncryptedHexLabel: "النص المشفر (معاينة)", + tribeSectionGovernance: "الحوكمة", + tribeSubStatusPublic: "عامة", + tribeSubStatusPrivate: "خاصة", + tribeSubInheritedPrivate: "خاصة (موروثة من القبيلة الرئيسية)", + tribePadInviteRequired: "لا يمكنك الوصول. اطلب دعوة.", + tribeChatInviteRequired: "لا يمكنك الوصول للدردشة. اطلب دعوة.", + tribeGovernanceDesc: "الحوكمة الداخلية لهذه القبيلة.", + tribeGovernanceNoGov: "لا توجد حكومة نشطة", + tribeGovernanceNoGovDesc: "لم تنتخب هذه القبيلة حكومة بعد.", + tribeGovCardTitle: "الحكومة الحالية", + tribeGovCycleSince: "بداية الدورة", + tribeGovCycleEnd: "نهاية الدورة", + tribeGovTimeRemaining: "الوقت المتبقي", + tribeGovPopulation: "السكان", + tribeGovMethod: "الطريقة", + tribeGovVotesReceived: "الأصوات المستلمة", + tribeGovLeader: "القائد", + tribeGovFilterGovernment: "الحكومة", + tribeGovFilterCandidatures: "الترشيحات", + tribeGovFilterLaws: "القوانين", + tribeGovCandidatureId: "الترشيح", + tribeGovCandidatureMethod: "الطريقة", + tribeGovCandidatureProposeBtn: "نشر الترشيح", + tribeGovRuleTitle: "عنوان القاعدة", + tribeGovRuleBody: "محتوى القاعدة", + tribeGovProposals: "الاقتراحات", + tribeGovRevocations: "الإلغاءات", + tribeGovHistorical: "السجل", + tribeGovRules: "القواعد", + tribeGovernanceAlreadyPublished: "لدى هذه القبيلة ترشح مفتوح.", + tribeGovernanceProposeInternal: "اقتراح ترشح داخلي", + tribeGovernanceInternalCandidatures: "الترشيحات الداخلية", + tribeGovernanceNoCandidatures: "لا توجد ترشيحات مفتوحة.", + tribeGovernanceAddRule: "إضافة قاعدة", + tribeGovernanceNoRules: "لا توجد قواعد بعد.", + tribeGovernanceNoLeaders: "لم يُنتخب أي قائد بعد.", + tribeGovernanceComingSoon: "قريبًا في وحدة الحوكمة.", + tribeGovNoProposals: "لا يوجد أي اقتراح بعد", + tribeGovNoLaws: "لا يوجد أي قانون بعد", + tribeGovNoRevocations: "لا يوجد أي إلغاء بعد", + tribeGovNoHistorical: "لا يوجد أي سجل بعد", + logsTitle: "السجل", + logsDescription: "سجل تجربتك في الشبكة.", + logsReadMore: "اقرأ المزيد", + logsViewTitle: "السجل", + logsColumnType: "النوع", + logsView: "عرض", + logsManualPrompt: "اكتب سجلك", + logsUpdateButton: "تحديث", + logsEditTitle: "تعديل الإدخال", + logsCreateDescription: "سجل تجربتك...", + logsCreateTitle: "إنشاء إدخال", + logsWriteButton: "اكتب", + logsTextPlaceholder: "صف تجاربك...", + logsTextField: "النص", + logsLabelPlaceholder: "تسمية...", + logsLabelField: "اكتب سجلك (تسمية)", + logsAiDisabledWarn: "فعّل وحدة الذكاء الاصطناعي في /modules لاستخدام السجلات المكتوبة بواسطة الذكاء.", + logsAiContextValue: "يوم blockchain", + logsAiContext: "السياق", + logsAiModStatus: "AImod", + logsModeApply: "تطبيق!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "يدوي", + logsModeAI: "ذكاء", + logsColumnDelete: "حذف", + logsColumnEdit: "تعديل", + logsColumnLog: "السجل", + logsColumnDate: "التاريخ", + logsDelete: "حذف", + logsEdit: "تعديل", + logsFilterAlways: "دائمًا", + logsFilterYear: "السنة الماضية", + logsFilterMonth: "الشهر الماضي", + logsFilterWeek: "الأسبوع الماضي", + logsFilterToday: "اليوم", + logsFilterRecent: "الأحدث", + logsFilterAll: "الكل", + logsCreate: "إنشاء إدخال", + logsExport: "تصدير السجل", + logsExportOne: "تصدير", + logsViewDetails: "عرض التفاصيل", + logsGenerateButton: "توليد نص", + logsSearchText: "ابحث في السجلات...", + logsSearchAnyType: "أي نوع", + logsSearchButton: "بحث", + logsEmpty: "لا توجد إدخالات بعد.", + modulesLogsLabel: "السجل", + modulesLogsDescription: "وحدة لتسجيل تجاربك (عبر مساعد ذكاء اصطناعي).", + statsLogsTitle: "السجل", + statsLogsEntries: "إدخالات", + typeLog: "سجل", + blockchainCycle: "دورة", + + larpTitle: "L.A.R.P.", + larpDescription: "طبقة لعب أدوار حية للتجربة التعاونية.", + larpAllHouses: "البيوت:", + larpFilterRuling: "يحكم", + larpFilterHouses: "البيوت", + larpFilterRules: "FAQ", + larpRulesTitle: "أسئلة شائعة عن L.A.R.P.", + larpRulesEntryTitle: "بيت البداية", + larpRulesEntryText: "يبدأ كل ساكن في ACADEMIA افتراضياً. من ACADEMIA، اختر بيتاً من لوحة «الانضمام إلى بيت»: ستفتح اختبار الدخول الخاص به. أجب عن الأسئلة العشرة وأرسلها. إذا تجاوزت الحد (7/10) فستُقبل آلياً في ذلك البيت؛ وإلا تُرفض وتبقى في ACADEMIA. في كل الأحوال يسجّل النظام OasisID الخاص بك ودورة المحاولة، ويمنع محاولة جديدة حتى انتهاء فترة الانتظار وهي 30 دورة.", + larpRulesLeaveText: "يستطيع أعضاء أي بيت العودة إلى ACADEMIA في أي وقت من صفحة بيتهم؛ يعيد ذلك ضبط عضويتهم لكنه لا يلغي فترة انتظار الاختبار.", + larpRulesGovernanceTitle: "حائط الحوكمة", + larpRulesGovernanceText: "خلال دورته، يحمل البيت الحاكم شارة «يحكم» وهو الوحيد الذي يُعرض حائطه علنياً ضمن تبويب «يحكم».", + larpRulesSignTitle: "شعار L.A.R.P.", + larpRulesSignText: "يستطيع السكان تفعيله (في /profile/edit > المستشعرات) لعرض صورة بيتهم الحالي كختم في الملف الشخصي وبطاقة المؤلف والساكن. يربط الختم بصفحة البيت.", + larpPostsTitle: "الحائط", + larpPostsEmpty: "لا توجد منشورات بعد.", + larpPostLabel: "منشور جديد", + larpPostPlaceholder: "ماذا يريد هذا البيت أن يقول؟", + larpPostPublic: "عام (يراه أي شخص، وإلا فالأعضاء فقط)", + larpPostSubmit: "انشر", + larpPostMembersOnly: "للأعضاء فقط", + larpCycleLabel: "الدورة:", + audioBackToBcs: "Back to BCS", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeCompositionEmpty: "This audio does not include a stored blockchain composition.", + audioTranscodeDetailDescription: "Decode the embedded payload and the original blockchain composition map.", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeStegoEmpty: "(none)", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoNotFound: "No steganographic payload could be decoded from this audio.", + audioTranscodeStegoOasisId: "By", + audioTranscodeStegoTimestamp: "Generated at", + audioTranscodeStegoUnknown: "—", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (monthly)", + bankOverviewYourTaxes: "Your taxes", + bankRulesAlpha: "Alpha (max share of pub balance per epoch)", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesArchTaxRate: "Rate", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesCapPerEpoch: "Cap per epoch (absolute)", + bankRulesCapUser: "Cap per user per epoch", + bankRulesCarbonFactor: "Carbon factor", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesChipTitle: "ECO Tax chip color band", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesEcoTaxRate: "Rate", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesEpochKind: "Epoch granularity", + bankRulesFloor: "Floor per user (gross)", + bankRulesGraceDays: "Claim grace period", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesKarmaTitle: "Karma (user engagement score)", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesPoolTitle: "Monthly pool", + bankRulesReserveMin: "Reserve minimum (kept in pub balance)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesShareTitle: "Per-user share", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankRulesWMax: "Maximum weight (w_max)", + bankRulesWMin: "Minimum weight (w_min)", + bankTaxes: "Taxes", + bankTaxesAnnualEcoin: "ECO Tax (annual)", + bankTaxesArchTaxAnnual: "ARCH Tax (annual)", + bankTaxesArchTaxFirstBlock: "Your first block age", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxLifetime: "ARCH Tax (lifetime)", + bankTaxesArchTaxMonthly: "ARCH Tax (monthly)", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxRate: "Rate", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesArchTaxUserAmount: "Your ARCH tax (price to return)", + bankTaxesEcoTaxLifetime: "ECO Tax (lifetime)", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesLookupAuthor: "Author", + bankTaxesLookupButton: "Inspect", + bankTaxesLookupCarbon: "Carbon footprint", + bankTaxesLookupEcoin: "ECO Tax", + bankTaxesLookupNotFound: "No block found in your local feed with that ID.", + bankTaxesLookupPlaceholder: "Block ID (e.g. %abc...=.sha256)", + bankTaxesLookupSize: "Size", + bankTaxesLookupTitle: "Inspect a block", + bankTaxesLookupType: "Type", + bankTaxesMonthlyEcoin: "ECO Tax (monthly)", + bankTaxesRate: "Rate", + bankTaxesSpan: "Sampling span", + bankTaxesTotalAnnual: "Total (annual)", + bankTaxesTotalBlocks: "Total blocks", + bankTaxesTotalBytes: "Total bytes", + bankTaxesTotalCarbon: "Total carbon", + bankTaxesTotalEcoin: "Total ECO tax (lifetime)", + bankTaxesTotalLifetime: "Total (lifetime)", + bankTaxesTotalMonthly: "Total (monthly)", + bankTaxesTypesApply: "Set my taxes", + bankTaxesTypesLabel: "Select which taxes you want to pay", + bankTaxesUserAmount: "Your ECO tax (price to return)", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + blockchainBackToBlockexplorer: "Back to blockexplorer", + blockchainBlockNotAvailable: "This block is not available in your local feed. It may belong to a peer you are not yet replicating from.", + ecoTaxLabel: "ECO Tax", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + melodyAllEmpty: "No BCS compositions from other inhabitants yet.", + melodyByLabel: "By", + melodyNoteUnavailable: "block not in your local feed", + melodyUploadBcsButton: "Publish", + melodyUploadBcsNameNote: "The audio will be published with the auto-generated name", + melodyUploadBcsTitle: "Publish", + melodyUploadStegoLabel: "Hidden message (steganography)", + melodyUploadStegoMaxLabel: "max 280 characters", + melodyUploadStegoPlaceholder: "Optional.", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + statsTombstoneEmpty: "No tombstones in the network yet.", + typeGameScore: "GAME SCORES", + bankTaxesLookupNote: "أدخل معرف كتلة للبحث عنه في خلاصتك المحلية.", + bankTaxesUserNoteChipsClick: "انقر على أي شريحة لفحص كتلتها في blockexplorer.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "رمز الدعوة", + larpRulesInviteEntryText: "يمكن لأعضاء البيوت إنشاء رموز دعوة تمنح الوصول المباشر إلى البيت.", + larpRulesOneHouseText: "يمكنك الانتماء إلى بيت واحد فقط في أي وقت. إذا لم تنتم إلى أي بيت، فأنت في ACADEMIA. كل صفحة بيت (غير ACADEMIA) تعرض لأعضائها زر «مغادرة البيت» الذي يعيد العضوية إلى ACADEMIA. المغادرة لا تلغي فترة انتظار الاختبار.", + larpRulesOneHouseTitle: "بيت واحد في كل مرة", + larpRulesTestEntry: "اختبار WILL", + larpRulesTestEntryText: "كل خيار يرجح بيتاً أو أكثر؛ عند الإرسال، يجمع النظام النقاط ويضمك تلقائياً إلى البيت صاحب أعلى نتيجة.", + larpWillTestHint: "بمجرد الانتهاء، ستُسند إليك البيت الذي يتطابق بشكل أفضل مع إجاباتك. تتم معالجة إجاباتك الفردية محلياً ولا يتم تخزينها أبداً — يُنشر في خلاصتك فقط نتيجة تعيين البيت.", + larpWillTestTitle: "اختبار WILL", + settingsLanDesc: "الإعلان بشكل دوري عن هذا النظير لمثيلات Oasis الأخرى على نفس الشبكة المحلية. تعطيل لإيقاف بث UDP.", + settingsLanEnable: "تفعيل بث الشبكة المحلية", + settingsLanTitle: "بث الشبكة المحلية", + settingsReplicationTitle: "النسخ", + settingsReplicationDesc: "اضبط عدد القفزات التي يتبعها نظيرك انطلاقًا من خلاصتك عند نسخ المحتوى.", + settingsReplicationHopsLabel: "قفزات", + aiApproveTagsLabel: "وسوم (مفصولة بفواصل)", + aiApproveTagsPlaceholder: "مثلاً oasis، حوكمة، بيئة", + aiApproveRatingLabel: "التقييم", + aiExchangeLang: "اللغة", + aiExchangeTags: "وسوم", + aiExchangeRating: "التقييم", + aiExchangeHelpful: "مفيد", + aiExchangeMarkHelpful: "+1 مفيد", + larpCyclesUntilRuling: "دورة الحكم القادمة", + larpVisitTribe: "زيارة القبيلة", + larpGoverning: "يحكم:", + larpMyHouse: "بيتي:", + larpMembersCount: "الأعضاء", + larpMembersTitle: "الأعضاء", + larpNoMembers: "لا يوجد أعضاء بعد.", + larpRolesLabel: "الأدوار", + larpFunctionLabel: "الوظيفة", + larpMonthLabel: "دورة الحكم", + larpLeaveToAcademia: "العودة إلى ACADEMIA", + larpAcademiaJoinTitle: "الانضمام إلى بيت", + larpAcademiaJoinHint: "أجرِ اختبار الشخصية النفسي ليُسنَد إليك البيت الأنسب لك، أو استخدم رمز دعوة تشاركَه أحد أعضاء البيوت.", + larpTakeTestButton: "أجرِ اختبار الشخصية", + larpInviteRedeem: "الانضمام إلى البيت", + larpInviteCreate: "إنشاء رمز دعوة", + larpInvitePlaceholder: "رمز الدعوة", + larpInviteBannerTitle: "رمز دعوة جديد", + larpInviteBannerHint: "شارك هذا الرمز مع شخص في ACADEMIA. ينتهي خلال 30 دورة أو بعد استخدام واحد.", + larpTestAssignedHouse: "البيت المُسنَد", + larpTestRankingTitle: "النقاط لكل بيت", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "رمز دعوة بيت", + invitesHouseJoinButton: "الانضمام إلى البيت", + larpLastAttemptTitle: "آخر محاولاتك", + larpLastAttemptHouse: "البيت", + larpLastAttemptResult: "النتيجة", + larpLastAttemptWhen: "متى", + larpLastAttemptCooldown: "المحاولة التالية خلال", + larpTestTitle: "اختبار الدخول", + larpTestIntro: "أجب عن الأسئلة العشرة. تحتاج إلى 7 إجابات صحيحة على الأقل للقبول. يمكنك إجراء اختبار كل 30 دورة بغض النظر عن النتيجة.", + larpTestSubmit: "الانضمام إلى البيت", + larpTestCooldownActive: "الاختبار التالي متاح خلال", + larpTestCooldownDays: "دورات", + larpTestResultTitle: "نتيجة الاختبار", + larpTestPassed: "اجتزت الاختبار — أهلاً!", + larpTestFailed: "لم تجتز الاختبار", + larpTestScore: "الدرجة", + larpTestNextAttempt: "يمكنك إجراء اختبار آخر بعد 30 دورة.", + larpGoToHouse: "اذهب إلى بيتك", + larpBackToAcademia: "العودة إلى ACADEMIA", + larpBackToHouses: "◀ البيوت", + larpBadgeYou: "أنت", + larpBadgeRuling: "يحكم", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "وحدة طبقة لعب الأدوار الحية في Oasis (البيوت التسعة).", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + ecoinTaxLabel: "ECOin Tax", + bankTaxesNetworkTitle: "Network Taxes", + bankTaxesUserTitle: "Your taxes", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + statsEcoTaxTitle: "ECO Tax", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + audioTranscodeStegoTitle: "Embedded in the waveform", + audioBackToAudio: "Back to audio", + audioAuthorLabel: "Author", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "عند مواجهة مشكلة معقدة، ما الذي تفعله أولاً؟", + larpProfileQ1O1: "التحدث مع الآخرين للوصول إلى توافق", + larpProfileQ1O2: "بناء نموذج أولي لاختباره", + larpProfileQ1O3: "البحث في المراجع", + larpProfileQ1O4: "تعطيل النظام الذي يسببها", + larpProfileQ2: "ما الذي يعطي معنى لعملك اليومي؟", + larpProfileQ2O1: "الدفاع عن من أحب", + larpProfileQ2O2: "إنشاء شيء ملموس", + larpProfileQ2O3: "شفاء الحياة أو رعايتها", + larpProfileQ2O4: "صياغة الكلمات والأفكار", + larpProfileQ2O5: "إضحاك الآخرين", + larpProfileQ3: "عند نشوء نزاع في مجموعتك، أنت…", + larpProfileQ3O1: "تقود الحوار", + larpProfileQ3O2: "تنحاز وتثبت", + larpProfileQ3O3: "تطلق نكتة لتهدئة الموقف", + larpProfileQ3O4: "تشكك في حقيقة النزاع", + larpProfileQ3O5: "تبحث عن الأسباب البيئية الجذرية", + larpProfileQ4: "مشروعك المفضل على المدى الطويل سيكون…", + larpProfileQ4O1: "بناء مدينة", + larpProfileQ4O2: "إعادة تأهيل غابة", + larpProfileQ4O3: "كتابة دستور", + larpProfileQ4O4: "تنظيم مهرجان", + larpProfileQ4O5: "إنشاء شبكة دفاع", + larpProfileQ4O6: "تصميم آلة جديدة", + larpProfileQ4O7: "تنظيم أرشيف", + larpProfileQ4O8: "تفكيك نظام جائر", + larpProfileQ5: "ما الذي يجعل القائدة جيدة؟", + larpProfileQ5O1: "من يستمع ويتوسط", + larpProfileQ5O2: "من يقاتل ويحمي", + larpProfileQ5O3: "من يعرف التاريخ", + larpProfileQ5O4: "من يجعلك تبتسم", + larpProfileQ6: "علاقتك بالقواعد:", + larpProfileQ6O1: "القواعد تنبثق من الحوار والقانون", + larpProfileQ6O2: "يجب اتباع القواعد بصرامة", + larpProfileQ6O3: "يجب كسر القواعد كثيراً", + larpProfileQ6O4: "يجب أن تخدم القواعد الحياة", + larpProfileQ6O5: "القواعد تبني بنية تحتية صلبة", + larpProfileQ7: "كيف تتعامل مع المعلومات؟", + larpProfileQ7O1: "أنظمها وأحفظها", + larpProfileQ7O2: "أشاركها عبر القصص", + larpProfileQ7O3: "أشكك في أصولها", + larpProfileQ7O4: "أستخرج الأجزاء المفيدة", + larpProfileQ7O5: "أستخدمها للشفاء", + larpProfileQ8: "فكرتك عن النجاح هي…", + larpProfileQ8O1: "آلة تعمل", + larpProfileQ8O2: "مجتمع مسالم", + larpProfileQ8O3: "مهرجان نابض بالحياة", + larpProfileQ8O4: "عائلة آمنة", + larpProfileQ8O5: "أرشيف سليم", + larpProfileQ8O6: "عقيدة متصدعة", + larpProfileQ8O7: "حصاد مزدهر", + larpProfileQ8O8: "بناء مكتمل", + larpProfileQ9: "عند الاستيقاظ، تريد…", + larpProfileQ9O1: "تدريب جسدك", + larpProfileQ9O2: "القراءة أو الكتابة", + larpProfileQ9O3: "البستنة أو الطهي", + larpProfileQ9O4: "العبث ببعض الأشياء", + larpProfileQ9O5: "تحدي السلطة", + larpProfileQ9O6: "التخطيط لمشروع", + larpProfileQ9O7: "التحدث مع الأصدقاء", + larpProfileQ9O8: "صنع الفن", + larpProfileQ10: "نقطة ضعفك قد تكون:", + larpProfileQ10O1: "الكلام كثيراً", + larpProfileQ10O2: "البراغماتية الزائدة", + larpProfileQ10O3: "المثالية الزائدة", + larpProfileQ10O4: "الاضطراب الزائد", + larpProfileQ10O5: "الجمود الزائد", + larpProfileQ10O6: "الخفة الزائدة", + larpProfileQ10O7: "الحذر الزائد", + larpProfileQ10O8: "الطموح الزائد", + uxModeTitle: "UX", + uxModeDescription: "اختر وضع التنقل UX الذي تريده لواجهتك.", + uxModeMenus: "الكتل", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_de.js b/src/client/assets/translations/oasis_de.js new file mode 100644 index 0000000..dc782ac --- /dev/null +++ b/src/client/assets/translations/oasis_de.js @@ -0,0 +1,3868 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + de: { + languageName: "Deutsch", + shopOrderStatusPaid: "Zahlung erhalten", + shopOrderStatusReceived: "Erhalten", + shopOrderMarkPaid: "Zahlung erhalten", + shopOrderConfirmReceived: "Erhalt bestätigen", + shopMyOrdersTitle: "Meine Bestellungen", + shopPurchasesButton: "Einkäufe", + shopMyOrdersDescription: "Deine Bestellungen.", + shopMyOrdersEmpty: "Du hast noch keine Käufe.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Verkäufer", + shopOrderPaymentConcept: "Zahlung", + shopOrderInvoice: "Rechnung", + shopOrderInvoiceConcept: "Rechnung", + shopOrderStatusPending: "Ausstehend", + shopOrderStatusAccepted: "Angenommen", + shopOrderStatusRejected: "Abgelehnt", + shopOrderStatusShipped: "Versandt", + shopOrderStatusDelivered: "Geliefert", + shopOrderAccept: "Annehmen", + shopOrderReject: "Ablehnen", + shopOrderMarkShipped: "Als versandt markieren", + shopOrderMarkDelivered: "Als geliefert markieren", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Vertrag", + shopOrderContractConcept: "Shop-Bestellung", + shopOrdersPending: "Ausstehende Bestellungen", + all: "Alle", + mine: "Meine", + recent: "Aktuell", + anonymous: "Anonym", + unnamed: "Anonym", + authorLabel: "AUTOR", + delete: "Löschen", + save: "Speichern", + vote: "Abstimmen", + url: "URL", + price: "Preis", + priority: "Priorität", + severity: "Schweregrad", + organizer: "Organisator", + edited: "bearbeitet", + isPublic: "Öffentlich", + searchIsPublicLabel: "Öffentlich", + privacyPrivate: "PRIVAT", + privacyPublic: "ÖFFENTLICH", + notFound: "Nicht gefunden", + no_results: "Keine Daten", + searchButton: "Suchen", + mapZoomLabel: "Zoom", + modulesTitle: "Module", + viewJson: "JSON anzeigen", + matchScore: "Übereinstimmung", + preferencesLabel: "Einstellungen", + inhabitantProfileTitle: "Bewohnerprofil", + contentWarningLabel: "Inhaltswarnung", + invalidPost: "Ungültiger Inhalt", + invalidPostHint: "Diese Nachricht hat ungültigen/leeren Text.", + spreadBy: "Von", + spreadChron: "Verbreitung", + spreaded: "Verbreitet", + spreadMore: "mehr", + spreadContentUnavailable: "Inhalt noch nicht verfügbar (Replikation ausstehend)", + filteredByTag: "Nach Tag gefiltert", + filteredByTagTitle: "Nach Tag gefiltert", + feedPublishedSuccess: "Beitrag erfolgreich veröffentlicht!", + tribeFeedSent: "Nachricht erfolgreich gesendet!", + tribeContentEncrypted: "Verschlüsselter Inhalt", + tribeTorrentsEmpty: "Noch keine Torrents.", + torrentDescription: "Beschreibung", + torrentDownload: "Herunterladen", + chatInviteMode: "Einladen", + padInviteMode: "Einladen", + noInviteMode: "Kein Einladungsmodus", + noDeadline: "Keine Frist", + noStatus: "Kein Status", + noSeverity: "Kein Schweregrad", + calendarDeleteDate: "Datum löschen", + calendarIntervalUntil: "Bis", + bookmarkCategory: "Kategorie", + bookmarkLastVisit: "Letzter Besuch", + profileClearnetBookmarksLabel: "Lesezeichen", + forumFilterHot: "Beliebt", + invitesPort: "Port", + currentlyUnrecheable: "FEHLER!", + peerHostValidation: "Gültige IPv4 (z. B. 192.168.1.100) oder Hostname (z. B. pub.example.com)", + peerPortValidation: "Port 1-65535", + peerKeyValidation: "SSB ed25519 öffentlicher Schlüssel (@<44 Zeichen base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Abmeldung von deinem Stellenangebot", + inboxProjectFollowedTitle: "Neuer Follower deines Projekts", + inboxProjectUnfollowedTitle: "Folgt deinem Projekt nicht mehr", + pmHasFollowedYourProject: "folgt deinem Projekt", + pmHasUnfollowedYourProject: "folgt deinem Projekt nicht mehr", + pmHasUnsubscribedFromYourJobOffer: "hat sich von deinem Stellenangebot abgemeldet", + marketDeleteButton: "Löschen", + marketNoBids: "Noch keine Gebote", + marketAuctionBidAmount: "Gebotsbetrag", + marketAuctionBidTime: "Gebotszeit", + marketAuctionUser: "Bieter", + parliamentActorInPower: "REGIERENDER STAMM", + parliamentWinningCandidature: "Siegreiche Kandidatur", + projectBounty: "Belohnung", + projectFollowing: "GEFOLGT", + projectBackerAuthor: "Unterstützer", + projectBackerDate: "Datum", + projectConfirmTransferButton: "Überweisung bestätigen", + projectPendingTransfersTitle: "Ausstehende Überweisungen", + transfersUpdateSectionTitle: "Überweisung aktualisieren", + taskUnassignedFrom: "Zugewiesen entfernt von", + taskDescriptionPlaceholder: "Aufgabenbeschreibung", + exportPasswordLabel: "Export-Passwort", + importPasswordPlaceholder: "Passwort eingeben", + searchCasesPlaceholder: "Fälle suchen…", + shopBuyDeliveryAddressPlaceholder: "Lieferadresse", + shopBuyNotesPlaceholder: "Notizen", + bankTaxesUserNoteBold: "die ECO-Steuer direkt senken", + bankTaxesUserNoteIntro: "Deine ECO-Steuer wird vom Überschuss abgezogen, den das Netzwerk über deinem BGE generiert; der Basis-BGE-Wert ist als unverrückbares Minimum festgelegt. Die Steuer wird nie vom festen Betrag abgezogen, der jedem Bewohner als BGE zusteht. Die abgezogenen Mittel speisen den Umverteilungsalgorithmus und fließen über deren BGE-Anträge an andere Bewohner zurück. Jene anderen Bewohner, die mehr BGE für ihre Projekte erhalten, haben wahrscheinlich eine Aufgabe, die dir hilft, deine ECO-Steuer zu senken. Oder sie widmen sich ", + bankTaxesUserNoteOutro: ", und ihr fortlaufender Beitrag kann durch den Netzwerk-Konsens erforderlich sein.", + courtsViewDetails: "Ansehen", + courtsViewDetailsShort: "Details", + courtsThDetails: "Details", + courtsDetailsAnswersTitle: "Antworten", + courtsDetailsEvidenceTitle: "Beweise", + courtsDetailsSettlementsTitle: "Vergleiche", + courtsDetailsVerdictTitle: "Urteil", + courtsDetailsNotPublic: "Nicht öffentlich", + courtsEvidenceSideAccuser: "Beweise des Anklägers", + courtsEvidenceSideRespondent: "Beweise der Verteidigung", + courtsEvidenceSideUnknown: "Unbekannt", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Fall unterstützen", + courtsSettlementAcceptBtn: "Vergleich annehmen", + courtsSupportCount: "Unterstützer", + courtsVerdictVoteTitle: "Über das Urteil abstimmen", + courtsVerdictVoteLabel: "Urteilsabstimmung", + courtsVerdictVoteAccept: "Annehmen", + courtsVerdictVoteReject: "Ablehnen", + courtsVerdictVoteSubmit: "Stimme abgeben", + reportsSetStatus: "Status setzen", + reportsStatusClosed: "GESCHLOSSEN", + statsAvgPerInhabitant: "Durchschnitt pro Bewohner", + statsCarbonMaxAnnual: "Jährliche Maximalschätzung", + statsCarbonNetwork: "Netzwerk gesamt", + statsCarbonOfEstMax: "der geschätzten Maximalkapazität", + statsCarbonOfNetwork: "des Netzwerk-Gesamtwerts", + statsCarbonUser: "Dein Fußabdruck", + statsContentCountColumn: "Anzahl", + statsContentTypeColumn: "Typ", + statsMsgsPerDay: "Nachrichten/Tag (gesamt)", + statsMyShare: "Dein Anteil am Netzwerk", + statsNetworkSpan: "Netzwerk-Zeitspanne", + statsTombstoneRatioLabel: "Tombstone-Verhältnis", + statsTopTagsTitle: "Top-Tags", + statsTopTypesTitle: "Top-Inhaltstypen", + statsTotalMsgs: "Nachrichten gesamt", + feedViewDetails: "Details anzeigen", + eventFileLabel: "Bild anhängen", + taskFileLabel: "Bild anhängen", + courtsRespondentRequired: "Beklagten-ID erforderlich", + extended: "Multiversum", + extendedDescription: [ + "Wenn du jemanden unterstützt, kannst du Beiträge von dessen unterstützten Bewohnern herunterladen. Diese erscheinen hier, nach Aktualität sortiert.", + ], + popular: "Höhepunkte", + popularDescription: [ + "Beiträge von Bewohnern in deinem Netzwerk, ", + strong("nach Verbreitung sortiert"), + ". Wähle den Zeitraum, um eine Liste zu erhalten.", + ], + week: "WOCHE", + month: "MONAT", + year: "JAHR", + latest: "Neueste", + latestDescription: [ + strong("Beiträge"), + " von dir und Bewohnern, die du unterstützt, nach Aktualität sortiert.", + ], + topics: "Themen", + topicsDescription: [ + strong("Themen"), + " von dir und Bewohnern, die du unterstützt, nach Aktualität sortiert.", + ], + summaries: "Zusammenfassungen", + summariesDescription: [ + strong("Themen mit Kommentaren"), + " von dir und Bewohnern, die du unterstützt, nach Aktualität sortiert.", + ], + threads: "Threads", + threadsDescription: [ + strong("Beiträge mit Kommentaren"), + " von Bewohnern, die du unterstützt (inkl. Multiversum), nach Aktualität sortiert.", + ], + profile: "Avatar", + inhabitants: "Bewohner", + peersReplicatedFeeds: "Replizierte Feeds", + graphos: "Graphos", + graphosDescription: "Interaktive Karte des Netzwerks um dich herum.", + graphosViewingGraphOf: "Ansicht", + graphosBackToMine: "← Mein Netzwerk", + graphosShowing: "Zeige", + graphosYou: "Du", + graphosTotalNodes: "Knoten insgesamt", + manualMode: "Manueller Modus", + mentions: "Erwähnungen", + mentionsDescription: [ + strong("Beiträge, die dich @erwähnen"), + ", nach Aktualität sortiert.", + ], + nextPage: "Weiter", + previousPage: "Zurück", + noMentions: "Du hast noch keine @Erwähnungen erhalten.", + privateReminders: "ERINNERUNGEN", + inboxFiltersLabel: "Filter:", + inboxToggleToMutuals: "Auf Gegenseitige umschalten", + inboxToggleToWhole: "Auf Alle umschalten", + privateFrom: "Von", + privateTo: "An", + privateDate: "Datum", + pmReply: "Antworten", + pmReplies: "Antworten", + pmNew: "neu", + pmMarkRead: "Als gelesen markieren", + inReplyTo: "ALS ANTWORT AUF", + pmPreview: "Vorschau", + pmPreviewTitle: "Nachrichtenvorschau", + peers: "Verbindungen", + searchDescription: "Beschreibung", + imageSearch: "Bildersuche", + searchFromLabel: "Von", + searchToLabel: "Bis", + searchMinOpinionsLabel: "Min. Meinungen", + searchInhabitantLabel: "Bewohner (Oasis ID)", + searchQueryLabel: "Suche", + searchPerPageLabel: "Ergebnisse pro Seite", + settings: "Einstellungen", + continueReading: "Weiterlesen", + moreComments: "weitere Kommentare", + readThread: "den Rest des Threads lesen", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Zeichne Pixel auf dem Raster und kollaboriere mit anderen in deinem Netzwerk.', + melodyTitle: 'Melody', + melodyDescription: 'Spiel die Melodie deiner Blockchain — jeder Block wird zu einer Note.', + melodyEmpty: 'Noch keine Noten — deine Blockchain hat noch keinen Block erzeugt.', + melodyCompositionHelp: 'Jeder Block deiner Blockchain wird je nach Typ und Größe zu einer Note.', + melodyStatsTitle: 'Aufschlüsselung nach Typ', + melodyInhabitantLabel: 'Bewohner', + melodyTotalBlocks: 'Noten', + melodyType: 'Typ', + melodyCount: 'Blöcke', + melodyFilterAll: 'ALLE', + melodyFilterShare: 'Melodie hochladen', + melodyShareTitle: 'Teile deine Melodie', + melodyShareHelp: 'Veröffentliche eine Momentaufnahme deiner aktuellen Melodie, damit andere sie anhören und remixen können.', + melodyShareTitleLabel: 'Titel (optional)', + melodyShareTitlePlaceholder: 'Ein Blockchain-Geist', + melodyShareSubmit: 'Melodie veröffentlichen', + melodyNoShared: 'Noch keine Blockchain-Melodien.', + melodyRegenerate: 'Neu erzeugen', + melodyDownload: "BCS herunterladen", + profileActivityTitle: 'Aktivität', + profileActivityMore: 'Alle Aktivität anzeigen', + profileContentSectionTitle: 'Avatar-Inhalt', + profileContentHelp: 'Wähle, welche deiner Module auf deinem Avatar angezeigt werden.', + profileSensorsHelp: 'Optionale Metriken, die auf deinem Avatar angezeigt werden.', + profileClearnetHelp: 'Module, die von außerhalb von Oasis aufgerufen werden können.', + profileHubAll: 'Alle', + profileHubTitle: 'Öffentlicher Inhalt', + footerPulseTitle: 'Puls', + footerPulsePeers: 'Peers', + footerPulseInbox: 'Posteingang', + footerPulseSync: 'Letzte Sync', + footerPulseEcoValue: 'ECO/h', + footerPulseLastActivity: 'Letzte Aktivität', + footerLicenseLabel: 'Lizenz', + profileSwitchToOasis: 'Zurück zu Oasis', + profileSwitchToClearnet: 'Eintauchen ins Clearnet', + profileVisibilityFediverse: "Fediverse", + profileSwitchToFediverse: "Ins Fediverse eintauchen", + coordLabel: 'Koordinate (z.B. A3)', + coordPlaceholder: 'Koordinate eingeben', + contributorsTitle: "Mitwirkende", + pixeliaBy: "von", + colorLabel: 'Farbe wählen', + paintButton: 'Malen!', + invalidCoordinate: 'Ungültige Koordinate', + goToMuralButton: "Wandbild anzeigen", + totalPixels: 'Pixel gesamt', + modules: "Module", + modulesViewTitle: "Module", + modulesViewDescription: "Konfiguriere deine Umgebung durch Aktivieren oder Deaktivieren von Modulen.", + inbox: "Posteingang", + multiverse: "Multiversum", + fediverse: "Fediverse", + fediverseDescription: "Verwalte deine anderen Fediverse-Konten, einschließlich Senden und Empfangen von Inhalten.", + fediverseStatus: "Status", + fediverseDisconnected: "Fediverse getrennt. Überprüfe %LINK% oder den Verbindungsstatus.", + fediverseSettingsTitle: "Fediverse", + fediverseInstanceLabel: "Adresse", + fediverseTokenLabel: "Zugriffstoken", + fediverseTokenHelp: "Lege dein Zugriffstoken fest. Es wird verwendet, um dein Mastodon-Konto aus Oasis heraus zu verwalten.", + fediverseConnect: "Verbinden", + fediverseConnectedAs: "Verbunden als", + fediverseDisconnect: "Trennen", + fediverseEmpty: "Wenn du weitere Fediverse-Konten in %LINK% verbindest, kannst du sie von hier aus verwalten.", + fediverseEmptyLink: "deinen Einstellungen", + fediverseComposePlaceholder: "Was denkst du gerade?", + fediverseReplyPlaceholder: "Schreibe deine Antwort…", + fediverseAttach: "Medien anhängen", + fediversePublish: "Veröffentlichen", + fediverseReply: "Antworten", + fediverseBoosted: "teilte", + fediverseNoPosts: "Deine Timeline ist leer.", + fediverseThread: "Thread", + fediverseTimeline: "Zeitleisten", + fediverseManageTimeline: "Zeitleiste verwalten", + fediverseFollowers: "Follower", + fediverseFollowing: "Folge ich", + fediversePosts: "Beiträge", + fediverseJoined: "Beigetreten", + fediverseOverview: "Übersicht", + fediverseRefresh: "Aktualisieren", + fediverseWrite: "Schreiben", + fediversePreview: "Vorschau", + fediverseEdit: "Bearbeiten", + fediverseOpenFeed: "Feed ansehen", + fediverseManage: "Verwalten", + fediverseStatusNotConnected: "Nicht verbunden", + fediverseError: "Mastodon konnte nicht erreicht werden.", + fediverseErrInstance: "Ungültige Instanz-URL.", + fediverseErrAuth: "Ungültiges oder abgelaufenes Token.", + fediverseErrConnect: "Verbindung zur Instanz fehlgeschlagen.", + fediverseErrToken: "Token erforderlich.", + fediverseErrPublic: "Im öffentlichen Modus nicht verfügbar.", + fediverseErrFetch: "Timeline konnte nicht geladen werden.", + fediverseErrPost: "Konnte nicht veröffentlichen.", + fediverseErrMedia: "Datei konnte nicht hochgeladen werden.", + fediverseErrEmpty: "Schreibe etwas oder hänge eine Datei an.", + popularLabel: "Höhepunkte", + topicsLabel: "Themen", + latestLabel: "Neueste", + summariesLabel: "Zusammenfassungen", + threadsLabel: "Diskussionen", + multiverseLabel: "Multiversum", + inboxLabel: "Posteingang", + invitesLabel: "Einladungen", + walletLabel: "Geldbörse", + legacyLabel: "Schlüssel", + cipherLabel: "Verschlüsselung", + bookmarksLabel: "Lesezeichen", + videosLabel: "Videos", + torrentsLabel: "Torrents", + docsLabel: "Dokumente", + audiosLabel: "Audios", + tagsLabel: "Tags", + imagesLabel: "Bilder", + inhabitantsLabel: "Bewohner", + trendingLabel: "Trends", + eventsLabel: "Termine", + tasksLabel: "Aufgaben", + apply: "Anwenden", + menuPersonal: "Persönlich", + menuContent: "Inhalt", + menuBlogs: "Blogs", + menuGovernance: "Regierung", + menuOffice: "Büro", + menuMultiverse: "Multiversum", + menuNetwork: "Netzwerk", + menuCreative: "Kreativ", + menuEconomy: "Wirtschaft", + menuMedia: "Medien", + menuTools: "Werkzeuge", + comment: "Kommentar", + subtopic: "Unterthema", + json: "JSON", + createdBy: "von", + unfollow: "Nicht mehr unterstützen", + follow: "Unterstützen", + block: "Blockieren", + unblock: "Entsperren", + newerPosts: "Neuere Beiträge", + olderPosts: "Ältere Beiträge", + feedRangeEmpty: "Der angegebene Bereich ist leer für diesen Feed. Versuchen Sie den ", + seeFullFeed: "gesamten Feed", + feedEmpty: "Das Oasis-Netzwerk hat nie Beiträge von diesem Konto gesehen.", + beginningOfFeed: "Dies ist der Anfang des Feeds", + noNewerPosts: "Es wurden noch keine neueren Beiträge empfangen.", + relationshipNotFollowing: "Du wirst nicht unterstützt", + relationshipTheyFollow: "Unterstützt dich", + relationshipMutuals: "Gegenseitige Unterstützung", + relationshipFollowing: "Du unterstützt", + relationshipYou: "Du", + relationshipBlocking: "Du blockierst", + relationshipBlockedBy: "Du bist blockiert", + relationshipMutualBlock: "Gegenseitige Blockierung", + relationshipNone: "Du unterstützt nicht", + relationshipConflict: "Konflikt", + relationshipBlockingPost: "Blockierter Beitrag", + viewLikes: "Verbreitungen ansehen", + spreadedDescription: "Liste der vom Bewohner verbreiteten Beiträge.", + totalspreads: "Verbreitungen gesamt", + attachFiles: "Dateien anhängen", + preview: "Vorschau", + publish: "Schreiben", + contentWarningPlaceholder: "Betreff zum Beitrag hinzufügen (optional)", + privateWarningPlaceholder: "Bewohner hinzufügen, um einen privaten Beitrag zu senden (optional)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "ACHTUNG: Aufgrund der Blockchain-Technologie kann ein veröffentlichter Beitrag nicht bearbeitet oder gelöscht werden.", + ], + commentWarning: [ + "ACHTUNG: Aufgrund der Blockchain-Technologie kann ein veröffentlichter Beitrag nicht bearbeitet oder gelöscht werden.", + ], + commentPublic: "öffentlich", + commentPrivate: "privat", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "ACHTUNG: Aufgrund der Blockchain-Technologie kann ein veröffentlichter Beitrag nicht bearbeitet oder gelöscht werden.", + ], + replyLabel: ({ markdownUrl }) => [ + "ACHTUNG: Aufgrund der Blockchain-Technologie kann ein veröffentlichter Beitrag nicht bearbeitet oder gelöscht werden.", + ], + publishCustomInfo: ({ href }) => [ + "Wenn Sie Erfahrung haben, können Sie auch ", + a({ href }, "einen erweiterten Beitrag schreiben"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "Wenn Sie keine Erfahrung haben, sollten Sie ", + a({ href }, "einen Beitrag schreiben"), + ".", + ], + publishCustom: "Erweiterten Beitrag schreiben", + subtopicLabel: "Ein Unterthema dieses Beitrags erstellen", + messagePreview: "Beitragsvorschau", + mentionsMatching: "Passende Erwähnungen", + mentionsName: "Name", + mentionsRelationship: "Beziehung", + updateit: "UPDATES HOLEN!", + updateBannerText: "Eine neue Version von Oasis ist verfügbar.", + updateBannerAction: "Jetzt aktualisieren →", + info: "Info", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "her", + sendTime: "etwa ", + theme: "Design", + legacy: "Schlüssel", + legacyTitle: "Schlüssel", + legacyDescription: "Verwalte deinen geheimen Schlüssel (privater Schlüssel) schnell und sicher.", + legacyExportButton: "Exportieren", + legacyImportButton: "Importieren", + ssbLogStream: "Blockchain", + ssbLogStreamDescription: "Konfiguriere das Nachrichtenlimit für Blockchain-Streams.", + saveSettings: "Einstellungen speichern", + exportTitle: "Daten exportieren", + exportDescription: "Passwort festlegen (mind. 32 Zeichen), um deinen Schlüssel zu verschlüsseln", + exportDataTitle: "Sicherung", + exportDataDescription: "Lade deine Daten herunter (geheimer Schlüssel ausgeschlossen!)", + exportDataButton: "Datenbank herunterladen", + pubWallet: "PUB-Wallet", + pubWalletDescription: "Setze die PUB-Wallet-URL. Diese wird für PUB-Transaktionen (einschließlich UBI) verwendet.", + pubWalletConfiguration: "Konfiguration speichern", + importTitle: "Daten importieren", + importDescription: "Importiere deinen verschlüsselten geheimen Schlüssel, um deinen Avatar zu aktivieren", + importAttach: "Verschlüsselte Datei anhängen (.enc)", + passwordLengthInfo: "Das Passwort muss mindestens 32 Zeichen lang sein.", + passwordImport: "Gib dein Passwort ein, um die Daten zu entschlüsseln", + exportPasswordPlaceholder: "Klein-, Großbuchstaben, Zahlen und Symbole verwenden", + fileInfo: "Dein verschlüsselter Schlüssel wird im Systemverzeichnis gespeichert (Name: oasis.enc)", + themeIntro: "Wähle ein Design.", + setTheme: "Design festlegen", + language: "Sprache", + languageDescription: "Wenn du eine andere Sprache verwenden möchtest, wähle sie hier aus.", + setLanguage: "Sprache festlegen", + peerConnections: "Verbindungen", + peerConnectionsIntro: "Verwalte alle Verbindungen mit anderen Peers.", + peerConnectionsTitle: "Verbindungen", + online: "Online", + offline: "Offline", + discovered: 'Entdeckt', + unknown: 'Unbekannt', + lanBroadcastLabel: "LAN-Broadcast", + lanBroadcastActive: "Aktiv (UDP-Multicast im LAN)", + lanBroadcastDisabled: "Deaktiviert (Pub-Modus)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Kürzlich", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Unterstützt", + recommended: "Empfohlen", + blocked: "Blockiert", + noConnections: "Keine Peers verbunden.", + noDiscovered: "Keine Peers entdeckt.", + noUnknownPeers: "Keine unbekannten Peers im Freundes-Graphen.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Aktualisieren", + peerPruneIdle: "Inaktive entfernen", + peerExport: "Exportieren", + peerImport: "Importieren", + peerImportTitle: "Peer-Liste importieren", + peerImportPlaceholder: "Eine Multiserver-Adresse pro Zeile einfügen oder eine .txt-Datei hochladen…", + noSupportedConnections: "Keine Peers unterstützt.", + noBlockedConnections: "Keine Peers blockiert.", + noRecommendedConnections: "Keine Peers empfohlen.", + connectionActionIntro: + "", + startNetworking: "Netzwerk starten", + stopNetworking: "Netzwerk stoppen", + restartNetworking: "Netzwerk neu starten", + sync: "Netzwerk synchronisieren", + indexes: "Indizes", + indexesDescription: "Das Neuerstellen deiner Indizes ist sicher und kann einige Fehlerarten beheben.", + homePageTitle: "Startseite", + homePageDescription: "Wähle, welches Modul deine Startseite sein soll.", + saveHomePage: "Startseite festlegen", + invites: "Einladungen", + invitesTitle: "Einladungen", + invitesInvites: "Einladungen", + invitesDescription: "Verwalte und verwende Einladungscodes in deinem Netzwerk.", + invitesTribesTitle: "Stämme", + invitesTribeInviteCodePlaceholder: "Stamm-Einladungscode eingeben", + invitesTribeJoinButton: "Stamm beitreten", + invitesChatsTitle: "Chats", + invitesChatInviteCodePlaceholder: "Chat-Einladungscode eingeben", + invitesChatJoinButton: "Chat beitreten", + invitesPadsTitle: "Pads", + invitesPadInviteCodePlaceholder: "Pad-Einladungscode eingeben", + invitesPadJoinButton: "Pad beitreten", + invitesCalendarsTitle: "Kalender", + invitesCalendarInviteCodePlaceholder: "Kalender-Einladungscode eingeben", + invitesCalendarJoinButton: "Kalender beitreten", + invitesEventsTitle: "Termine", + invitesEventInviteCodePlaceholder: "Termin-Einladungscode eingeben", + invitesEventJoinButton: "Termin beitreten", + invitesForumsTitle: "Foren", + invitesForumInviteCodePlaceholder: "Forum-Einladungscode eingeben", + invitesForumJoinButton: "Forum beitreten", + invitesMapsTitle: "Karten", + invitesMapInviteCodePlaceholder: "Karten-Einladungscode eingeben", + invitesMapJoinButton: "Karte beitreten", + invitesShopsTitle: "Shops", + invitesShopInviteCodePlaceholder: "Shop-Einladungscode eingeben", + invitesShopJoinButton: "Shop beitreten", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "PUB-Einladungscode eingeben", + invitesAcceptInvite: "PUB beitreten", + invitesAlreadyFederated: "Du bist bereits mit diesem Pub föderiert.", + invitesFederationsTitle: "Föderationen", + invitesAcceptedInvites: "Föderiert", + invitesNoInvites: "Noch keine Einladungen angenommen.", + invitesUnfollow: "Entfolgen", + invitesFollow: "Folgen", + invitesUnfollowedInvites: "Nicht föderiert", + invitesNoFederatedPubs: "Keine föderierten Pubs.", + invitesNoUnfollowed: "Keine nicht föderierten Pubs.", + invitesUnreachablePubs: "Nicht erreichbar", + invitesNoUnreachablePubs: "Keine nicht erreichbaren Pubs.", + invitesPubsRefresh: "Aktualisieren", + invitesPubsClearUnreachable: "Nicht erreichbare entfernen", + invitesPubsExport: "Exportieren", + invitesPubsImport: "Importieren", + invitesPubsImportTitle: "Pubs importieren", + invitesPubsImportPlaceholder: "Eine Multiserver-Adresse oder einen Einladungscode pro Zeile einfügen oder eine .txt-Datei hochladen…", + currentlyUnreachable: "FEHLER!", + errorDetails: "Fehlerdetails", + genericError: "Ein Fehler ist aufgetreten.", + panicMode: "Panikmodus!", + encryptData: "Passwort festlegen (mind. 32 Zeichen), um deine Blockchain zu verschlüsseln", + decryptData: "Passwort eingeben, um deine Blockchain zu entschlüsseln", + panicModeDescription: "Verschlüssele/Entschlüssele oder LÖSCHE deine Blockchain", + removeDataDescription: "WARNUNG: Dieser Vorgang kann nicht rückgängig gemacht werden.", + encryptPanicButton: "Blockchain verschlüsseln", + decryptPanicButton: "Blockchain entschlüsseln", + removePanicButton: "ALLE DEINE DATEN LÖSCHEN!", + searchTitle: "Suche", + searchDescriptionLabel: "Suche nach Inhalten in deinem Netzwerk.", + searchLanguagesLabel: "Sprachen", + searchSkillsLabel: "Fähigkeiten", + searchPlaceholder:"Nach Inhalten suchen...", + searchDateLabel:"Datum", + searchLocationLabel:"Ort", + searchPriceLabel:"Preis", + searchUrlLabel: "URL", + searchCategoryLabel:"Kategorie", + searchStartLabel: "Startzeit", + searchEndLabel: "Endzeit", + searchPriorityLabel:"Priorität", + searchStatusLabel: "Status", + statusLabel: "Status", + totalVotesLabel: "Stimmen gesamt", + noResultsFound: "Keine Ergebnisse gefunden.", + votesOption: "Abstimmungsoptionen", + voteYesLabel:"Ja", + voteNoLabel:"Nein", + allTypesLabel: "UNBEGRENZT", + ABSTENTIONLabel: "ENTHALTUNG", + YESLabel: "JA", + NOLabel: "NEIN", + FOLLOW_MAJORITYLabel: "MEHRHEIT FOLGEN", + CONFUSEDLabel: "VERWIRRT", + NOT_INTERESTEDLabel: "NICHT INTERESSIERT", + StatusLabel: "Status", + votesOptionYesLabel:"Ja", + votesOptionNoLabel:"Nein", + votesOptionAbstentionLabel: "Enthaltung", + votesQuestionLabel: "Frage", + votesCreatedByLabel: "Erstellt von", + voteStatusOpen: "OFFEN", + voteStatusClosed: "GESCHLOSSEN", + imageSearchLabel: "Gib Wörter ein, um nach Bildern zu suchen.", + commentDescription: ({ parentUrl }) => [ + " hat kommentiert in ", + a({ href: parentUrl }, " Thread"), + ], + commentTitle: ({ authorName }) => [`Kommentar zu @${authorName}s Beitrag`], + subtopicDescription: ({ parentUrl }) => [ + " hat ein Unterthema erstellt aus ", + a({ href: parentUrl }, " einem Beitrag"), + ], + subtopicTitle: ({ authorName }) => [`Unterthema zu @${authorName}s Beitrag`], + mysteryDescription: "hat einen geheimnisvollen Beitrag veröffentlicht", + oasisDescription: "OASIS Projekt-Netzwerk", + searchSubmit: "Suchen...", + postLabel: "BEITRÄGE", + aboutLabel: "BEWOHNER", + feedLabel: "FEEDS", + votesLabel: "ABSTIMMUNGEN", + reportLabel: "BERICHTE", + imageLabel: "BILDER", + videoLabel: "VIDEOS", + audioLabel: "AUDIOS", + documentLabel: "DOKUMENTE", + torrentLabel: "TORRENTS", + pdfFallbackLabel: "PDF-Dokument", + eventLabel: "VERANSTALTUNGEN", + taskLabel: "AUFGABEN", + transferLabel: "ÜBERWEISUNGEN", + curriculumLabel: "LEBENSLAUF", + bookmarkLabel: "LESEZEICHEN", + tribeLabel: "STÄMME", + marketLabel: "MARKT", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "GELDBÖRSEN", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Absenden", + subjectLabel: "Betreff", + editProfile: "Avatar bearbeiten", + profileQrAlt: "Profil-QR-Code", + profileQrHint: "Scannen, um diese Oasis-ID zu kopieren.", + oasisIdLabel: "OasisID", + spreadLabel: "Verbreiten", + spreadHint: "Verbreite dies an deine Unterstützer (über deinen Feed).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Willkommen bei Oasis — ein freies, peer-to-peer, föderiertes und verschlüsseltes soziales Netzwerk mit einer verteilten Architektur nur mit Einladung.\n\nEinige interessante Orte zum Anfangen:\n\n- /profile — Bearbeite deinen Avatar, Namen, deine Beschreibung und welche Module auf deiner öffentlichen Seite erscheinen.\n- /invites — Füge einen Pub hinzu, um dich mit dem weiteren Netzwerk zu föderieren.\n- /peers — Sieh, wer verbunden ist, scanne dein LAN erneut, exportiere oder importiere Peer-Listen.\n- /inhabitants — Entdecke und besuche die Avatare anderer Personen.\n- /tribes — Erstelle private Gruppen mit Ende-zu-Ende-Verschlüsselung oder tritt ihnen bei.\n- /inbox — Lies und sende private Nachrichten.\n- /activity — Sieh die jüngste Aktivität, deine und die deines Netzwerks.\n- /publish — Schreibe einen Beitrag oder teile ein Bild, Audio, Video oder Dokument.\n\nEinige interessante Orte zum Verbinden:\n\n- /fediverse — Verwalte deine anderen Fediverse-Konten, einschließlich Senden und Empfangen von Inhalten.\n\nDiese Nachricht wurde privat von dir an dich selbst gesendet, daher war keine Verbindung nötig, um sie zu empfangen.", + profileVisibilityTitle: "Sichtbarkeit des öffentlichen Profils", + profileVisibilityHint: "Wähle, welche Felder andere Bewohner in deinem Profil sehen. Standardmäßig wird nur Karma angezeigt.", + profileSensorsSectionTitle: "Sensoren", + profileVisibilityActivity: "Aktivitätsstufe", + profileVisibilityDevice: "Gerät", + profileDeviceLockedHint: "Dieser Sensor ist immer aktiv: Er zeigt anderen, von welchem Gerät du dich verbindest, und kann nicht deaktiviert werden.", + profileVisibilityKarma: "KARMA-Punkte", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "ECOIN-Wallet", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "GPG-Schlüssel", + profileVisibilityClearnet: "Mein Profil veröffentlichen", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Shops", + profileClearnetJobsLabel: "Stellen", + profileClearnetEventsLabel: "Events", + profileClearnetProjectsLabel: "Projekte", + profileClearnetPostsLabel: "Blogs", + profileClearnetAudiosLabel: "Audios", + profileClearnetVideosLabel: "Videos", + profileClearnetImagesLabel: "Bilder", + profileClearnetDocumentsLabel: "Dokumente", + profileClearnetTorrentsLabel: "Torrents", + editProfileDescription: + "", + profileName: "Name", + profileImage: "Avatar-Bild", + profileGpgKey: "Öffentlicher GPG-Schlüssel (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Herunterladen", + profileGpgRemove: "Entfernen", + profileDescription: "Beschreibung", + hashtagDescription: + "Beiträge von Bewohnern in deinem Netzwerk, die diesen #Hashtag referenzieren, nach Aktualität sortiert.", + rebuildName: "Datenbank neu erstellen", + wallet: "Geldbörse", + walletAddress: "Adresse", + walletAmount: "Betrag", + walletAddressLine: ({ address }) => `Adresse: ${address}`, + walletAmountLine: ({ amount }) => `Betrag: ${amount} ECO`, + walletBack: "Zurück", + walletBalanceTitle: "Kontostand", + walletWalletSendTitle: "Senden", + walletReceiveTitle: "Empfangen", + walletHistoryTitle: "Verlauf", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Best.", + walletConfirm: "Bestätigen", + walletDescription: "Verwalte deine digitalen Vermögenswerte.", + walletDate: "Datum", + walletFee: "Gebühr (Je höher, desto schneller)", + walletFeeLine: ({ fee }) => `Gebühr: ECO ${fee}`, + walletHistory: "Verlauf", + walletReceive: "Empfangen", + walletReset: "Zurücksetzen", + walletSend: "Senden", + walletStatus: "Status", + walletDisconnected: [ + "ECOin ", + strong("Wallet getrennt"), + ". Überprüfe ", + a({ href: '/settings#wallet' }, "deine Einstellungen"), + " oder den Verbindungsstatus.", + ], + walletSentToLine: ({ destination, amount }) => `ECO ${amount} gesendet an ${destination}`, + walletSettingsTitle: "Geldbörse", + walletSettingsDescription: "Integriere Oasis mit deiner ECOin-Wallet.", + walletSettingsDocLink: "ECOin-Installationsanleitung", + walletStatusMessages: { + invalid_amount: "Ungültiger Betrag", + invalid_dest: "Ungültige Zieladresse", + invalid_fee: "Ungültige Gebühr", + validation_errors: "Validierungsfehler", + send_tx_success: "Transaktion erfolgreich", + }, + walletTitle: "Geldbörse", + walletTotalCostLine: ({ totalCost }) => `Gesamtkosten: ECO ${totalCost}`, + walletTransactionId: "Transaktions-ID", + walletTxId: "Tx ID", + walletType: "Typ", + walletUser: "Benutzername", + walletPass: "Passwort", + walletConfiguration: "Wallet konfigurieren", + cipher: "Verschlüsselung", + cipherTitle: "Verschlüsselung", + cipherDescription: "Verschlüssele und entschlüssele Text symmetrisch.", + randomPassword: "Zufälliges Passwort", + cipherEncryptTitle: "Text verschlüsseln", + cipherEncryptDescription: "Text zum Verschlüsseln eingeben", + cipherTextLabel: "Zu verschlüsselnder Text", + cipherTextPlaceholder: "Text eingeben...", + cipherPasswordLabel: "Passwort (mind. 32 Zeichen)", + cipherPasswordPlaceholder: "Passwort eingeben...", + cipherEncryptButton: "Verschlüsseln", + cipherDecryptTitle: "Text entschlüsseln", + cipherDecryptDescription: "Text zum Entschlüsseln eingeben", + cipherEncryptedMessageLabel: "Verschlüsselter Text", + cipherDecryptedMessageLabel: "Entschlüsselter Text", + cipherPasswordUsedLabel: "Verwendetes Passwort (aufbewahren!)", + cipherEncryptedTextPlaceholder: "Verschlüsselten Text eingeben...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "Initialisierungsvektor eingeben...", + cipherDecryptButton: "Entschlüsseln", + password: "Passwort", + text: "Text", + encryptedText: "Verschlüsselter Text", + iv: "Initialisierungsvektor (IV)", + encryptTitle: "Text verschlüsseln", + encryptDescription: "Text und Passwort eingeben.", + encryptButton: "Verschlüsseln", + decryptTitle: "Text entschlüsseln", + decryptDescription: "Verschlüsselten Text und Passwort eingeben.", + decryptButton: "Entschlüsseln", + passwordLengthError: "Passwort muss mind. 32 Zeichen lang sein.", + missingFieldsError: "Text, Passwort oder IV fehlt.", + encryptionError: "Fehler beim Verschlüsseln.", + decryptionError: "Fehler beim Entschlüsseln.", + bookmarkTitle: "Lesezeichen", + bookmarkDescription: "Lesezeichen in deinem Netzwerk entdecken und verwalten.", + bookmarkAllSectionTitle: "Lesezeichen", + bookmarkMineSectionTitle: "Deine Lesezeichen", + bookmarkRecentSectionTitle: "Neueste Lesezeichen", + bookmarkTopSectionTitle: "Top-Lesezeichen", + bookmarkFavoritesSectionTitle: "Favoriten", + bookmarkCreateSectionTitle: "Lesezeichen erstellen", + bookmarkUpdateSectionTitle: "Lesezeichen aktualisieren", + bookmarkFilterAll: "ALLE", + bookmarkFilterMine: "MEINE", + bookmarkFilterTop: "TOP", + bookmarkFilterFavorites: "FAVORITEN", + bookmarkFilterRecent: "NEUESTE", + bookmarkCreateButton: "Lesezeichen erstellen", + bookmarkUpdateButton: "Aktualisieren", + bookmarkDeleteButton: "Löschen", + bookmarkAddFavoriteButton: "Favorit hinzufügen", + bookmarkRemoveFavoriteButton: "Favorit entfernen", + bookmarkUrlLabel: "Link", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "Beschreibung", + bookmarkDescriptionPlaceholder: "Optional", + bookmarkTagsLabel: "Tags", + bookmarkTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + bookmarkCategoryLabel: "Kategorie", + bookmarkCategoryPlaceholder: "Optional", + bookmarkLastVisitLabel: "Letzter Besuch", + bookmarkSearchPlaceholder: "URL, Tags, Kategorie, Autor suchen...", + bookmarkSortRecent: "Neueste zuerst", + bookmarkSortOldest: "Älteste zuerst", + bookmarkSortTop: "Meistgewählt", + bookmarkSearchButton: "Suche", + bookmarkUpdatedAt: "Aktualisiert", + bookmarkNoMatch: "Keine Lesezeichen gefunden.", + noBookmarks: "Keine Lesezeichen vorhanden.", + noUrl: "Kein Link", + noCategory: "Keine Kategorie", + noLastVisit: "Kein letzter Besuch", + videoTitle: "Videos", + videoDescription: "Videoinhalte in deinem Netzwerk entdecken und verwalten.", + videoPluginTitle: "Titel", + videoPluginDescription: "Beschreibung", + videoMineSectionTitle: "Deine Videos", + videoCreateSectionTitle: "Video hochladen", + videoUpdateSectionTitle: "Video aktualisieren", + videoAllSectionTitle: "Videos", + videoRecentSectionTitle: "Neueste Videos", + videoTopSectionTitle: "Top-Videos", + videoFavoritesSectionTitle: "Favoriten", + videoFilterAll: "ALLE", + videoFilterMine: "MEINE", + videoFilterRecent: "NEUESTE", + videoFilterTop: "TOP", + videoFilterFavorites: "FAVORITEN", + videoCreateButton: "Video hochladen", + videoUpdateButton: "Aktualisieren", + videoDeleteButton: "Löschen", + videoAddFavoriteButton: "Favorit hinzufügen", + videoRemoveFavoriteButton: "Favorit entfernen", + videoFileLabel: "Videodatei auswählen (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Tags", + videoTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + videoTitleLabel: "Titel", + videoTitlePlaceholder: "Optional", + videoDescriptionLabel: "Beschreibung", + videoDescriptionPlaceholder: "Optional", + videoNoFile: "Keine Videodatei angegeben", + noVideos: "Keine Videos vorhanden.", + videoSearchPlaceholder: "Titel, Tags, Autor suchen...", + videoSortRecent: "Neueste zuerst", + videoSortOldest: "Älteste zuerst", + videoSortTop: "Meistgewählt", + videoSearchButton: "Suche", + videoMessageAuthorButton: "PN", + videoUpdatedAt: "Aktualisiert", + videoNoMatch: "Keine Videos gefunden.", + documentTitle: "Dokumente", + documentDescription: "Dokumente in deinem Netzwerk entdecken und verwalten.", + documentAllSectionTitle: "Dokumente", + documentMineSectionTitle: "Deine Dokumente", + documentRecentSectionTitle: "Neueste Dokumente", + documentTopSectionTitle: "Top-Dokumente", + documentFavoritesSectionTitle: "Favoriten", + documentCreateSectionTitle: "Dokument hochladen", + documentUpdateSectionTitle: "Dokument bearbeiten", + documentFilterAll: "ALLE", + documentFilterMine: "MEINE", + documentFilterRecent: "NEUESTE", + documentFilterTop: "TOP", + documentFilterFavorites: "FAVORITEN", + documentCreateButton: "Dokument hochladen", + documentUpdateButton: "Aktualisieren", + documentDeleteButton: "Löschen", + documentAddFavoriteButton: "Favorit hinzufügen", + documentRemoveFavoriteButton: "Aus Favoriten entfernen", + documentMessageAuthorButton: "PN", + documentFileLabel: "Dokument hochladen (.pdf)", + documentTagsLabel: "Tags", + documentTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + documentTitleLabel: "Titel", + documentTitlePlaceholder: "Optional", + documentDescriptionLabel: "Beschreibung", + documentDescriptionPlaceholder: "Optional", + documentNoFile: "Keine Datei.", + noDocuments: "Keine Dokumente vorhanden.", + documentSearchPlaceholder: "Titel, Tags, Beschreibung, Autor suchen...", + documentSortRecent: "Neueste zuerst", + documentSortOldest: "Älteste zuerst", + documentSortTop: "Meistgewählt", + documentSearchButton: "Suche", + documentNoMatch: "Keine Dokumente gefunden.", + documentUpdatedAt: "Aktualisiert", + audioTitle: "Audios", + audioDescription: "Audioinhalte in deinem Netzwerk entdecken und verwalten.", + audioPluginTitle: "Titel", + audioPluginDescription: "Beschreibung", + audioMineSectionTitle: "Deine Audios", + audioCreateSectionTitle: "Audio hochladen", + audioUpdateSectionTitle: "Audio aktualisieren", + audioAllSectionTitle: "Audios", + audioRecentSectionTitle: "Neueste Audios", + audioTopSectionTitle: "Top-Audios", + audioFilterAll: "ALLE", + audioFilterMine: "MEINE", + audioFilterRecent: "NEUESTE", + audioFilterTop: "TOP", + audioFilterBlockchain: "BLOCKCHAIN", + audioCreateButton: "Audio hochladen", + audioUpdateButton: "Aktualisieren", + audioDeleteButton: "Löschen", + audioAddFavoriteButton: "Favorit hinzufügen", + audioRemoveFavoriteButton: "Favorit entfernen", + audioFileLabel: "Audiodatei auswählen (.mp3, .wav, .ogg)", + audioTagsLabel: "Tags", + audioTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + audioTitleLabel: "Titel", + audioTitlePlaceholder: "Optional", + audioDescriptionLabel: "Beschreibung", + audioDescriptionPlaceholder: "Optional", + audioNoFile: "Keine Audiodatei angegeben", + noAudios: "Keine Audios vorhanden.", + audioSearchPlaceholder: "Titel, Tags, Autor suchen...", + audioSortRecent: "Neueste zuerst", + audioSortOldest: "Älteste zuerst", + audioSortTop: "Meistgewählt", + audioSearchButton: "Suche", + audioMessageAuthorButton: "PN", + audioUpdatedAt: "Aktualisiert", + audioNoMatch: "Keine Audios gefunden.", + audioFavoritesSectionTitle: "Favoriten", + audioFilterFavorites: "FAVORITEN", + favoritesTitle: "Favoriten", + favoritesDescription: "Alle deine favorisierten Inhalte an einem Ort.", + favoritesFilterAll: "ALLE", + favoritesFilterRecent: "NEUESTE", + favoritesFilterAudios: "AUDIOS", + favoritesFilterBookmarks: "LESEZEICHEN", + favoritesFilterDocuments: "DOKUMENTE", + favoritesFilterImages: "BILDER", + favoritesFilterMaps: "KARTEN", + favoritesFilterPads: "PADS", + favoritesFilterChats: "CHATS", + favoritesFilterCalendars: "KALENDER", + favoritesFilterVideos: "VIDEOS", + favoritesRemoveButton: "Aus Favoriten entfernen", + favoritesNoItems: "Noch keine Favoriten.", + yourContacts: "Deine Kontakte", + allInhabitants: "Bewohner", + allCVs: "Alle Lebensläufe", + discoverPeople: "Entdecke Bewohner in deinem Netzwerk.", + allInhabitantsButton: "ALLE", + contactsButton: "UNTERSTÜTZT", + CVsButton: "CVs", + matchSkills: "Fähigkeiten abgleichen", + matchSkillsButton: "FÄHIGKEITEN ABGLEICHEN", + suggestedButton: "VORGESCHLAGEN", + searchInhabitantsPlaceholder: "BEWOHNER NACH NAME FILTERN …", + filterLocation: "BEWOHNER NACH ORT FILTERN …", + filterLanguage: "BEWOHNER NACH SPRACHE FILTERN …", + filterSkills: "BEWOHNER NACH FÄHIGKEITEN FILTERN …", + applyFilters: "Filter anwenden", + locationLabel: "Ort", + languagesLabel: "Sprachen", + skillsLabel: "Fähigkeiten", + commonSkills: "Gemeinsame Fähigkeiten", + mutualFollowers: "Gemeinsame Follower", + suggestedFollowsYou: "Folgt dir", + latestInteractions: "Letzte Interaktionen", + viewAvatar: "Avatar anzeigen", + viewCV: "Lebenslauf anzeigen", + suggestedSectionTitle: "Vorgeschlagen", + topkarmaSectionTitle: "Top Karma", + topecoSectionTitle: "Top Öko", + topactivitySectionTitle: "Top Aktivität", + blockedSectionTitle: "Blockiert", + gallerySectionTitle: "GALERIE", + blockedButton: "BLOCKIERT", + blockedLabel: "Blockierter Bewohner", + inhabitantviewDetails: "Details anzeigen", + keepReading: "Weiterlesen...", + oasisId: "ID", + noInhabitantsFound: "Noch keine Bewohner gefunden.", + inhabitantActivityLevel: "Aktivitätsstufe", + lifespanLabel: "Lebensdauer", + deviceLabel: "Gerät", + parliamentTitle: "Parlament", + parliamentDescription: "Regierungsformen und kollektive Gesetzesverwaltung erkunden.", + parliamentFilterGovernment: "REGIERUNG", + parliamentFilterCandidatures: "KANDIDATUREN", + parliamentFilterProposals: "VORSCHLÄGE", + parliamentFilterLaws: "GESETZE", + parliamentFilterHistorical: "HISTORISCH", + parliamentFilterLeaders: "ANFÜHRER", + parliamentFilterRules: "REGELN", + parliamentGovernmentCard: "Aktuelle Regierung", + parliamentActorInPowerInhabitant: 'REGIERENDER BEWOHNER', + parliamentActorInPowerTribe: 'REGIERENDER STAMM', + parliamentHistoricalGovernmentsTitle: 'REGIERUNGEN', + parliamentHistoricalElectionsTitle: 'WAHLZYKLEN', + parliamentHistoricalLawsTitle: 'HISTORISCH', + parliamentHistoricalLeadersTitle: 'ANFÜHRER', + parliamentThCycles: 'ZYKLEN', + parliamentThTimesInPower: 'REGIERUNGSZEITEN', + parliamentThTotalCandidatures: 'KANDIDATUREN', + parliamentThProposed: 'VORGESCHLAGENE GESETZE', + parliamentThApproved: 'GENEHMIGTE GESETZE', + parliamentThDeclined: 'ABGELEHNTE GESETZE', + parliamentThDiscarded: 'VERWORFENE GESETZE', + parliamentMembers: "MITGLIEDER", + parliamentLegSince: "ZYKLUS SEIT", + parliamentLegEnd: "ZYKLUS ENDE", + parliamentPoliciesProposal: "GESETZESVORSCHLÄGE", + parliamentPoliciesApproved: "GESETZE GENEHMIGT", + parliamentPoliciesDeclined: "GESETZE ABGELEHNT", + parliamentPoliciesDiscarded: "GESETZE VERWORFEN", + parliamentEfficiency: "% EFFIZIENZ", + parliamentFilterRevocations: 'WIDERRUFE', + parliamentRevocationFormTitle: 'Gesetz widerrufen', + parliamentRevocationLaw: 'Gesetz', + parliamentRevocationTitle: 'Titel', + parliamentRevocationReasons: 'Begründung', + parliamentRevocationPublish: 'Widerruf veröffentlichen', + parliamentCurrentRevocationsTitle: 'Aktuelle Widerrufe', + parliamentFutureRevocationsTitle: 'Zukünftige Widerrufe', + parliamentPoliciesRevocated: 'WIDERRUFENE GESETZE', + parliamentPoliciesTitle: 'HISTORISCH', + parliamentLawsTitle: 'GENEHMIGTE GESETZE', + parliamentRulesRevocations: 'Jedes genehmigte Gesetz kann mit der aktuellen Regierungsmethode widerrufen werden.', + parliamentNoStableGov: "Noch keine Regierung gewählt.", + parliamentNoGovernments: "Noch keine Regierungen vorhanden.", + parliamentCandidatureFormTitle: "Kandidatur vorschlagen", + parliamentCandidatureIdPh: "Oasis-ID (@...) oder Stammname", + parliamentCandidatureSlogan: "Slogan (max. 140 Zeichen)", + parliamentCandidatureSloganPh: "Ein kurzes Motto", + parliamentCandidatureMethod: "Methode", + parliamentCandidatureProposeBtn: "Kandidatur veröffentlichen", + parliamentThDate: "Vorschlagsdatum", + parliamentThSlogan: "Slogan", + parliamentThSince: "Profil seit", + parliamentThVotes: "Erhaltene Stimmen", + parliamentThVoteAction: "Abstimmen", + parliamentTypeUser: "Bewohner", + parliamentTypeTribe: "Stamm", + parliamentVoteBtn: "Abstimmen", + parliamentProposalFormTitle: "Gesetz vorschlagen", + parliamentProposalDescription: "Beschreibung (≤1000)", + parliamentProposalPublish: "Vorschlag veröffentlichen", + parliamentFinalize: "Abschließen", + parliamentDeadline: "Frist", + parliamentNoProposals: "Noch keine Gesetzesvorschläge vorhanden.", + parliamentNoLaws: "Noch keine Gesetze genehmigt.", + parliamentMethodDEMOCRACY: "Demokratie", + parliamentMethodMAJORITY: "Mehrheit (80%)", + parliamentMethodMINORITY: "Minderheit (20%)", + parliamentMethodDICTATORSHIP: "Diktatur", + parliamentMethodKARMATOCRACY: "Karmatokratie", + parliamentThId: "ID", + parliamentThProposalDate: "Vorschlagsdatum", + parliamentThMethod: "Methode", + parliamentThKarma: "Karma", + parliamentThSupports: "Unterstützungen", + parliamentThVote: "Abstimmen", + parliamentCurrentProposalsTitle: "Aktuelle Vorschläge", + parliamentVotesSlashTotal: "Stimmen/Gesamt", + parliamentVotesNeeded: "Benötigte Stimmen", + parliamentFutureLawsTitle: "Zukünftige Gesetze", + parliamentNoFutureLaws: "Noch keine zukünftigen Gesetze.", + parliamentVoteAction: "Abstimmen", + parliamentLeadersTitle: "Anführer", + parliamentThLeader: "AVATAR", + parliamentPopulation: "Bevölkerung", + parliamentThType: "Typ", + parliamentThInPower: "An der Macht", + parliamentThPresented: "KANDIDATUREN", + parliamentNoLeaders: "Noch keine Anführer.", + parliamentCandidaturesListTitle: "Liste der Kandidaturen", + parliamentTimeRemaining: "Verbleibende Zeit", + parliamentNoLeader: "Noch keine führende Kandidatur.", + parliamentElectionsStatusTitle: "Nächste Regierung", + parliamentProposalDeadlineLabel: "Frist", + parliamentProposalTimeLeft: "Verbleibende Zeit", + parliamentProposalOnTrack: "Auf Kurs zur Annahme", + parliamentProposalOffTrack: "Noch nicht genügend Unterstützung", + parliamentRulesTitle: "Wie das Parlament funktioniert", + parliamentRulesIntro: "Wahlen werden alle 2 Monate aufgelöst; Kandidaturen laufen durchgehend und werden zurückgesetzt, wenn eine Regierung gewählt wird.", + parliamentRulesCandidates: "Jeder Bewohner kann sich selbst, einen anderen Bewohner oder einen Stamm vorschlagen. Jeder Bewohner kann bis zu 3 Kandidaturen pro Zyklus einreichen; Duplikate im selben Zyklus werden abgelehnt.", + parliamentRulesElection: "Der Gewinner ist die Kandidatur mit den meisten Stimmen zum Zeitpunkt der Auflösung.", + parliamentRulesTies: "Stichwahl-Reihenfolge: höchstes Bewohner-Karma; bei Gleichstand ältestes Profil; bei weiterem Gleichstand frühester Vorschlag; dann lexikographisch nach ID.", + parliamentRulesFallback: "Wenn niemand abstimmt, gewinnt die zuletzt vorgeschlagene Kandidatur. Wenn keine Kandidaturen vorliegen, wird ein zufälliger Stamm ausgewählt.", + parliamentRulesTerm: "Der Regierungs-Tab zeigt die aktuelle Regierung und Statistiken.", + parliamentRulesMethods: "Formen: Anarchie (einfache Mehrheit), Demokratie (50%+1), Mehrheit (80%), Minderheit (20%), Karmatokratie (Vorschläge mit höchstem Karma), Diktatur (sofortige Genehmigung).", + parliamentRulesAnarchy: "Anarchie ist der Standardmodus: Wenn bei der Auflösung keine Kandidatur gewählt wird, wird Anarchie ausgerufen. Unter Anarchie kann jeder Bewohner Gesetze vorschlagen.", + parliamentRulesProposals: "Wenn du der regierende Bewohner oder Mitglied des regierenden Stammes bist, kannst du Gesetzesvorschläge veröffentlichen. Nicht-diktatorische Methoden erzeugen eine öffentliche Abstimmung.", + parliamentRulesLimit: "Jeder Bewohner darf höchstens 3 Gesetzesvorschläge pro Zyklus veröffentlichen.", + parliamentRulesLaws: "Wenn ein Vorschlag seine Schwelle erreicht, wird er zum Gesetz und erscheint im Gesetze-Tab mit seinem Inkrafttretungsdatum.", + parliamentRulesHistorical: "Im Historisch-Tab kannst du jeden Regierungszyklus sehen, der stattgefunden hat, und Daten über dessen Verwaltung.", + parliamentRulesLeaders: "Im Anführer-Tab kannst du ein Ranking der Bewohner/Stämme sehen, die regiert (oder kandidiert) haben, sortiert nach Effizienz.", + parliamentProposalVoteStatusLabel: "Abstimmungsstatus", + parliamentProposalOnTrackYes: "Schwelle erreicht", + parliamentProposalOnTrackNo: "Unter der Schwelle", + courtsTitle: "Gerichte", + courtsDescription: "Formen der Konfliktlösung und kollektiven Justizverwaltung erkunden.", + courtsFilterCases: "FÄLLE", + courtsFilterMyCases: "MEINE", + courtsFilterJudges: "RICHTER", + courtsFilterHistory: "VERLAUF", + courtsFilterRules: "REGELN", + courtsCaseFormTitle: "Fall eröffnen", + courtsCaseRespondent: "Angeklagter / Beklagter", + courtsCaseRespondentPh: "Oasis-ID (@...) oder Stammname", + courtsCaseMediatorsAccuser: "Mediatoren (Ankläger)", + courtsCaseMethod: "Lösungsmethode", + courtsCaseDescription: "Beschreibung (max. 1000 Zeichen)", + courtsCaseEvidenceTitle: "Fallbeweise", + courtsCaseEvidenceHelp: "Bilder, Audios, Dokumente (PDF) oder Videos anhängen, die deinen Fall unterstützen.", + courtsCaseSubmit: "Fall einreichen", + courtsNominateJudge: "Richter nominieren", + courtsJudgeId: "Richter", + courtsJudgeIdPh: "Oasis-ID (@...) oder Bewohnername", + courtsNominateBtn: "Nominieren", + courtsAddEvidence: "Beweis hinzufügen", + courtsEvidenceText: "Text", + courtsEvidenceLink: "Link", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "Anhängen", + courtsAnswerText: "Antwortschrift", + courtsAnswerSubmit: "Antwort senden", + courtsVerdictTitle: "Urteil erlassen", + courtsVerdictOrdersPh: "Maßnahmen, Fristen, wiedergutmachende Schritte ...", + courtsIssueVerdict: "Urteil erlassen", + courtsMediationPropose: "Vergleich vorschlagen", + courtsSettlementProposeBtn: "Vorschlagen", + courtsNominationsTitle: "Richternominierungen", + courtsThJudge: "Richter", + courtsThDate: "Datum", + courtsThVote: "Stimme", + courtsNoNominations: "Noch keine Nominierungen.", + courtsCaseMediatorsRespondentTitle: "Verteidigungsmediatoren hinzufügen", + courtsCaseMediatorsRespondent: "Mediatoren (Verteidigung)", + courtsMediatorsAccuserLabel: "Mediatoren (Ankläger)", + courtsMediatorsRespondentLabel: "Mediatoren (Verteidigung)", + courtsMediatorsSubmit: "Mediatoren speichern", + courtsNoMyCases: "Du hast noch keine Konflikte.", + courtsNoHistory: "Noch keine aufgezeichneten Verhandlungen.", + courtsMethodDICTATOR: "Diktator", + courtsMethodPOPULAR: "Volksabstimmung", + courtsMethodKARMATOCRACY: "Karmatokratie", + courtsRulesTitle: "Wie Gerichte funktionieren", + courtsRulesIntro: "Gerichte sind ein gemeinschaftlich geführter Prozess zur Konfliktlösung und Förderung restaurativer Gerechtigkeit. Dialog, klare Beweise und verhältnismäßige Maßnahmen werden priorisiert.", + courtsRulesLifecycle: "Ablauf: 1) Fall eröffnen 2) Methode wählen 3) Beweise einreichen 4) Anhörung und Beratung 5) Urteil und Maßnahme 6) Einhaltung und Abschluss 7) Berufung (falls zutreffend).", + courtsRulesRoles: "Ankläger: eröffnet den Fall. Verteidigung: angeklagte Person oder Stamm. Methode: von der Gemeinschaft gewählter Mechanismus zur Erleichterung, Beweiswürdigung und Urteilsfindung. Zeuge: liefert Aussagen oder Beweise. Mediatoren: neutrale Personen, eingeladen vom Ankläger und/oder der Verteidigung, mit Zugang zu allen Details, die helfen, den Konflikt zu deeskalieren und Vereinbarungen zu schaffen.", + courtsRulesEvidence: "Beschreibung bis zu 1000 Zeichen. Relevante und rechtmäßige Bilder, Audios, Videos und PDF-Dokumente anhängen. Keine sensiblen privaten Daten ohne Einwilligung teilen.", + courtsRulesDeliberation: "Anhörungen können öffentlich oder privat sein. Richter sorgen für Respekt, fordern Klarstellungen an und können irrelevantes oder rechtswidriges Material verwerfen.", + courtsRulesVerdict: "Wiederherstellung wird priorisiert: Entschuldigungen, Mediationsvereinbarungen, Inhaltsmoderation, vorübergehende Einschränkungen oder andere verhältnismäßige Maßnahmen. Die Begründung muss protokolliert werden.", + courtsRulesAppeals: "Berufung: zulässig bei neuen Beweisen oder einem klaren Verfahrensfehler. Muss innerhalb von 7 Tagen eingereicht werden, sofern nicht anders angegeben.", + courtsRulesPrivacy: "Privatsphäre und Sicherheit respektieren. Doxxing, Hass oder Drohungen werden entfernt. Richter können Teile der Akte bearbeiten oder versiegeln, um gefährdete Personen zu schützen.", + courtsRulesMisconduct: "Belästigung, Manipulation oder gefälschte Beweise können zu einer sofortigen negativen Lösung führen.", + courtsRulesGlossary: "Fall: Aufzeichnung eines Konflikts. Beweis: Materialien, die Ansprüche unterstützen. Urteil: Entscheidung mit Maßnahme. Berufung: Antrag auf Überprüfung des Urteils.", + courtsFilterActions: "AKTIONEN", + courtsNoActions: "Keine ausstehenden Aktionen für deine Rolle.", + courtsCaseTitlePlaceholder: "Kurze Beschreibung des Konflikts", + courtsCaseSeverity: "Schweregrad", + courtsCaseSeverityNone: "Kein Schweregrad", + courtsCaseSeverityLOW: "Niedrig", + courtsCaseSeverityMEDIUM: "Mittel", + courtsCaseSeverityHIGH: "Hoch", + courtsCaseSeverityCRITICAL: "Kritisch", + courtsCaseSubject: "Thema", + courtsCaseSubjectNone: "Kein Thema-Tag", + courtsCaseSubjectBEHAVIOUR: "Verhalten", + courtsCaseSubjectCONTENT: "Inhalt", + courtsCaseSubjectGOVERNANCE: "Regierung / Regeln", + courtsCaseSubjectFINANCIAL: "Finanzen / Ressourcen", + courtsCaseSubjectOTHER: "Sonstiges", + courtsHiddenRespondent: "Verborgen (nur für beteiligte Rollen sichtbar).", + courtsThRole: "Rolle", + courtsRoleAccuser: "Ankläger", + courtsRoleDefence: "Verteidigung", + courtsRoleMediator: "Mediator", + courtsRoleJudge: "Richter", + courtsRoleDictator: "Diktator", + courtsAssignJudgeTitle: "Richter auswählen", + courtsAssignJudgeBtn: "Richter auswählen", + trendingTitle: "Trends", + exploreTrending: "Entdecke die beliebtesten Inhalte in deinem Netzwerk.", + bookmarkButton: "LESEZEICHEN", + transferButton: "ÜBERWEISUNGEN", + eventButton: "TERMINE", + taskButton: "AUFGABEN", + votesButton: "ABSTIMMUNGEN", + reportButton: "BERICHTE", + feedButton: "FEED", + marketButton: "MARKT", + imageButton: "BILDER", + audioButton: "AUDIOS", + videoButton: "VIDEOS", + documentButton: "DOKUMENTE", + torrentButton: "TORRENTS", + noTrendingFound: "Keine Trendinhalte gefunden.", + noContentMessage: "Noch keine Trendinhalte vorhanden.", + trendingDescription: "Beschreibung", + trendingDate: "Datum", + trendingLocation: "Ort", + trendingPrice: "Preis", + trendingUrl: "URL", + trendingCategory: "Kategorie", + trendingStart: "Start", + trendingEnd: "Ende", + trendingPriority: "Priorität", + trendingStatus: "Status", + trendingFrom: "Von", + trendingTo: "An", + trendingConcept: "Konzept", + trendingAmount: "Betrag", + trendingDeadline: "Frist", + trendingItemStatus: "Status", + trendingTotalVotes: "Stimmen gesamt", + trendingTotalOpinions: "Meinungen gesamt", + trendingNoContentMessage: "Noch keine Trendinhalte vorhanden.", + trendingAuthor: "Von", + trendingCreatedAtLabel: "Erstellt am", + trendingTotalCount: "Gesamtzahl", + tasksTitle: "Aufgaben", + tasksDescription: "Aufgaben in deinem Netzwerk entdecken und verwalten.", + taskTitleLabel: "Titel", + taskDescriptionLabel: "Beschreibung", + taskStartTimeLabel: "Startzeit", + taskEndTimeLabel: "Endzeit", + taskPriorityLabel: "Priorität", + taskPrioritySelect: "Priorität auswählen", + taskPriorityUrgent: "Dringend", + taskPriorityHigh: "Hoch", + taskPriorityMedium: "Mittel", + taskPriorityLow: "Niedrig", + taskLocationLabel: "Ort", + taskTagsLabel: "Tags", + taskVisibilityLabel: "Sichtbarkeit", + taskPublic: "öffentlich", + taskPrivate: "privat", + taskCreatedAt: "Erstellt am", + taskBy: "Von", + taskStatus: "Status", + taskStatusOpen: "Öffnen", + taskStatusInProgress: "In Bearbeitung", + taskStatusClosed: "Geschlossen", + taskAssignedTo: "Zugewiesen an", + taskAssignees: "Zugewiesene", + taskAssignButton: "Mir zuweisen", + taskUnassignButton: "Zuweisung aufheben", + taskCreateButton: "Aufgabe erstellen", + taskUpdateButton: "Aktualisieren", + taskDeleteButton: "Löschen", + taskFilterAll: "ALLE", + taskFilterMine: "MEINE", + taskFilterOpen: "OFFEN", + taskFilterInProgress: "IN BEARBEITUNG", + taskFilterClosed: "GESCHLOSSEN", + taskFilterAssigned: "ZUGEWIESEN", + taskFilterArchived: "ARCHIVIERT", + taskFilterUrgent: "DRINGEND", + taskFilterHigh: "HOCH", + taskFilterMedium: "MITTEL", + taskFilterLow: "NIEDRIG", + taskAllSectionTitle: "Aufgaben", + taskMineSectionTitle: "Deine Aufgaben", + taskCreateSectionTitle: "Aufgabe erstellen", + taskUpdateSectionTitle: "Aktualisieren", + taskOpenTitle: "Offene Aufgaben", + taskInProgressTitle: "Aufgaben in Bearbeitung", + taskClosedTitle: "Geschlossene Aufgaben", + taskAssignedTitle: "Zugewiesene Aufgaben", + taskArchivedTitle: "Archivierte Aufgaben", + taskPublicTitle: "Öffentliche Aufgaben", + taskPrivateTitle: "Private Aufgaben", + notasks: "Keine Aufgaben vorhanden.", + noLocation: "Kein Ort angegeben", + taskSetStatus: "Status setzen", + eventTitle: "Termine", + eventDateLabel: "Datum", + eventsTitle: "Termine", + eventsDescription: "Termine in deinem Netzwerk entdecken und verwalten.", + eventDescription: "Beschreibung", + eventPrice: "Preis", + eventStatus: "Status", + eventOrganizer: "Veranstalter", + eventAllSectionTitle: "Termine", + eventMineSectionTitle: "Deine Termine", + eventArchivedTitle: "Archivierte Termine", + eventCreateSectionTitle: "Termin erstellen", + eventUpdateSectionTitle: "Termin aktualisieren", + eventDeleteButton: "Löschen", + eventCreateButton: "Termin erstellen", + eventAddToCalendar: "Zum Kalender hinzufügen", + eventVisitCalendar: "Kalender ansehen", + viewTask: "Aufgabe ansehen", + viewReport: "Bericht ansehen", + viewTransfer: "Transfer ansehen", + viewItem: "Artikel ansehen", + viewShop: "Shop ansehen", + viewProject: "Projekt ansehen", + viewVotation: "Abstimmung ansehen", + voteCastTitle: "Stimme abgeben", + voteResults: "Ergebnisse", + eventTitleLabel: "Titel", + eventDescriptionLabel: "Beschreibung", + eventDescriptionPlaceholder: "Terminbeschreibung eingeben...", + eventUpdateButton: "Aktualisieren", + eventAttendeesLabel: "Teilnehmer", + eventAttendeesPlaceholder: "Teilnehmer eingeben, durch Kommas getrennt...", + eventTagsLabel: "Tags", + eventTagsPlaceholder: "Termin-Tags eingeben, durch Kommas getrennt...", + eventTags: "Tags", + eventPriceLabel: "Preis", + eventUrlLabel: "URL", + eventAttendees: "Teilnehmer", + noAttendees: "Noch keine Teilnehmer", + eventCreatedAt: "Erstellt am", + eventLocation: "Ort", + eventLocationLabel: "Ort", + eventNoLocation: "Kein Ort angegeben", + eventNoURL: "Keine URL angegeben", + eventBy: "Von", + noevents: "Keine Termine vorhanden.", + eventDate: "Datum", + eventDateFormat: "DD:MM:YYYY HH:mm", + eventAttendButton: "Teilnehmen", + eventUnattendButton: "Nicht mehr teilnehmen", + eventCreatedBy: "Erstellt von", + eventAttendeesCount: "Teilnehmerzahl", + eventCreatedByYou: "Du hast diesen Termin erstellt", + eventFilterAll: "ALLE", + eventFilterMine: "MEINE", + eventFilterToday: "HEUTE", + eventFilterWeek: "DIESE WOCHE", + eventFilterMonth: "DIESER MONAT", + eventFilterYear: "DIESES JAHR", + eventFilterArchived: "ARCHIVIERT", + eventTodayTitle: "Heutige Termine", + eventThisWeekTitle: "Termine dieser Woche", + eventThisMonthTitle: "Termine dieses Monats", + eventThisYearTitle: "Termine dieses Jahres", + eventPrivacyLabel: "Sichtbarkeit", + eventPublic: "Öffentlich", + eventPrivate: "Privat", + eventPublicTitle: "Öffentliche Termine", + eventNoPrice: "Kostenloser Termin", + eventNoImage: "Kein Bild hochgeladen", + eventAttendConfirmation: "Du nimmst jetzt an diesem Termin teil", + eventUnattendConfirmation: "Du nimmst nicht mehr an diesem Termin teil", + eventAttended: "Teilgenommen", + eventUnattended: "Nicht teilgenommen", + eventStatusOpen: "Öffnen", + eventStatusClosed: "Geschlossen", + tagsTitle: "Tags", + tagsDescription: "Taxonomiemuster in deinem Netzwerk entdecken und erkunden.", + tagsAllSectionTitle: "Tags", + tagsTopSectionTitle: "Top-Tags", + tagsCloudSectionTitle: "Tag-Wolke", + tagsFilterAll: "ALLE", + tagsFilterTop: "TOP", + tagsFilterCloud: "WOLKE", + tagsNoItems: "Keine Tags vorhanden.", + tagsTableHeaderTag: "TAG/LINK:", + tagsTableHeaderCount: "ANZAHL:", + transfersTitle: "Überweisungen", + transfersDescription: "Überweisungen in deinem Netzwerk entdecken und verwalten.", + transfersFrom: "Von", + transfersTo: "An", + transfersFilterAll: "ALLE", + transfersFilterMine: "MEINE", + transfersFilterUBI: "UBI", + transfersFilterMarket: "MARKT", + transfersFilterTop: "TOP", + transfersFilterPending: "AUSSTEHEND", + transfersFilterUnconfirmed: "UNBESTÄTIGT", + transfersFilterClosed: "GESCHLOSSEN", + transfersFilterDiscarded: "VERWORFEN", + transfersFilterEconomic: "WIRTSCHAFTLICH", + transfersFilterTime: "ZEIT", + transfersFilterTrust: "VERTRAUEN", + transfersCategory: "Kategorie", + transfersCategoryEconomic: "Wirtschaftlich", + transfersCategoryTime: "Zeit", + transfersCategoryTrust: "Vertrauen", + transfersCategoryApply: "Anwenden", + transfersUnitEco: "ECO", + transfersUnitHours: "Std", + transfersUnitTrust: "Vertrauen", + transfersEconomicSectionTitle: "Wirtschaftliche Überweisungen", + transfersTimeSectionTitle: "Zeit-Überweisungen", + transfersTrustSectionTitle: "Vertrauens-Überweisungen", + transfersCreateButton: "Überweisung erstellen", + transfersUpdateButton: "Aktualisieren", + transfersDeleteButton: "Löschen", + transfersToUser: "Oasis ID", + transfersToUserValidation: "Gültige Oasis-ID, z.B. @…=.ed25519", + transfersConcept: "Konzept", + transfersAmount: "Betrag", + transfersDeadline: "Frist", + transfersTags: "Tags", + transfersStatus: "Status", + transfersStatusUnconfirmed: "UNBESTÄTIGT", + transfersStatusClosed: "GESCHLOSSEN", + transfersStatusDiscarded: "VERWORFEN", + transfersCreatedAt: "Erstellt am", + transfersConfirmations: "BESTÄTIGUNGEN", + transfersContainingBlock: "Enthaltender Block", + transfersExportContract: "Vertrag erstellen", + transfersConfirmButton: "Überweisung bestätigen", + transfersNoItems: "Keine Überweisungen gefunden.", + transfersMineSectionTitle: "Deine Überweisungen", + transfersUBISectionTitle: "UBI Überweisungen", + transfersMarketSectionTitle: "Markt-Überweisungen", + transfersTopSectionTitle: "Top-Überweisungen", + transfersPendingSectionTitle: "Ausstehende Überweisungen", + transfersUnconfirmedSectionTitle: "Unbestätigte Überweisungen", + transfersClosedSectionTitle: "Geschlossene Überweisungen", + transfersDiscardedSectionTitle: "Verworfene Überweisungen", + transfersCreateSectionTitle: "Transfer erstellen", + transfersAllSectionTitle: "Überweisungen", + transfersFilterFavs: "Favoriten", + transfersFavsSectionTitle: "Favorisierte Überweisungen", + transfersSearchLabel: "Suche", + transfersSearchPlaceholder: "Konzept, Tags, Bewohner suchen...", + transfersMinAmountLabel: "Mindestbetrag", + transfersMaxAmountLabel: "Höchstbetrag", + transfersSortLabel: "Sortieren nach", + transfersSortRecent: "Neueste zuerst", + transfersSortAmount: "Höchster Betrag", + transfersSortDeadline: "Nächste Frist", + transfersSearchButton: "Suche", + transfersFavoriteButton: "Favorit", + transfersUnfavoriteButton: "Kein Favorit", + transfersMessageUserButton: "Nachricht", + transfersExpiringSoonBadge: "LÄUFT AB", + transfersExpiredBadge: "ABGELAUFEN", + transfersUpdatedAt: "Aktualisiert", + transfersNoMatch: "Keine Überweisungen gefunden.", + votationsTitle: "Abstimmungen", + votationsDescription: "Abstimmungen in deinem Netzwerk entdecken und verwalten.", + voteMineSectionTitle: "Deine Abstimmungen", + voteCreateSectionTitle: "Abstimmung erstellen", + voteUpdateSectionTitle: "Aktualisieren", + voteOpenTitle: "Offene Abstimmungen", + voteClosedTitle: "Geschlossene Abstimmungen", + voteAllSectionTitle: "Abstimmungen", + voteCreateButton: "Abstimmung erstellen", + voteUpdateButton: "Aktualisieren", + voteDeleteButton: "Löschen", + voteOptionYes: "JA", + voteOptionNo: "NEIN", + voteOptionAbstention: "ENTHALTUNG", + voteConfused: "VERWIRRT", + voteFollowMajority: "MEHRHEIT FOLGEN", + voteNotInterested: "NICHT INTERESSIERT", + voteFilterAll: "ALLE", + voteFilterMine: "MEINE", + voteFilterOpen: "OFFEN", + voteFilterClosed: "GESCHLOSSEN", + voteQuestionLabel: "Frage", + voteDeadlineLabel: "Frist", + voteOptionsLabel: "Deine Stimme", + voteBreakdown: "Aufschlüsselung", + voteFinalResult: "ERGEBNIS", + voteNoQuorum: "KEIN QUORUM", + voteTagsLabel: "Tags", + voteDeadline: "Frist", + voteStatus: "Status", + voteTags: "Tags", + voteOpinions: "Meinungen", + novotes: "Keine Abstimmungsvorschläge vorhanden.", + voteBy: "Von", + voteCreatedAt: "Erstellt am", + voteNoQuestion: "Keine Frage angegeben", + voteUnknownCreator: "Unbekannter Ersteller", + voteUnknownDate: "Unbekanntes Datum", + errorVoteNotFound: "Abstimmung nicht gefunden", + errorAlreadyVoted: "Du hast bereits abgestimmt.", + errorVoteClosedCannotEdit: "Eine geschlossene Abstimmung kann nicht bearbeitet werden", + errorVoteDeadlinePassed: "Die Frist für diese Abstimmung ist abgelaufen", + errorRetrievingVote: "Fehler beim Abrufen der Abstimmung", + errorCreatingVote: "Fehler beim Erstellen der Abstimmung", + errorVoteAlreadyVoted: "Nach Abgabe einer Meinung nicht mehr bearbeitbar", + errorDeletingOldVote: "Fehler beim Löschen der alten Meinung", + errorCreatingUpdatedVote: "Fehler beim Erstellen der aktualisierten Meinung", + errorCreatingTombstone: "Fehler beim Erstellen des Tombstone", + voteDetailSectionTitle: 'Abstimmungsdetails', + voteCommentsLabel: 'Kommentare', + voteCommentsForumButton: 'Diskussion eröffnen', + voteCommentsSectionTitle: 'Offene Diskussion', + voteNoCommentsYet: 'Noch keine Kommentare. Sei der Erste, der antwortet.', + voteNewCommentPlaceholder: 'Schreibe deinen Kommentar hier…', + voteNewCommentButton: 'Kommentar veröffentlichen', + voteNewCommentLabel: 'Einen Kommentar hinzufügen', + cvTitle: "Lebenslauf", + cvLabel: "LEBENSLÄUFE", + cvEditSectionTitle: "Lebenslauf bearbeiten", + cvCreateSectionTitle: "Lebenslauf erstellen", + cvDescription: "Verwalte und teile deine beruflichen Fähigkeiten und Informationen.", + cvNameLabel: "Vollständiger Name", + cvDescriptionLabel: "Zusammenfassung", + cvPhotoLabel: "Foto", + cvPersonalExperiencesLabel: "Persönliche Erfahrungen", + cvPersonalSkillsLabel: "Persönliche Fähigkeiten (durch Komma getrennt)", + cvOasisExperiencesLabel: "Oasis-Beitragserfahrungen", + cvOasisSkillsLabel: "Oasis-Beitragsfähigkeiten (durch Komma getrennt)", + cvEducationExperiencesLabel: "Bildungserfahrungen", + cvEducationalSkillsLabel: "Bildungsfähigkeiten (durch Komma getrennt)", + cvProfessionalExperiencesLabel: "Berufserfahrungen", + cvProfessionalSkillsLabel: "Berufliche Fähigkeiten (durch Komma getrennt)", + cvLanguagesLabel: "Sprachen", + cvLocationLabel: "Ort", + cvStatusLabel: "Status", + cvPreferencesLabel: "Präferenzen", + cvOasisContributorLabel: "Oasis-Mitwirkender", + cvPersonal: "Persönlich", + cvOasis: "Oasis-Mitwirkender (optional)", + cvOasisContributorView: "Oasis-Beitrag", + cvEducational: "Bildung (optional)", + cvEducationalView: "Bildung", + cvProfessional: "Beruf (optional)", + cvProfessionalView: "Beruf", + cvAvailability: "Verfügbarkeit (optional)", + cvAvailabilityView: "Verfügbarkeit", + cvUpdateButton: "Aktualisieren", + cvCreateButton: "Lebenslauf erstellen", + cvContactLabel: "Kontakt", + cvCreatedAt: "Erstellt am", + cvUpdatedAt: "Aktualisiert am", + cvEditButton: "Aktualisieren", + cvDeleteButton: "Löschen", + cvNoCV: "Kein Lebenslauf gefunden.", + blogSubject: "Betreff", + blogMessage: "Text auf 8096 Zeichen begrenzt.", + blogImage: "Medien hochladen (max: 50MB)", + blogPublish: "Vorschau", + noPopularMessages: "Noch keine beliebten Nachrichten veröffentlicht", + forumTitle: "Foren", + forumCategoryLabel: "Kategorie", + forumTitleLabel: "Titel", + forumTitlePlaceholder: "Forentitel...", + forumCreateButton: "Forum erstellen", + forumCreateSectionTitle: "Forum erstellen", + forumDescription: "Diskutiere offen mit anderen Bewohnern in deinem Netzwerk.", + forumFilterAll: "ALLE", + forumFilterMine: "MEINE", + forumFilterRecent: "NEUESTE", + forumFilterTop: "TOP", + forumMineSectionTitle: "Deine Foren", + forumRecentSectionTitle: "Neueste Foren", + forumAllSectionTitle: "Foren", + forumDeleteButton: "Löschen", + forumParticipants: "Teilnehmer", + forumMessages: "Nachrichten", + forumLastMessage: "Letzte Nachricht", + forumMessageLabel: "Nachricht", + forumMessagePlaceholder: "Schreibe deine Nachricht...", + forumSendButton: "Senden", + forumVisitForum: "Forum besuchen", + noForums: "Keine Foren gefunden.", + forumVisitButton: "Forum besuchen", + forumCatGENERAL: "Allgemein", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "Politik", + forumCatTECH: "Technik", + forumCatSCIENCE: "Wissenschaft", + forumCatMUSIC: "Musik", + forumCatART: "Kunst", + forumCatGAMING: "Gaming", + forumCatBOOKS: "Bücher", + forumCatFILMS: "Filme", + forumCatPHILOSOPHY: "Philosophie", + forumCatSOCIETY: "Gesellschaft", + forumCatPRIVACY: "Privatsphäre", + forumCatCYBERWARFARE: "Cyberkrieg", + forumCatSURVIVALISM: "Survivalismus", + imageTitle: "Bilder", + imageDescription: "Bildinhalte in deinem Netzwerk entdecken und verwalten.", + imagePluginTitle: "Titel", + imagePluginDescription: "Beschreibung", + imageMineSectionTitle: "Deine Bilder", + imageCreateSectionTitle: "Bild hochladen", + imageUpdateSectionTitle: "Bild aktualisieren", + imageAllSectionTitle: "Bilder", + imageRecentSectionTitle: "Neueste Bilder", + imageTopSectionTitle: "Top-Bilder", + imageFavoritesSectionTitle: "Favoriten", + imageGallerySectionTitle: "Galerie", + imageMemeSectionTitle: "Memes", + imageFilterAll: "ALLE", + imageFilterMine: "MEINE", + imageFilterRecent: "NEUESTE", + imageFilterTop: "TOP", + imageFilterFavorites: "FAVORITEN", + imageFilterGallery: "GALERIE", + imageFilterMeme: "MEMES", + imageCreateButton: "Bild hochladen", + imageUpdateButton: "Aktualisieren", + imageDeleteButton: "Löschen", + imageAddFavoriteButton: "Favorit hinzufügen", + imageRemoveFavoriteButton: "Favorit entfernen", + imageFileLabel: "Bilddatei auswählen (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Tags", + imageTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + imageTitleLabel: "Titel", + imageTitlePlaceholder: "Optional", + imageDescriptionLabel: "Beschreibung", + imageDescriptionPlaceholder: "Optional", + imageMemeLabel: "MEME?", + imageNoFile: "Keine Bilddatei angegeben", + noImages: "Keine Bilder vorhanden.", + imageSearchPlaceholder: "Titel, Tags, Beschreibung, Autor suchen...", + imageSortRecent: "Neueste zuerst", + imageSortOldest: "Älteste zuerst", + imageSortTop: "Meistgewählt", + imageSearchButton: "Suche", + imageMessageAuthorButton: "Nachricht", + imageUpdatedAt: "Aktualisiert", + imageNoMatch: "Keine Bilder gefunden.", + feedTitle: "Feed", + createFeedTitle: "Feed erstellen", + createFeedButton: "Feed senden!", + feedPlaceholder: "Was passiert? (max. 280 Zeichen)", + TODAYButton: "HEUTE", + CREATEButton: "Feed erstellen", + totalOpinions: "Meinungen gesamt", + moreVoted: "Meistgewählt", + noFeedsFound: "Keine Feeds gefunden.", + createdAtLabel: "Erstellt am", + FeedshareYourOpinions: "Kurztexte in deinem Netzwerk entdecken und teilen.", + refeedButton: "Refeed", + alreadyRefeeded: "Du hast dies bereits refeeded.", + activityTitle: "Aktivität", + yourActivity: "Deine Aktivität", + globalActivity: "Globale Aktivität", + activityList: "Aktivität", + activityDesc: "Sieh dir die neueste Aktivität in deinem Netzwerk an.", + allButton: "ALLE", + mineButton: "MEINE", + noActions: "Keine Aktivität vorhanden.", + performed: "→", + from: "Von", + to: "An", + amount: "Betrag", + concept: "Konzept", + description: "Beschreibung", + meme: "Meme", + activityContact: "Kontakt", + activityBy: "Name", + activityPixelia: "Neues Pixel hinzugefügt", + viewImage: "Bild anzeigen", + playAudio: "Audio abspielen", + playVideo: "Video abspielen", + typeRecent: "NEUESTE", + errorActivity: "Fehler beim Abrufen der Aktivität", + typePost: "BLOGS", + recentButton: "NEUESTE", + typeTribe: "STÄMME", + typeLarp: "L.A.R.P.", + typeInhabitants: "BEWOHNER", + activityProfileUpdated: "hat sein Profil aktualisiert", + typeAbout: "BEWOHNER", + typeCurriculum: "LEBENSLÄUFE", + typeImage: "BILDER", + typeBookmark: "LESEZEICHEN", + typeDocument: "DOKUMENTE", + typeVotes: "ABSTIMMUNGEN", + typeAudio: "AUDIOS", + typeMarket: "MARKT", + typeJob: "STELLEN", + typeProject: "PROJEKTE", + typeVideo: "VIDEOS", + typeVote: "VERBREITUNG", + typeEvent: "TERMINE", + typeTransfer: "ÜBERWEISUNGEN", + typeTask: "AUFGABEN", + typePixelia: "PIXELIA", + typeForum: "FOREN", + typeReport: "BERICHTE", + typeFeed: "FEEDS", + typeContact: "KONTAKT", + typePub: "PUB", + typeTombstone: "TOMBSTONE", + typeBanking: "BANKWESEN", + typeBankWallet: "BANKWESEN/WALLET", + typeBankClaim: "BANKWESEN/UBI", + typeKarmaScore: "KARMA", + typeParliament: "PARLAMENT", + typeSpread: "VERBREITUNGEN", + typeChat: "CHATS", + typeChatMessage: "CHATNACHRICHT", + typeGameScore: "SPIELERGEBNISSE", + typeParliamentCandidature: "Parlament · Kandidatur", + typeParliamentTerm: "Parlament · Amtszeit", + typeParliamentProposal:"Parlament · Vorschlag", + typeParliamentRevocation:"Parlament · Widerruf", + typeParliamentLaw: "Parlament · Neues Gesetz", + typeCourts: "GERICHTE", + typeCourtsCase: "Gerichte · Fall", + typeCourtsEvidence: "Gerichte · Beweis", + typeCourtsAnswer: "Gerichte · Antwort", + typeCourtsVerdict: "Gerichte · Urteil", + typeCourtsSettlement: "Gerichte · Vergleich", + typeCourtsSettlementProposal: "Gerichte · Vergleichsvorschlag", + typeCourtsSettlementAccepted: "Gerichte · Vergleich angenommen", + typeCourtsNomination: "Gerichte · Nominierung", + typeCourtsNominationVote: "Gerichte · Nominierungsabstimmung", + activitySupport: "Neue Allianz geschmiedet", + activityJoin: "Neuem PUB beigetreten", + question: "Frage", + deadline: "Frist", + status: "Status", + votes: "Stimmen", + totalVotes: "Stimmen gesamt", + voteTotalVotes: "Stimmen gesamt", + name: "Name", + skills: "Fähigkeiten", + tags: "Tags", + title: "Titel", + date: "Datum", + category: "Kategorie", + attendees: "Teilnehmer", + activitySpread: "->", + visitLink: "Link besuchen", + viewDocument: "Dokument anzeigen", + location: "Ort", + contentWarning: "Betreff", + personName: "Bewohnername", + bankWalletConnected: "ECOin Wallet", + bankUbiReceived: "UBI erhalten", + bankTx: "Tx", + bankEpochShort: "Epoche", + bankingUserEngagementScore: "KARMA-Bewertung", + viewDetails: "Details anzeigen", + link: "Link", + activityProjectFollow: "%OASIS% unterstützt jetzt dieses Projekt %PROJECT%", + activityProjectUnfollow: "%OASIS% unterstützt dieses Projekt nicht mehr %PROJECT%", + activityProjectPledged: "%OASIS% hat %AMOUNT% für das Projekt %PROJECT% zugesagt", + following: "UNTERSTÜTZT", + unfollowing: "NICHT MEHR UNTERSTÜTZT", + pledged: "ZUGESAGT", + parliamentCandidatureId: "Kandidatur", + parliamentGovMethod: "REGIERUNGSMETHODE", + parliamentVotesReceived: "STIMMEN ERHALTEN", + parliamentMethodANARCHY: "Anarchie", + parliamentMethodVOTE: "Gemeinschaftsabstimmung", + parliamentMethodRANKED: "Rangfolgewahl", + parliamentMethodPLURALITY: "Pluralität", + parliamentMethodCOUNCIL: "Rat", + parliamentMethodJURY: "Geschworene", + parliamentAnarchy: "ANARCHIE", + parliamentElectionsStart: "Wahlbeginn", + parliamentElectionsEnd: "Wahlende", + parliamentCurrentLeader: "Führende Kandidatur", + parliamentProposalTitle: "Titel", + parliamentOpenVote: "Abstimmung eröffnen", + parliamentStatus: "Status", + parliamentLawQuestion: "Frage", + parliamentLawMethod: "Methode", + parliamentLawProposer: "Vorgeschlagen von", + parliamentLawEnacted: "Inkraftgetreten am", + parliamentLawVotes: "JA/Gesamt", + createdAt: "Erstellt am", + courtsCaseTitle: "Fall", + courtsMethod: "Methode", + courtsMethodJUDGE: "Richter", + courtsMethodJUDGES: "Richtergremium", + courtsMethodSINGLE_JUDGE: "Einzelrichter", + courtsMethodJURY: "Geschworene", + courtsMethodCOUNCIL: "Rat", + courtsMethodCOMMUNITY: "Gemeinschaft", + courtsMethodARBITRATION: "Schiedsverfahren", + courtsMethodVOTE: "Gemeinschaftsabstimmung", + courtsAccuser: "Ankläger", + courtsRespondent: "Beklagter", + courtsThStatus: "Status", + courtsThAnswerBy: "Antwort von", + courtsThEvidenceBy: "Beweis von", + courtsThDecisionBy: "Entscheidung von", + courtsVotesNeeded: "Benötigte Stimmen", + courtsVotesSlashTotal: "JA / GESAMT", + courtsOpenVote: "Abstimmung eröffnen", + courtsAnswerTitle: "Auf die Klage antworten", + courtsStanceADMIT: "Zugeben", + courtsStanceDENY: "Bestreiten", + courtsStancePARTIAL: "Teilweise", + courtsStanceCOUNTERCLAIM: "Gegenklage", + courtsStanceNEUTRAL: "Neutral", + courtsVerdictResult: "Ergebnis", + courtsVerdictOrders: "Anordnungen", + courtsSettlementText: "Bedingungen", + courtsSettlementAccepted: "Angenommen", + courtsSettlementPending: "Ausstehend", + courtsJudge: "Richter", + courtsThSupports: "Unterstützungen", + courtsFilterOpenCase: 'FALL ERÖFFNEN', + courtsEvidenceFileLabel: 'Beweisdatei (Bild, Audio, Video oder PDF)', + courtsCaseMediators: 'Mediatoren', + courtsCaseMediatorsPh: 'Oasis-IDs der Mediatoren, durch Komma getrennt', + courtsMediatorsLabel: 'Mediatoren', + courtsThCase: 'Fall', + courtsThCreatedAt: 'Startdatum', + courtsThActions: 'Aktionen', + courtsPublicPrefLabel: 'Sichtbarkeit nach Klärung', + courtsPublicPrefYes: 'Ich stimme zu, dass dieser Fall vollständig öffentlich sein kann', + courtsPublicPrefNo: 'Ich bevorzuge, die Details privat zu halten', + courtsPublicPrefSubmit: 'Sichtbarkeitseinstellung speichern', + courtsMethodMEDIATION: 'Mediation', + courtsNoCases: 'Keine Fälle.', + reportsTitle: "Berichte", + reportsDescription: "Berichte zu Problemen, Fehlern, Missbrauch und Inhaltswarnungen in deinem Netzwerk verwalten und verfolgen.", + reportsFilterAll: "ALLE", + reportsFilterMine: "MEINE", + reportsFilterFeatures: "FUNKTIONEN", + reportsFilterBugs: "FEHLER", + reportsFilterAbuse: "MISSBRAUCH", + reportsFilterContent: "INHALT", + reportsFilterConfirmed: "BESTÄTIGT", + reportsFilterResolved: "GELÖST", + reportsFilterOpen: "OFFEN", + reportsFilterUnderReview: "IN PRÜFUNG", + reportsFilterInvalid: "UNGÜLTIG", + reportsCreateButton: "Bericht erstellen", + reportsTitleLabel: "Titel", + reportsDescriptionLabel: "Beschreibung", + reportsDescriptionPlaceholder: "Bitte eine ausführliche Beschreibung angeben.", + reportsCategory: "Kategorie", + reportsCategoryLabel: "Kategorie", + reportsCategoryFeatures: "Funktionen", + reportsCategoryBugs: "Fehler", + reportsCategoryAbuse: "Missbrauch", + reportsCategoryContent: "Inhaltsprobleme", + reportsUpdateButton: "Aktualisieren", + reportsDeleteButton: "Löschen", + reportsDateLabel: "Datum", + reportsUploadFile: "Medien hochladen (max: 50MB)", + reportsMineSectionTitle: "Deine Berichte", + reportsFeaturesSectionTitle: "Funktionsanfragen", + reportsBugsSectionTitle: "Fehler", + reportsAbuseSectionTitle: "Missbrauchsberichte", + reportsContentSectionTitle: "Inhaltsprobleme", + reportsAllSectionTitle: "Berichte", + reportsNoItems: "Keine Berichte vorhanden.", + reportsValidationTitle: "Bitte einen gültigen Titel eingeben.", + reportsValidationDescription: "Beschreibung darf nicht leer sein.", + reportsValidationCategory: "Bitte eine Kategorie auswählen.", + reportsCreatedAt: "Erstellt am", + reportsCreatedBy: "Von", + reportsSeverity: "Schweregrad", + reportsSeverityLow: "Niedrig", + reportsSeverityMedium: "Mittel", + reportsSeverityHigh: "Hoch", + reportsSeverityCritical: "Kritisch", + reportsStatus: "Status", + reportsStatusOpen: "Öffnen", + reportsStatusUnderReview: "In Prüfung", + reportsStatusResolved: "Gelöst", + reportsStatusInvalid: "Ungültig", + reportsUpdateStatusButton: "Status aktualisieren", + reportsAnonymityOption: "Anonym einreichen", + reportsAnonymousAuthor: "Anonym", + reportsConfirmButton: "BERICHT BESTÄTIGEN!", + reportsConfirmations: "Bestätigungen", + reportsConfirmedSectionTitle: "Bestätigte Berichte", + reportsCreateTaskButton: "AUFGABE ERSTELLEN", + reportsOpenSectionTitle: "Offene Berichte", + reportsUnderReviewSectionTitle: "Berichte in Prüfung", + reportsResolvedSectionTitle: "Gelöste Berichte", + reportsInvalidSectionTitle: "Ungültige Berichte", + reportsTemplateSectionTitle: 'Berichtsvorlage', + reportsBugTemplateTitle: 'Reproduktionsdetails (Fehler)', + reportsFeatureTemplateTitle: 'Details (Funktionen)', + reportsAbuseTemplateTitle: 'Details (Missbrauch)', + reportsContentTemplateTitle: 'Details (Inhaltsprobleme)', + reportsStepsToReproduceLabel: 'Reproduktionsschritte', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'Erwartetes Ergebnis', + reportsExpectedBehaviorPlaceholder: 'Was sollte passieren?', + reportsActualBehaviorLabel: 'Tatsächliches Ergebnis', + reportsActualBehaviorPlaceholder: 'Was passiert tatsächlich?', + reportsEnvironmentLabel: 'Umgebung', + reportsEnvironmentPlaceholder: 'Version, Gerät, Betriebssystem, Browser, Einstellungen, Protokolle, etc.', + reportsReproduceRateLabel: 'Reproduktionsrate', + reportsReproduceRateAlways: 'Immer', + reportsReproduceRateOften: 'Häufig', + reportsReproduceRateSometimes: 'Manchmal', + reportsReproduceRateRarely: 'Selten', + reportsReproduceRateUnable: 'Nicht reproduzierbar', + reportsReproduceRateUnknown: 'Nicht angegeben', + reportsProblemStatementLabel: 'Problem / Bedarf', + reportsProblemStatementPlaceholder: 'Welches Problem löst diese Funktion?', + reportsUserStoryLabel: 'Bewohner-Geschichte', + reportsUserStoryPlaceholder: 'Als möchte ich , damit .', + reportsAcceptanceCriteriaLabel: 'Akzeptanzkriterien', + reportsAcceptanceCriteriaPlaceholder: '- Gegeben...\n- Wenn...\n- Dann...', + reportsWhatHappenedLabel: 'Was ist passiert?', + reportsWhatHappenedPlaceholder: 'Beschreibe den Vorfall mit Kontext und ungefährem Datum.', + reportsReportedUserLabel: 'Gemeldeter Bewohner / Entität', + reportsReportedUserPlaceholder: '@Bewohner, Feed, ID, Link, etc.', + reportsEvidenceLinksLabel: 'Beweise / Links', + reportsEvidenceLinksPlaceholder: 'Links, Nachrichten-IDs, Screenshots (falls zutreffend), etc.', + reportsContentLocationLabel: 'Wo sich der Inhalt befindet', + reportsContentLocationPlaceholder: 'Link, ID, Kanal, Thread, Autor, etc.', + reportsWhyInappropriateLabel: "Warum es unangemessen ist", + reportsWhyInappropriatePlaceholder: 'Grund und Auswirkung erklären.', + reportsRequestedActionLabel: 'Gewünschte Maßnahme', + reportsRequestedActionPlaceholder: 'Entfernen, verbergen, markieren, warnen, etc.', + tribesTitle: "Stämme", + tribeAllSectionTitle: "Stämme", + tribeMineSectionTitle: "Deine Stämme", + tribeCreateSectionTitle: "Stamm erstellen", + tribeUpdateSectionTitle: "Stamm aktualisieren", + tribeGallerySectionTitle: "Stämme-Galerie", + tribeRecentSectionTitle: "Neueste Stämme", + tribeTopSectionTitle: "Beliebte Stämme", + tribeviewTribeButton: "Stamm besuchen", + tribeviewSubTribeButton: "Unter-Stamm besuchen", + tribeRootLabel: "WURZEL", + tribeDescription: "Stämme in deinem Netzwerk erkunden oder erstellen.", + tribeFilterAll: "ALLE", + tribeFilterMine: "MEINE", + tribeFilterMembership: "MITGLIEDSCHAFT", + tribeFilterRecent: "NEUESTE", + tribeFilterTop: "TOP", + tribeFilterSubtribes: "UNTER-STÄMME", + tribeFilterGallery: "GALERIE", + tribeMainTribeLabel: "HAUPTSTAMM", + tribeCreateButton: "Stamm erstellen", + tribeUpdateButton: "Aktualisieren", + tribeDeleteButton: "Löschen", + tribeImageLabel: "Medien hochladen (max: 50MB)", + tribeTitleLabel: "Titel", + searchTribesPlaceholder: "STÄMME NACH NAME FILTERN …", + tribeTitlePlaceholder: "Name des Stammes", + tribeDescriptionLabel: "Beschreibung", + tribeDescriptionPlaceholder: "Diesen Stamm beschreiben", + tribeLocationLabel: "Ort", + tribeLocationPlaceholder: "Wo befindet sich dieser Stamm?", + tribeTagsLabel: "Tags", + tribeTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + tribeInviteMode: "Einladungsmodus", + tribeModeLabel: "Einladungsmodus", + tribeIsAnonymousLabel: "STATUS", + tribeMembersCount: "Mitgliederzahl", + tribeInviteCodePlaceholder: "Einladungscode eingeben", + tribeJoinByCodeButton: "Mit Code beitreten", + tribeJoinButton: "BEITRETEN", + tribeEnterInvite: "CODE EINGEBEN", + tribeLeaveButton: "VERLASSEN", + tribeYes: "JA", + tribeNo: "NEIN", + tribePublic: "ÖFFENTLICH", + tribePrivate: "PRIVAT", + tribeGenerateInvite: "EINZELNE EINLADUNG", + tribeOpenInvitation: "OFFENE EINLADUNG", + tribeJoinOpen: "TRIBE BEITRETEN", + tribeRemoveInvitation: "EINLADUNG ENTFERNEN", + tribeCreatedAt: "Erstellt am", + tribeAuthor: "Von", + tribeAuthorLabel: "AUTOR", + tribeStrict: "Streng", + tribeOpen: "Öffnen", + tribeFeedFilterRECENT: "NEUESTE", + tribeFeedFilterMINE: "MEINE", + tribeFeedFilterALL: "ALLE", + tribeFeedFilterTOP: "TOP", + tribeFeedRefeeds: "Weiterleitungen", + tribeFeedRefeed: "Weiterleiten", + tribeFeedMessagePlaceholder: "Einen Feed schreiben…", + tribeFeedSend: "Senden", + tribeFeedEmpty: "Noch keine Feed-Nachrichten vorhanden.", + noTribes: "Noch keine Stämme gefunden.", + tribeNotFound: "Stamm nicht gefunden!", + createTribeTitle: "Stamm erstellen", + updateTribeTitle: "Stamm aktualisieren", + tribeSectionOverview: "Übersicht", + tribeSectionInhabitants: "Bewohner", + tribeSectionVotations: "ABSTIMMUNGEN", + tribeSectionEvents: "TERMINE", + tribeSectionReports: "Berichte", + tribeSectionTasks: "AUFGABEN", + tribeSectionFeed: "FEED", + tribeSectionForum: "FORUM", + tribeSectionMarket: "Markt", + tribeSectionJobs: "Stellen", + tribeSectionProjects: "Projekte", + tribeSectionMedia: "Medien", + tribeSectionImages: "BILDER", + tribeSectionAudios: "AUDIOS", + tribeSectionVideos: "VIDEOS", + tribeSectionDocuments: "DOKUMENTE", + tribeSectionBookmarks: "LESEZEICHEN", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "Noch keine Bewohner in diesem Stamm.", + tribeEventCreate: "Termin erstellen", + tribeEventsEmpty: "Noch keine Termine.", + tribeEventTitle: "Titel", + tribeEventDescription: "Beschreibung", + tribeEventDate: "Datum", + tribeEventLocation: "Ort", + tribeEventAttend: "TEILNEHMEN", + tribeEventUnattend: "VERLASSEN", + tribeEventAttendees: "Teilnehmer", + tribeTaskCreate: "Aufgabe erstellen", + tribeTasksEmpty: "Noch keine Aufgaben.", + tribeTaskTitle: "Titel", + tribeTaskDescription: "Beschreibung", + tribeTaskPriority: "Priorität", + tribeTaskDeadline: "Frist", + tribeTaskAssignees: "Zugewiesene", + tribeTaskStatusInProgress: "IN BEARBEITUNG", + tribeTaskStatusClosed: "SCHLIESSEN", + tribeTaskAssign: "ZUWEISEN", + tribeTaskUnassign: "ENTZIEHEN", + tribeReportCreate: "Bericht erstellen", + tribeReportsEmpty: "Noch keine Berichte.", + tribeReportTitle: "Titel", + tribeReportDescription: "Beschreibung", + tribeReportCategory: "Kategorie", + tribeVotationCreate: "Abstimmung erstellen", + tribeVotationsEmpty: "Noch keine Abstimmungen.", + tribeVotationTitle: "Titel", + tribeVotationDescription: "Beschreibung", + tribeVotationOptions: "Optionen", + tribeVotationDeadline: "Frist", + tribeVotationVote: "ABSTIMMEN", + tribeVotationResults: "Stimmen", + tribeVotationClose: "ABSTIMMUNG SCHLIESSEN", + tribeVotationOptionPlaceholder: "Option", + tribeForumCreate: "Forum erstellen", + tribeForumEmpty: "Noch keine Threads.", + tribeForumTitle: "Titel", + tribeForumText: "Nachricht", + tribeForumCategory: "Kategorie", + tribeForumReply: "Antworten", + tribeForumReplies: "Antworten", + tribeMarketCreate: "Angebot erstellen", + tribeMarketEmpty: "Noch keine Angebote.", + tribeMarketTitle: "Titel", + tribeMarketDescription: "Beschreibung", + tribeMarketPrice: "Preis", + tribeMarketImage: "Bild", + tribeMarketCategory: "Kategorie", + tribeJobCreate: "Stelle erstellen", + tribeJobsEmpty: "Noch keine Stellen.", + tribeJobTitle: "Titel", + tribeJobDescription: "Beschreibung", + tribeJobLocation: "Ort", + tribeJobSalary: "Gehalt", + tribeJobDeadline: "Frist", + tribeProjectCreate: "Projekt erstellen", + tribeProjectsEmpty: "Noch keine Projekte.", + tribeProjectTitle: "Titel", + tribeProjectDescription: "Beschreibung", + tribeProjectGoal: "Ziel", + tribeProjectFunded: "Finanziert", + tribeProjectDeadline: "Frist", + tribeMediaUpload: "Medien hochladen", + readDocument: "Dokument lesen", + tribeCreateImage: "Bild erstellen", + tribeCreateAudio: "Audio erstellen", + tribeCreateVideo: "Video erstellen", + tribeCreateDocument: "Dokument erstellen", + tribeCreateBookmark: "Lesezeichen erstellen", + tribeMediaEmpty: "Noch keine Medien.", + tribeMediaTitle: "Titel", + tribeMediaDescription: "Beschreibung", + tribeMediaType: "Typ", + tribeMediaTypeImage: "Bild", + tribeMediaTypeVideo: "Video", + tribeMediaTypeAudio: "Audio", + tribeMediaTypeDocument: "Dokument", + tribeMediaTypeBookmark: "Lesezeichen", + tribeContentDelete: "LÖSCHEN", + tribeInviteCodeText: "Einladungscode: ", + oasisVersionLabel: "Oasis-Version", + tribeInviteCodeHint: "Teile diesen Code mit jemandem, den du einladen möchtest. Er kann über /invites → Tribes beitreten.", + tribeGroupTribe: "Stamm", + tribeGroupOffice: "Büro", + tribeGroupNetwork: "Netzwerk", + tribeGroupEconomy: "Wirtschaft", + tribeGroupMedia: "Medien", + tribeStatusOpen: "OFFEN", + tribeStatusClosed: "GESCHLOSSEN", + tribeStatusInProgress: "IN BEARBEITUNG", + tribePriorityLow: "NIEDRIG", + tribePriorityMedium: "MITTEL", + tribePriorityHigh: "HOCH", + tribePriorityCritical: "KRITISCH", + tribeTaskFilterAll: "ALLE", + tribeMediaFilterAll: "ALLE", + tribeReportCatBug: "BUG", + tribeReportCatAbuse: "MISSBRAUCH", + tribeReportCatContent: "INHALT", + tribeReportCatOther: "SONSTIGES", + tribeForumCatGeneral: "ALLGEMEIN", + tribeForumCatProposal: "VORSCHLAG", + tribeForumCatQuestion: "FRAGE", + tribeForumCatAnnouncement: "ANKÜNDIGUNG", + tribeMarketCatGoods: "WAREN", + tribeMarketCatServices: "DIENSTE", + tribeMarketCatFood: "LEBENSMITTEL", + tribeMarketCatOther: "SONSTIGES", + tribeStatusLabel: "Status", + tribeSubTribes: "UNTER-STÄMME", + tribeSubTribesCreate: "Unter-Stamm Erstellen", + tribeSubTribesStrictDenied: "Der strikte Modus des Stammes erlaubt Ihnen nicht, neue Unter-Stämme zu erstellen. Bitte wenden Sie sich an den Administrator.", + tribeSubTribesEmpty: "Noch keine Unter-Stämme erstellt.", + tribeActivityJoined: "BEIGETRETEN", + tribeActivityLeft: "VERLASSEN", + tribeActivityFeed: "FEED", + tribeActivityRefeed: "WEITERLEITUNG", + tribeGroupAnalytics: "Analysen", + tribeGroupCreative: "Kreativ", + tribeSectionActivity: "AKTIVITÄT", + tribeSectionTrending: "TRENDS", + tribeSectionOpinions: "MEINUNGEN", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "TAGS", + tribeSectionSearch: "SUCHE", + tribeActivityEmpty: "Noch keine Aktivität.", + tribeActivityCreated: "erstellt", + tribeActivityPosted: "veröffentlicht", + tribeActivityReplied: "geantwortet", + tribeTrendingEmpty: "Noch keine Trendinhalte.", + tribeTrendingPeriodDay: "Heute", + tribeTrendingPeriodWeek: "Diese Woche", + tribeTrendingPeriodAll: "Gesamte Zeit", + tribeTrendingEngagement: "Beteiligung", + tribeOpinionsEmpty: "Noch keine Meinungen.", + tribeOpinionsCast: "Abstimmen", + tribeOpinionsRankings: "Ranglisten", + tribeOpinionsAlreadyVoted: "Bereits abgestimmt", + tribeTopCategory: "Meistgewählt", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Gemeinschaftliche Pixel-Art-Leinwand.", + tribePixeliaPaint: "Malen", + tribePixeliaContributors: "Mitwirkende", + tribePixeliaTotalPixels: "gemalte Pixel", + tribeTagsEmpty: "Keine Tags gefunden.", + tribeTagsCloud: "Tag-Wolke", + tribeTagsContentWith: "Inhalt mit Tag", + tribeSearchPlaceholder: "Stamminhalt durchsuchen...", + tribeSearchEmpty: "Keine Ergebnisse gefunden.", + tribeSearchResults: "Ergebnisse", + tribeSearchMinChars: "Mindestens 2 Zeichen eingeben, um zu suchen.", + agendaTitle: "Agenda", + agendaDescription: "Hier findest du alle dir zugewiesenen Einträge.", + agendaFilterAll: "ALLE", + agendaFilterToday: "HEUTE", + agendaFilterUpcoming: "BEVORSTEHEND", + agendaFilterOverdue: "ÜBERFÄLLIG", + agendaFilterOpen: "OFFEN", + agendaFilterClosed: "GESCHLOSSEN", + agendaFilterTasks: "AUFGABEN", + agendaFilterMarket: "MARKT", + agendaFilterTribes: "STÄMME", + agendaFilterEvents: "TERMINE", + agendaFilterReports: "BERICHTE", + agendaFilterTransfers: "ÜBERWEISUNGEN", + agendaFilterJobs: "STELLEN", + agendaFilterProjects: "PROJEKTE", + agendaFilterCalendars: "KALENDER", + agendaNoItems: "Keine Zuweisungen gefunden.", + agendaAuthor: "Von", + agendaDiscardButton: "Verwerfen", + agendaRestoreButton: "Wiederherstellen", + agendaCreatedAt: "Erstellt am", + agendaTitleLabel: "Titel", + agendaMembersCount: "Mitglieder", + agendaDescriptionLabel: "Beschreibung", + agendaStatus: "Status", + agendaVisibility: "Sichtbarkeit", + agendaEventDate: "Termindatum", + agendaEventLocation: "Ort", + agendaEventPrice: "Preis", + agendaEventUrl: "URL", + agendaTaskStart: "Startzeit", + agendaLocationLabel: "Ort", + agendaYes: "JA", + agendaNo: "NEIN", + agendaInviteModeLabel: "Status", + agendaAnonymousLabel: "Anonym", + agendaMembersLabel: "Mitglieder", + agendareportCategory: "Kategorie", + agendareportSeverity: "Schweregrad", + agendareportStatus: "Status", + agendareportDescription: "Beschreibung", + agendaTaskEnd: "Endzeit", + agendaTaskPriority: "Priorität", + agendaTransferFrom: "Von", + agendaTransferTo: "An", + agendaTransferConcept: "Konzept", + agendaTransferAmount: "Betrag", + agendaTransferDeadline: "Frist", + opinionsTitle: "Meinungen", + shareYourOpinions: "Meinungen in deinem Netzwerk entdecken und bewerten.", + author: "Von", + voteNow: "Jetzt abstimmen", + alreadyVoted: "Du hast bereits abgestimmt.", + noOpinionsFound: "Keine Meinungen gefunden.", + RECENTButton: "NEUESTE", + TOPButton: "TOP", + interestingButton: "INTERESSANT", + necessaryButton: "NOTWENDIG", + funnyButton: "LUSTIG", + disgustingButton: "WIDERLICH", + sensibleButton: "SENSIBEL", + propagandaButton: "PROPAGANDA", + adultOnlyButton: "NUR FÜR ERWACHSENE", + boringButton: "LANGWEILIG", + confusingButton: "VERWIRREND", + usefulButton: "NÜTZLICH", + informativeButton: "INFORMATIV", + wellResearchedButton: "GUT RECHERCHIERT", + accurateButton: "ZUTREFFEND", + needsSourcesButton: "QUELLEN NÖTIG", + wrongButton: "FALSCH", + lowQualityButton: "NIEDRIGE QUALITÄT", + creativeButton: "KREATIV", + insightfulButton: "AUFSCHLUSSREICH", + actionableButton: "UMSETZBAR", + inspiringButton: "INSPIRIEREND", + loveButton: "LIEBE", + clearButton: "KLAR", + upliftingButton: "ERHEBEND", + unnecessaryButton: "UNNÖTIG", + rejectedButton: "ABGELEHNT", + misleadingButton: "IRREFÜHREND", + offTopicButton: "THEMENFREMD", + duplicateButton: "DUPLIKAT", + clickbaitButton: "CLICKBAIT", + spamButton: "SPAM", + trollButton: "TROLL", + nsfwButton: "NSFW", + violentButton: "GEWALT", + toxicButton: "TOXISCH", + harassmentButton: "BELÄSTIGUNG", + hateButton: "HASS", + scamButton: "BETRUG", + triggeringButton: "TRIGGERND", + opinionsCreatedAt: "Erstellt am", + opinionsTotalCount: "Meinungen gesamt", + voteInteresting: "Interessant", + voteNecessary: "Notwendig", + voteUseful: "Nützlich", + voteInformative: "Informativ", + voteWellResearched: "Gut recherchiert", + voteNeedsSources: "Quellen nötig", + voteWrong: "Falsch", + voteLowQuality: "Niedrige Qualität", + voteLove: "Liebe", + voteClear: "Leeren", + voteMisleading: "Irreführend", + voteOffTopic: "Themenfremd", + voteDuplicate: "Duplikat", + voteClickbait: "Clickbait", + votePropaganda: "Propaganda", + voteFunny: "Lustig", + voteInspiring: "Inspirierend", + voteUplifting: "Erhebend", + voteUnnecessary: "Unnötig", + voteRejected: "Abgelehnt", + voteConfusing: "Verwirrend", + voteTroll: "Troll", + voteNsfw: "NSFW", + voteViolent: "Gewalt", + voteToxic: "Toxisch", + voteHarassment: "Belästigung", + voteHate: "Hass", + voteScam: "Betrug", + voteTriggering: "Triggernd", + voteInsightful: "Aufschlussreich", + voteAccurate: "Zutreffend", + voteActionable: "Umsetzbar", + voteCreative: "Kreativ", + voteSpam: "Spam", + voteAdultOnly: "Nur für Erwachsene", + publishBlog: "Blog veröffentlichen", + privateMessage: "PN", + pmSendTitle: "Private Nachrichten", + pmSend: "Senden!", + pmDescription: "Verwende dieses Formular, um eine verschlüsselte Nachricht an andere Bewohner zu senden.", + pmRecipients: "Empfänger", + pmRecipientsHint: "Oasis-IDs durch Kommas getrennt eingeben", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Betreff", + pmSubjectHint: "Nachrichtenbetreff eingeben", + pmText: "Nachricht", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Entschlüsseln", + pmCrypterBadKey: "Der geteilte Schlüssel ist falsch!", + pmCrypterTooLong: "Die Nachricht ist zu lang, um mit dem Crypter verschlüsselt zu werden. Bitte kürze sie und versuche es erneut.", + pmCrypterCipherLabel: "Erneut verschlüsselte Nachricht", + pmCrypterCharsLabel: "Zeichen", + pmInvalidRecipients: "Ungültige Oasis-ID (sollte wie @…=.ed25519 aussehen).", + pmEncryptionLabel: "Verschlüsselung", + pmFile: "Anhang", + private: "Privat", + privateDescription: "Deine verschlüsselten Nachrichten.", + privateInbox: "POSTEINGANG", + privateSent: "GESENDET", + privateDelete: "Löschen", + pmCreateButton: "PM schreiben", + noPrivateMessages: "Keine privaten Nachrichten.", + pmFromLabel: "Von:", + pmToLabel: "An:", + pmInvalidMessage: "Ungültige Nachricht", + pmNoSubject: "(kein Betreff)", + pmSubjectLabel: "Betreff:", + pmBodyLabel: "Inhalt", + pmBotJobs: "42-StellenBOT", + pmBotProjects: "42-ProjekteBOT", + pmBotMarket: "42-MarktBOT", + inboxJobSubscribedTitle: "Neues Abonnement deines Stellenangebots", + pmInhabitantWithId: "Bewohner mit OASIS-ID:", + pmHasSubscribedToYourJobOffer: "hat dein Stellenangebot abonniert", + inboxProjectCreatedTitle: "Neues Projekt erstellt", + pmHasCreatedAProject: "hat ein Projekt erstellt", + inboxMarketItemSoldTitle: "Artikel verkauft", + pmYourItem: "Dein Artikel", + pmHasBeenSoldTo: "wurde verkauft an", + pmFor: "für", + inboxProjectPledgedTitle: "Neue Zusage für dein Projekt", + pmHasPledged: "hat zugesagt", + pmToYourProject: "für dein Projekt", + blockchain: 'BlockExplorer', + blockchainTitle: 'BlockExplorer', + blockchainDescription: 'Blöcke in der Blockchain erkunden und visualisieren.', + blockchainNoBlocks: 'Keine Blöcke in der Blockchain gefunden.', + blockchainStatsTitle: 'Statistiken', + blockchainStatsTypeBreakdown: 'Aufschlüsselung nach Typ', + blockchainStatsTopAuthors: 'Top-Autoren', + blockchainStatsCount: 'Blöcke', + blockchainBlockID: 'Block-ID', + blockchainBlockAuthor: 'Autor', + blockchainBlockCarbon: 'CO₂-Fußabdruck', + blockchainBlockType: 'Typ', + blockchainBlockTimestamp: 'Zeitstempel', + blockchainBlockContent: 'Block', + blockchainBlockURL: 'URL:', + blockchainContent: 'Block', + blockchainContentPreview: 'Vorschau des Blockinhalts', + blockchainLatestDatagram: 'Letztes Datagramm', + blockchainDatagram: 'Datagramm', + blockchainDetails: 'Blockdetails anzeigen', + blockchainViewBlockexplorer: "Im Blockexplorer ansehen", + blockchainBlockInfo: 'Blockinformationen', + blockchainBlockDetails: 'Details des ausgewählten Blocks', + blockchainBack: 'Zurück zum BlockExplorer', + blockchainContentDeleted: "Dieser Inhalt wurde gelöscht (Tombstone)", + visitContent: "Inhalt besuchen", + banking: 'Bankwesen', + bankingTitle: 'Bankwesen', + bankingDescription: 'Den aktuellen Wert von ECOin und die entsprechende UBI-Zuweisung erkunden, die pro Epoche basierend auf Teilnahme und Vertrauen verteilt wird.', + bankOverview: 'Übersicht', + bankEpochs: 'Epochen', + bankRules: 'Regeln', + pending: 'Ausstehend', + closed: 'Geschlossen', + bankBack: 'Zurück zum Bankwesen', + bankViewTx: 'Tx anzeigen', + bankClaimNow: 'Jetzt beanspruchen', + bankClaimUBI: 'UBI beanspruchen!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'Keine ausstehenden UBI-Zuweisungen für diese Epoche.', + bankEpoch: 'Epoche', + bankPool: 'Pool (diese Epoche)', + bankWeightsSum: 'Gewichtssumme', + bankAllocations: 'Zuweisungen', + bankNoAllocations: 'Keine Zuweisungen gefunden.', + bankNoEpochs: 'Keine Epochen gefunden.', + bankEpochAllocations: 'Epochen-Zuweisungen', + bankAllocId: 'Zuweisungs-ID', + bankAllocDate: 'Datum', + bankAllocConcept: 'Konzept', + bankAllocFrom: 'Von', + bankAllocTo: 'An', + bankAllocAmount: 'Betrag', + bankAllocStatus: 'Status', + bankEpochId: 'Epochen-ID', + bankRuleHash: 'Regelversions-Hash', + bankViewEpoch: 'Epoche anzeigen', + bankUserBalance: 'Dein Kontostand', + ecoWalletNotConfigured: 'ECOin Wallet nicht konfiguriert', + editWallet: 'Geldbörse bearbeiten', + addWallet: 'Geldbörse hinzufügen', + bankAddresses: 'Adressen', + bankNoAddresses: 'Keine Adressen gefunden.', + bankUser: 'Oasis-ID', + bankAddress: 'Adresse', + bankAddAddressTitle: 'ECOIN-Adresse hinzufügen', + bankAddAddressUser: 'Oasis-ID', + bankAddAddressAddress: 'ECOIN-Adresse', + bankAddAddressSave: 'Speichern', + bankAddressAdded: 'Adresse hinzugefügt', + bankAddressUpdated: 'Adresse aktualisiert', + bankAddressExists: 'Adresse bereits vorhanden', + bankAddressInvalid: 'Ungültige Adresse', + bankAddressDeleted: 'Adresse gelöscht', + bankAddressNotFound: 'Adresse nicht gefunden', + bankAddressForbidden: 'Du kannst nur deine eigene Auszahlungsadresse festlegen', + bankAddressTotal: 'Adressen gesamt', + bankAddressSearch: 'Nach @Bewohner oder Adresse suchen', + bankAddressActions: 'Aktionen', + bankAddressDelete: 'Löschen', + bankAddressSource: 'Quelle', + bankAddressDeleteConfirm: 'Diese Adresse löschen?', + search: 'Suchen!', + bankLocal: 'Lokal', + bankFromOasis: 'Oasis', + bankMyAddress: 'Deine Adresse', + bankRemoveMyAddress: 'Meine Adresse entfernen', + bankNotRemovableOasis: 'Adressen können nicht lokal entfernt werden', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "KEIN GUTHABEN!", + bankUbiAvailableOk: "VERFÜGBAR!", + bankUbiAvailability: "UBI-Verfügbarkeit", + bankAlreadyClaimedThisMonth: "Diesen Monat bereits beansprucht", + bankUbiThisMonth: "UBI (diesen Monat)", + bankUbiLastClaimed: "UBI (zuletzt beansprucht)", + bankUbiNeverClaimed: "Nie beansprucht", + bankUbiTotalClaimed: "UBI (gesamt beansprucht)", + bankUbiPub: "PUB", + bankUbiInhabitant: "BEWOHNER", + bankUbiClaimedAmount: "BEANSPRUCHT (ECO)", + typeBankUbiResult: "BANKING - UBI", + bankNoPubConfigured: "Kein PUB konfiguriert. Lege deine PUB-ID in den Einstellungen fest.", + shopsTitle: "Shops", + shopDescription: "Shops im Netzwerk entdecken und verwalten.", + shopTitle: "Shop", + shopFilterAll: "ALLE", + shopFilterMine: "MEINE", + shopFilterRecent: "NEUESTE", + shopFilterTop: "TOP", + shopFilterProducts: "PRODUKTE", + shopFilterPrices: "PREISE", + shopFilterFavorites: "FAVORITEN", + shopUpload: "Shop erstellen", + shopAllSectionTitle: "Alle Shops", + shopMineSectionTitle: "Meine Shops", + shopRecentSectionTitle: "Neueste Shops", + shopTopSectionTitle: "Top Shops", + shopProductsSectionTitle: "Top Produkte", + shopPricesSectionTitle: "Produkte nach Preis", + shopFavoritesSectionTitle: "Favoriten", + shopCreateSectionTitle: "Shop erstellen", + shopUpdateSectionTitle: "Shop aktualisieren", + shopCreate: "Erstellen", + shopUpdate: "Aktualisieren", + shopDelete: "Löschen", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Im Clearnet veröffentlichen", + shopClearnetUnpublish: "Aus Clearnet entfernen", + shopClearnetUrlLabel: "Clearnet-URL", + shopClearnetWarning: "Die Veröffentlichung im Clearnet macht diesen Shop für externe Suchmaschinen und Crawler indexierbar.", + shopAddFavorite: "Favorit hinzufügen", + shopRemoveFavorite: "Favorit entfernen", + shopOpen: "OFFEN", + shopClosed: "GESCHLOSSEN", + shopOpenShop: "Shop öffnen", + shopCloseShop: "Shop schließen", + shopMakePrivate: "PRIVAT MACHEN (E2E)", + shopMakePublic: "ÖFFENTLICH MACHEN", + shopProducts: "Produkte", + shopProductAdd: "Produkt hinzufügen", + shopProductTitle: "Produkt", + shopProductUpdate: "Produkt aktualisieren", + shopProductPrice: "Preis (ECO)", + shopProductStock: "Bestand", + shopProductUntitled: "Produkt ohne Titel", + shopUntitled: "Shop ohne Titel", + shopNoItems: "Keine Shops gefunden.", + shopNoProducts: "Noch keine Produkte.", + shopOutOfStock: "Ausverkauft", + shopBuy: "Kaufen", + shopBuyEncryptedNote: "Deine Lieferdaten werden verschlüsselt übertragen; nur der Shop-Inhaber kann sie lesen.", + shopBuyDeliveryAddress: "Lieferadresse", + shopBuyContact: "Kontakt", + shopBuyContactPlaceholder: "E-Mail, Telefon, usw.", + shopBuyNotes: "Notizen", + shopBuyDeliveryRequired: "Lieferadresse ist erforderlich.", + shopOrdersTitle: "Bestellungen", + shopOrdersDescription: "Von diesem Shop erhaltene Bestellungen.", + shopOrdersEmpty: "Noch keine Bestellungen.", + shopOrderProduct: "Produkt", + shopOrderPrice: "Preis", + shopOrderBuyer: "Käufer", + shopBackToShop: "Zurück zum Shop", + shopShareUrl: "URL teilen", + shopVisitShop: "SHOP BESUCHEN", + marketVisitItem: "ARTIKEL ANZEIGEN", + shopStatus: "STATUS", + shopCreatedAt: "ERSTELLT", + shopSearchPlaceholder: "Shops suchen...", + shopUrl: "URL", + shopLocation: "Standort", + shopTags: "Tags", + shopVisibility: "Sichtbarkeit", + shopImage: "Bild", + shopShortDescription: "Kurzbeschreibung", + shopShortDescriptionPlaceholder: "Kurzbeschreibung fuer Shopkarten (max. 160 Zeichen)", + shopTitlePlaceholder: "Name Ihres Shops", + shopDescriptionPlaceholder: "Detaillierte Beschreibung Ihres Shops", + shopUrlPlaceholder: "https://ihre-shop-url.com", + shopLocationPlaceholder: "Stadt, Land", + shopTagsPlaceholder: "tag1, tag2, tag3", + mapTitlePlaceholder: "Kartentitel", + shopProductFeatured: "Hervorgehobenes Produkt", + shopSendToMarket: "Send to Market", + typeShop: "SHOPS", + typeShopProduct: "SHOP-PRODUKT", + bankExchange: 'Umtausch', + bankExchangeCurrentValue: 'ECOin-Wert (1h)', + bankTotalSupply: 'ECOin Gesamtmenge', + bankEcoinHours: "ECOin-Äquivalent in Zeit", + bankHoursOfWork: 'Stunden', + bankUnitMs: "ms", + bankUnitSeconds: "Sekunden", + bankUnitMinutes: "Minuten", + bankUnitDays: "Tage", + bankExchangeNoData: 'Keine Daten verfügbar', + bankExchangeIndex: 'ECOin-Wert (1h)', + bankInflation: 'ECOin-Inflation (anual)', + bankInflationMonthly: 'ECOin-Inflation (mensual)', + bankExchangeChartTitle: 'Progression des ECOin-Werts im Zeitverlauf', + bankExchangeChartValue: 'Wert (ECO/h)', + bankExchangeChartSupply: 'Umlauf', + bankExchangeChartInflation: 'Inflation %', + bankExchangeChartEmpty: 'Noch nicht genug Datenpunkte — komm später zurück', + bankCurrentSupply: 'ECOin aktuelle Menge', + bankingSyncStatus: 'ECOin-Status', + bankingSyncStatusSynced: 'Synchronisiert', + bankingSyncStatusOutdated: 'Veraltet', + statsTitle: 'Statistiken', + statistics: "Statistiken", + statsInhabitant: "Bewohner-Statistiken", + statsDescription: "Entdecke Statistiken über dein Netzwerk.", + ALLButton: "ALLE", + MINEButton: "MEINE", + TOMBSTONEButton: "TOMBSTONES", + statsYou: "Du", + statsUserId: "Oasis-ID", + statsCreatedAt: "Erstellt am", + statsYourContent: "Inhalt", + statsYourOpinions: "Meinungen", + statsYourTombstone: "Tombstones", + statsNetwork: "Netzwerk", + statsTotalInhabitants: "Bewohner", + statsDiscoveredTribes: "Stämme (Öffentlich)", + statsPrivateDiscoveredTribes: "Stämme (Privat)", + statsNetworkContent: "Inhalt", + statsYourMarket: "Markt", + statsYourJob: "Stellen", + statsYourProject: "Projekte", + statsYourTransfer: "Überweisungen", + statsYourForum: "Foren", + statsNetworkOpinions: "Meinungen", + statsDiscoveredMarket: "Markt", + statsDiscoveredJob: "Stellen", + statsDiscoveredProject: "Projekte", + statsBankingTitle: "Bankwesen", + statsEcoWalletLabel: "ECOIN Wallet", + statsEcoWalletNotConfigured: "Nicht konfiguriert!", + statsTotalEcoAddresses: "Adressen gesamt", + statsDiscoveredTransfer: "Überweisungen", + statsDiscoveredForum: "Foren", + statsNetworkTombstone: "Tombstones", + statsBookmark: "Lesezeichen", + statsEvent: "Termine", + statsTask: "Aufgaben", + statsVotes: "Stimmen", + statsMarket: "Markt", + statsForum: "Foren", + statsJob: "Stellen", + statsReport: "Berichte", + statsFeed: "Feeds", + statsTribe: "Stämme", + statsImage: "Bilder", + statsAudio: "Audios", + statsVideo: "Videos", + statsDocument: "Dokumente", + statsMap: "Karten", + statsShop: "Shops", + statsShopProduct: "Shop-Produkte", + statsTransfer: "Überweisungen", + statsAiExchange: "KI", + statsPUBs: 'PUBs', + statsPost: "Beiträge", + statsOasisID: "Oasis-ID", + statsSize: "Gesamt (Größe)", + statsBlockchainSize: "Blockchain (Größe)", + statsBlobsSize: "Blobs (Größe)", + statsActivity7d: "Aktivität (letzte 7 Tage)", + statsActivity7dTotal: "7-Tage-Gesamt", + statsActivity30dTotal: "30-Tage-Gesamt", + statsKarmaScore: "KARMA-Punktzahl", + statsPublic: "Öffentlich", + statsPrivate: "Privat", + day: "TAG", + messages: "Nachrichten", + statsProject: "Projekte", + statsProjectsTitle: "Projekte", + statsProjectsTotal: "Projekte gesamt", + statsProjectsActive: "Aktiv", + statsProjectsCompleted: "Abgeschlossen", + statsProjectsPaused: "Pausiert", + statsProjectsCancelled: "Abgebrochen", + statsProjectsGoalTotal: "Ziel gesamt", + statsProjectsPledgedTotal: "Zugesagt gesamt", + statsProjectsSuccessRate: "Erfolgsquote", + statsProjectsAvgProgress: "Durchschnittlicher Fortschritt", + statsProjectsMedianProgress: "Medianer Fortschritt", + statsProjectsActiveFundingAvg: "Durchschn. aktive Finanzierung", + statsJobsTitle: "Stellen", + statsJobsTotal: "Stellen gesamt", + statsJobsOpen: "Öffnen", + statsJobsClosed: "Geschlossen", + statsJobsOpenVacants: "Offene Stellen", + statsJobsSubscribersTotal: "Abonnenten gesamt", + statsJobsAvgSalary: "Durchschnittsgehalt", + statsJobsMedianSalary: "Mediangehalt", + statsMarketTitle: "Markt", + statsMarketTotal: "Artikel gesamt", + statsMarketForSale: "Zum Verkauf", + statsMarketReserved: "Reserviert", + statsMarketClosed: "Geschlossen", + statsMarketSold: "Verkauft", + statsMarketRevenue: "Umsatz", + statsMarketAvgSoldPrice: "Durchschn. Verkaufspreis", + statsUsersTitle: "Bewohner", + user: "Bewohner", + statsTombstoneTitle: "Tombstones", + statsNetworkTombstones: "Netzwerk-Tombstones", + statsTombstoneRatio: "Tombstone-Anteil (%)", + statsParliamentCandidature: "Parlamentskandidaturen", + statsParliamentTerm: "Parlamentsperioden", + statsParliamentProposal: "Parlamentsvorschläge", + statsParliamentRevocation: "Parlamentswiderrufe", + statsParliamentLaw: "Parlamentsgesetze", + statsCourtsCase: "Gerichtsfälle", + statsCourtsEvidence: "Gerichtsbeweise", + statsCourtsAnswer: "Gerichtsantworten", + statsCourtsVerdict: "Gerichtsurteile", + statsCourtsSettlement: "Gerichtsvergleiche", + statsCourtsSettlementProposal: "Vergleichsvorschläge", + statsCourtsSettlementAccepted: "Vergleiche angenommen", + statsCourtsNomination: "Richternominierungen", + statsCourtsNominationVote: "Nominierungsabstimmungen", + ai: "KI", + aiTitle: "KI", + aiDescription: "Eine kollektive künstliche Intelligenz (KKI) namens '42', die von deinem Netzwerk lernt.", + aiUserQuestion: "Frage", + aiResponseTitle: "Antworten", + aiSubmitButton: "Senden!", + aiSettingsDescription: "Setze deinen Prompt (max. 128 Zeichen) für das KI-Modell.", + aiPrompt: "Gib eine informative und präzise Antwort.", + aiConfiguration: "Prompt setzen", + aiPromptUsed: "Prompt", + aiClearHistory: "Chatverlauf löschen", + aiSharePrompt: "Diese Antwort zum kollektiven Training hinzufügen?", + aiShareYes: "Ja", + aiShareNo: "Nein", + aiSharedLabel: "Zum Training hinzugefügt", + aiRejectedLabel: "Nicht zum Training hinzugefügt", + aiServerError: "Die KI konnte nicht antworten. Bitte versuche es erneut.", + aiInputPlaceholder: "Was gibt es Neues?", + typeAiExchange: "KI", + aiApproveTrain: "Zum kollektiven Training hinzufügen", + aiRejectTrain: "Nicht trainieren", + aiTrainPending: "Genehmigung ausstehend", + aiTrainApproved: "Für Training genehmigt", + aiTrainRejected: "Für Training abgelehnt", + aiSnippetsUsed: "Verwendete Fragmente", + aiSnippetsLearned: "Erlernte Fragmente", + statsAITraining: "KI-Training", + aiApproveCustomTrain: "Mit dieser benutzerdefinierten Antwort trainieren", + aiCustomAnswerPlaceholder: "Schreibe deine benutzerdefinierte Antwort…", + statsAIExchanges: "Austausche", + marketMineSectionTitle: "Deine Artikel", + marketCreateSectionTitle: "Artikel erstellen", + marketUpdateSectionTitle: "Aktualisieren", + marketAllSectionTitle: "Markt", + marketRecentSectionTitle: "Neueste Marktartikel", + marketTitle: "Markt", + marketDescription: "Ein Marktplatz zum Austausch von Waren oder Dienstleistungen in deinem Netzwerk.", + marketFilterAll: "ALLE", + marketFilterMine: "MEINE", + marketFilterAuctions: "AUKTIONEN", + marketFilterItems: "TAUSCH", + marketFilterNew: "NEU", + marketFilterUsed: "GEBRAUCHT", + marketFilterBroken: "DEFEKT", + marketFilterForSale: "ZU VERKAUFEN", + marketFilterSold: "VERKAUFT", + marketFilterDiscarded: "VERWORFEN", + marketFilterRecent: "NEUESTE", + marketFilterMyBids: "GEBOTE", + marketCreateButton: "Artikel erstellen", + marketItemType: "Typ", + marketItemTitle: "Titel", + marketItemAvailable: "Frist", + marketItemDescription: "Beschreibung", + marketItemDescriptionPlaceholder: "Beschreibe den Artikel, den du verkaufst", + marketItemStatus: "Status", + marketShopLabel: "Shop", + marketItemCondition: "Zustand", + marketItemPrice: "Preis", + marketItemTags: "Tags", + marketItemTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + marketItemDeadline: "Frist", + marketItemIncludesShipping: "Versand inklusive?", + marketItemHighestBid: "Höchstgebot", + marketItemHighestBidder: "Höchstbietender", + marketItemStock: "Bestand", + marketOutOfStock: "Nicht vorrätig", + marketItemBidTime: "Gebotszeit", + marketActionsUpdate: "Aktualisieren", + marketUpdateButton: "Aktualisieren", + marketActionsDelete: "Löschen", + marketActionsSold: "Als verkauft markieren", + marketActionsChangeStatus: "STATUS ÄNDERN", + marketActionsBuy: "KAUFEN!", + marketAuctionBids: "Aktuelle Gebote", + marketPlaceBidButton: "Gebot abgeben", + marketItemSeller: "Verkäufer", + marketNoItems: "Noch keine Artikel vorhanden.", + marketYourBid: "Dein Gebot", + marketCreateFormImageLabel: "Medien hochladen (max: 50MB)", + marketSearchLabel: "Suche", + marketSearchPlaceholder: "Titel oder Tags suchen", + marketMinPriceLabel: "Mindestpreis", + marketMaxPriceLabel: "Höchstpreis", + marketSortLabel: "Sortieren nach", + marketSortRecent: "Neueste zuerst", + marketSortPrice: "Preis", + marketSortDeadline: "Frist", + marketSearchButton: "Suche", + marketAuctionEndsIn: "Endet", + marketAuctionEnded: "Beendet", + marketMyBidBadge: "Du hast geboten", + marketNoItemsMatch: "Keine Artikel gefunden.", + jobsTitle: "Stellen", + jobsCandidatesTitle: "Vorgeschlagene Kandidaten", + jobsCandidatesDescription: "Einwohner, deren Fähigkeiten zu dieser Stelle passen. Sende ihnen eine private Nachricht.", + jobsCandidatesPmBody: "Hallo, ich denke, dein Profil passt zu meiner Stelle", + jobsDescription: "Stellen in deinem Netzwerk entdecken und verwalten.", + jobsFilterRecent: "NEUESTE", + jobsFilterMine: "MEINE", + jobsFilterAll: "ALLE", + jobsFilterRemote: "REMOTE", + jobsFilterOpen: "OFFEN", + jobsFilterClosed: "GESCHLOSSEN", + jobsCV: "Lebensläufe", + jobsCreateJob: "Stelle erstellen", + jobsRecentTitle: "Neueste Stellen", + jobsMineTitle: "Deine Stellen", + jobsAllTitle: "Stellen", + jobsRemoteTitle: "Remote-Stellen", + jobsOpenTitle: "Offene Stellen", + jobsClosedTitle: "Geschlossene Stellen", + jobsCVTitle: "Lebensläufe", + jobsFilterPresencial: "VOR ORT", + jobsFilterFreelancer: "FREIBERUFLER", + jobsFilterEmployee: "ANGESTELLTER", + jobsPresencialTitle: "Stellen vor Ort", + jobsFreelancerTitle: "Freiberufliche Stellen", + jobsEmployeeTitle: "Angestelltenstellen", + jobTitle: "Titel", + jobLocation: "Ort", + jobSalary: "Gehalt (ECO/1h)", + jobVacants: "Offene Stellen", + jobDescription: "Beschreibung", + jobRequirements: "Anforderungen", + jobLanguages: "Sprachen", + jobStatus: "Status", + jobStatusOPEN: "OFFEN", + jobStatusCLOSED: "GESCHLOSSEN", + jobSetClosed: "Als GESCHLOSSEN setzen", + jobSubscribeButton: "Diesem Angebot beitreten!", + jobUnsubscribeButton: "Dieses Angebot verlassen!", + jobTitlePlaceholder: "Stellentitel eingeben", + jobDescriptionPlaceholder: "Stelle beschreiben", + jobRequirementsPlaceholder: "Anforderungen eingeben", + jobLanguagesPlaceholder: "Englisch, Spanisch, Französisch, Baskisch...", + jobTasksPlaceholder: "Aufgaben auflisten", + jobLocationPresencial: "Vor Ort", + jobLocationRemote: "Remote", + jobVacantsPlaceholder: "Anzahl der Positionen", + jobSalaryPlaceholder: "Gehalt in ECO für 1 Arbeitsstunde", + jobImage: "Medien hochladen (max: 50MB)", + jobTasks: "Aufgaben", + jobType: "Stellenart", + jobTime: "Arbeitszeit", + jobSubscribers: "Abonnenten", + noSubscribers: "Keine Abonnenten", + jobsFilterTop: "TOP", + jobsTopTitle: "Top-Gehalt-Stellen", + createJobButton: "Stelle veröffentlichen", + viewDetailsButton: "Details anzeigen", + noJobsFound: "Keine Stellenangebote gefunden.", + jobAuthor: "Von", + jobTypeFreelance: "Freiberufler", + jobTypeSalary: "Angestellter", + jobTypeExchange: "Stundentausch", + jobTypeEXCHANGE: "STUNDENTAUSCH", + jobsFilterExchange: "TAUSCH", + jobsExchangeTitle: "Stundentausch-Angebote", + jobsHoursOffered: "Angebotene Stunden", + jobsHoursRequested: "Im Tausch gefragte Stunden", + jobsExchangeSkill: "Im Tausch gefragte Fähigkeit", + jobsHoursOfferedPlaceholder: "z. B. 4", + jobsHoursRequestedPlaceholder: "z. B. 4", + jobsExchangeSkillPlaceholder: "z. B. Tischlerei, Design", + jobExchangeHint: "Für Stundentausch-Angebote füllen Sie statt des Gehalts die folgenden Felder aus.", + jobTimePartial: "Teilzeit", + jobTimeComplete: "Vollzeit", + jobsDeleteButton: "LÖSCHEN", + jobsUpdateButton: "AKTUALISIEREN", + jobsFilterApplied: "BEWORBEN", + jobsAppliedTitle: "Meine Bewerbungen", + jobsAppliedBadge: "Beworben", + jobsFilterFavs: "Favoriten", + jobsFavsTitle: "Favoriten", + jobsFilterNeeds: "Braucht Hilfe", + jobsNeedsTitle: "Braucht Hilfe", + jobsSearchLabel: "Suche", + jobsSearchPlaceholder: "Titel, Tags, Beschreibung suchen...", + jobsMinSalaryLabel: "Mindestgehalt", + jobsMaxSalaryLabel: "Höchstgehalt", + jobsSortLabel: "Sortieren nach", + jobsSortRecent: "Neueste zuerst", + jobsSortSalary: "Höchstes Gehalt", + jobsSortSubscribers: "Meiste Bewerber", + jobsSearchButton: "Suche", + jobsFavoriteButton: "Favorit", + jobsUnfavoriteButton: "Kein Favorit", + jobsMessageAuthorButton: "PN", + jobsApplicants: "Bewerber", + jobsUpdatedAt: "Aktualisiert", + jobSetOpen: "Als OFFEN setzen", + jobNewBadge: "NEU", + jobsTagsLabel: "Tags", + jobsTagsPlaceholder: "tag1, tag2, tag3", + noJobsMatch: "Keine Stellen gefunden.", + projectsTitle: "Projekte", + projectsDescription: "Gemeinschaftsprojekte in deinem Netzwerk erstellen, finanzieren und verfolgen.", + projectCreateProject: "Projekt erstellen", + projectCreateButton: "Projekt erstellen", + projectUpdateButton: "AKTUALISIEREN", + projectDeleteButton: "LÖSCHEN", + projectNoProjectsFound: "Keine Projekte gefunden.", + projectFilterAll: "ALLE", + projectFilterMine: "MEINE", + projectFilterActive: "AKTIV", + projectFilterPaused: "PAUSIERT", + projectFilterCompleted: "ABGESCHLOSSEN", + projectFilterFollowing: "VERFOLGT", + projectFilterRecent: "NEUESTE", + projectFilterTop: "TOP", + projectAllTitle: "Projekte", + projectMineTitle: "Deine Projekte", + projectActiveTitle: "Aktive Projekte", + projectPausedTitle: "Pausierte Projekte", + projectCompletedTitle: "Abgeschlossene Projekte", + projectFollowingTitle: "Verfolgte Projekte", + projectRecentTitle: "Neueste Projekte", + projectTopTitle: "Meistfinanziert", + projectTitlePlaceholder: "Projektname", + projectImage: "Medien hochladen (max: 50MB)", + projectDescription: "Beschreibung", + projectDescriptionPlaceholder: "Erzähle die Geschichte und Ziele…", + projectGoal: "Ziel (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Frist", + projectProgress: "Anfangsfortschritt (%)", + projectStatus: "Status", + projectFunding: "Finanzierung", + projectPledged: "Zugesagt", + projectSetStatus: "Status setzen", + projectSetProgress: "Fortschritt aktualisieren", + projectFollowButton: "FOLGEN", + projectUnfollowButton: "ENTFOLGEN", + projectStatusACTIVE: "AKTIV", + projectStatusPAUSED: "PAUSIERT", + projectStatusCOMPLETED: "ABGESCHLOSSEN", + projectStatusCANCELLED: "ABGEBROCHEN", + projectPledgeTitle: "Dieses Projekt unterstützen", + projectPledgePlaceholder: "Betrag in ECO", + projectBounties: "Prämien", + projectBountiesInputLabel: "Prämien (eine pro Zeile: Titel|Betrag [ECO]|Beschreibung)", + projectBountiesPlaceholder: "UI-Fehler beheben|100|Link zum Problem\\nDoku schreiben|250|Anwendungsbeispiele skizzieren", + projectNoBounties: "Keine Prämien gefunden.", + projectTitle: "Titel", + projectAddBountyTitle: "Neue Prämie", + projectBountyTitle: "Prämientitel", + projectBountyAmount: "Betrag (ECO)", + projectBountyDescription: "Beschreibung", + projectMilestoneSelect: "Meilenstein auswählen", + projectBountyCreateButton: "Prämie erstellen", + projectBountyStatus: "Prämienstatus", + projectBountyOpen: "offen", + projectBountyClaimed: "beansprucht", + projectBountyDone: "abgeschlossen", + projectBountyClaimedBy: "beansprucht von", + projectBountyClaimButton: "beanspruchen", + projectBountyCompleteButton: "Als abgeschlossen markieren", + projectMilestones: "Meilensteine", + projectAddMilestoneTitle: "Neuer Meilenstein", + projectMilestoneTitle: "Meilensteintitel", + projectMilestoneTargetPercent: "Prozent (%)", + projectMilestoneDueDate: "Datum", + projectMilestoneCreateButton: "Meilenstein erstellen", + projectMilestoneStatus: "Meilensteinstatus", + projectMilestoneOpen: "offen", + projectMilestoneDone: "abgeschlossen", + projectMilestoneDue: "fällig", + projectNoMilestones: "Keine Meilensteine gefunden.", + projectMilestoneMarkDone: "Als abgeschlossen markieren", + projectMilestoneTitlePlaceholder: "Meilensteintitel eingeben", + projectMilestoneDescriptionPlaceholder: "Beschreibung für diesen Meilenstein eingeben", + projectMilestoneDescription: "Meilensteinbeschreibung", + projectBudgetGoal: "Budget (Ziel)", + projectBudgetAssigned: "Prämien zugewiesen", + projectBudgetRemaining: "Verbleibend", + projectBudgetOver: "Budget überschritten: Zuweisungen übersteigen das Ziel", + projectFollowers: "Follower", + projectFollowersTitle: "Follower", + projectFollowersNone: "Noch keine Follower.", + projectMore: "mehr", + projectYouFollowHint: "Du verfolgst dieses Projekt", + projectBackers: "Unterstützer", + projectBackersTitle: "Unterstützer", + projectBackersTotal: "Unterstützer gesamt", + projectBackersTotalPledged: "Zugesagt gesamt", + projectBackersYourPledge: "Deine Zusage", + projectBackersNone: "Noch keine Zusagen.", + projectNoRemainingBudget: "Kein verbleibendes Budget.", + projectFilterBackers: "UNTERSTÜTZER", + projectFilterApplied: "BEWORBEN", + projectAppliedTitle: "BEWORBEN", + projectBackersLeaderboardTitle: "Top-Unterstützer", + projectNoBackersFound: "Keine Unterstützer gefunden.", + projectBackerAmount: "Gesamtbeitrag", + projectBackerPledges: "Zusagen", + projectBackerProjects: "Projekte", + projectPledgeAmount: "Betrag", + projectSelectMilestoneOrBounty: "Meilenstein oder Prämie auswählen", + projectPledgeButton: "Zusagen", + footerLicense: "GPLv3", + footerPackage: "Paket", + footerVersion: "Version", + modulesModuleName: "Name", + modulesModuleDescription: "Beschreibung", + modulesModuleStatus: "Status", + modulesTotalModulesLabel: "Geladene Module", + modulesEnabledModulesLabel: "Aktiviert", + modulesDisabledModulesLabel: "Deaktiviert", + modulesPopularLabel: "Beliebt", + modulesPopularDescription: "Modul zum Empfang von Beiträgen, die im Trend liegen, am meisten angesehen oder kommentiert werden.", + modulesTopicsLabel: "Themen", + modulesTopicsDescription: "Modul zum Empfang von Diskussionskategorien basierend auf gemeinsamen Interessen.", + modulesSummariesLabel: "Zusammenfassungen", + modulesSummariesDescription: "Modul zum Empfang von Zusammenfassungen langer Diskussionen oder Beiträge.", + modulesLatestLabel: "Neueste", + modulesLatestDescription: "Modul zum Empfang der neuesten Beiträge und Diskussionen.", + modulesThreadsLabel: "Diskussionen", + modulesThreadsDescription: "Modul zum Empfang von Unterhaltungen, gruppiert nach Thema oder Frage.", + modulesMultiverseLabel: "Multiversum", + modulesMultiverseDescription: "Modul zum Empfang von Inhalten anderer föderierter Peers.", + modulesInvitesLabel: "Einladungen", + modulesInvitesDescription: "Modul zum Verwalten und Einlösen von Einladungscodes.", + modulesWalletLabel: "Geldbörse", + modulesWalletDescription: "Modul zum Verwalten deiner digitalen Vermögenswerte (ECOin).", + modulesLegacyLabel: "Schlüssel", + modulesLegacyDescription: "Modul zum schnellen und sicheren Verwalten deines geheimen Schlüssels (privater Schlüssel).", + modulesCipherLabel: "Verschlüsselung", + modulesCipherDescription: "Modul zum symmetrischen Ver- und Entschlüsseln von Text (mit einem gemeinsamen Passwort).", + modulesBookmarksLabel: "Lesezeichen", + modulesBookmarksDescription: "Modul zum Entdecken und Verwalten von Lesezeichen.", + modulesVideosLabel: "Videos", + modulesVideosDescription: "Modul zum Entdecken und Verwalten von Videos.", + modulesDocsLabel: "Dokumente", + modulesDocsDescription: "Modul zum Entdecken und Verwalten von Dokumenten.", + modulesAudiosLabel: "Audios", + modulesAudiosDescription: "Modul zum Entdecken und Verwalten von Audios.", + modulesTagsLabel: "Tags", + modulesTagsDescription: "Modul zum Entdecken und Erkunden von Taxonomiemustern (Tags).", + modulesImagesLabel: "Bilder", + modulesImagesDescription: "Modul zum Entdecken und Verwalten von Bildern.", + modulesTrendingLabel: "Trends", + modulesTrendingDescription: "Modul zum Erkunden der beliebtesten Inhalte.", + modulesEventsLabel: "Termine", + modulesEventsDescription: "Modul zum Entdecken und Verwalten von Terminen.", + modulesTasksLabel: "Aufgaben", + modulesTasksDescription: "Modul zum Entdecken und Verwalten von Aufgaben.", + modulesMarketLabel: "Markt", + modulesMarketDescription: "Modul zum Austausch von Waren oder Dienstleistungen.", + modulesShopsLabel: "Shops", + modulesShopsDescription: "Modul zum Verwalten und Entdecken von Shops.", + modulesTribesLabel: "Stämme", + modulesTribesDescription: "Modul zum Erkunden oder Erstellen von Stämmen (Gruppen).", + modulesVotationsLabel: "Abstimmungen", + modulesVotationsDescription: "Modul zum Entdecken und Verwalten von Abstimmungen.", + modulesReportsLabel: "Berichte", + modulesReportsDescription: "Modul zum Verwalten und Verfolgen von Berichten zu Problemen, Fehlern, Missbrauch und Inhaltswarnungen.", + modulesOpinionsLabel: "Meinungen", + modulesOpinionsDescription: "Modul zum Entdecken und Bewerten von Meinungen.", + modulesTransfersLabel: "Überweisungen", + modulesTransfersDescription: "Modul zum Entdecken und Verwalten von Smart Contracts (Überweisungen).", + modulesFediverseLabel: "Fediverse", + modulesFediverseDescription: "Verwalte deine anderen Fediverse-Konten, einschließlich Senden und Empfangen von Inhalten.", + modulesFeedLabel: "Feed", + modulesFeedDescription: "Modul zum Entdecken und Teilen von Kurztexten (Feeds).", + modulesParliamentLabel: "Parlament", + modulesParliamentDescription: "Modul zum Wählen von Regierungen und Abstimmen über Gesetze.", + modulesCourtsLabel: "Gerichte", + modulesCourtsDescription: "Modul zur Konfliktlösung und Urteilsfindung.", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Modul zum Zeichnen auf einem gemeinschaftlichen Raster.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Modul zum Erzeugen und Teilen des „Klangs“ deiner Blockchain.", + modulesAgendaLabel: "Agenda", + modulesAgendaDescription: "Modul zum Verwalten aller dir zugewiesenen Einträge.", + modulesAILabel: "KI", + modulesAIDescription: "Modul zum Kommunizieren mit einer KI namens '42'.", + modulesForumLabel: "Foren", + modulesForumDescription: "Modul zum Entdecken und Verwalten von Foren.", + modulesJobsLabel: "Stellen", + modulesJobsDescription: "Modul zum Entdecken und Verwalten von Stellen.", + modulesProjectsLabel: "Projekte", + modulesProjectsDescription: "Modul zum Erkunden, Crowdfunding und Verwalten von Projekten.", + modulesBankingLabel: "Bankwesen", + modulesBankingDescription: "Modul zur Bestimmung des realen Werts von ECOIN und Verteilung eines UBI über die gemeinsame Kasse.", + modulesFavoritesLabel: "Favoriten", + modulesFavoritesDescription: "Modul zum Verwalten deiner favorisierten Inhalte.", + fileTooLargeTitle: "Datei zu groß", + fileTooLargeMessage: "Die Datei überschreitet die maximal erlaubte Größe (50 MB). Bitte wähle eine kleinere Datei.", + goBack: "Zurück", + errorPageTitle: "Etwas ist schiefgelaufen", + visibilityLabel: "Sichtbarkeit", + visibilityPublic: "Öffentlich", + visibilityHidden: "Versteckt", + visibilityMakePublic: "Öffentlich machen", + visibilityMakeHidden: "Verstecken", + invitesInhabitantsTitle: "Bewohner", + invitesInhabitantsFollow: "Folgen", + aiNavPlaceholder: "Wohin möchtest du gehen?", + aiNavDisabled: "KI-Navigation ist deaktiviert.", + aiNavResultsTitle: "KI-Navigationsvorschläge", + aiNavQueryLabel: "Anfrage", + aiNavResultsDescription: "Wähle die Route, die am besten zu deiner Suche passt.", + aiNavResultPath: "Route", + aiNavResultDescription: "Was du dort tun kannst", + aiNavResultMatch: "Übereinstimmung", + aiNavResultsEmpty: "Keine passenden Routen. Versuche stattdessen /search.", + aiNavSearchInstead: "Stattdessen suchen", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Modul für natürlichsprachliche Anfragen zum Inhalt des Netzwerks.", + directConnect: "Direkte Verbindung", + directConnectDescription: "Verbinde dich direkt mit einem Peer, indem du IP-Adresse, Port und öffentlichen Schlüssel eingibst.", + peerHost: "IP", + peerPort: "Port (Standard: 8008)", + peerPublicKey: "Öffentlicher Schlüssel (@...ed25519)", + connectAndFollow: "Verbinden", + deviceSourceLabel: "Gerätetyp", + modulesPresetTitle: "Gängige Konfigurationen", + modulesPreset_minimal: "Minimal", + modulesPreset_basic: "Basis", + modulesPreset_social: "Sozial", + modulesPreset_economy: "Wirtschaft", + modulesPreset_full: "Vollständig", + statsCarbonFootprintTitle: "CO2-Fußabdruck", + statsCarbonFootprintNetwork: "Netzwerk-CO2-Fußabdruck", + statsCarbonFootprintYours: "Dein CO2-Fußabdruck", + statsCarbonTombstone: "Tombstoning-Fußabdruck", + feedSuccessMsg: "Feed erfolgreich veröffentlicht!", + dominantOpinionLabel: "Vorherrschende Meinung", + uploadMedia: "Medien hochladen (max: 50MB)", + cipherPasswordDecryptLabel: "Passwort (zum Entschlüsseln)", + courtsRespondentInvalid: "Ungültiger Beklagter", + feedDetailTitle: "Feed-Details", + feedOpenDiscussion: "Diskussion eröffnen", + feedPostComment: "Kommentar veröffentlichen", + noComments: "Noch keine Kommentare.", + mapsLabel: "Karten", + mapTitle: "Karten", + mapDescription: "Erkunden und verwalten Sie Offline-Karten in Ihrem Netzwerk.", + mapMineSectionTitle: "Ihre Karten", + mapCreateSectionTitle: "Karte erstellen", + mapUpdateSectionTitle: "Karte aktualisieren", + mapAllSectionTitle: "Karten", + mapRecentSectionTitle: "Neueste Karten", + mapFavoritesSectionTitle: "Favoriten", + mapFilterAll: "ALLE", + mapFilterMine: "MEINE", + mapFilterRecent: "NEUESTE", + mapFilterFavorites: "FAVORITEN", + mapUploadButton: "Karte erstellen", + mapCreateButton: "Karte erstellen", + mapUpdateButton: "Aktualisieren", + mapDeleteButton: "Löschen", + mapAddFavoriteButton: "Zu Favoriten", + mapRemoveFavoriteButton: "Aus Favoriten", + mapLatLabel: "Breitengrad", + mapLatPlaceholder: "z.B. 52.5200", + mapLngLabel: "Längengrad", + mapLngPlaceholder: "z.B. 13.4050", + mapDescriptionLabel: "Beschreibung", + mapDescriptionPlaceholder: "Beschreiben Sie die Karte oder den Standort...", + mapTypeLabel: "Kartentyp", + mapTypeSingle: "EINZELN (nur initialer Standort)", + mapTypeOpen: "OFFEN (jeder kann Markierungen hinzufügen)", + mapTypeClosed: "GESCHLOSSEN (nur Ersteller fügt Markierungen hinzu)", + mapTagsLabel: "Tags", + mapTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + mapUrlLabel: "Karten-URL", + mapPickCoordLabel: "Oder wählen Sie einen Standort auf dem Raster:", + mapMarkersLabel: "Markierungen", + mapMarkersTitle: "Markierungen", + mapMarkerDefault: "Markierung", + mapMarkerLatLabel: "Markierung Breitengrad", + mapMarkerLngLabel: "Markierung Längengrad", + mapMarkerLabelField: "Markierungsbezeichnung", + mapMarkerLabelPlaceholder: "Beschreiben Sie diese Markierung...", + markerImageLabel: "Marker-Bild", + mapAddMarkerTitle: "Markierung hinzufügen", + mapAddMarkerButton: "Markierung hinzufügen", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Zoom anwenden", + mapSearchPlaceholder: "Beschreibung, Tags, Autor suchen...", + mapSearchButton: "Suchen", + mapUpdatedAt: "Aktualisiert", + mapNoMatch: "Keine Karten entsprechen Ihrer Suche.", + noMaps: "Keine Karten verfügbar.", + mapLocationTitle: "Standort", + mapVisitLabel: "Karte besuchen", + typeMap: "KARTEN", + typeMapMarker: "KARTENMARKIERUNG", + modulesMapLabel: "Karten", + modulesMapDescription: "Modul zum Verwalten und Teilen von Offline-Karten.", + padsTitle: "Pads", + padTitle: "Pad", + modulesPadsLabel: "Pads", + modulesPadsDescription: "Modul zur Verwaltung kollaborativer Texteditoren.", + padFilterAll: "ALLE", + padFilterMine: "MEINE", + padFilterRecent: "AKTUELL", + padFilterOpen: "OFFEN", + padFilterClosed: "GESCHLOSSEN", + padCreate: "Pad Erstellen", + padUpdate: "Pad Aktualisieren", + padDelete: "Pad Löschen", + padTitleLabel: "Titel", + padTitlePlaceholder: "Pad-Titel eingeben...", + padStatusLabel: "Status", + padStatusOpen: "OFFEN", + padStatusInviteOnly: "NUR AUF EINLADUNG", + padStatusClosed: "GESCHLOSSEN", + padDeadlineLabel: "Frist", + padTagsLabel: "Tags", + padTagsPlaceholder: "tag1, tag2, ...", + padMembersLabel: "Mitglieder", + padVisitPad: "Pad Besuchen", + padShareUrl: "URL Teilen", + padCreated: "Erstellt", + padAuthor: "Autor", + padGenerateCode: "Code Generieren", + padInviteCodeLabel: "Einladungscode", + padInviteCodePlaceholder: "Einladungscode eingeben...", + padValidateInvite: "Validieren", + padStartEditing: "BEARBEITUNG STARTEN!", + padEditorPlaceholder: "Schreiben beginnen...", + padSubmitEntry: "Senden", + padNoEntries: "Noch keine Einträge.", + padAllSectionTitle: "Alle Pads", + padMineSectionTitle: "Meine Pads", + padsDescription: "Verwalte kollaborative verschlüsselte Texteditoren in deinem Netzwerk.", + padRecentSectionTitle: "Aktuelle Pads", + padOpenSectionTitle: "Offene Pads", + padClosedSectionTitle: "Geschlossene Pads", + padCreateSectionTitle: "Neues Pad Erstellen", + padUpdateSectionTitle: "Pad Aktualisieren", + padInviteGenerated: "Einladungscode Generiert", + typePad: "PADS", + padNew: "NEU", + padAddFavorite: "Zu Favoriten Hinzufügen", + padRemoveFavorite: "Aus Favoriten Entfernen", + padClose: "Pad schließen", + padBackToEditor: "Zurück zum Editor", + padSearchPlaceholder: "Pads suchen...", + gamesTitle: "Spiele", + gamesDescription: "Entdecke und spiele einige Spiele.", + gamesFilterAll: "ALLE", + gamesPlayButton: "SPIELEN!", + gamesBackToGames: "Zurück zu Spielen", + modulesGamesLabel: "Spiele", + modulesGamesDescription: "Modul zum Entdecken und Spielen von Spielen.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Modul, um das Netzwerk als interaktive Karte der Knoten zu erkunden.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Verbinde PUBs mit Bewohnern über Validatoren, Shops und Akkumulatoren. Überstehe die CBDC-Bedrohung!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Infiltriere das Gitter, sammle vertrauliche Daten, weiche Sicherheitsdrohnen aus und fliehe. Wie viele Level schaffst du?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "Stoppt die Alien-Invasion! Schießt Wellen von Eindringlingen ab.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Brecht alle Steine mit eurem Schläger und Ball. Ein klassisches Arcade-Spiel.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Klassisches Ping-Pong gegen eine KI. Wer zuerst 5 Punkte hat, gewinnt.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "Rennen gegen die Zeit! Weiche dem Verkehr aus und erreiche das Ziel rechtzeitig.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Steuere dein Raumschiff durch ein tödliches Asteroidenfeld. Schieße sie ab, bevor sie dich treffen.", + gamesRockPaperScissorsTitle: "Schere Stein Papier", + gamesRockPaperScissorsDesc: "Schere, Stein, Papier gegen eine KI. Das Beste aus drei Runden gewinnt.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Klassisches Tic-Tac-Toe gegen die KI. Drei in einer Reihe gewinnt.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Werfe eine Münze und wette auf Kopf oder Zahl. Wie viel Glück hast du?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "Noch keine Ergebnisse.", + modulesChatsLabel: "Chats", + modulesChatsDescription: "Modul zum Entdecken und Verwalten verschlüsselter Chats.", + chatMessageLabel: "CHATNACHRICHTEN", + chatsTitle: "Chats", + chatMineSectionTitle: "Meine Chats", + chatRecentTitle: "Aktuelle Chats", + chatFavoritesTitle: "Favoriten", + chatOpenTitle: "Offene Chats", + chatClosedTitle: "Geschlossene Chats", + chatDescription: "Beschreibung", + chatCategory: "Kategorie", + chatStatus: "STATUS", + chatFilterAll: "ALLE", + chatFilterMine: "MEINE", + chatFilterRecent: "AKTUELL", + chatFilterFavorites: "FAVORITEN", + chatFilterOpen: "OFFEN", + chatFilterClosed: "GESCHLOSSEN", + chatCreate: "Chat Erstellen", + chatUpdate: "Chat Aktualisieren", + chatDelete: "Chat Löschen", + chatClose: "Chat Schließen", + chatVisitChat: "CHAT BESUCHEN", + chatUntitled: "Unbenannter Chat", + chatNoItems: "Keine Chats gefunden.", + chatParticipants: "Teilnehmer", + chatStartChatting: "CHAT STARTEN!", + chatGenerateCode: "Code Generieren", + chatShareUrl: "URL Teilen", + chatCreatedAt: "ERSTELLT", + chatSearchPlaceholder: "Chats suchen...", + chatStatusOpen: "OFFEN", + chatStatusInviteOnly: "NUR AUF EINLADUNG", + chatStatusClosed: "GESCHLOSSEN", + chatSendMessage: "Senden", + chatMessagePlaceholder: "Nachricht eingeben...", + chatNoMessages: "Noch keine Nachrichten.", + chatLeave: "Chat Verlassen", + chatInviteCodeLabel: "Einladungscode eingeben", + chatJoinByInvite: "Beitreten", + chatTitlePlaceholder: "Chat-Titel", + chatDescriptionPlaceholder: "Chat-Beschreibung", + chatTagsPlaceholder: "tag1, tag2, tag3", + chatImageLabel: "Bilddatei auswählen (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Einladungscode", + chatAuthor: "Autor", + chatCreated: "Erstellt", + chatAddFavorite: "Zu Favoriten Hinzufügen", + chatRemoveFavorite: "Aus Favoriten Entfernen", + chatPM: "PM", + chatStatusLabel: "Status", + chatCategoryLabel: "Kategorie", + chatParticipantsLabel: "Teilnehmer", + calendarsTitle: "Kalender", + calendarTitle: "Kalender", + modulesCalendarsLabel: "Kalender", + modulesCalendarsDescription: "Modul zum Entdecken und Verwalten von Kalendern.", + typeCalendar: "KALENDER", + calendarFilterAll: "ALLE", + calendarFilterMine: "MEINE", + calendarFilterOpen: "OFFEN", + calendarFilterClosed: "GESCHLOSSEN", + calendarFilterRecent: "KÜRZLICH", + calendarFilterFavorites: "FAVORITEN", + calendarCreate: "Kalender erstellen", + calendarUpdate: "Aktualisieren", + calendarDelete: "Löschen", + calendarTitleLabel: "Titel", + calendarTitlePlaceholder: "Kalendertitel...", + calendarStatusLabel: "Status", + calendarStatusOpen: "OFFEN", + calendarStatusClosed: "GESCHLOSSEN", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Tags", + calendarTagsPlaceholder: "tag1, tag2...", + calendarParticipantsLabel: "Teilnehmer", + calendarParticipantsCount: "Teilnehmer", + calendarVisitCalendar: "Kalender besuchen", + calendarCreated: "Erstellt", + calendarAuthor: "Autor", + calendarJoin: "Beitreten", + calendarGenerateInvite: "Einladung erstellen", + calendarInviteCodePlaceholder: "Einladungscode eingeben...", + calendarValidateInvite: "Code prüfen", + calendarJoined: "Beigetreten", + calendarAddDate: "Datum hinzufügen", + calendarAddNote: "Notiz hinzufügen", + calendarDateLabel: "Datum", + calendarDatePlaceholder: "Dieses Datum beschreiben...", + calendarNoteLabel: "Notiz", + calendarNotePlaceholder: "Notiz hinzufügen...", + calendarFirstDateLabel: "Datum", + calendarFirstNoteLabel: "Notizen", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Beschreibung", + calendarNoDates: "Noch keine Daten hinzugefügt.", + calendarNoNotes: "Keine Notizen.", + calendarsNoItems: "Keine Kalender gefunden.", + calendarsDescription: "Entdecke und verwalte Kalender in deinem Netzwerk.", + calendarMonthPrev: "← Zurück", + calendarMonthNext: "Weiter →", + calendarMonthLabel: "Daten", + calendarsShareUrl: "Teilen-URL", + calendarAllSectionTitle: "Kalender", + calendarRecentSectionTitle: "Kürzliche Kalender", + calendarFavoritesSectionTitle: "Favoriten", + calendarMineSectionTitle: "Deine Kalender", + calendarOpenSectionTitle: "Offene Kalender", + calendarClosedSectionTitle: "Geschlossene Kalender", + calendarCreateSectionTitle: "Neuen Kalender erstellen", + calendarUpdateSectionTitle: "Kalender aktualisieren", + calendarAddFavorite: "Zu Favoriten hinzufügen", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Aus Favoriten entfernen", + calendarSearchPlaceholder: "Kalender suchen...", + calendarAddEntry: "Eintrag hinzufügen", + calendarLeave: "Kalender verlassen", + statsCalendar: "Kalender", + statsCalendarDate: "Kalenderdaten", + statsCalendarNote: "Kalendernotizen", + chatAccessDenied: "Sie haben keinen Zugang zu diesem Chat. Fordern Sie eine Einladung an.", + padAccessDenied: "Sie haben keinen Zugang zu diesem Pad. Fordern Sie eine Einladung an.", + contentAccessDenied: "Sie haben keinen Zugang zu diesem Inhalt.", + blockAccessRestricted: "Zugang gesperrt", + tribeContentAccessDenied: "Zugang Verweigert", + tribeContentAccessDeniedMsg: "Dieser Inhalt gehört zu einem Stamm. Sie müssen Mitglied sein, um darauf zuzugreifen.", + tribeViewTribes: "Stämme Anzeigen", + torrentsTitle: "Torrents", + torrentsDescription: "Torrents in Ihrem Netzwerk erkunden und verwalten.", + torrentAllSectionTitle: "Torrents", + torrentMineSectionTitle: "Ihre Torrents", + torrentRecentSectionTitle: "Neueste Torrents", + torrentTopSectionTitle: "Top Torrents", + torrentFavoritesSectionTitle: "Favoriten", + torrentFilterAll: "ALLE", + torrentFilterMine: "MEINE", + torrentFilterRecent: "NEUESTE", + torrentFilterTop: "TOP", + torrentFilterFavorites: "FAVORITEN", + torrentCreateSectionTitle: "Torrent Hochladen", + torrentUpdateSectionTitle: "Torrent Aktualisieren", + torrentCreateButton: "Torrent Hochladen", + torrentUpdateButton: "Aktualisieren", + torrentDeleteButton: "Löschen", + torrentAddFavoriteButton: "Zu Favoriten Hinzufügen", + torrentRemoveFavoriteButton: "Aus Favoriten Entfernen", + torrentFileLabel: "Torrent-Datei auswählen (.torrent)", + torrentTitleLabel: "Titel", + torrentTitlePlaceholder: "Titel", + torrentDescriptionLabel: "Beschreibung", + torrentDescriptionPlaceholder: "Beschreibung", + torrentTagsLabel: "Tags", + torrentTagsPlaceholder: "Tags durch Kommas getrennt eingeben", + torrentSizeLabel: "Größe", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "Keine Torrent-Datei", + noTorrents: "Keine Torrents gefunden.", + torrentSearchPlaceholder: "Titel, Tags, Autor suchen...", + torrentSearchButton: "Suchen", + torrentSortRecent: "Neueste", + torrentSortOldest: "Älteste", + torrentSortTop: "Meistgewählt", + torrentNoMatch: "Keine passenden Torrents.", + torrentMessageAuthorButton: "Nachricht an Autor", + torrentUpdatedAt: "Aktualisiert", + statsTorrent: "Torrents", + typeTorrent: "TORRENTS", + modulesTorrentsLabel: "Torrents", + modulesTorrentsDescription: "Modul zum Entdecken und Verwalten von Torrents.", + favoritesFilterTorrents: "TORRENTS", + favoritesFilterMarket: "MARKT", + favoritesFilterShopProducts: "SHOP-ARTIKEL", + tribeSectionTorrents: "TORRENTS", + tribeCreateTorrent: "Torrent Hochladen", + tribeMediaTypeTorrent: "Torrent", + settingsWishTitle: "Wunsch", + settingsWishDesc: "Konfiguriere den Wunsch deines Avatars.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Private Nachrichten", + settingsPmVisibilityDesc: "Konfiguriere dein Niveau der Offenlegung für private Nachrichten.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "Ausgang ist ein UX-Schutz. Eingang ist ein Aufmerksamkeitsfilter.", + pmBlockedNonMutual: "Du kannst PNs nur an Bewohner mit gegenseitiger Unterstützung senden.", + inhabitantsPendingFollowsTitle: "Ausstehende Unterstützungsanfragen", + inhabitantsPendingAccept: "Akzeptieren", + inhabitantsPendingReject: "Ablehnen", + bxEncrypted: "VERSCHLÜSSELT", + bxEncryptedHexLabel: "Chiffretext (Vorschau)", + tribeSectionGovernance: "GOVERNANCE", + tribeSubStatusPublic: "ÖFFENTLICH", + tribeSubStatusPrivate: "PRIVAT", + tribeSubInheritedPrivate: "PRIVAT (vom Haupt-Stamm geerbt)", + tribePadInviteRequired: "Kein Zugriff auf das Pad. Bitte um Einladung.", + tribeChatInviteRequired: "Kein Zugriff auf den Chat. Bitte um Einladung.", + tribeGovernanceDesc: "Interne Governance dieses Stammes.", + tribeGovernanceNoGov: "Keine aktive Regierung", + tribeGovernanceNoGovDesc: "Dieser Stamm hat noch keine Regierung gewählt.", + tribeGovCardTitle: "Aktuelle Regierung", + tribeGovCycleSince: "ZYKLUS SEIT", + tribeGovCycleEnd: "ZYKLUS ENDE", + tribeGovTimeRemaining: "VERBLEIBENDE ZEIT", + tribeGovPopulation: "BEVÖLKERUNG", + tribeGovMethod: "METHODE", + tribeGovVotesReceived: "ERHALTENE STIMMEN", + tribeGovLeader: "ANFÜHRER", + tribeGovFilterGovernment: "REGIERUNG", + tribeGovFilterCandidatures: "KANDIDATUREN", + tribeGovFilterLaws: "GESETZE", + tribeGovCandidatureId: "Kandidatur", + tribeGovCandidatureMethod: "Methode", + tribeGovCandidatureProposeBtn: "Kandidatur veröffentlichen", + tribeGovRuleTitle: "Regeltitel", + tribeGovRuleBody: "Regelinhalt", + tribeGovProposals: "VORSCHLÄGE", + tribeGovRevocations: "WIDERRUFE", + tribeGovHistorical: "VERLAUF", + tribeGovRules: "REGELN", + tribeGovernanceAlreadyPublished: "Dieser Stamm hat bereits eine offene Kandidatur.", + tribeGovernanceProposeInternal: "Interne Kandidatur vorschlagen", + tribeGovernanceInternalCandidatures: "Interne Kandidaturen", + tribeGovernanceNoCandidatures: "Keine offenen Kandidaturen.", + tribeGovernanceAddRule: "Regel hinzufügen", + tribeGovernanceNoRules: "Noch keine Regeln.", + tribeGovernanceNoLeaders: "Noch keine Führungskräfte gewählt.", + tribeGovernanceComingSoon: "Bald im Governance-Modul.", + tribeGovNoProposals: "Noch kein Vorschlag", + tribeGovNoLaws: "Noch kein Gesetz", + tribeGovNoRevocations: "Noch kein Widerruf", + tribeGovNoHistorical: "Noch kein Eintrag", + logsTitle: "Logbuch", + logsDescription: "Erfasse deine Erfahrung im Netzwerk.", + logsReadMore: "Mehr lesen", + logsViewTitle: "Logbuch", + logsColumnType: "Typ", + logsView: "Ansehen", + logsManualPrompt: "Schreibe dein Logbuch", + logsUpdateButton: "Aktualisieren", + logsEditTitle: "Eintrag bearbeiten", + logsCreateDescription: "Erfasse deine Erfahrung...", + logsCreateTitle: "Eintrag erstellen", + logsWriteButton: "Schreiben", + logsTextPlaceholder: "Beschreibe deine Erfahrungen...", + logsTextField: "Text", + logsLabelPlaceholder: "Bezeichnung...", + logsLabelField: "Schreibe dein Logbuch (Bezeichnung)", + logsAiDisabledWarn: "Aktiviere das KI-Modul in /modules, um KI-Einträge zu verwenden.", + logsAiContextValue: "Blockchain-Tag", + logsAiContext: "Kontext", + logsAiModStatus: "AImod", + logsModeApply: "Anwenden!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Manuell", + logsModeAI: "KI", + logsColumnDelete: "Löschen", + logsColumnEdit: "Bearbeiten", + logsColumnLog: "Logbuch", + logsColumnDate: "Datum", + logsDelete: "Löschen", + logsEdit: "Bearbeiten", + logsFilterAlways: "IMMER", + logsFilterYear: "LETZTES JAHR", + logsFilterMonth: "LETZTER MONAT", + logsFilterWeek: "LETZTE WOCHE", + logsFilterToday: "HEUTE", + logsFilterRecent: "AKTUELL", + logsFilterAll: "ALLE", + logsCreate: "Eintrag erstellen", + logsExport: "Logbuch exportieren", + logsExportOne: "Exportieren", + logsViewDetails: "Details ansehen", + logsGenerateButton: "Text generieren", + logsSearchText: "In Logbüchern suchen...", + logsSearchAnyType: "Beliebiger Typ", + logsSearchButton: "Suchen", + logsEmpty: "Noch keine Einträge.", + modulesLogsLabel: "Logbuch", + modulesLogsDescription: "Modul, um (über einen KI-Assistenten) deine Erfahrungen aufzuzeichnen.", + statsLogsTitle: "Logbuch", + statsLogsEntries: "Einträge", + typeLog: "LOGBUCH", + blockchainCycle: "Zyklus", + + larpTitle: "L.A.R.P.", + larpDescription: "Eine Live-Rollenspiel-Schicht für kollaboratives Experimentieren.", + larpAllHouses: "Häuser:", + larpFilterRuling: "REGIERT", + larpFilterHouses: "HÄUSER", + larpFilterRules: "FAQ", + larpRulesTitle: "L.A.R.P.-FAQ", + larpRulesEntryTitle: "Starthaus", + larpRulesEntryText: "Jede Bewohnerin beginnt standardmäßig in ACADEMIA. Von ACADEMIA aus wählst du ein Haus im Panel \"Haus beitreten\": dadurch öffnet sich seine Eintrittsprüfung. Beantworte die 10 Fragen und sende sie ab. Wenn du die Schwelle (7/10) erreichst, wirst du automatisch in das Haus aufgenommen; andernfalls wirst du abgelehnt und bleibst in ACADEMIA. In beiden Fällen speichert das System deine OasisID und den Zyklus des Versuchs und verhindert weitere Versuche, bis die Abklingzeit von 30 Zyklen abgelaufen ist.", + larpRulesLeaveText: "Mitglieder eines beliebigen Hauses können jederzeit von ihrer Hausseite zu ACADEMIA zurückkehren; dies setzt ihre Mitgliedschaft zurück, hebt aber die Test-Abklingzeit nicht auf.", + larpRulesGovernanceTitle: "Regierungswand", + larpRulesGovernanceText: "In ihrem Zyklus trägt das regierende Haus das \"Regiert\"-Abzeichen und ist das einzige, dessen Wand öffentlich unter dem REGIERT-Tab erscheint.", + larpRulesSignTitle: "L.A.R.P.-Emblem", + larpRulesSignText: "Bewohner können dies aktivieren (unter /profile/edit > Sensoren), um das Bild ihres aktuellen Hauses als Siegel auf ihrem Profil, in der Autoren- und Bewohnerkarte anzuzeigen. Das Siegel verlinkt zur Hausseite.", + larpPostsTitle: "Wand", + larpPostsEmpty: "Noch keine Beiträge.", + larpPostLabel: "Neuer Beitrag", + larpPostPlaceholder: "Was hat dieses Haus zu sagen?", + larpPostPublic: "Öffentlich (für alle sichtbar, sonst nur für Mitglieder)", + larpPostSubmit: "Veröffentlichen", + larpPostMembersOnly: "Nur Mitglieder", + larpCycleLabel: "Zyklus:", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + bankTaxesLookupNote: "Gib eine Block-ID ein, um sie in deinem lokalen Feed nachzuschlagen.", + bankTaxesUserNoteChipsClick: "Klicke auf einen Chip, um seinen Block im Blockexplorer zu inspizieren.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "Einladungscode", + larpRulesInviteEntryText: "Hausmitglieder können Einladungscodes erzeugen, die direkten Zugang zum Haus gewähren.", + larpRulesOneHouseText: "Du kannst zu jedem Zeitpunkt höchstens einem Haus angehören. Gehörst du keinem an, bist du in ACADEMIA. Jede Nicht-ACADEMIA-Hausseite zeigt seinen Mitgliedern eine Schaltfläche „Haus verlassen\", die die Mitgliedschaft zurück nach ACADEMIA setzt. Das Verlassen hebt die Test-Abklingzeit NICHT auf.", + larpRulesOneHouseTitle: "Ein Haus auf einmal", + larpRulesTestEntry: "WILL-Test", + larpRulesTestEntryText: "Jede Option gewichtet ein oder mehrere Häuser; beim Absenden zählt das System die Punkte und nimmt dich automatisch in das Haus mit der höchsten Punktzahl auf.", + larpWillTestHint: "Nach Abschluss wirst du dem Haus zugewiesen, das am besten zu deinen Antworten passt. Deine einzelnen Antworten werden lokal verarbeitet und nie gespeichert — nur die resultierende Hauszuweisung wird in deinem Feed veröffentlicht.", + larpWillTestTitle: "WILL-Test", + settingsLanDesc: "Diesen Peer periodisch an andere Oasis-Instanzen im selben lokalen Netzwerk ankündigen. Deaktivieren, um UDP-Broadcasts zu stoppen.", + settingsLanEnable: "LAN-Broadcasting aktivieren", + settingsLanTitle: "LAN-Broadcasting", + settingsReplicationTitle: "Replikation", + settingsReplicationDesc: "Konfiguriere die Anzahl der Hops, die dein Peer von deinem eigenen Feed aus beim Replizieren von Inhalten folgt.", + settingsReplicationHopsLabel: "Hops", + aiApproveTagsLabel: "Tags (kommagetrennt)", + aiApproveTagsPlaceholder: "z. B. oasis, governance, Ökologie", + aiApproveRatingLabel: "Bewertung", + aiExchangeLang: "Sprache", + aiExchangeTags: "Tags", + aiExchangeRating: "Bewertung", + aiExchangeHelpful: "Hilfreich", + aiExchangeMarkHelpful: "+1 hilfreich", + larpCyclesUntilRuling: "Nächster Regierungszyklus", + larpVisitTribe: "Stamm besuchen", + larpGoverning: "Regiert:", + larpMyHouse: "Mein Haus:", + larpMembersCount: "Mitglieder", + larpMembersTitle: "Mitglieder", + larpNoMembers: "Noch keine Mitglieder.", + larpRolesLabel: "Rollen", + larpFunctionLabel: "Funktion", + larpMonthLabel: "Regierungszyklus", + larpLeaveToAcademia: "Zurück zu ACADEMIA", + larpAcademiaJoinTitle: "Haus beitreten", + larpAcademiaJoinHint: "Lege den psychologischen Profiltest ab, um dem Haus zugewiesen zu werden, das am besten zu dir passt, oder löse einen Einladungscode ein, den dir ein Mitglied eines Hauses gegeben hat.", + larpTakeTestButton: "Profiltest ablegen", + larpInviteRedeem: "Haus beitreten", + larpInviteCreate: "Einladungscode erzeugen", + larpInvitePlaceholder: "Einladungscode", + larpInviteBannerTitle: "Neuer Einladungscode", + larpInviteBannerHint: "Teile diesen Code mit jemandem in ACADEMIA. Er läuft in 30 Zyklen oder nach einmaliger Nutzung ab.", + larpTestAssignedHouse: "Zugewiesenes Haus", + larpTestRankingTitle: "Punktzahl nach Haus", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "Haus-Einladungscode", + invitesHouseJoinButton: "Haus beitreten", + ecoTaxLabel: "ECO Tax", + ecoinTaxLabel: "ECOin Tax", + bankTaxes: "Steuern", + bankOverviewYourTaxes: "Deine Steuern", + bankTaxesNetworkTitle: "Netzwerksteuern", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesUserTitle: "Deine Steuern", + bankTaxesUserAmount: "Deine ECO Tax (zu zahlender Beitrag)", + bankTaxesArchTaxUserAmount: "Deine ARCH Tax (zu zahlender Beitrag)", + bankTaxesArchTaxFirstBlock: "Alter deines ersten Blocks", + bankTaxesArchTaxRate: "Rate", + bankTaxesTotalBlocks: "Blöcke gesamt", + bankTaxesTotalBytes: "Bytes gesamt", + bankTaxesTotalCarbon: "Kohlenstoff gesamt", + bankTaxesTotalEcoin: "ECO Tax gesamt (Lebenszeit)", + bankTaxesEcoTaxLifetime: "ECO Tax (Lebenszeit)", + bankTaxesAnnualEcoin: "ECO Tax (jährlich)", + bankTaxesMonthlyEcoin: "ECO Tax (monatlich)", + bankTaxesArchTaxLifetime: "ARCH Tax (Lebenszeit)", + bankTaxesArchTaxAnnual: "ARCH Tax (jährlich)", + bankTaxesArchTaxMonthly: "ARCH Tax (monatlich)", + bankTaxesTotalLifetime: "Total (Lebenszeit)", + bankTaxesTotalAnnual: "Total (jährlich)", + bankTaxesTotalMonthly: "Total (monatlich)", + bankTaxesRate: "Rate", + bankTaxesSpan: "Beobachtungszeitraum", + bankTaxesLookupTitle: "Block inspizieren", + bankTaxesLookupPlaceholder: "Block-ID (z. B. %abc...=.sha256)", + bankTaxesLookupButton: "Inspizieren", + bankTaxesLookupNotFound: "Es wurde kein Block mit dieser ID in deinem lokalen Feed gefunden.", + bankTaxesLookupAuthor: "Autor", + bankTaxesLookupType: "Typ", + bankTaxesLookupSize: "Größe", + bankTaxesLookupCarbon: "CO₂-Fußabdruck", + bankTaxesLookupEcoin: "ECO Tax", + bankExchangeEcoTaxAnnual: "ECOin-Steuern (jährlich)", + bankExchangeEcoTaxMonthly: "ECOin-Steuern (monatlich)", + bankTaxesTypesLabel: "Wähle, welche Steuern du zahlen willst", + bankTaxesTypesApply: "Steuern festlegen", + bankRulesPoolTitle: "Monatlicher Pool", + bankRulesShareTitle: "Anteil pro Nutzer", + bankRulesKarmaTitle: "Karma (Engagement-Score)", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesChipTitle: "ECO-Tax-Chip-Farbband", + bankRulesEpochKind: "Epoch-Granularität", + bankRulesAlpha: "Alpha (max. Anteil am Pub-Balance pro Epoch)", + bankRulesReserveMin: "Reservemindestbetrag (im Pub-Balance gehalten)", + bankRulesCapPerEpoch: "Obergrenze pro Epoch (absolut)", + bankRulesGraceDays: "Anspruchsfrist", + bankRulesWMin: "Mindestgewicht (w_min)", + bankRulesWMax: "Höchstgewicht (w_max)", + bankRulesFloor: "Sockel pro Nutzer (brutto)", + bankRulesCapUser: "Obergrenze pro Nutzer pro Epoch", + bankRulesEcoTaxRate: "Rate", + bankRulesArchTaxRate: "Rate", + bankRulesCarbonFactor: "Kohlenstofffaktor", + statsEcoTaxTitle: "ECO Tax", + statsTombstoneEmpty: "Noch keine Tombstones im Netzwerk.", + blockchainBlockNotAvailable: "Dieser Block ist in deinem lokalen Feed nicht verfügbar. Er gehört möglicherweise einem Peer, von dem du noch nicht replizierst.", + blockchainBackToBlockexplorer: "Zurück zum Blockexplorer", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeTitle: "BCS-Transcode", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeDetailDescription: "Entschlüssle die eingebettete Nutzlast und die ursprüngliche Blockchain-Kompositionskarte.", + audioTranscodeStegoTitle: "In die Wellenform eingebettet", + audioTranscodeStegoOasisId: "Von", + audioTranscodeStegoTimestamp: "Erzeugt am", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoEmpty: "(keine)", + audioTranscodeStegoUnknown: "—", + audioTranscodeStegoNotFound: "Aus diesem Audio konnte keine steganographische Nutzlast entschlüsselt werden.", + audioTranscodeCompositionEmpty: "Dieses Audio enthält keine gespeicherte Blockchain-Komposition.", + audioBackToAudio: "Zurück zum Audio", + audioBackToBcs: "Zurück zu BCS", + audioAuthorLabel: "Autor", + melodyCompositionTitle: "Blockchain-Kompositionskarte", + melodyFilterMine: "MEINS", + melodyByLabel: "Von", + melodyAllEmpty: "Noch keine BCS-Kompositionen anderer Bewohner.", + melodyNoteUnavailable: "Block ist nicht in deinem lokalen Feed", + melodyUploadBcsTitle: "Veröffentlichen", + melodyUploadBcsButton: "Veröffentlichen", + melodyUploadStegoLabel: "Versteckte Nachricht (Steganografie)", + melodyUploadStegoMaxLabel: "max. 280 Zeichen", + melodyUploadStegoPlaceholder: "Optional.", + melodyUploadBcsNameNote: "Das Audio wird unter dem automatisch generierten Namen veröffentlicht", + larpLastAttemptTitle: "Dein letzter Versuch", + larpLastAttemptHouse: "Haus", + larpLastAttemptResult: "Ergebnis", + larpLastAttemptWhen: "Wann", + larpLastAttemptCooldown: "Nächster Versuch in", + larpTestTitle: "Eintrittsprüfung", + larpTestIntro: "Beantworte die 10 Fragen. Du brauchst mindestens 7 richtige Antworten, um aufgenommen zu werden. Du kannst unabhängig vom Ergebnis alle 30 Zyklen einen Test absolvieren.", + larpTestSubmit: "Haus beitreten", + larpTestCooldownActive: "Nächster Test verfügbar in", + larpTestCooldownDays: "Zyklen", + larpTestResultTitle: "Testergebnis", + larpTestPassed: "Test bestanden — willkommen!", + larpTestFailed: "Test nicht bestanden", + larpTestScore: "Punktzahl", + larpTestNextAttempt: "Du kannst in 30 Zyklen einen weiteren Test absolvieren.", + larpGoToHouse: "Zu deinem Haus", + larpBackToAcademia: "Zurück zu ACADEMIA", + larpBackToHouses: "◀ Häuser", + larpBadgeYou: "Du", + larpBadgeRuling: "Regiert", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Modul für die Live-Rollenspielschicht von Oasis (die 9 Häuser).", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + chatLabel: "CHATS", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "Wenn du vor einem komplexen Problem stehst, was tust du zuerst?", + larpProfileQ1O1: "Mit anderen reden, um Konsens zu finden", + larpProfileQ1O2: "Einen Prototyp zum Testen bauen", + larpProfileQ1O3: "Die Literatur erforschen", + larpProfileQ1O4: "Das System sabotieren, das es verursacht", + larpProfileQ2: "Was gibt deiner täglichen Arbeit Sinn?", + larpProfileQ2O1: "Die Menschen verteidigen, die ich liebe", + larpProfileQ2O2: "Etwas Greifbares schaffen", + larpProfileQ2O3: "Leben heilen oder nähren", + larpProfileQ2O4: "Worte und Ideen formen", + larpProfileQ2O5: "Andere zum Lachen bringen", + larpProfileQ3: "Wenn in deiner Gruppe ein Konflikt entsteht, du…", + larpProfileQ3O1: "Führst den Dialog", + larpProfileQ3O2: "Beziehst Position und bleibst standhaft", + larpProfileQ3O3: "Machst einen Witz zur Entschärfung", + larpProfileQ3O4: "Fragst, ob der Konflikt real ist", + larpProfileQ3O5: "Suchst die ökologischen Wurzeln", + larpProfileQ4: "Dein liebstes Langzeitprojekt wäre…", + larpProfileQ4O1: "Eine Stadt bauen", + larpProfileQ4O2: "Einen Wald wiederherstellen", + larpProfileQ4O3: "Eine Verfassung schreiben", + larpProfileQ4O4: "Ein Festival organisieren", + larpProfileQ4O5: "Ein Verteidigungsnetzwerk aufbauen", + larpProfileQ4O6: "Eine neue Maschine entwerfen", + larpProfileQ4O7: "Ein Archiv kuratieren", + larpProfileQ4O8: "Eine ungerechte Ordnung stören", + larpProfileQ5: "Was macht eine gute Anführerin aus?", + larpProfileQ5O1: "Jemand, der zuhört und vermittelt", + larpProfileQ5O2: "Jemand, der kämpfen und beschützen kann", + larpProfileQ5O3: "Jemand, der die Geschichte kennt", + larpProfileQ5O4: "Jemand, der dich zum Lächeln bringt", + larpProfileQ6: "Dein Verhältnis zu Regeln:", + larpProfileQ6O1: "Regeln entstehen aus Dialog und Gesetz", + larpProfileQ6O2: "Regeln sollten strikt befolgt werden", + larpProfileQ6O3: "Regeln sollten oft gebrochen werden", + larpProfileQ6O4: "Regeln sollten dem Leben dienen", + larpProfileQ6O5: "Regeln bauen solide Infrastruktur", + larpProfileQ7: "Wie gehst du mit Information um?", + larpProfileQ7O1: "Ich kuratiere und bewahre sie", + larpProfileQ7O2: "Ich teile sie durch Geschichten", + larpProfileQ7O3: "Ich hinterfrage ihren Ursprung", + larpProfileQ7O4: "Ich extrahiere das Nützliche", + larpProfileQ7O5: "Ich nutze sie zur Heilung", + larpProfileQ8: "Deine Vorstellung von Erfolg ist…", + larpProfileQ8O1: "Eine funktionierende Maschine", + larpProfileQ8O2: "Eine friedliche Gemeinschaft", + larpProfileQ8O3: "Ein lebendiges Festival", + larpProfileQ8O4: "Eine sichere Familie", + larpProfileQ8O5: "Ein ungebrochenes Archiv", + larpProfileQ8O6: "Ein gesprengtes Dogma", + larpProfileQ8O7: "Eine reiche Ernte", + larpProfileQ8O8: "Ein fertiges Gebäude", + larpProfileQ9: "Wenn du aufwachst, willst du…", + larpProfileQ9O1: "Deinen Körper trainieren", + larpProfileQ9O2: "Lesen oder schreiben", + larpProfileQ9O3: "Garten oder Kochen", + larpProfileQ9O4: "An etwas basteln", + larpProfileQ9O5: "Autorität hinterfragen", + larpProfileQ9O6: "Ein Projekt planen", + larpProfileQ9O7: "Mit Freunden reden", + larpProfileQ9O8: "Kunst machen", + larpProfileQ10: "Deine Schwäche könnte sein:", + larpProfileQ10O1: "Zu viel reden", + larpProfileQ10O2: "Zu pragmatisch sein", + larpProfileQ10O3: "Zu idealistisch sein", + larpProfileQ10O4: "Zu disruptiv sein", + larpProfileQ10O5: "Zu starr sein", + larpProfileQ10O6: "Zu unbeschwert sein", + larpProfileQ10O7: "Zu vorsichtig sein", + larpProfileQ10O8: "Zu ehrgeizig sein", + uxModeTitle: "UX", + uxModeDescription: "Wähle den UX-Navigationsmodus für deine Oberfläche.", + uxModeMenus: "Blöcke", + uxModeAINav: "AI", + + } +} \ No newline at end of file diff --git a/src/client/assets/translations/oasis_en.js b/src/client/assets/translations/oasis_en.js new file mode 100644 index 0000000..76a876b --- /dev/null +++ b/src/client/assets/translations/oasis_en.js @@ -0,0 +1,3873 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + en: { + languageName: "English", + shopOrderStatusPaid: "Payment received", + shopOrderStatusReceived: "Received", + shopOrderMarkPaid: "Payment received", + shopOrderConfirmReceived: "Confirm received", + shopMyOrdersTitle: "My orders", + shopPurchasesButton: "Purchases", + shopMyOrdersDescription: "Your purchase orders.", + shopMyOrdersEmpty: "You have no purchases yet.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Seller", + shopOrderPaymentConcept: "Payment", + shopOrderInvoice: "Invoice", + shopOrderInvoiceConcept: "Invoice", + shopOrderStatusPending: "Pending", + shopOrderStatusAccepted: "Accepted", + shopOrderStatusRejected: "Rejected", + shopOrderStatusShipped: "Shipped", + shopOrderStatusDelivered: "Delivered", + shopOrderAccept: "Accept", + shopOrderReject: "Reject", + shopOrderMarkShipped: "Mark shipped", + shopOrderMarkDelivered: "Mark delivered", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Smart-contract", + shopOrderContractConcept: "Shop order", + shopOrdersPending: "Pending orders", + all: "All", + mine: "Mine", + recent: "Recent", + anonymous: "Anonymous", + unnamed: "Anonymous", + authorLabel: "AUTHOR", + delete: "Delete", + save: "Save", + vote: "Vote", + url: "URL", + price: "Price", + priority: "Priority", + severity: "Severity", + organizer: "Organizer", + edited: "edited", + isPublic: "Public", + searchIsPublicLabel: "Public", + privacyPrivate: "PRIVATE", + privacyPublic: "PUBLIC", + notFound: "Not found", + no_results: "No data", + searchButton: "Search", + mapZoomLabel: "Zoom", + modulesTitle: "Modules", + viewJson: "View JSON", + matchScore: "Match score", + preferencesLabel: "Preferences", + inhabitantProfileTitle: "Inhabitant profile", + contentWarningLabel: "Content warning", + invalidPost: "Invalid content", + invalidPostHint: "This message has invalid/empty text.", + spreadBy: "By", + spreadChron: "Spread", + spreaded: "Spread", + spreadMore: "more", + spreadContentUnavailable: "Content not yet available (pending replication)", + filteredByTag: "Filtered by tag", + filteredByTagTitle: "Filtered by tag", + feedPublishedSuccess: "Feed published successfully!", + tribeFeedSent: "Message sent successfully!", + tribeContentEncrypted: "Encrypted content", + tribeTorrentsEmpty: "No torrents, yet.", + torrentDescription: "Description", + torrentDownload: "Download", + chatInviteMode: "Invite", + padInviteMode: "Invite", + noInviteMode: "No invite mode", + noDeadline: "No deadline", + noStatus: "No status", + noSeverity: "No severity", + calendarDeleteDate: "Delete Date", + calendarIntervalUntil: "Until", + bookmarkCategory: "Category", + bookmarkLastVisit: "Last visit", + profileClearnetBookmarksLabel: "Bookmarks", + forumFilterHot: "Hot", + invitesPort: "Port", + currentlyUnrecheable: "ERROR!", + peerHostValidation: "Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.example.com)", + peerPortValidation: "Port 1-65535", + peerKeyValidation: "SSB ed25519 public key (@<44 chars base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Unsubscription from your job offer", + inboxProjectFollowedTitle: "New follower of your project", + inboxProjectUnfollowedTitle: "Unfollowed your project", + pmHasFollowedYourProject: "has followed your project", + pmHasUnfollowedYourProject: "has unfollowed your project", + pmHasUnsubscribedFromYourJobOffer: "has unsubscribed from your job offer", + marketDeleteButton: "Delete", + marketNoBids: "No bids yet", + marketAuctionBidAmount: "Bid amount", + marketAuctionBidTime: "Bid time", + marketAuctionUser: "Bidder", + parliamentActorInPower: "TRIBE RULING", + parliamentWinningCandidature: "Winning candidature", + projectBounty: "Bounty", + projectFollowing: "FOLLOWING", + projectBackerAuthor: "Backer", + projectBackerDate: "Date", + projectConfirmTransferButton: "Confirm transfer", + projectPendingTransfersTitle: "Pending transfers", + transfersUpdateSectionTitle: "Update transfer", + taskUnassignedFrom: "Unassigned from", + taskDescriptionPlaceholder: "Task description", + exportPasswordLabel: "Export password", + importPasswordPlaceholder: "Enter password", + searchCasesPlaceholder: "Search cases…", + shopBuyDeliveryAddressPlaceholder: "Delivery address", + shopBuyNotesPlaceholder: "Notes", + bankTaxesUserNoteBold: "reducing the ECO tax directly", + bankTaxesUserNoteIntro: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to ", + bankTaxesUserNoteOutro: ", and their ongoing contribution may be required by networking consensus.", + courtsViewDetails: "View", + courtsViewDetailsShort: "Details", + courtsThDetails: "Details", + courtsDetailsAnswersTitle: "Answers", + courtsDetailsEvidenceTitle: "Evidence", + courtsDetailsSettlementsTitle: "Settlements", + courtsDetailsVerdictTitle: "Verdict", + courtsDetailsNotPublic: "Not public", + courtsEvidenceSideAccuser: "Accuser evidence", + courtsEvidenceSideRespondent: "Defence evidence", + courtsEvidenceSideUnknown: "Unknown", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Support case", + courtsSettlementAcceptBtn: "Accept settlement", + courtsSupportCount: "Supporters", + courtsVerdictVoteTitle: "Vote on verdict", + courtsVerdictVoteLabel: "Verdict vote", + courtsVerdictVoteAccept: "Accept", + courtsVerdictVoteReject: "Reject", + courtsVerdictVoteSubmit: "Submit vote", + reportsSetStatus: "Set status", + reportsStatusClosed: "CLOSED", + statsAvgPerInhabitant: "Avg per inhabitant", + statsCarbonMaxAnnual: "Annual max estimate", + statsCarbonNetwork: "Network total", + statsCarbonOfEstMax: "of estimated max capacity", + statsCarbonOfNetwork: "of network total", + statsCarbonUser: "Your footprint", + statsContentCountColumn: "Count", + statsContentTypeColumn: "Type", + statsMsgsPerDay: "Messages/day (lifetime)", + statsMyShare: "Your share of the network", + statsNetworkSpan: "Network span", + statsTombstoneRatioLabel: "Tombstone ratio", + statsTopTagsTitle: "Top Tags", + statsTopTypesTitle: "Top Content Types", + statsTotalMsgs: "Total messages", + feedViewDetails: "View details", + eventFileLabel: "Attach image", + taskFileLabel: "Attach image", + courtsRespondentRequired: "Respondent ID is required", + extended: "Multiverse", + extendedDescription: [ + "When you support someone you may download posts from the inhabitants they support, and those posts show up here, sorted by recency.", + ], + popular: "Highlights", + popularDescription: [ + "Posts from inhabitants in your network, ", + strong("sorted by spreads"), + ". Select the period of time, to get a list.", + ], + week: "WEEK", + month: "MONTH", + year: "YEAR", + latest: "Latest", + latestDescription: [ + strong("Posts"), + " from yourself and inhabitants you support, sorted by recency.", + ], + topics: "Topics", + topicsDescription: [ + strong("Topics"), + " from yourself and inhabitants you support, sorted by recency.", + ], + summaries: "Summaries", + summariesDescription: [ + strong("Topics that have comments"), + " from yourself and inhabitants you support, sorted by recency.", + ], + threads: "Threads", + threadsDescription: [ + strong("Posts that have comments"), + " from inhabitants you support (included from multiverse), sorted by recency.", + ], + profile: "Avatar", + inhabitants: "Inhabitants", + peersReplicatedFeeds: "Replicated feeds", + graphos: "Graphos", + graphosDescription: "Interactive map of the network around you.", + graphosViewingGraphOf: "Viewing", + graphosBackToMine: "← My network", + graphosShowing: "Showing", + graphosYou: "You", + graphosTotalNodes: "Total nodes", + manualMode: "Manual Mode", + mentions: "Mentions", + mentionsDescription: [ + strong("Posts that @mention you"), + ", sorted by recency.", + ], + nextPage: "Next", + previousPage: "Previous", + noMentions: "You haven't received @mentions, yet.", + privateReminders: "REMINDERS", + inboxFiltersLabel: "Filters:", + inboxToggleToMutuals: "Switch to mutuals", + inboxToggleToWhole: "Switch to whole", + privateFrom: "From", + privateTo: "To", + privateDate: "Date", + pmReply: "Reply", + pmReplies: "replies", + pmNew: "new", + pmMarkRead: "Mark as read", + inReplyTo: "IN REPLY TO", + pmPreview: "Preview", + pmPreviewTitle: "Message preview", + peers: "Peers", + searchDescription: "Description", + imageSearch: "Image Search", + searchFromLabel: "From", + searchToLabel: "To", + searchMinOpinionsLabel: "Min opinions", + searchInhabitantLabel: "Inhabitant (Oasis ID)", + searchQueryLabel: "Query", + searchPerPageLabel: "Results per page", + settings: "Settings", + continueReading: "Continue reading", + moreComments: "more comment", + readThread: "read the rest of the thread", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Draw pixels on the grid and collaborARTe with others in your network.', + melodyTitle: 'Melody', + melodyDescription: 'Play the melody of your blockchain — each block becomes a note.', + melodyEmpty: 'No notes yet — your blockchain has not produced any block.', + melodyCompositionTitle: 'Blockchain Composition Map', + melodyCompositionHelp: 'Each block of your blockchain becomes a musical note based on its type and size.', + melodyStatsTitle: 'Type breakdown', + melodyInhabitantLabel: 'Inhabitant', + melodyTotalBlocks: 'Notes', + melodyType: 'Type', + melodyCount: 'Blocks', + melodyFilterAll: 'ALL', + melodyFilterShare: 'Upload Melody', + melodyShareTitle: 'Share your melody', + melodyShareHelp: 'Publish a snapshot of your current melody so others can listen and remix it.', + melodyShareTitleLabel: 'Title (optional)', + melodyShareTitlePlaceholder: 'A blockchain ghost', + melodyShareSubmit: 'Publish Melody', + melodyNoShared: 'No blockchain melodies yet.', + melodyRegenerate: 'Regenerate', + melodyDownload: "Download BCS", + melodyUploadBcsTitle: "Publish", + melodyUploadBcsButton: "Publish", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + melodyUploadStegoLabel: "Hidden message (steganography)", + melodyUploadStegoPlaceholder: "Optional.", + melodyUploadStegoMaxLabel: "max 280 characters", + melodyNoteUnavailable: "block not in your local feed", + blockchainBlockNotAvailable: "This block is not available in your local feed. It may belong to a peer you are not yet replicating from.", + blockchainBackToBlockexplorer: "Back to blockexplorer", + melodyUploadBcsNameNote: "The audio will be published with the auto-generated name", + melodyFilterMine: "MINE", + melodyByLabel: "By", + melodyAllEmpty: "No BCS compositions from other inhabitants yet.", + profileActivityTitle: 'Activity', + profileActivityMore: 'View all activity', + profileContentSectionTitle: 'Avatar Content', + profileContentHelp: 'Choose which of your modules will be displayed on your avatar.', + profileSensorsHelp: 'Optional metrics shown on your avatar.', + profileClearnetHelp: 'Modules that can be accessed from outside Oasis.', + profileHubAll: 'All', + profileHubTitle: 'Public Content', + footerPulseTitle: 'Pulse', + footerPulsePeers: 'Peers', + footerPulseInbox: 'Inbox', + footerPulseSync: 'Last Sync', + footerPulseEcoValue: 'ECO/h', + footerPulseLastActivity: 'Last Activity', + footerLicenseLabel: 'License', + profileSwitchToOasis: 'Return to Oasis', + profileSwitchToClearnet: 'Dive into Clearnet', + profileVisibilityFediverse: "Fediverse", + profileSwitchToFediverse: "Dive into Fediverse", + coordLabel: 'Coordinate (e.g., A3)', + coordPlaceholder: 'Enter coordinate', + contributorsTitle: "Contributors", + pixeliaBy: "by", + colorLabel: 'Pick a color', + paintButton: 'Paint it!', + invalidCoordinate: 'Incorrect coordinate', + goToMuralButton: "View Mural", + totalPixels: 'Total Pixels', + modules: "Modules", + modulesViewTitle: "Modules", + modulesViewDescription: "Set your environment by enabling or disabling modules.", + inbox: "Inbox", + multiverse: "Multiverse", + fediverse: "Fediverse", + fediverseDescription: "Manage your other fediverse accounts, including sending and receiving content.", + fediverseStatus: "Status", + fediverseDisconnected: "Fediverse disconnected. Check %LINK% or connection status.", + fediverseSettingsTitle: "Fediverse", + fediverseInstanceLabel: "Address", + fediverseTokenLabel: "Access token", + fediverseTokenHelp: "Set your access token. This will be used to manage your Mastodon account from inside Oasis.", + fediverseConnect: "Connect it", + fediverseConnectedAs: "Connected as", + fediverseDisconnect: "Disconnect", + fediverseEmpty: "When you connect other fediverse accounts from %LINK%, you can manage them from here.", + fediverseEmptyLink: "your settings", + fediverseComposePlaceholder: "What's on your mind?", + fediverseReplyPlaceholder: "Write your reply…", + fediverseAttach: "Attach media", + fediversePublish: "Publish", + fediverseReply: "Reply", + fediverseBoosted: "boosted", + fediverseNoPosts: "Your timeline is empty.", + fediverseThread: "Thread", + fediverseTimeline: "Timelines", + fediverseManageTimeline: "Manage timeline", + fediverseFollowers: "Followers", + fediverseFollowing: "Following", + fediversePosts: "Posts", + fediverseJoined: "Joined", + fediverseOverview: "Overview", + fediverseRefresh: "Refresh", + fediverseWrite: "Write", + fediversePreview: "Preview", + fediverseEdit: "Edit", + fediverseOpenFeed: "Visit feed", + fediverseManage: "Manage", + fediverseStatusNotConnected: "Not connected", + fediverseError: "Could not contact Mastodon.", + fediverseErrInstance: "Invalid instance URL.", + fediverseErrAuth: "Invalid or expired token.", + fediverseErrConnect: "Could not connect to the instance.", + fediverseErrToken: "Token required.", + fediverseErrPublic: "Not available in public mode.", + fediverseErrFetch: "Could not load the timeline.", + fediverseErrPost: "Could not publish.", + fediverseErrMedia: "Could not upload the file.", + fediverseErrEmpty: "Write something or attach a file.", + popularLabel: "Highlights", + topicsLabel: "Topics", + latestLabel: "Latest", + summariesLabel: "Summaries", + threadsLabel: "Threads", + multiverseLabel: "Multiverse", + inboxLabel: "Inbox", + invitesLabel: "Invites", + walletLabel: "Wallet", + legacyLabel: "Keys", + cipherLabel: "Crypter", + bookmarksLabel: "Bookmarks", + videosLabel: "Videos", + torrentsLabel: "Torrents", + docsLabel: "Documents", + audiosLabel: "Audios", + tagsLabel: "Tags", + imagesLabel: "Images", + inhabitantsLabel: "Inhabitants", + trendingLabel: "Trending", + eventsLabel: "Events", + tasksLabel: "Tasks", + apply: "Apply", + menuPersonal: "Personal", + menuContent: "Content", + menuBlogs: "Blogs", + menuGovernance: "Governance", + menuOffice: "Office", + menuMultiverse: "Multiverse", + menuNetwork: "Network", + menuCreative: "Creative", + menuEconomy: "Economy", + menuMedia: "Media", + menuTools: "Tools", + comment: "Comment", + subtopic: "Subtopic", + json: "JSON", + createdBy: "by", + unfollow: "Unsupport", + follow: "Support", + block: "Block", + unblock: "Unblock", + newerPosts: "Newer posts", + olderPosts: "Older posts", + feedRangeEmpty: "The given range is empty for this feed. Try viewing the ", + seeFullFeed: "full feed", + feedEmpty: "The Oasis network has never seen posts from this account.", + beginningOfFeed: "This is the beginning of the feed", + noNewerPosts: "No newer posts have been received yet.", + relationshipNotFollowing: "You are not supported", + relationshipTheyFollow: "Supports you", + relationshipMutuals: "Mutual support", + relationshipFollowing: "You are supporting", + relationshipYou: "You", + relationshipBlocking: "You are blocking", + relationshipBlockedBy: "You are blocked", + relationshipMutualBlock: "Mutual block", + relationshipNone: "You are not supporting", + relationshipConflict: "Conflict", + relationshipBlockingPost: "Blocked post", + viewLikes: "View spreads", + spreadedDescription: "List of posts spread by the inhabitant.", + totalspreads: "Total spreads", + attachFiles: "Attach files", + preview: "Preview", + publish: "Write", + contentWarningPlaceholder: "Add a subject to the post (optional)", + privateWarningPlaceholder: "Add inhabitants to send a private post (optional)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.", + ], + commentWarning: [ + "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.", + ], + commentPublic: "public", + commentPrivate: "private", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.", + ], + replyLabel: ({ markdownUrl }) => [ + "REMEMBER: Due to blockchain technology, once a post is published it cannot be edited or deleted.", + ], + publishCustomInfo: ({ href }) => [ + "If you have experience, you can also ", + a({ href }, "write an advanced post"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "If you have not experience, you should ", + a({ href }, "write a post"), + ".", + ], + publishCustom: "Write advanced post", + subtopicLabel: "Create a subtopic of this post", + messagePreview: "Post Preview", + mentionsMatching: "Matching Mentions", + mentionsName: "Name", + mentionsRelationship: "Relationship", + updateit: "GET UPDATES!", + updateBannerText: "A new version of Oasis is available.", + updateBannerAction: "Update now →", + info: "Info", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "ago", + sendTime: "about ", + theme: "Theme", + legacy: "Keys", + legacyTitle: "Keys", + legacyDescription: "Manage your secret (private key) quickly and safely.", + legacyExportButton: "Export", + legacyImportButton: "Import", + ssbLogStream: "Blokchain", + ssbLogStreamDescription: "Configure the message limit for Blockchain streams.", + settingsLanTitle: "LAN Broadcasting", + settingsLanDesc: "Periodically announce this peer to other Oasis instances on the same local network. Disable to stop UDP broadcasts.", + settingsReplicationTitle: "Replication", + settingsReplicationDesc: "Configure the number of hops your peer follows out from your own feed when replicating content.", + settingsReplicationHopsLabel: "Hops", + settingsLanEnable: "Enable LAN broadcasting", + saveSettings: "Save settings", + exportTitle: "Export data", + exportDescription: "Set password (min 32 characters long) to encrypt your key", + exportDataTitle: "Backup", + exportDataDescription: "Download your data (secret key excluded!)", + exportDataButton: "Download database", + pubWallet: "PUB Wallet", + pubWalletDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).", + pubWalletConfiguration: "Save configuration", + importTitle: "Import data", + importDescription: "Import your encrypted secret (private key) to enable your avatar", + importAttach: "Attach encrypted file (.enc)", + passwordLengthInfo: "Password must be at least 32 characters long.", + passwordImport: "Write your password to decrypt data that will be saved at your system home (name: secret)", + exportPasswordPlaceholder: "Use lowercase, uppercase, numbers & symbols", + fileInfo: "Your encrypted secret key will be saved at your system home (name: oasis.enc)", + themeIntro: + "Choose a theme.", + setTheme: "Set theme", + language: "Language", + languageDescription: + "If you'd like to use another language, select it here.", + setLanguage: "Set language", + peerConnections: "Peers", + peerConnectionsIntro: "Manage all your connections with other peers.", + peerConnectionsTitle: "Connections", + online: "Online", + offline: "Offline", + discovered: 'Discovered', + unknown: 'Unknown', + lanBroadcastLabel: "LAN broadcast", + lanBroadcastActive: "Active (UDP multicast on LAN)", + lanBroadcastDisabled: "Disabled (pub mode)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Recent", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Supported", + recommended: "Recommended", + blocked: "Blocked", + noConnections: "No peers connected.", + noDiscovered: "No peers discovered.", + noUnknownPeers: "No unknown peers in the friend graph.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Refresh", + peerPruneIdle: "Remove idle", + peerExport: "Export", + peerImport: "Import", + peerImportTitle: "Import peer list", + peerImportPlaceholder: "Paste one multiserver address per line, or upload a .txt file…", + noSupportedConnections: "No peers supported.", + noBlockedConnections: "No peers blocked.", + noRecommendedConnections: "No peers recommended.", + connectionActionIntro: + "", + startNetworking: "Start networking", + stopNetworking: "Stop networking", + restartNetworking: "Restart networking", + sync: "Sync network", + indexes: "Indexes", + indexesDescription: + "Rebuilding your indexes is safe, and may fix some types of bugs.", + homePageTitle: "Home", + homePageDescription: "Select which module you want as your home page.", + saveHomePage: "Set home", + invites: "Invites", + invitesTitle: "Invites", + invitesInvites: "Invitations", + invitesDescription: "Manage and apply invite codes in your network.", + invitesTribesTitle: "Tribes", + invitesTribeInviteCodePlaceholder: "Enter tribe invite code", + invitesTribeJoinButton: "Join Tribe", + invitesChatsTitle: "Chats", + invitesChatInviteCodePlaceholder: "Enter chat invite code", + invitesChatJoinButton: "Join Chat", + invitesPadsTitle: "Pads", + invitesPadInviteCodePlaceholder: "Enter pad invite code", + invitesPadJoinButton: "Join Pad", + invitesCalendarsTitle: "Calendars", + invitesCalendarInviteCodePlaceholder: "Enter calendar invite code", + invitesCalendarJoinButton: "Join Calendar", + invitesEventsTitle: "Events", + invitesEventInviteCodePlaceholder: "Enter event invite code", + invitesEventJoinButton: "Join Event", + invitesForumsTitle: "Forums", + invitesForumInviteCodePlaceholder: "Enter forum invite code", + invitesForumJoinButton: "Join Forum", + invitesMapsTitle: "Maps", + invitesMapInviteCodePlaceholder: "Enter map invite code", + invitesMapJoinButton: "Join Map", + invitesShopsTitle: "Shops", + invitesShopInviteCodePlaceholder: "Enter shop invite code", + invitesShopJoinButton: "Join Shop", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "Enter PUB invite code", + invitesAcceptInvite: "Join PUB", + invitesAlreadyFederated: "You are already federated with this pub.", + invitesFederationsTitle: "Federations", + invitesAcceptedInvites: "Federated", + invitesNoInvites: "No invitations accepted, yet.", + invitesUnfollow: "Unfollow", + invitesFollow: "Follow", + invitesUnfollowedInvites: "Unfederated", + invitesNoFederatedPubs: "No federated pubs.", + invitesNoUnfollowed: "No unfederated pubs.", + invitesUnreachablePubs: "Unreachable", + invitesNoUnreachablePubs: "No unreachable networks.", + invitesPubsRefresh: "Refresh", + invitesPubsClearUnreachable: "Remove unreachable", + invitesPubsExport: "Export", + invitesPubsImport: "Import", + invitesPubsImportTitle: "Import pubs", + invitesPubsImportPlaceholder: "Paste one multiserver address or invite code per line, or upload a .txt file…", + currentlyUnreachable: "ERROR!", + errorDetails: "Error Details", + genericError: "An error occurred.", + panicMode: "Panic Mode!", + encryptData: "Set password (min 32 characters long) to encrypt your blockchain", + decryptData: "Enter password to decrypt your blockchain", + panicModeDescription: "Encrypt/Decrypt or DELETE your blockchain", + removeDataDescription: "WARNING: This process cannot be undone.", + encryptPanicButton: "Encrypt blockchain", + decryptPanicButton: "Decrypt blockchain", + removePanicButton: "DELETE ALL YOUR DATA!", + searchTitle: "Search", + searchDescriptionLabel: "Search for content in your network.", + searchLanguagesLabel: "Languages", + searchSkillsLabel: "Skills", + searchPlaceholder:"Search for content...", + searchDateLabel:"Date", + searchLocationLabel:"Location", + searchPriceLabel:"Price", + searchUrlLabel:"URL", + searchCategoryLabel:"Category", + searchStartLabel:"Start Time", + searchEndLabel:"End Time", + searchPriorityLabel:"Priority", + searchStatusLabel:"Status", + statusLabel:"Status", + totalVotesLabel:"Total Votes", + noResultsFound:"No results found.", + votesOption:"Vote Options", + voteYesLabel:"Yes", + voteNoLabel:"No", + allTypesLabel: "UNLIMITED", + ABSTENTIONLabel:"ABSTENTION", + YESLabel:"YES", + NOLabel:"NO", + FOLLOW_MAJORITYLabel: "FOLLOW MAJORITY", + CONFUSEDLabel: "CONFUSED", + NOT_INTERESTEDLabel: "NOT INTERESTED", + StatusLabel:"Status", + votesOptionYesLabel:"Yes", + votesOptionNoLabel:"No", + votesOptionAbstentionLabel:"Abstention", + votesQuestionLabel:"Question", + votesCreatedByLabel:"Created by", + voteStatusOpen:"OPEN", + voteStatusClosed:"CLOSED", + imageSearchLabel: "Enter words to search for images labelled with them.", + commentDescription: ({ parentUrl }) => [ + " commented on ", + a({ href: parentUrl }, " thread"), + ], + commentTitle: ({ authorName }) => [`Comment on @${authorName}'s post`], + subtopicDescription: ({ parentUrl }) => [ + " created a subtopic from ", + a({ href: parentUrl }, " a post"), + ], + subtopicTitle: ({ authorName }) => [`Subtopic on @${authorName}'s post`], + mysteryDescription: "posted a mysterious post", + oasisDescription: "OASIS Project Network", + searchSubmit: "Let's Search...", + postLabel: "POSTS", + aboutLabel: "INHABITANTS", + feedLabel: "FEEDS", + votesLabel: "VOTATIONS", + reportLabel: "REPORTS", + imageLabel: "IMAGES", + videoLabel: "VIDEOS", + audioLabel: "AUDIOS", + documentLabel: "DOCUMENTS", + torrentLabel: "TORRENTS", + pdfFallbackLabel: "PDF Document", + eventLabel: "EVENTS", + taskLabel: "TASKS", + transferLabel: "TRANSFERS", + curriculumLabel: "CURRICULUM", + bookmarkLabel: "BOOKMARKS", + tribeLabel: "TRIBES", + marketLabel: "MARKET", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "WALLETS", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Submit", + subjectLabel: "Subject", + editProfile: "Edit Avatar", + profileQrAlt: "Profile QR code", + profileQrHint: "Scan to copy this Oasis ID.", + oasisIdLabel: "OasisID", + spreadLabel: "Spread", + spreadHint: "Spread this to your supporters (replicates via your feed).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Welcome to Oasis — a libre, peer-to-peer, federated and encrypted social network with a distributed invite-only architecture.\n\nSome interesting places to start:\n\n- /profile — Edit your avatar, name, description and which modules appear on your public side.\n- /settings — Tune your local instance: language, theme, modules, privacy and sync.\n- /invites — Add a pub to federate with the wider network.\n- /peers — See who is connected, rescan your LAN, export or import peer lists.\n- /inhabitants — Discover and visit other people's avatars.\n- /tribes — Create or join private groups with end-to-end encryption.\n- /inbox — Read and send private messages.\n- /banking — See your ECO balance, UBI, ECO tax and the wealth-redistribution rules.\n- /activity — See recent activity, yours and your network's.\n- /publish — Write a post or share an image, audio, video or document.\n\nSome interesting places to connect:\n\n- /fediverse — Manage your other fediverse accounts, including sending and receiving content.\n\nThis message was sent privately from you to yourself, so no connection was required to receive it.", + indexingTitle: "Synchronizing", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingEditProfileLink: "Set up my avatar", + profileVisibilityTitle: "Public profile visibility", + profileVisibilityHint: "Toggle which fields other inhabitants see on your profile. By default only Karma is shown.", + profileSensorsSectionTitle: "Sensors", + profileVisibilityActivity: "Activity Level", + profileVisibilityDevice: "Device", + profileDeviceLockedHint: "This sensor is always on: it lets other users see which device you connect from and cannot be disabled.", + profileVisibilityKarma: "KARMA Scoring", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "ECOIN Wallet", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "GPG Key", + profileVisibilityClearnet: "Publish my profile", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Shops", + profileClearnetJobsLabel: "Jobs", + profileClearnetEventsLabel: "Events", + profileClearnetProjectsLabel: "Projects", + profileClearnetPostsLabel: "Blogs", + profileClearnetAudiosLabel: "Audios", + profileClearnetVideosLabel: "Videos", + profileClearnetImagesLabel: "Images", + profileClearnetDocumentsLabel: "Documents", + profileClearnetTorrentsLabel: "Torrents", + editProfileDescription: + "", + profileName: "Name", + profileImage: "Avatar Image", + profileGpgKey: "GPG Public Key (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Download", + profileGpgRemove: "Remove", + profileDescription: "Description", + hashtagDescription: + "Posts from inhabitants in your network that reference this #hashtag, sorted by recency.", + rebuildName: "Rebuild database", + wallet: "Wallet", + walletAddress: "Address", + walletAmount: "Amount", + walletAddressLine: ({ address }) => `Address: ${address}`, + walletAmountLine: ({ amount }) => `Amount: ${amount} ECO`, + walletBack: "Back", + walletBalanceTitle: "Balance", + walletWalletSendTitle: "Send", + walletReceiveTitle: "Receive", + walletHistoryTitle: "History", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Cnfrs", + walletConfirm: "Confirm", + walletDescription: "Manage your digital assets, including sending and receiving ECOin, viewing your balance, and accessing your transaction history.", + walletDate: "Date", + walletFee: "Fee (The higher the fee, the faster your transaction will be processed)", + walletFeeLine: ({ fee }) => `Fee: ECO ${fee}`, + walletHistory: "History", + walletReceive: "Receive", + walletReset: "Reset", + walletSend: "Send", + walletStatus: "Status", + walletDisconnected: [ + "ECOin ", + strong("wallet disconnected"), + ". Check ", + a({ href: '/settings#wallet' }, "your settings"), + " or connection status.", + ], + walletSentToLine: ({ destination, amount }) => `Sent ECO ${amount} to ${destination}`, + walletSettingsTitle: "Wallet", + walletSettingsDescription: "Integrate Oasis with your ECOin wallet.", + walletSettingsDocLink: "ECOin installation guide", + walletStatusMessages: { + invalid_amount: "Invalid amount", + invalid_dest: "Invalid destination address", + invalid_fee: "Invalid fee", + validation_errors: "Validation errors", + send_tx_success: "Transaction successful", + }, + walletTitle: "Wallet", + walletTotalCostLine: ({ totalCost }) => `Total cost: ECO ${totalCost}`, + walletTransactionId: "Transaction ID", + walletTxId: "Tx ID", + walletType: "Type", + walletUser: "Username", + walletPass: "Password", + walletConfiguration: "Set wallet", + cipher: "Crypter", + cipherTitle: "Crypter", + cipherDescription: "Encrypt and decrypt your text symmetrically (using a shared password).", + randomPassword: "Random Password", + cipherEncryptTitle: "Encrypt Text", + cipherEncryptDescription: "Enter text to encrypt", + cipherTextLabel: "Text to Encrypt", + cipherTextPlaceholder: "Enter text to encrypt...", + cipherPasswordLabel: "Set password (min 32 characters long) to encrypt your text", + cipherPasswordDecryptLabel: "Set password (min 32 characters long) to decrypt your text", + cipherPasswordPlaceholder: "Enter a password...", + cipherEncryptButton: "Encrypt", + cipherDecryptTitle: "Decrypt Text", + cipherDecryptDescription: "Enter text to decrypt", + cipherEncryptedMessageLabel: "Encrypted Text", + cipherDecryptedMessageLabel: "Decrypted Text", + cipherPasswordUsedLabel: "Password used to encrypt (keep it!)", + cipherEncryptedTextPlaceholder: "Enter the encrypted text...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "Enter the initialization vector...", + cipherDecryptButton: "Decrypt", + password: "Password", + text: "Text", + encryptedText: "Encrypted Text", + iv: "Initialization Vector (IV)", + encryptTitle: "Encrypt your text", + encryptDescription: "Enter the text you want to encrypt and provide a password.", + encryptButton: "Encrypt", + decryptTitle: "Decrypt your text", + decryptDescription: "Enter the encrypted text and provide the same password used for encryption.", + decryptButton: "Decrypt", + passwordLengthError: "Password must be at least 32 characters long.", + missingFieldsError: "Text, password or IV not provided.", + encryptionError: "Error encrypting text.", + decryptionError: "Error decrypting text.", + bookmarkTitle: "Bookmarks", + bookmarkDescription: "Discover and manage bookmarks in your network.", + bookmarkAllSectionTitle: "Bookmarks", + bookmarkMineSectionTitle: "Your Bookmarks", + bookmarkRecentSectionTitle: "Recent Bookmarks", + bookmarkTopSectionTitle: "Top Bookmarks", + bookmarkFavoritesSectionTitle: "Favorites", + bookmarkCreateSectionTitle: "Create Bookmark", + bookmarkUpdateSectionTitle: "Update Bookmark", + bookmarkFilterAll: "ALL", + bookmarkFilterMine: "MINE", + bookmarkFilterTop: "TOP", + bookmarkFilterFavorites: "FAVORITES", + bookmarkFilterRecent: "RECENT", + bookmarkCreateButton: "Create Bookmark", + bookmarkUpdateButton: "Update", + bookmarkDeleteButton: "Delete", + bookmarkAddFavoriteButton: "Add favorite", + bookmarkRemoveFavoriteButton: "Remove favorite", + bookmarkUrlLabel: "Link", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "Description", + bookmarkDescriptionPlaceholder: "Optional", + bookmarkTagsLabel: "Tags", + bookmarkTagsPlaceholder: "Enter tags separated by commas", + bookmarkCategoryLabel: "Category", + bookmarkCategoryPlaceholder: "Optional", + bookmarkLastVisitLabel: "Last Visit", + bookmarkSearchPlaceholder: "Search URL, tags, category, author...", + bookmarkSortRecent: "Most recent", + bookmarkSortOldest: "Oldest", + bookmarkSortTop: "Most voted", + bookmarkSearchButton: "Search", + bookmarkUpdatedAt: "Updated", + bookmarkNoMatch: "No bookmarks match your search.", + noBookmarks: "No bookmarks available.", + noUrl: "No link", + noCategory: "No category", + noLastVisit: "No last visit", + videoTitle: "Videos", + videoDescription: "Explore and manage video content in your network.", + videoPluginTitle: "Title", + videoPluginDescription: "Description", + videoMineSectionTitle: "Your Videos", + videoCreateSectionTitle: "Upload Video", + videoUpdateSectionTitle: "Update Video", + videoAllSectionTitle: "Videos", + videoRecentSectionTitle: "Recent Videos", + videoTopSectionTitle: "Top Videos", + videoFavoritesSectionTitle: "Favorites", + videoFilterAll: "ALL", + videoFilterMine: "MINE", + videoFilterRecent: "RECENT", + videoFilterTop: "TOP", + videoFilterFavorites: "FAVORITES", + videoCreateButton: "Upload Video", + videoUpdateButton: "Update", + videoDeleteButton: "Delete", + videoAddFavoriteButton: "Add favorite", + videoRemoveFavoriteButton: "Remove favorite", + videoFileLabel: "Select a video file (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Tags", + videoTagsPlaceholder: "Enter tags separated by commas", + videoTitleLabel: "Title", + videoTitlePlaceholder: "Optional", + videoDescriptionLabel: "Description", + videoDescriptionPlaceholder: "Optional", + videoNoFile: "No video file provided", + noVideos: "No videos available.", + videoSearchPlaceholder: "Search title, tags, author...", + videoSortRecent: "Most recent", + videoSortOldest: "Oldest", + videoSortTop: "Most voted", + videoSearchButton: "Search", + videoMessageAuthorButton: "PM", + videoUpdatedAt: "Updated", + videoNoMatch: "No videos match your search.", + documentTitle: "Documents", + documentDescription: "Discover and manage documents in your network.", + documentAllSectionTitle: "Documents", + documentMineSectionTitle: "Your Documents", + documentRecentSectionTitle: "Recent Documents", + documentTopSectionTitle: "Top Documents", + documentFavoritesSectionTitle: "Favorites", + documentCreateSectionTitle: "Upload Document", + documentUpdateSectionTitle: "Edit Document", + documentFilterAll: "ALL", + documentFilterMine: "MINE", + documentFilterRecent: "RECENT", + documentFilterTop: "TOP", + documentFilterFavorites: "FAVORITES", + documentCreateButton: "Upload Document", + documentUpdateButton: "Update", + documentDeleteButton: "Delete", + documentAddFavoriteButton: "Add favorite", + documentRemoveFavoriteButton: "Remove from favorites", + documentMessageAuthorButton: "PM", + documentFileLabel: "Upload Document (.pdf)", + documentTagsLabel: "Tags", + documentTagsPlaceholder: "Enter tags separated by commas", + documentTitleLabel: "Title", + documentTitlePlaceholder: "Optional", + documentDescriptionLabel: "Description", + documentDescriptionPlaceholder: "Optional", + documentNoFile: "No file.", + noDocuments: "No documents available.", + documentSearchPlaceholder: "Search title, tags, description, author...", + documentSortRecent: "Most recent", + documentSortOldest: "Oldest", + documentSortTop: "Most voted", + documentSearchButton: "Search", + documentNoMatch: "No documents match your search.", + documentUpdatedAt: "Updated", + audioTitle: "Audios", + audioDescription: "Explore and manage audio content in your network.", + audioPluginTitle: "Title", + audioPluginDescription: "Description", + audioMineSectionTitle: "Your Audios", + audioCreateSectionTitle: "Upload Audio", + audioUpdateSectionTitle: "Update Audio", + audioAllSectionTitle: "Audios", + audioRecentSectionTitle: "Recent Audios", + audioTopSectionTitle: "Top Audios", + audioFilterAll: "ALL", + audioFilterMine: "MINE", + audioFilterRecent: "RECENT", + audioFilterTop: "TOP", + audioFilterBlockchain: "BLOCKCHAIN", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeDetailDescription: "Decode the embedded payload and the original blockchain composition map.", + ecoTaxLabel: "ECO Tax", + ecoinTaxLabel: "ECOin Tax", + bankTaxes: "Taxes", + bankTaxesNetworkTitle: "Network Taxes", + bankTaxesUserTitle: "Your taxes", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteChipsClick: "Click any chip to inspect its block in the blockexplorer.", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesTypesLabel: "Select which taxes you want to pay", + bankTaxesTypesApply: "Set my taxes", + bankTaxesArchTaxLifetime: "ARCH Tax (lifetime)", + bankTaxesArchTaxAnnual: "ARCH Tax (annual)", + bankTaxesArchTaxMonthly: "ARCH Tax (monthly)", + bankTaxesArchTaxRate: "Rate", + bankTaxesArchTaxFirstBlock: "Your first block age", + bankTaxesArchTaxUserAmount: "Your ARCH tax (price to return)", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesArchTaxRate: "Rate", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankTaxesExample: "Example", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + bankTaxesUserAmount: "Your ECO tax (price to return)", + bankOverviewYourTaxes: "Your taxes", + bankTaxesTotalBlocks: "Total blocks", + bankTaxesTotalBytes: "Total bytes", + bankTaxesTotalCarbon: "Total carbon", + bankTaxesTotalEcoin: "Total ECO tax (lifetime)", + bankTaxesAnnualEcoin: "ECO Tax (annual)", + bankTaxesMonthlyEcoin: "ECO Tax (monthly)", + bankTaxesEcoTaxLifetime: "ECO Tax (lifetime)", + bankTaxesTotalLifetime: "Total (lifetime)", + bankTaxesTotalAnnual: "Total (annual)", + bankTaxesTotalMonthly: "Total (monthly)", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + bankTaxesRate: "Rate", + bankTaxesSpan: "Sampling span", + bankTaxesLookupTitle: "Inspect a block", + bankTaxesLookupNote: "Enter a block ID to look it up in your local feed.", + bankTaxesLookupPlaceholder: "Block ID (e.g. %abc...=.sha256)", + bankTaxesLookupButton: "Inspect", + bankTaxesLookupNotFound: "No block found in your local feed with that ID.", + bankTaxesLookupAuthor: "Author", + bankTaxesLookupType: "Type", + bankTaxesLookupSize: "Size", + bankTaxesLookupCarbon: "Carbon footprint", + bankTaxesLookupEcoin: "ECO Tax", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (monthly)", + bankRulesPoolTitle: "Monthly pool", + bankRulesEpochKind: "Epoch granularity", + bankRulesAlpha: "Alpha (max share of pub balance per epoch)", + bankRulesReserveMin: "Reserve minimum (kept in pub balance)", + bankRulesCapPerEpoch: "Cap per epoch (absolute)", + bankRulesGraceDays: "Claim grace period", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesShareTitle: "Per-user share", + bankRulesWMin: "Minimum weight (w_min)", + bankRulesWMax: "Maximum weight (w_max)", + bankRulesFloor: "Floor per user (gross)", + bankRulesCapUser: "Cap per user per epoch", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesKarmaTitle: "Karma (user engagement score)", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesEcoTaxRate: "Rate", + bankRulesCarbonFactor: "Carbon factor", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesChipTitle: "ECO Tax chip color band", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + statsEcoTaxTitle: "ECO Tax", + statsTombstoneEmpty: "No tombstones in the network yet.", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + audioTranscodeStegoTitle: "Embedded in the waveform", + audioTranscodeStegoOasisId: "By", + audioTranscodeStegoTimestamp: "Generated at", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoEmpty: "(none)", + audioTranscodeStegoUnknown: "—", + audioTranscodeStegoNotFound: "No steganographic payload could be decoded from this audio.", + audioTranscodeCompositionEmpty: "This audio does not include a stored blockchain composition.", + audioBackToAudio: "Back to audio", + audioBackToBcs: "Back to BCS", + audioAuthorLabel: "Author", + audioCreateButton: "Upload Audio", + audioUpdateButton: "Update", + audioDeleteButton: "Delete", + audioAddFavoriteButton: "Add favorite", + audioRemoveFavoriteButton: "Remove favorite", + audioFileLabel: "Select an audio file (.mp3, .wav, .ogg)", + audioTagsLabel: "Tags", + audioTagsPlaceholder: "Enter tags separated by commas", + audioTitleLabel: "Title", + audioTitlePlaceholder: "Optional", + audioDescriptionLabel: "Description", + audioDescriptionPlaceholder: "Optional", + audioNoFile: "No audio file provided", + noAudios: "No audios available.", + audioSearchPlaceholder: "Search title, tags, author...", + audioSortRecent: "Most recent", + audioSortOldest: "Oldest", + audioSortTop: "Most voted", + audioSearchButton: "Search", + audioMessageAuthorButton: "PM", + audioUpdatedAt: "Updated", + audioNoMatch: "No audios match your search.", + audioFavoritesSectionTitle: "Favorites", + audioFilterFavorites: "FAVORITES", + favoritesTitle: "Favorites", + favoritesDescription: "All your favorited content in one place.", + favoritesFilterAll: "ALL", + favoritesFilterRecent: "RECENT", + favoritesFilterAudios: "AUDIOS", + favoritesFilterBookmarks: "BOOKMARKS", + favoritesFilterDocuments: "DOCUMENTS", + favoritesFilterImages: "IMAGES", + favoritesFilterMaps: "MAPS", + favoritesFilterPads: "PADS", + favoritesFilterChats: "CHATS", + favoritesFilterCalendars: "CALENDARS", + favoritesFilterVideos: "VIDEOS", + favoritesRemoveButton: "Remove from favorites", + favoritesNoItems: "No favorites yet.", + yourContacts: "Your Contacts", + allInhabitants: "Inhabitants", + allCVs: "All CVs", + discoverPeople: "Discover inhabitants in your network.", + allInhabitantsButton: "ALL", + contactsButton: "SUPPORTS", + CVsButton: "CVs", + matchSkills: "Match Skills", + matchSkillsButton: "MATCH SKILLS", + suggestedButton: "SUGGESTED", + searchInhabitantsPlaceholder: "FILTER inhabitants BY NAME …", + filterLocation: "FILTER inhabitants BY LOCATION …", + filterLanguage: "FILTER inhabitants BY LANGUAGE …", + filterSkills: "FILTER inhabitants BY SKILLS …", + applyFilters: "Apply Filters", + locationLabel: "Location", + languagesLabel: "Languages", + skillsLabel: "Skills", + commonSkills: "Common Skills", + mutualFollowers: "Mutual Followers", + suggestedFollowsYou: "Follows you", + latestInteractions: "Latest Interactions", + viewAvatar: "View Avatar", + viewCV: "View CV", + suggestedSectionTitle: "Suggested", + topkarmaSectionTitle: "Top Karma", + topecoSectionTitle: "Top Eco", + topactivitySectionTitle: "Top Activity", + blockedSectionTitle: "Blocked", + gallerySectionTitle: "GALLERY", + blockedButton: "BLOCKED", + blockedLabel: "Blocked User", + inhabitantviewDetails: "View Details", + keepReading: "Keep reading...", + oasisId: "ID", + noInhabitantsFound: "No inhabitants found, yet.", + inhabitantActivityLevel: "Activity Level", + lifespanLabel: "Lifespan", + deviceLabel: "Device", + parliamentTitle: "Parliament", + parliamentDescription: "Explore forms of government and collective management laws.", + parliamentFilterGovernment: "GOVERMENT", + parliamentFilterCandidatures: "CANDIDATURES", + parliamentFilterProposals: "PROPOSALS", + parliamentFilterLaws: "LAWS", + parliamentFilterHistorical: "HISTORICAL", + parliamentFilterLeaders: "LEADERS", + parliamentFilterRules: "RULES", + parliamentGovernmentCard: "Current Government", + parliamentActorInPowerInhabitant: 'INHABITANT RULING', + parliamentActorInPowerTribe: 'TRIBE RULING', + parliamentHistoricalGovernmentsTitle: 'GOVERMENTS', + parliamentHistoricalElectionsTitle: 'ELECTION CYCLES', + parliamentHistoricalLawsTitle: 'HISTORICAL', + parliamentHistoricalLeadersTitle: 'LEADERS', + parliamentThCycles: 'CYCLES', + parliamentThTimesInPower: 'RULING', + parliamentThTotalCandidatures: 'CANDIDATURES', + parliamentThProposed: 'LAWS PROPOSED', + parliamentThApproved: 'LAWS APPROVED', + parliamentThDeclined: 'LAWS DECLINED', + parliamentThDiscarded: 'LAWS DISCARDED', + parliamentMembers: "MEMBERS", + parliamentLegSince: "CYCLE SINCE", + parliamentLegEnd: "CYCLE END", + parliamentPoliciesProposal: "LAWS PROPOSAL", + parliamentPoliciesApproved: "LAWS APPROVED", + parliamentPoliciesDeclined: "LAWS DECLINED", + parliamentPoliciesDiscarded: "LAWS DISCARDED", + parliamentEfficiency: "% EFFICIENCY", + parliamentFilterRevocations: 'REVOCATIONS', + parliamentRevocationFormTitle: 'Revocate Law', + parliamentRevocationLaw: 'Law', + parliamentRevocationTitle: 'Title', + parliamentRevocationReasons: 'Reasons', + parliamentRevocationPublish: 'Publish Revocation', + parliamentCurrentRevocationsTitle: 'Current Revocations', + parliamentFutureRevocationsTitle: 'Future Revocations', + parliamentPoliciesRevocated: 'LAWS REVOCATED', + parliamentPoliciesTitle: 'HISTORICAL', + parliamentLawsTitle: 'LAWS APPROVED', + parliamentRulesRevocations: 'Any approved law can be revocate using current goverment method ruling.', + parliamentNoStableGov: "No goverment choosen, yet.", + parliamentNoGovernments: "There are not governments, yet.", + parliamentCandidatureFormTitle: "Propose Candidature", + parliamentCandidatureIdPh: "Oasis ID (@...) or Tribe name", + parliamentCandidatureSlogan: "Slogan (max 140 chars)", + parliamentCandidatureSloganPh: "A short motto", + parliamentCandidatureMethod: "Method", + parliamentCandidatureProposeBtn: "Publish Candidature", + parliamentThDate: "Proposal date", + parliamentThSlogan: "Slogan", + parliamentThSince: "Profile since", + parliamentThVotes: "Votes recieved", + parliamentThVoteAction: "Vote", + parliamentTypeUser: "Inhabitant", + parliamentTypeTribe: "Tribe", + parliamentVoteBtn: "Vote", + parliamentProposalFormTitle: "Propose Law", + parliamentProposalDescription: "Description (≤1000)", + parliamentProposalPublish: "Publish Proposal", + parliamentFinalize: "Finalize", + parliamentDeadline: "Deadline", + parliamentNoProposals: "There are not law proposals, yet.", + parliamentNoLaws: "There are not laws approved, yet.", + parliamentMethodDEMOCRACY: "Democracy", + parliamentMethodMAJORITY: "Majority (80%)", + parliamentMethodMINORITY: "Minority (20%)", + parliamentMethodDICTATORSHIP: "Dictatorship", + parliamentMethodKARMATOCRACY: "Karmatocracy", + parliamentThId: "ID", + parliamentThProposalDate: "Proposal date", + parliamentThMethod: "Method", + parliamentThKarma: "Karma", + parliamentThSupports: "Supports", + parliamentThVote: "Vote", + parliamentCurrentProposalsTitle: "Current Proposals", + parliamentVotesSlashTotal: "Votes/Total", + parliamentVotesNeeded: "Votes Needed", + parliamentFutureLawsTitle: "Future Laws", + parliamentNoFutureLaws: "No future laws, yet.", + parliamentVoteAction: "Vote", + parliamentLeadersTitle: "Leaders", + parliamentThLeader: "AVATAR", + parliamentPopulation: "Population", + parliamentThType: "Type", + parliamentThInPower: "In power", + parliamentThPresented: "CANDIDATURES", + parliamentNoLeaders: "No leaders, yet.", + parliamentCandidaturesListTitle: "List of Candidatures", + parliamentTimeRemaining: "Time remaining", + parliamentNoLeader: "No winning candidature, yet.", + parliamentElectionsStatusTitle: "Next Government", + parliamentProposalDeadlineLabel: "Deadline", + parliamentProposalTimeLeft: "Time left", + parliamentProposalOnTrack: "On track to pass", + parliamentProposalOffTrack: "Not enough support yet", + parliamentRulesTitle: "How Parliament works", + parliamentRulesIntro: "Election resolve every 2 months; candidatures are continuous and reset when a government is chosen.", + parliamentRulesCandidates: "Any inhabitant may propose themself, another inhabitant, or any tribe. Each inhabitant can propose up to 3 candidatures per cycle; duplicates in the same cycle are rejected.", + parliamentRulesElection: "The winner is the candidature with the most votes at resolution time.", + parliamentRulesTies: "Tie-break order: highest inhabitant karma; if tied, oldest profile; if still tied, earliest proposal; then lexicographic by ID.", + parliamentRulesFallback: "If no one votes, the latest proposed candidature wins. If there are no candidatures, a random tribe is selected.", + parliamentRulesTerm: "The Government tab shows the current government and stats.", + parliamentRulesMethods: "Forms: Anarchy (simple majority), Democracy (50%+1), Majority (80%), Minority (20%), Karmatocracy (highest-karma proposals), Dictatorship (instant approval).", + parliamentRulesAnarchy: "Anarchy is the default mode: if no candidature is elected at resolution, Anarchy is proclaimed. Under Anarchy, any inhabitant can propose laws.", + parliamentRulesProposals: "If you are the ruling inhabitant or a member of the ruling tribe, you can publish law proposals. Non-dictatorship methods create a public vote.", + parliamentRulesLimit: "Each inhabitant may publish at most 3 law proposals per cycle.", + parliamentRulesLaws: "When a proposal meets its threshold, it becomes a Law and appears in the Laws tab with its enactment date.", + parliamentRulesHistorical: "In the Historical tab you can see every government cycle that has occurred and data about its management.", + parliamentRulesLeaders: "In the Leaders tab you can see a ranking of inhabitants/tribes that have governed (or stood as candidates), ordered by efficiency.", + parliamentProposalVoteStatusLabel: "Vote status", + parliamentProposalOnTrackYes: "Threshold reached", + parliamentProposalOnTrackNo: "Below threshold", + courtsTitle: "Courts", + courtsDescription: "Explore forms of conflict resolution and collective justice management.", + courtsFilterCases: "CASES", + courtsFilterMyCases: "MINE", + courtsFilterJudges: "JUDGES", + courtsFilterHistory: "HISTORY", + courtsFilterRules: "RULES", + courtsCaseFormTitle: "Open Case", + courtsCaseRespondent: "Accused / Respondent", + courtsCaseRespondentPh: "Oasis ID (@...) or Tribe name", + courtsCaseMediatorsAccuser: "Mediators (accuser)", + courtsCaseMethod: "Resolution method", + courtsCaseDescription: "Description (max 1000 chars.)", + courtsCaseEvidenceTitle: "Case evidence", + courtsCaseEvidenceHelp: "Attach images, audios, documents (PDF) or videos that support your case.", + courtsCaseSubmit: "File Case", + courtsNominateJudge: "Nominate Judge", + courtsJudgeId: "Judge", + courtsJudgeIdPh: "Oasis ID (@...) or Inhabitant name", + courtsNominateBtn: "Nominate", + courtsAddEvidence: "Add evidence", + courtsEvidenceText: "Text", + courtsEvidenceLink: "Link", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "Attach", + courtsAnswerText: "Response brief", + courtsAnswerSubmit: "Send response", + courtsVerdictTitle: "Issue verdict", + courtsVerdictOrdersPh: "Actions, deadlines, restorative steps ...", + courtsIssueVerdict: "Issue verdict", + courtsMediationPropose: "Propose settlement", + courtsSettlementProposeBtn: "Propose", + courtsNominationsTitle: "Judiciary nominations", + courtsThJudge: "Judge", + courtsThDate: "Date", + courtsThVote: "Vote", + courtsNoNominations: "No nominations yet.", + courtsRespondentInvalid: "Must be a valid SSB ID (@...ed25519)", + courtsCaseMediatorsRespondentTitle: "Add defence mediators", + courtsCaseMediatorsRespondent: "Mediators (defence)", + courtsMediatorsAccuserLabel: "Mediators (accuser)", + courtsMediatorsRespondentLabel: "Mediators (defence)", + courtsMediatorsSubmit: "Save mediators", + courtsNoMyCases: "You have no conflicts yet.", + courtsNoHistory: "No recorded trials yet.", + courtsMethodDICTATOR: "Dictator", + courtsMethodPOPULAR: "Popular", + courtsMethodKARMATOCRACY: "Karmatocracy", + courtsRulesTitle: "How Courts work", + courtsRulesIntro: "Courts are a community-run process to resolve conflicts and promote restorative justice. Dialogue, clear evidence, and proportional remedies are prioritized.", + courtsRulesLifecycle: "Process: 1) Open case 2) Select method 3) Submit evidence 4) Hearing and deliberation 5) Verdict and remedy 6) Compliance and closure 7) Appeal (if applicable).", + courtsRulesRoles: "Accuser: opens the case. Defence: accused person or tribe. Method: mechanism chosen by the community to facilitate, assess evidence, and issue a verdict. Witness: provides testimony or evidence. Mediators: neutral people invited by the accuser and/or the defence, with access to all details, who help de-escalate the conflict and co-create agreements.", + courtsRulesEvidence: "Description up to 1000 characters. Attach relevant and lawful images, audio, video, and PDF documents. Do not share sensitive private data without consent.", + courtsRulesDeliberation: "Hearings may be public or private. Judges ensure respect, request clarifications, and may discard irrelevant or unlawful material.", + courtsRulesVerdict: "Restoration is prioritized: apologies, mediation agreements, content moderation, temporary restrictions, or other proportional measures. The reasoning must be recorded.", + courtsRulesAppeals: "Appeal: allowed when there is new evidence or a clear procedural error. Must be filed within 7 days unless otherwise stated.", + courtsRulesPrivacy: "Respect privacy and safety. Doxing, hate, or threats are removed. Judges may edit or seal parts of the record to protect people at risk.", + courtsRulesMisconduct: "Harassment, manipulation, or fabricated evidence may lead to immediate negative resolution.", + courtsRulesGlossary: "Case: record of a conflict. Evidence: materials that support claims. Verdict: decision with remedy. Appeal: request to review the verdict.", + courtsFilterActions: "ACTIONS", + courtsNoActions: "No pending actions for your role.", + courtsCaseTitlePlaceholder: "Short description of the conflict", + courtsCaseSeverity: "Severity", + courtsCaseSeverityNone: "No severity tag", + courtsCaseSeverityLOW: "Low", + courtsCaseSeverityMEDIUM: "Medium", + courtsCaseSeverityHIGH: "High", + courtsCaseSeverityCRITICAL: "Critical", + courtsCaseSubject: "Topic", + courtsCaseSubjectNone: "No topic tag", + courtsCaseSubjectBEHAVIOUR: "Behaviour", + courtsCaseSubjectCONTENT: "Content", + courtsCaseSubjectGOVERNANCE: "Governance / rules", + courtsCaseSubjectFINANCIAL: "Financial / resources", + courtsCaseSubjectOTHER: "Other", + courtsHiddenRespondent: "Hidden (only visible to involved roles).", + courtsThRole: "Role", + courtsRoleAccuser: "Accuser", + courtsRoleDefence: "Defence", + courtsRoleMediator: "Mediator", + courtsRoleJudge: "Judge", + courtsRoleDictator: "Dictator", + courtsAssignJudgeTitle: "Choose judge", + courtsAssignJudgeBtn: "Choose judge", + trendingTitle: "Trending", + exploreTrending: "Explore the most popular content in your network.", + bookmarkButton: "BOOKMARKS", + transferButton: "TRANSFERS", + eventButton: "EVENTS", + taskButton: "TASKS", + votesButton: "VOTATIONS", + reportButton: "REPORTS", + feedButton: "FEED", + marketButton: "MARKET", + imageButton: "IMAGES", + audioButton: "AUDIOS", + videoButton: "VIDEOS", + documentButton: "DOCUMENTS", + torrentButton: "TORRENTS", + noTrendingFound: "No trending content found.", + noContentMessage: "No trending content available, yet.", + trendingDescription: "Description", + trendingDate: "Date", + trendingLocation: "Location", + trendingPrice: "Price", + trendingUrl: "URL", + trendingCategory: "Category", + trendingStart: "Start", + trendingEnd: "End", + trendingPriority: "Priority", + trendingStatus: "Status", + trendingFrom: "From", + trendingTo: "To", + trendingConcept: "Concept", + trendingAmount: "Amount", + trendingDeadline: "Deadline", + trendingItemStatus: "Item Status", + trendingTotalVotes: "Total Votes", + trendingTotalOpinions: "Total Opinions", + trendingNoContentMessage: "No trending content available, yet.", + trendingAuthor: "By", + trendingCreatedAtLabel: "Created At", + trendingTotalCount: "Total Count", + tasksTitle: "Tasks", + tasksDescription: "Discover and manage tasks in your network.", + taskTitleLabel: "Title", + taskDescriptionLabel: "Description", + taskStartTimeLabel: "Start Time", + taskEndTimeLabel: "End Time", + taskPriorityLabel: "Priority", + taskPrioritySelect: "Select priority", + taskPriorityUrgent: "Urgent", + taskPriorityHigh: "High", + taskPriorityMedium: "Medium", + taskPriorityLow: "Low", + taskLocationLabel: "Location", + taskTagsLabel: "Tags", + taskVisibilityLabel: "Visibility", + taskPublic: "public", + taskPrivate: "private", + taskCreatedAt: "Created At", + taskBy: "By", + taskStatus: "Status", + taskStatusOpen: "Open", + taskStatusInProgress: "In Progress", + taskStatusClosed: "Closed", + taskAssignedTo: "Assigned to", + taskAssignees: "Assignees", + taskAssignButton: "Assign to Me", + taskUnassignButton: "Unassign", + taskCreateButton: "Create Task", + taskUpdateButton: "Update", + taskDeleteButton: "Delete", + taskFilterAll: "ALL", + taskFilterMine: "MINE", + taskFilterOpen: "OPEN", + taskFilterInProgress: "IN-PROGRESS", + taskFilterClosed: "CLOSED", + taskFilterAssigned: "ASSIGNED", + taskFilterArchived: "ARCHIVED", + taskFilterUrgent: "URGENT", + taskFilterHigh: "HIGH", + taskFilterMedium: "MEDIUM", + taskFilterLow: "LOW", + taskAllSectionTitle: "Tasks", + taskMineSectionTitle: "Your Tasks", + taskCreateSectionTitle: "Create Task", + taskUpdateSectionTitle: "Update", + taskOpenTitle: "Open Tasks", + taskInProgressTitle: "In Progress Tasks", + taskClosedTitle: "Closed Tasks", + taskAssignedTitle: "Assigned Tasks", + taskArchivedTitle: "Archived Tasks", + taskPublicTitle: "Public Tasks", + taskPrivateTitle: "Private Tasks", + notasks: "No tasks available.", + noLocation: "No location specified", + taskSetStatus: "Set Status", + eventTitle: "Events", + eventDateLabel: "Date", + eventsTitle: "Events", + eventsDescription: "Discover and manage events in your network.", + eventDescription: "Description", + eventPrice: "Price", + eventStatus: "Status", + eventOrganizer: "Organizer", + eventAllSectionTitle: "Events", + eventMineSectionTitle: "Your Events", + eventArchivedTitle: "Archived Events", + eventCreateSectionTitle: "Create Event", + eventUpdateSectionTitle: "Update Event", + eventDeleteButton: "Delete", + eventCreateButton: "Create Event", + eventAddToCalendar: "Add to Calendar", + eventVisitCalendar: "Visit Calendar", + viewTask: "View Task", + viewReport: "View Report", + viewTransfer: "View Transfer", + viewItem: "View Item", + viewShop: "View Shop", + viewProject: "View Project", + viewVotation: "View Votation", + voteCastTitle: "Cast Vote", + voteResults: "Results", + eventTitleLabel: "Title", + eventDescriptionLabel: "Description", + eventDescriptionPlaceholder: "Enter event description...", + eventUpdateButton: "Update", + eventAttendeesLabel: "Attendees", + eventAttendeesPlaceholder: "Enter attendees, separated by commas...", + eventTagsLabel: "Tags", + eventTagsPlaceholder: "Enter event tags, separated by commas...", + eventTags: "Tags", + eventPriceLabel: "Price", + eventUrlLabel: "URL", + eventAttendees: "Attendees", + noAttendees: "No attendees yet", + eventCreatedAt: "Created At", + eventLocation: "Location", + eventLocationLabel: "Location", + eventNoLocation: "No location specified", + eventNoURL: "No URL specified", + eventBy: "By", + noevents: "No events available.", + eventDate: "Date", + eventDateFormat: "DD:MM:YYYY HH:mm", + eventAttendButton: "Attend Event", + eventUnattendButton: "Unattend Event", + eventCreatedBy: "Created By", + eventAttendeesCount: "Attendees Count", + eventCreatedByYou: "You created this event", + eventFilterAll: "ALL", + eventFilterMine: "MINE", + eventFilterToday: "TODAY", + eventFilterWeek: "THIS WEEK", + eventFilterMonth: "THIS MONTH", + eventFilterYear: "THIS YEAR", + eventFilterArchived: "ARCHIVED", + eventTodayTitle: "Today's Events", + eventThisWeekTitle: "This Week's Events", + eventThisMonthTitle: "This Month's Events", + eventThisYearTitle: "This Year's Events", + eventPrivacyLabel: "Visibility", + eventPublic: "Public", + eventPrivate: "Private", + eventPublicTitle: "Public Events", + eventNoPrice: "Free Event", + eventNoImage: "No image uploaded", + eventAttendConfirmation: "You are now attending this event", + eventUnattendConfirmation: "You are no longer attending this event", + eventAttended: "Attended", + eventUnattended: "Unattended", + eventStatusOpen: "Open", + eventStatusClosed: "Closed", + tagsTitle: "Tags", + tagsDescription: "Discover and explore taxonomy patterns in your network.", + tagsAllSectionTitle: "Tags", + tagsTopSectionTitle: "Top Tags", + tagsCloudSectionTitle: "Tags Cloud", + tagsFilterAll: "ALL", + tagsFilterTop: "TOP", + tagsFilterCloud: "CLOUD", + tagsNoItems: "No tags available.", + tagsTableHeaderTag: "TAG/LINK:", + tagsTableHeaderCount: "COUNTER:", + transfersTitle: "Transfers", + transfersDescription: "Discover and manage transfers in your network.", + transfersFrom: "From", + transfersTo: "To", + transfersFilterAll: "ALL", + transfersFilterMine: "MINE", + transfersFilterUBI: "UBI", + transfersFilterMarket: "MARKET", + transfersFilterTop: "TOP", + transfersFilterPending: "PENDING", + transfersFilterUnconfirmed: "UNCONFIRMED", + transfersFilterClosed: "CLOSED", + transfersFilterDiscarded: "DISCARDED", + transfersFilterEconomic: "ECONOMIC", + transfersFilterTime: "TIME", + transfersFilterTrust: "TRUST", + transfersCategory: "Category", + transfersCategoryEconomic: "Economic", + transfersCategoryTime: "Time", + transfersCategoryTrust: "Trust", + transfersCategoryApply: "Apply", + transfersUnitEco: "ECO", + transfersUnitHours: "h", + transfersUnitTrust: "trust", + transfersEconomicSectionTitle: "Economic Transfers", + transfersTimeSectionTitle: "Time Transfers", + transfersTrustSectionTitle: "Trust Transfers", + transfersCreateButton: "Create Transfer", + transfersUpdateButton: "Update", + transfersDeleteButton: "Delete", + transfersToUser: "Oasis ID", + transfersToUserValidation: "Valid Oasis ID, e.g. @…=.ed25519", + transfersConcept: "Concept", + transfersAmount: "Amount", + transfersDeadline: "Deadline", + transfersTags: "Tags", + transfersStatus: "Status", + transfersStatusUnconfirmed: "UNCONFIRMED", + transfersStatusClosed: "CLOSED", + transfersStatusDiscarded: "DISCARDED", + transfersCreatedAt: "Created At", + transfersConfirmations: "CONFIRMATIONS", + transfersContainingBlock: "Containing block", + transfersExportContract: "Create Contract", + transfersConfirmButton: "Confirm Transfer", + transfersNoItems: "No transfers found.", + transfersMineSectionTitle: "Your Transfers", + transfersUBISectionTitle: "UBI Transfers", + transfersMarketSectionTitle: "Market Transfers", + transfersTopSectionTitle: "Top Transfers", + transfersPendingSectionTitle: "Pending Transfers", + transfersUnconfirmedSectionTitle: "Unconfirmed Transfers", + transfersClosedSectionTitle: "Closed Transfers", + transfersDiscardedSectionTitle: "Discarded Transfers", + transfersCreateSectionTitle: "Create transfer", + transfersAllSectionTitle: "Transfers", + transfersFilterFavs: "Favorites", + transfersFavsSectionTitle: "Favorite transfers", + transfersSearchLabel: "Search", + transfersSearchPlaceholder: "Search concept, tags, users...", + transfersMinAmountLabel: "Min amount", + transfersMaxAmountLabel: "Max amount", + transfersSortLabel: "Sort by", + transfersSortRecent: "Most recent", + transfersSortAmount: "Highest amount", + transfersSortDeadline: "Closest deadline", + transfersSearchButton: "Search", + transfersFavoriteButton: "Favorite", + transfersUnfavoriteButton: "Unfavorite", + transfersMessageUserButton: "Message", + transfersExpiringSoonBadge: "EXPIRING", + transfersExpiredBadge: "EXPIRED", + transfersUpdatedAt: "Updated", + transfersNoMatch: "No transfers match your search.", + votationsTitle: "Votations", + votationsDescription: "Discover and manage votations in your network.", + voteMineSectionTitle: "Your Votations", + voteCreateSectionTitle: "Create Votation", + voteUpdateSectionTitle: "Update", + voteOpenTitle: "Open Votations", + voteClosedTitle: "Closed Votations", + voteAllSectionTitle: "Votations", + voteCreateButton: "Create Votation", + voteUpdateButton: "Update", + voteDeleteButton: "Delete", + voteOptionYes: "YES", + voteOptionNo: "NO", + voteOptionAbstention: "ABSTENTION", + voteConfused: "CONFUSED", + voteFollowMajority: "FOLLOW MAJORITY", + voteNotInterested: "NOT INTERESTED", + voteFilterAll: "ALL", + voteFilterMine: "MINE", + voteFilterOpen: "OPEN", + voteFilterClosed: "CLOSED", + voteQuestionLabel: "Question", + voteDeadlineLabel: "Deadline", + voteOptionsLabel: "Your vote", + voteBreakdown: "Breakdown", + voteFinalResult: "RESULT", + voteNoQuorum: "NO QUORUM", + voteTagsLabel: "Tags", + voteDeadline: "Deadline", + voteStatus: "Status", + voteTags: "Tags", + voteOpinions: "Opinions", + novotes: "No voting proposals available.", + voteBy: "By", + voteCreatedAt: "Created At", + voteNoQuestion: "No question provided", + voteUnknownCreator: "Unknown Creator", + voteUnknownDate: "Unknown Date", + errorVoteNotFound: "Vote not found", + errorAlreadyVoted: "You have already opined.", + errorVoteClosedCannotEdit: "You cannot edit a closed votation", + errorVoteDeadlinePassed: "The deadline for this votation has passed", + errorRetrievingVote: "Error retrieving vote", + errorCreatingVote: "Error creating vote", + errorVoteAlreadyVoted: "Cannot edit after opinion has been cast", + errorDeletingOldVote: "Error deleting old opinion", + errorCreatingUpdatedVote: "Error creating updated opinion", + errorCreatingTombstone: "Error creating tombstone", + voteDetailSectionTitle: 'Vote details', + voteCommentsLabel: 'Comments', + voteCommentsForumButton: 'Open discussion', + voteCommentsSectionTitle: 'Open discussion', + voteNoCommentsYet: 'There are no comments yet. Be the first to reply.', + voteNewCommentPlaceholder: 'Write your comment here…', + voteNewCommentButton: 'Post comment', + voteNewCommentLabel: 'Add a comment', + cvTitle: "CV", + cvLabel: "Curriculum Vitae (CV)", + cvEditSectionTitle: "Edit CV", + cvCreateSectionTitle: "Create CV", + cvDescription: "Manage and share your professional skills and information.", + cvNameLabel: "Full Name", + cvDescriptionLabel: "Summary", + cvPhotoLabel: "Photo", + cvPersonalExperiencesLabel: "Personal Experiences", + cvPersonalSkillsLabel: "Personal Skills (comma-separated)", + cvOasisExperiencesLabel: "Oasis Contribution Experiences", + cvOasisSkillsLabel: "Oasis Contribution Skills (comma-separated)", + cvEducationExperiencesLabel: "Educational Experiences", + cvEducationalSkillsLabel: "Educational Skills (comma-separated)", + cvProfessionalExperiencesLabel: "Professional Experiences", + cvProfessionalSkillsLabel: "Professional Skills (comma-separated)", + cvLanguagesLabel: "Languages", + cvLocationLabel: "Location", + cvStatusLabel: "Status", + cvPreferencesLabel: "Preferences", + cvOasisContributorLabel: "Oasis Contributor", + cvPersonal: "Personal", + cvOasis: "Oasis Contributor (optional)", + cvOasisContributorView: "Oasis Contribution", + cvEducational: "Educational (optional)", + cvEducationalView: "Educational", + cvProfessional: "Professional (optional)", + cvProfessionalView: "Professional", + cvAvailability: "Availability (optional)", + cvAvailabilityView: "Availability", + cvUpdateButton: "Update", + cvCreateButton: "Create CV", + cvContactLabel: "Contact", + cvCreatedAt: "Created At", + cvUpdatedAt: "Updated At", + cvEditButton: "Update", + cvDeleteButton: "Delete", + cvNoCV: "No CV found.", + blogSubject: "Subject", + blogMessage: "Body capped at 8096 characters.", + blogImage: "Upload media (max-size: 50MB)", + blogPublish: "Preview", + noPopularMessages: "No popular messages published, yet", + forumTitle: "Forums", + forumCategoryLabel: "Category", + forumTitleLabel: "Title", + forumTitlePlaceholder: "Forum title...", + forumCreateButton: "Create forum", + forumCreateSectionTitle: "Create forum", + forumDescription: "Talk openly with other inhabitants in your network.", + forumFilterAll: "ALL", + forumFilterMine: "MINE", + forumFilterRecent: "RECENT", + forumFilterTop: "TOP", + forumMineSectionTitle: "Your Forums", + forumRecentSectionTitle: "Recent Forums", + forumAllSectionTitle: "Forums", + forumDeleteButton: "Delete", + forumParticipants: "participants", + forumMessages: "messages", + forumLastMessage: "Last message", + forumMessageLabel: "Message", + forumMessagePlaceholder: "Write your message...", + forumSendButton: "Send", + forumVisitForum: "Visit Forum", + noForums: "No forums found.", + forumVisitButton: "Visit forum", + forumCatGENERAL: "General", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + larpTitle: "L.A.R.P.", + larpDescription: "A live action role-playing layer for collaborative experimentation.", + larpAllHouses: "Houses:", + larpFilterRuling: "RULING", + larpFilterHouses: "HOUSES", + larpFilterRules: "FAQ", + larpRulesTitle: "F.A.Q.", + larpRulesEntryTitle: "Starting house", + larpRulesEntryText: "Every inhabitant begins in ACADEMIA by default.", + larpRulesTestEntry: "WILL test", + larpRulesTestEntryText: "Each option weights one or more houses; when you submit, the system tallies the scores and auto-admits you into the highest-scoring house.", + larpRulesInviteEntry: "Invitation code", + larpRulesInviteEntryText: "House members can generate invitation codes that grant direct access to the house.", + larpRulesOneHouseTitle: "One house at a time", + larpRulesOneHouseText: "You can belong to at most one house at any given moment. If you belong to none, you are in ACADEMIA. Every non-ACADEMIA house page shows a \"Leave House\" button to its members that resets membership back to ACADEMIA. Leaving does NOT waive the test cooldown.", + larpRulesLeaveText: "Members of any house can return to ACADEMIA at any time from their house page; doing so resets their membership but does not waive the test cooldown.", + larpRulesGovernanceTitle: "Governance Wall", + larpRulesGovernanceText: "During its cycle, the governing house wears the \"Ruling\" badge and is the only one whose Wall is surfaced publicly under the RULING tab.", + larpRulesSignTitle: "L.A.R.P. Emblem", + larpRulesSignText: "Inhabitants can choose whether to add a sensor on their avatar with the emblem of their current house.", + larpPostsTitle: "Wall", + larpPostsEmpty: "No posts yet.", + larpPostLabel: "New post", + larpPostPlaceholder: "What does this house need to say?", + larpPostPublic: "Public (visible to anyone, otherwise only members)", + larpPostSubmit: "Publish", + larpPostMembersOnly: "Members only", + larpCycleLabel: "Cycle:", + larpCyclesUntilRuling: "Next governance cycle", + larpVisitTribe: "Visit Tribe", + larpGoverning: "Governing:", + larpMyHouse: "My House:", + larpMembersCount: "Members", + larpMembersTitle: "Members", + larpNoMembers: "No members yet.", + larpRolesLabel: "Roles", + larpFunctionLabel: "Function", + larpMonthLabel: "Governance cycle", + larpLeaveToAcademia: "Leave House", + larpAcademiaJoinTitle: "Houses Current Status", + larpWillTestTitle: "WILL test", + larpWillTestHint: "Once completed, you will be assigned the house that best matches your answers. Your individual answers are processed locally and never stored — only the resulting house assignment is published to your feed.", + larpAcademiaJoinHint: "Take the psychological profile test to be assigned the house that fits you best, or redeem an invitation code shared by a member of a house.", + larpTakeTestButton: "Take the profile test", + larpInviteRedeem: "Join House", + larpInviteCreate: "Generate invitation code", + larpInvitePlaceholder: "Invitation code", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "House invitation code", + invitesHouseJoinButton: "Join House", + larpInviteBannerTitle: "New invitation code", + larpInviteBannerHint: "Share this code with someone in ACADEMIA. It expires in 30 cycles or after one use.", + larpTestAssignedHouse: "Assigned house", + larpTestRankingTitle: "Score by house", + larpLastAttemptTitle: "Your last attempt", + larpLastAttemptHouse: "House", + larpLastAttemptResult: "Result", + larpLastAttemptWhen: "When", + larpLastAttemptCooldown: "Next attempt in", + larpTestTitle: "Profile test", + larpTestIntro: "Answer the psychological questions. The system will assign you the house that best fits your answers. You can attempt one test every 30 cycles.", + larpProfileQ1: "When you face a complex problem, what do you do first?", + larpProfileQ1O1: "Talk to others to find consensus", + larpProfileQ1O2: "Build a prototype to test", + larpProfileQ1O3: "Research the literature", + larpProfileQ1O4: "Disrupt the system that creates it", + larpProfileQ2: "What gives meaning to your daily work?", + larpProfileQ2O1: "Defending the people I love", + larpProfileQ2O2: "Creating something tangible", + larpProfileQ2O3: "Healing or nurturing life", + larpProfileQ2O4: "Crafting words and ideas", + larpProfileQ2O5: "Making others laugh", + larpProfileQ3: "When conflict arises in your group, you…", + larpProfileQ3O1: "Lead the dialogue", + larpProfileQ3O2: "Take a side and stand firm", + larpProfileQ3O3: "Crack a joke to defuse", + larpProfileQ3O4: "Question whether the conflict is real", + larpProfileQ3O5: "Look for root ecological causes", + larpProfileQ4: "Your favorite long-term project would be…", + larpProfileQ4O1: "Building a city", + larpProfileQ4O2: "Restoring a forest", + larpProfileQ4O3: "Writing a constitution", + larpProfileQ4O4: "Organising a festival", + larpProfileQ4O5: "Setting up a defense network", + larpProfileQ4O6: "Designing a new machine", + larpProfileQ4O7: "Curating an archive", + larpProfileQ4O8: "Disrupting an unjust order", + larpProfileQ5: "What makes a good leader?", + larpProfileQ5O1: "Someone who listens and mediates", + larpProfileQ5O2: "Someone who can fight and protect", + larpProfileQ5O3: "Someone who knows history", + larpProfileQ5O4: "Someone who makes you smile", + larpProfileQ6: "Your relationship with rules:", + larpProfileQ6O1: "Rules emerge from dialogue and law", + larpProfileQ6O2: "Rules should be followed strictly", + larpProfileQ6O3: "Rules should be broken often", + larpProfileQ6O4: "Rules should serve life", + larpProfileQ6O5: "Rules build solid infrastructure", + larpProfileQ7: "How do you handle information?", + larpProfileQ7O1: "I curate and preserve it", + larpProfileQ7O2: "I share it through stories", + larpProfileQ7O3: "I question its origins", + larpProfileQ7O4: "I extract the useful bits", + larpProfileQ7O5: "I use it to heal", + larpProfileQ8: "Your idea of success is…", + larpProfileQ8O1: "A working machine", + larpProfileQ8O2: "A peaceful community", + larpProfileQ8O3: "A lively festival", + larpProfileQ8O4: "A safe family", + larpProfileQ8O5: "An unbroken archive", + larpProfileQ8O6: "A cracked dogma", + larpProfileQ8O7: "A thriving harvest", + larpProfileQ8O8: "A finished building", + larpProfileQ9: "When you wake up, you want to…", + larpProfileQ9O1: "Train your body", + larpProfileQ9O2: "Read or write", + larpProfileQ9O3: "Garden or cook", + larpProfileQ9O4: "Tinker with something", + larpProfileQ9O5: "Question authority", + larpProfileQ9O6: "Plan a project", + larpProfileQ9O7: "Talk to friends", + larpProfileQ9O8: "Make art", + larpProfileQ10: "Your weakness might be:", + larpProfileQ10O1: "Talking too much", + larpProfileQ10O2: "Being too pragmatic", + larpProfileQ10O3: "Being too idealistic", + larpProfileQ10O4: "Being too disruptive", + larpProfileQ10O5: "Being too rigid", + larpProfileQ10O6: "Being too lighthearted", + larpProfileQ10O7: "Being too cautious", + larpProfileQ10O8: "Being too ambitious", + larpTestSubmit: "Join House", + larpTestCooldownActive: "Next test available in", + larpTestCooldownDays: "cycles", + larpTestResultTitle: "Test result", + larpTestPassed: "Test passed — welcome!", + larpTestFailed: "Test not passed", + larpTestScore: "Score", + larpTestNextAttempt: "You can attempt another test in 30 cycles.", + larpGoToHouse: "Go to your house", + larpBackToAcademia: "Back to ACADEMIA", + larpBackToHouses: "◀ Houses", + larpBadgeYou: "You", + larpBadgeRuling: "Ruling", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Module for the live-action role-playing layer of Oasis (the 9 Houses).", + forumCatPOLITICS: "Politics", + forumCatTECH: "Tech", + forumCatSCIENCE: "Science", + forumCatMUSIC: "Music", + forumCatART: "Art", + forumCatGAMING: "Gaming", + forumCatBOOKS: "Books", + forumCatFILMS: "Films", + forumCatPHILOSOPHY: "Philosophy", + forumCatSOCIETY: "Society", + forumCatPRIVACY: "Privacy", + forumCatCYBERWARFARE: "Cyberwarfare", + forumCatSURVIVALISM: "Survivalism", + imageTitle: "Images", + imageDescription: "Explore and manage image content in your network.", + imagePluginTitle: "Title", + imagePluginDescription: "Description", + imageMineSectionTitle: "Your Images", + imageCreateSectionTitle: "Upload Image", + imageUpdateSectionTitle: "Update Image", + imageAllSectionTitle: "Images", + imageRecentSectionTitle: "Recent Images", + imageTopSectionTitle: "Top Images", + imageFavoritesSectionTitle: "Favorites", + imageGallerySectionTitle: "Gallery", + imageMemeSectionTitle: "Memes", + imageFilterAll: "ALL", + imageFilterMine: "MINE", + imageFilterRecent: "RECENT", + imageFilterTop: "TOP", + imageFilterFavorites: "FAVORITES", + imageFilterGallery: "GALLERY", + imageFilterMeme: "MEMES", + imageCreateButton: "Upload Image", + imageUpdateButton: "Update", + imageDeleteButton: "Delete", + imageAddFavoriteButton: "Add favorite", + imageRemoveFavoriteButton: "Remove favorite", + imageFileLabel: "Select an image file (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Tags", + imageTagsPlaceholder: "Enter tags separated by commas", + imageTitleLabel: "Title", + imageTitlePlaceholder: "Optional", + imageDescriptionLabel: "Description", + imageDescriptionPlaceholder: "Optional", + imageMemeLabel: "¿MEME?", + imageNoFile: "No image file provided", + noImages: "No images available.", + imageSearchPlaceholder: "Search title, tags, description, author...", + imageSortRecent: "Most recent", + imageSortOldest: "Oldest", + imageSortTop: "Most voted", + imageSearchButton: "Search", + imageMessageAuthorButton: "Message", + imageUpdatedAt: "Updated", + imageNoMatch: "No images match your search.", + feedTitle: "Feed", + feedDetailTitle: "Feed", + feedOpenDiscussion: "Open discussion", + feedPostComment: "Post comment", + noComments: "No comments yet", + createFeedTitle: "Create Feed", + createFeedButton: "Send Feed!", + feedPlaceholder: "What's happening? (max 280 characters)", + TODAYButton: "TODAY", + CREATEButton: "Create Feed", + totalOpinions: "Total Opinions", + moreVoted: "More Voted", + noFeedsFound: "No feeds found.", + createdAtLabel: "Created at", + FeedshareYourOpinions: "Discover and share short-texts in your network.", + refeedButton: "Refeed", + alreadyRefeeded: "You already refeeded this.", + activityTitle: "Activity", + yourActivity: "Your Activity", + globalActivity: "Global Activity", + activityList: "Activity", + activityDesc: "See the latest activity in your network.", + allButton: "ALL", + mineButton: "MINE", + noActions: "No activity available.", + performed: "→", + from: "From", + to: "To", + amount: "Amount", + concept: "Concept", + description: "Description", + meme: "Meme", + activityContact: "Contact", + activityBy: "Name", + activityPixelia: "New pixel added", + viewImage: "View image", + playAudio: "Play audio", + playVideo: "Play video", + typeRecent: "RECENT", + errorActivity: "Error retrieving activity", + typePost: "BLOGS", + recentButton: "RECENT", + typeTribe: "TRIBES", + typeLarp: "L.A.R.P.", + typeInhabitants: "INHABITANTS", + activityProfileUpdated: "updated their profile", + typeAbout: "INHABITANTS", + typeCurriculum: "CVS", + typeImage: "IMAGES", + typeBookmark: "BOOKMARKS", + typeDocument: "DOCUMENTS", + typeVotes: "VOTATIONS", + typeAudio: "AUDIOS", + typeMarket: "MARKET", + typeJob: "JOBS", + typeProject: "PROJECTS", + typeVideo: "VIDEOS", + typeVote: "SPREAD", + typeEvent: "EVENTS", + typeTransfer: "TRANSFERS", + typeTask: "TASKS", + typePixelia: "PIXELIA", + typeForum: "FORUMS", + typeReport: "REPORTS", + typeFeed: "FEEDS", + typeContact: "CONTACT", + typePub: "PUB", + typeTombstone: "TOMBSTONE", + typeBanking: "BANKING", + typeBankWallet: "BANKING/WALLET", + typeBankClaim: "BANKING/UBI", + typeKarmaScore: "KARMA", + typeParliament: "PARLIAMENT", + typeSpread: "SPREADS", + typeParliamentCandidature: "Parliament · Candidature", + typeParliamentTerm: "Parliament · Term", + typeParliamentProposal:"Parliament · Proposal", + typeParliamentRevocation:"Parliament · Revocation", + typeParliamentLaw: "Parliament · New Law", + typeCourts: "COURTS", + typeCourtsCase: "Courts · Case", + typeCourtsEvidence: "Courts · Evidence", + typeCourtsAnswer: "Courts · Answer", + typeCourtsVerdict: "Courts · Verdict", + typeCourtsSettlement: "Courts · Settlement", + typeCourtsSettlementProposal: "Courts · Settlement Proposal", + typeCourtsSettlementAccepted: "Courts · Settlement Accepted", + typeCourtsNomination: "Courts · Nomination", + typeCourtsNominationVote: "Courts · Nomination Vote", + activitySupport: "New alliance forged", + activityJoin: "New PUB joined", + question: "Question", + deadline: "Deadline", + status: "Status", + votes: "Votes", + totalVotes: "Total Votes", + voteTotalVotes: "Total Votes", + name: "Name", + skills: "Skills", + tags: "Tags", + title: "Title", + date: "Date", + category: "Category", + attendees: "Attendees", + activitySpread: "->", + visitLink: "Visit Link", + viewDocument: "View Document", + location: "Location", + contentWarning: "Subject", + personName: "Inhabitant Name", + bankWalletConnected: "ECOin Wallet", + bankUbiReceived: "UBI Received", + bankTx: "Tx", + bankEpochShort: "Epoch", + bankingUserEngagementScore: "KARMA Scoring", + viewDetails: "View details", + link: "Link", + aiApproveTagsLabel: "Tags (comma-separated)", + aiApproveTagsPlaceholder: "e.g. oasis, governance, ecology", + aiApproveRatingLabel: "Rating", + aiExchangeLang: "Language", + aiExchangeTags: "Tags", + aiExchangeRating: "Rating", + aiExchangeHelpful: "Helpful", + aiExchangeMarkHelpful:"+1 helpful", + activityProjectFollow: "%OASIS% is now %ACTION% this project %PROJECT%", + activityProjectUnfollow: "%OASIS% is now %ACTION% this project %PROJECT%", + activityProjectPledged: "%OASIS% has %ACTION% %AMOUNT% to project %PROJECT%", + following: "FOLLOWING", + unfollowing: "UNFOLLOWING", + pledged: "PLEDGED", + parliamentCandidatureId: "Candidature", + parliamentGovMethod: "Method", + parliamentVotesReceived: "Votes received", + parliamentMethodANARCHY: "Anarchy", + parliamentMethodVOTE: "Community Vote", + parliamentMethodRANKED: "Ranked Choice", + parliamentMethodPLURALITY: "Plurality", + parliamentMethodCOUNCIL: "Council", + parliamentMethodJURY: "Jury", + parliamentAnarchy: "ANARCHY", + parliamentElectionsStart: "Elections start", + parliamentElectionsEnd: "Elections end", + parliamentCurrentLeader: "Winning candidature", + parliamentProposalTitle: "Title", + parliamentOpenVote: "Open vote", + parliamentStatus: "Status", + parliamentLawQuestion: "Question", + parliamentLawMethod: "Method", + parliamentLawProposer: "Proposer", + parliamentLawEnacted: "Enacted at", + parliamentLawVotes: "Votes", + createdAt: "Created at", + courtsCaseTitle: "Case", + courtsMethod: "Method", + courtsMethodJUDGE: "Judge", + courtsMethodJUDGES: "Judges Panel", + courtsMethodSINGLE_JUDGE: "Single Judge", + courtsMethodJURY: "Jury", + courtsMethodCOUNCIL: "Council", + courtsMethodCOMMUNITY: "Community", + courtsMethodARBITRATION: "Arbitration", + courtsMethodVOTE: "Community Vote", + courtsAccuser: "Accuser", + courtsRespondent: "Respondent", + courtsThStatus: "Status", + courtsThAnswerBy: "Answer by", + courtsThEvidenceBy: "Evidence by", + courtsThDecisionBy: "Decision by", + courtsVotesNeeded: "Votes needed", + courtsVotesSlashTotal: "YES/TOTAL", + courtsOpenVote: "Open vote", + courtsAnswerTitle: "Answer", + courtsStanceADMIT: "Admit", + courtsStanceDENY: "Deny", + courtsStancePARTIAL: "Partial", + courtsStanceCOUNTERCLAIM: "Counterclaim", + courtsStanceNEUTRAL: "Neutral", + courtsVerdictResult: "Result", + courtsVerdictOrders: "Orders", + courtsSettlementText: "Settlement", + courtsSettlementAccepted: "Accepted", + courtsSettlementPending: "Pending", + courtsJudge: "Judge", + courtsThSupports: "Supports", + courtsFilterOpenCase: 'Open Case', + courtsEvidenceFileLabel: 'Evidence file (image, audio, video or PDF)', + courtsCaseMediators: 'Mediators', + courtsCaseMediatorsPh: 'Mediator Oasis IDs, separated by comma', + courtsMediatorsLabel: 'Mediators', + courtsThCase: 'Case', + courtsThCreatedAt: 'Start date', + courtsThActions: 'Actions', + courtsPublicPrefLabel: 'Visibility after resolution', + courtsPublicPrefYes: 'I agree this case can be fully public', + courtsPublicPrefNo: 'I prefer to keep the details private', + courtsPublicPrefSubmit: 'Save visibility preference', + courtsMethodMEDIATION: 'Mediation', + courtsNoCases: 'No cases.', + reportsTitle: "Reports", + reportsDescription: "Manage and track reports related to issues, bugs, abuses and content warnings in your network.", + reportsFilterAll: "ALL", + reportsFilterMine: "MINE", + reportsFilterFeatures: "FEATURES", + reportsFilterBugs: "BUGS", + reportsFilterAbuse: "ABUSE", + reportsFilterContent: "CONTENT", + reportsFilterConfirmed: "CONFIRMED", + reportsFilterResolved: "RESOLVED", + reportsFilterOpen: "OPEN", + reportsFilterUnderReview: "UNDER REVIEW", + reportsFilterInvalid: "INVALID", + reportsCreateButton: "Create Report", + reportsTitleLabel: "Title", + reportsDescriptionLabel: "Description", + reportsDescriptionPlaceholder: "Please provide a detailed description.", + reportsCategory: "Category", + reportsCategoryLabel: "Category", + reportsCategoryFeatures: "Features", + reportsCategoryBugs: "Bugs", + reportsCategoryAbuse: "Abuse", + reportsCategoryContent: "Content Issues", + reportsUpdateButton: "Update", + reportsDeleteButton: "Delete", + reportsDateLabel: "Date", + reportsUploadFile: "Upload media (max-size: 50MB)", + reportsMineSectionTitle: "Your Reports", + reportsFeaturesSectionTitle: "Feature Requests", + reportsBugsSectionTitle: "Bugs", + reportsAbuseSectionTitle: "Abuse Reports", + reportsContentSectionTitle: "Content Issues", + reportsAllSectionTitle: "Reports", + reportsNoItems: "No reports available.", + reportsValidationTitle: "Please enter a valid title.", + reportsValidationDescription: "Description cannot be empty.", + reportsValidationCategory: "Please select a category.", + reportsCreatedAt: "Created At", + reportsCreatedBy: "By", + reportsSeverity: "Severity", + reportsSeverityLow: "Low", + reportsSeverityMedium: "Medium", + reportsSeverityHigh: "High", + reportsSeverityCritical: "Critical", + reportsStatus: "Status", + reportsStatusOpen: "Open", + reportsStatusUnderReview: "Under Review", + reportsStatusResolved: "Resolved", + reportsStatusInvalid: "Invalid", + reportsUpdateStatusButton: "Update Status", + reportsAnonymityOption: "Submit Anonymously", + reportsAnonymousAuthor: "Anonymous", + reportsConfirmButton: "CONFIRM REPORT!", + reportsConfirmations: "Confirmations", + reportsConfirmedSectionTitle: "Confirmed Reports", + reportsCreateTaskButton: "CREATE TASK", + reportsOpenSectionTitle: "Open Reports", + reportsUnderReviewSectionTitle: "Under Review Reports", + reportsResolvedSectionTitle: "Resolved Reports", + reportsInvalidSectionTitle: "Invalid Reports", + reportsTemplateSectionTitle: 'Report template', + reportsBugTemplateTitle: 'Reproduction details (Bugs)', + reportsFeatureTemplateTitle: 'Details (Features)', + reportsAbuseTemplateTitle: 'Details (Abuse)', + reportsContentTemplateTitle: 'Details (Content Issues)', + reportsStepsToReproduceLabel: 'Steps to reproduce', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'Expected result', + reportsExpectedBehaviorPlaceholder: 'What should happen?', + reportsActualBehaviorLabel: 'Actual result', + reportsActualBehaviorPlaceholder: 'What actually happens?', + reportsEnvironmentLabel: 'Environment', + reportsEnvironmentPlaceholder: 'Version, device, OS, browser, settings, logs, etc.', + reportsReproduceRateLabel: 'Reproduction rate', + reportsReproduceRateAlways: 'Always', + reportsReproduceRateOften: 'Often', + reportsReproduceRateSometimes: 'Sometimes', + reportsReproduceRateRarely: 'Rarely', + reportsReproduceRateUnable: 'Unable to reproduce', + reportsReproduceRateUnknown: 'Not specified', + reportsProblemStatementLabel: 'Problem / need', + reportsProblemStatementPlaceholder: 'What problem does this feature solve?', + reportsUserStoryLabel: 'Inhabitant story', + reportsUserStoryPlaceholder: 'As a , I want , so that .', + reportsAcceptanceCriteriaLabel: 'Acceptance criteria', + reportsAcceptanceCriteriaPlaceholder: '- Given...\n- When...\n- Then...', + reportsWhatHappenedLabel: 'What happened?', + reportsWhatHappenedPlaceholder: 'Describe the incident with context and approximate dates.', + reportsReportedUserLabel: 'Reported inhabitant / entity', + reportsReportedUserPlaceholder: '@inhabitant, feed, ID, link, etc.', + reportsEvidenceLinksLabel: 'Evidence / links', + reportsEvidenceLinksPlaceholder: 'Paste links, message IDs, screenshots (if applicable), etc.', + reportsContentLocationLabel: 'Where the content is', + reportsContentLocationPlaceholder: 'Link, ID, channel, thread, author, etc.', + reportsWhyInappropriateLabel: "Why it's inappropriate", + reportsWhyInappropriatePlaceholder: 'Explain the reason and impact.', + reportsRequestedActionLabel: 'Requested action', + reportsRequestedActionPlaceholder: 'Remove, hide, tag, warn, etc.', + tribesTitle: "Tribes", + tribeAllSectionTitle: "Tribes", + tribeMineSectionTitle: "Your Tribes", + tribeCreateSectionTitle: "Create Tribe", + tribeUpdateSectionTitle: "Update Tribe", + tribeGallerySectionTitle: "Tribes Gallery", + tribeRecentSectionTitle: "Recent Tribes", + tribeTopSectionTitle: "Popular Tribes", + tribeviewTribeButton: "Visit Tribe", + tribeviewSubTribeButton: "Visit Sub-Tribe", + tribeRootLabel: "ROOT", + tribeDescription: "Explore or create tribes on your network.", + tribeFilterAll: "ALL", + tribeFilterMine: "MINE", + tribeFilterMembership: "MEMBERSHIP", + tribeFilterRecent: "RECENT", + tribeFilterTop: "TOP", + tribeFilterSubtribes: "SUB-TRIBES", + tribeFilterGallery: "GALLERY", + tribeMainTribeLabel: "MAIN TRIBE", + tribeCreateButton: "Create Tribe", + tribeUpdateButton: "Update", + tribeDeleteButton: "Delete", + tribeImageLabel: "Upload media (max-size: 50MB)", + tribeTitleLabel: "Title", + searchTribesPlaceholder: "FILTER tribes BY NAME …", + tribeTitlePlaceholder: "Name of the tribe", + tribeDescriptionLabel: "Description", + tribeDescriptionPlaceholder: "Describe this tribe", + tribeLocationLabel: "Location", + tribeLocationPlaceholder: "Where is this tribe located?", + tribeTagsLabel: "Tags", + tribeTagsPlaceholder: "Enter tags separated by commas", + tribeInviteMode: "Invite mode", + tribeModeLabel: "MODE", + tribeIsAnonymousLabel: "STATUS", + tribeMembersCount: "Members", + tribeInviteCodePlaceholder: "Enter invite code", + tribeJoinByCodeButton: "Join with code", + tribeJoinButton: "JOIN", + tribeEnterInvite: "ENTER INVITE CODE", + tribeLeaveButton: "LEAVE", + tribeYes: "YES", + tribeNo: "NO", + tribePublic: "PUBLIC", + tribePrivate: "PRIVATE", + tribeGenerateInvite: "SINGLE INVITATION", + tribeOpenInvitation: "OPEN INVITATION", + tribeJoinOpen: "JOIN TRIBE", + tribeRemoveInvitation: "REMOVE INVITATION", + tribeCreatedAt: "Created at", + tribeAuthor: "By", + tribeAuthorLabel: "AUTHOR", + tribeStrict: "Strict", + tribeOpen: "Open", + tribeFeedFilterRECENT: "RECENT", + tribeFeedFilterMINE: "MINE", + tribeFeedFilterALL: "ALL", + tribeFeedFilterTOP: "TOP", + tribeFeedRefeeds: "Refeeds", + tribeFeedRefeed: "Refeed", + tribeFeedMessagePlaceholder: "Write a feed…", + tribeFeedSend: "Send", + tribeFeedEmpty: "No feed messages available, yet.", + noTribes: "No tribes found, yet.", + tribeNotFound: "Tribe not found!", + createTribeTitle: "Create Tribe", + updateTribeTitle: "Update Tribe", + tribeSectionOverview: "Overview", + tribeSectionInhabitants: "Inhabitants", + tribeSectionVotations: "VOTATIONS", + tribeSectionEvents: "EVENTS", + tribeSectionReports: "Reports", + tribeSectionTasks: "TASKS", + tribeSectionFeed: "FEED", + tribeSectionForum: "FORUM", + tribeSectionMarket: "Market", + tribeSectionJobs: "Jobs", + tribeSectionProjects: "Projects", + tribeSectionMedia: "Media", + tribeSectionImages: "IMAGES", + tribeSectionAudios: "AUDIOS", + tribeSectionVideos: "VIDEOS", + tribeSectionDocuments: "DOCUMENTS", + tribeSectionBookmarks: "BOOKMARKS", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "No inhabitants in this tribe, yet.", + tribeEventCreate: "Create Event", + tribeEventsEmpty: "No events, yet.", + tribeEventTitle: "Title", + tribeEventDescription: "Description", + tribeEventDate: "Date", + tribeEventLocation: "Location", + tribeEventAttend: "ATTEND", + tribeEventUnattend: "LEAVE", + tribeEventAttendees: "Attendees", + tribeTaskCreate: "Create Task", + tribeTasksEmpty: "No tasks, yet.", + tribeTaskTitle: "Title", + tribeTaskDescription: "Description", + tribeTaskPriority: "Priority", + tribeTaskDeadline: "Deadline", + tribeTaskAssignees: "Assignees", + tribeTaskStatusInProgress: "IN PROGRESS", + tribeTaskStatusClosed: "CLOSE", + tribeTaskAssign: "ASSIGN", + tribeTaskUnassign: "UNASSIGN", + tribeReportCreate: "Create Report", + tribeReportsEmpty: "No reports, yet.", + tribeReportTitle: "Title", + tribeReportDescription: "Description", + tribeReportCategory: "Category", + tribeVotationCreate: "Create Votation", + tribeVotationsEmpty: "No votations, yet.", + tribeVotationTitle: "Title", + tribeVotationDescription: "Description", + tribeVotationOptions: "Options", + tribeVotationDeadline: "Deadline", + tribeVotationVote: "VOTE", + tribeVotationResults: "Votes", + tribeVotationClose: "CLOSE VOTATION", + tribeVotationOptionPlaceholder: "Option", + tribeForumCreate: "Create Forum", + tribeForumEmpty: "No threads, yet.", + tribeForumTitle: "Title", + tribeForumText: "Message", + tribeForumCategory: "Category", + tribeForumReply: "Reply", + tribeForumReplies: "Replies", + tribeMarketCreate: "Create Listing", + tribeMarketEmpty: "No listings, yet.", + tribeMarketTitle: "Title", + tribeMarketDescription: "Description", + tribeMarketPrice: "Price", + tribeMarketImage: "Image", + tribeMarketCategory: "Category", + tribeJobCreate: "Create Job", + tribeJobsEmpty: "No jobs, yet.", + tribeJobTitle: "Title", + tribeJobDescription: "Description", + tribeJobLocation: "Location", + tribeJobSalary: "Salary", + tribeJobDeadline: "Deadline", + tribeProjectCreate: "Create Project", + tribeProjectsEmpty: "No projects, yet.", + tribeProjectTitle: "Title", + tribeProjectDescription: "Description", + tribeProjectGoal: "Goal", + tribeProjectFunded: "Funded", + tribeProjectDeadline: "Deadline", + tribeMediaUpload: "Upload Media", + readDocument: "Read Document", + tribeCreateImage: "Create Image", + tribeCreateAudio: "Create Audio", + tribeCreateVideo: "Create Video", + tribeCreateDocument: "Create Document", + tribeCreateBookmark: "Create Bookmark", + tribeMediaEmpty: "No media, yet.", + tribeMediaTitle: "Title", + tribeMediaDescription: "Description", + tribeMediaType: "Type", + tribeMediaTypeImage: "Image", + tribeMediaTypeVideo: "Video", + tribeMediaTypeAudio: "Audio", + tribeMediaTypeDocument: "Document", + tribeMediaTypeBookmark: "Bookmark", + tribeContentDelete: "DELETE", + tribeInviteCodeText: "Invite code: ", + oasisVersionLabel: "Oasis Version", + tribeInviteCodeHint: "Share this code with someone you want to invite. They can join via /invites → Tribes.", + tribeGroupTribe: "Tribe", + tribeGroupOffice: "Office", + tribeGroupNetwork: "Network", + tribeGroupEconomy: "Economy", + tribeGroupMedia: "Media", + tribeStatusOpen: "OPEN", + tribeStatusClosed: "CLOSED", + tribeStatusInProgress: "IN PROGRESS", + tribePriorityLow: "LOW", + tribePriorityMedium: "MEDIUM", + tribePriorityHigh: "HIGH", + tribePriorityCritical: "CRITICAL", + tribeTaskFilterAll: "ALL", + tribeMediaFilterAll: "ALL", + tribeReportCatBug: "BUG", + tribeReportCatAbuse: "ABUSE", + tribeReportCatContent: "CONTENT", + tribeReportCatOther: "OTHER", + tribeForumCatGeneral: "GENERAL", + tribeForumCatProposal: "PROPOSAL", + tribeForumCatQuestion: "QUESTION", + tribeForumCatAnnouncement: "ANNOUNCEMENT", + tribeMarketCatGoods: "GOODS", + tribeMarketCatServices: "SERVICES", + tribeMarketCatFood: "FOOD", + tribeMarketCatOther: "OTHER", + tribeStatusLabel: "Status", + tribeSubTribes: "SUB-TRIBES", + tribeSubTribesCreate: "Create Sub-Tribe", + tribeSubTribesStrictDenied: "Tribe strict mode does not allow you to create new sub-tribes. Please contact the administrator.", + tribeSubTribesEmpty: "No sub-tribes created, yet.", + tribeActivityJoined: "JOINED", + tribeActivityLeft: "LEFT", + tribeActivityFeed: "FEED", + tribeActivityRefeed: "REFEED", + tribeGroupAnalytics: "Analytics", + tribeGroupCreative: "Creative", + tribeSectionActivity: "ACTIVITY", + tribeSectionTrending: "TRENDING", + tribeSectionOpinions: "OPINIONS", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "TAGS", + tribeSectionSearch: "SEARCH", + tribeActivityEmpty: "No activity yet.", + tribeActivityCreated: "created", + tribeActivityPosted: "posted", + tribeActivityReplied: "replied", + tribeTrendingEmpty: "No trending content yet.", + tribeTrendingPeriodDay: "Today", + tribeTrendingPeriodWeek: "This Week", + tribeTrendingPeriodAll: "All Time", + tribeTrendingEngagement: "engagement", + tribeOpinionsEmpty: "No opinions yet.", + tribeOpinionsCast: "Vote", + tribeOpinionsRankings: "Rankings", + tribeOpinionsAlreadyVoted: "Already voted", + tribeTopCategory: "More Voted", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Collaborative pixel art canvas.", + tribePixeliaPaint: "Paint", + tribePixeliaContributors: "contributors", + tribePixeliaTotalPixels: "pixels painted", + tribeTagsEmpty: "No tags found.", + tribeTagsCloud: "Tag Cloud", + tribeTagsContentWith: "Content with tag", + tribeSearchPlaceholder: "Search tribe content...", + tribeSearchEmpty: "No results found.", + tribeSearchResults: "Results", + tribeSearchMinChars: "Enter at least 2 characters to search.", + agendaTitle: "Agenda", + agendaDescription: "Here you can find all your assigned items.", + agendaFilterAll: "ALL", + agendaFilterToday: "TODAY", + agendaFilterUpcoming: "UPCOMING", + agendaFilterOverdue: "OVERDUE", + agendaFilterOpen: "OPEN", + agendaFilterClosed: "CLOSED", + agendaFilterTasks: "TASKS", + agendaFilterMarket: "MARKET", + agendaFilterTribes: "TRIBES", + agendaFilterEvents: "EVENTS", + agendaFilterReports: "REPORTS", + agendaFilterTransfers: "TRANSFERS", + agendaFilterJobs: "JOBS", + agendaFilterProjects: "PROJECTS", + agendaFilterCalendars: "CALENDARS", + agendaNoItems: "No assignments found.", + agendaAuthor: "By", + agendaDiscardButton: "Discard", + agendaRestoreButton: "Restore", + agendaCreatedAt: "Created At", + agendaTitleLabel: "Title", + agendaMembersCount: "Members", + agendaDescriptionLabel: "Description", + agendaStatus: "Status", + agendaVisibility: "Visibility", + agendaEventDate: "Event Date", + agendaEventLocation: "Location", + agendaEventPrice: "Price", + agendaEventUrl: "URL", + agendaTaskStart: "Start Time", + agendaLocationLabel: "Location", + agendaYes: "YES", + agendaNo: "NO", + agendaInviteModeLabel: "Status", + agendaAnonymousLabel: "Anonymous", + agendaMembersLabel: "Members", + agendareportCategory: "Category", + agendareportSeverity: "Severity", + agendareportStatus: "Status", + agendareportDescription: "Description", + agendaTaskEnd: "End Time", + agendaTaskPriority: "Priority", + agendaTransferFrom: "From", + agendaTransferTo: "To", + agendaTransferConcept: "Concept", + agendaTransferAmount: "Amount", + agendaTransferDeadline: "Deadline", + opinionsTitle: "Opinions", + shareYourOpinions: "Discover and vote for opinions in your network.", + author: "By", + voteNow: "Vote now", + alreadyVoted: "You have already opined.", + noOpinionsFound: "No opinions found.", + RECENTButton: "RECENT", + TOPButton: "TOP", + interestingButton: "INTERESTING", + necessaryButton: "NECESSARY", + funnyButton: "FUNNY", + disgustingButton: "DISGUSTING", + sensibleButton: "SENSIBLE", + propagandaButton: "PROPAGANDA", + adultOnlyButton: "ADULT ONLY", + boringButton: "BORING", + confusingButton: "CONFUSING", + usefulButton: "USEFUL", + informativeButton: "INFORMATIVE", + wellResearchedButton: "WELL RESEARCHED", + accurateButton: "ACCURATE", + needsSourcesButton: "NEEDS SOURCES", + wrongButton: "WRONG", + lowQualityButton: "LOW QUALITY", + creativeButton: "CREATIVE", + insightfulButton: "INSIGHTFUL", + actionableButton: "ACTIONABLE", + inspiringButton: "INSPIRING", + loveButton: "LOVE", + clearButton: "CLEAR", + upliftingButton: "UPLIFTING", + unnecessaryButton: "UNNECESSARY", + rejectedButton: "REJECTED", + misleadingButton: "MISLEADING", + offTopicButton: "OFF TOPIC", + duplicateButton: "DUPLICATE", + clickbaitButton: "CLICKBAIT", + spamButton: "SPAM", + trollButton: "TROLL", + nsfwButton: "NSFW", + violentButton: "VIOLENT", + toxicButton: "TOXIC", + harassmentButton: "HARASSMENT", + hateButton: "HATE", + scamButton: "SCAM", + triggeringButton: "TRIGGERING", + opinionsCreatedAt: "Created At", + opinionsTotalCount: "Total Opinions", + voteInteresting: "Interesting", + voteNecessary: "Necessary", + voteUseful: "Useful", + voteInformative: "Informative", + voteWellResearched: "Well researched", + voteNeedsSources: "Needs sources", + voteWrong: "Wrong", + voteLowQuality: "Low quality", + voteLove: "Love", + voteClear: "Clear", + voteMisleading: "Misleading", + voteOffTopic: "Off topic", + voteDuplicate: "Duplicate", + voteClickbait: "Clickbait", + votePropaganda: "Propaganda", + voteFunny: "Funny", + voteInspiring: "Inspiring", + voteUplifting: "Uplifting", + voteUnnecessary: "Unnecessary", + voteRejected: "Rejected", + voteConfusing: "Confusing", + voteTroll: "Troll", + voteNsfw: "NSFW", + voteViolent: "Violent", + voteToxic: "Toxic", + voteHarassment: "Harassment", + voteHate: "Hate", + voteScam: "Scam", + voteTriggering: "Triggering", + voteInsightful: "Insightful", + voteAccurate: "Accurate", + voteActionable: "Actionable", + voteCreative: "Creative", + voteSpam: "Spam", + voteAdultOnly: "Adult Only", + publishBlog: "Publish Blog", + privateMessage: "PM", + pmSendTitle: "Private Messages", + pmSend: "Send!", + pmDescription: "Use this form to send an encrypted message to other inhabitants.", + pmRecipients: "Recipients", + pmRecipientsHint: "Enter Oasis IDs separated by commas", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Subject", + pmSubjectHint: "Enter the message subject", + pmText: "Message", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Decrypt", + pmCrypterBadKey: "Your shared key is incorrect!", + pmCrypterTooLong: "The message is too long to encrypt with the Crypter. Please shorten it and try again.", + pmCrypterCipherLabel: "Re-encrypted message", + pmCrypterCharsLabel: "characters", + pmInvalidRecipients: "Invalid Oasis ID (should be like @…=.ed25519).", + pmEncryptionLabel: "Encryption", + pmFile: "Attachment", + private: "Private", + privateDescription: "Your encrypted messages.", + privateInbox: "INBOX", + privateSent: "SENT", + privateDelete: "Delete", + pmCreateButton: "Write a PM", + noPrivateMessages: "No private messages.", + pmFromLabel: "From:", + pmToLabel: "To:", + pmInvalidMessage: "Invalid message", + pmNoSubject: "(no subject)", + pmSubjectLabel: "Subject:", + pmBodyLabel: "Body", + pmBotJobs: "42-JobsBOT", + pmBotProjects: "42-ProjectsBOT", + pmBotMarket: "42-MarketBOT", + inboxJobSubscribedTitle: "New subscription to your job offer", + pmInhabitantWithId: "Inhabitant with OASIS ID:", + pmHasSubscribedToYourJobOffer: "has subscribed to your job offer", + inboxProjectCreatedTitle: "New project created", + pmHasCreatedAProject: "has created a project", + inboxMarketItemSoldTitle: "Item Sold", + pmYourItem: "Your item", + pmHasBeenSoldTo: "has been sold to", + pmFor: "for", + inboxProjectPledgedTitle: "New pledge to your project", + pmHasPledged: "has pledged", + pmToYourProject: "to your project", + blockchain: 'BlockExplorer', + blockchainTitle: 'BlockExplorer', + blockchainDescription: 'Explore and visualize the blocks in the blockchain.', + blockchainNoBlocks: 'No blocks found in the blockchain.', + blockchainStatsTitle: 'Stats', + blockchainStatsTypeBreakdown: 'Type breakdown', + blockchainStatsTopAuthors: 'Top authors', + blockchainStatsCount: 'Blocks', + blockchainBlockID: 'Block ID', + blockchainBlockAuthor: 'Author', + blockchainBlockCarbon: 'Carbon footprint', + blockchainBlockType: 'Type', + blockchainBlockTimestamp: 'Timestamp', + blockchainBlockContent: 'Block', + blockchainBlockURL: 'URL:', + blockchainContent: 'Block', + blockchainContentPreview: 'Preview of the block content', + blockchainLatestDatagram: 'Latest Datagram', + blockchainDatagram: 'Datagram', + blockchainDetails: 'View block details', + blockchainViewBlockexplorer: "View blockexplorer", + blockchainBlockInfo: 'Block Information', + blockchainBlockDetails: 'Details of the selected block', + blockchainBack: 'Back to Blockexplorer', + blockchainContentDeleted: "This content has been tombstoned", + visitContent: "Visit Content", + banking: 'Banking', + bankingTitle: 'Banking', + bankingDescription: 'Explore the current value of ECOin and the corresponding UBI allocation, distributed per epoch based on participation and trust.', + bankOverview: 'Overview', + bankEpochs: 'Epochs', + bankRules: 'Rules', + pending: 'Pending', + closed: 'Closed', + bankBack: 'Back to Banking', + bankViewTx: 'View Tx', + bankClaimNow: 'Claim now', + bankClaimUBI: 'Claim UBI!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'No pending UBI allocations for this epoch.', + bankEpoch: 'Epoch', + bankPool: 'Pool (this epoch)', + bankWeightsSum: 'Sum of weights', + bankAllocations: 'Allocations', + bankNoAllocations: 'No allocations found.', + bankNoEpochs: 'No epochs found.', + bankEpochAllocations: 'Epoch allocations', + bankAllocId: 'Allocation ID', + bankAllocDate: 'Date', + bankAllocConcept: 'Concept', + bankAllocFrom: 'From', + bankAllocTo: 'To', + bankAllocAmount: 'Amount', + bankAllocStatus: 'Status', + bankEpochId: 'Epoch ID', + bankRuleHash: 'Rules Snapshot Hash', + bankViewEpoch: 'View Epoch', + bankUserBalance: 'Your Balance', + ecoWalletNotConfigured: 'ECOin Wallet not configured', + editWallet: 'Edit wallet', + addWallet: 'Add wallet', + bankAddresses: 'Addresses', + bankNoAddresses: 'No addresses found.', + bankUser: 'Oasis ID', + bankAddress: 'Address', + bankAddAddressTitle: 'Add ECOIN address', + bankAddAddressUser: 'Oasis ID', + bankAddAddressAddress: 'ECOIN Address', + bankAddAddressSave: 'Save', + bankAddressAdded: 'Address added', + bankAddressUpdated: 'Address updated', + bankAddressExists: 'Address already exists', + bankAddressInvalid: 'Invalid address', + bankAddressDeleted: 'Address deleted', + bankAddressNotFound: 'Address not found', + bankAddressForbidden: 'You can only set your own payout address', + bankAddressTotal: 'Total Addresses', + bankAddressSearch: 'Search @inhabitant or address', + bankAddressActions: 'Actions', + bankAddressDelete: 'Delete', + bankAddressSource: 'Source', + bankAddressDeleteConfirm: 'Delete this address?', + search: 'Search!', + bankLocal: 'Local', + bankFromOasis: 'Oasis', + bankMyAddress: 'Your address', + bankRemoveMyAddress: 'Remove my address', + bankNotRemovableOasis: 'Addresses cannot be removed locally', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "NO FUNDS!", + bankUbiAvailableOk: "AVAILABLE!", + bankUbiAvailability: "UBI availability", + bankAlreadyClaimedThisMonth: "Already claimed this month", + bankUbiThisMonth: "UBI (this month)", + bankUbiLastClaimed: "UBI (last claimed)", + bankUbiNeverClaimed: "Never claimed", + bankUbiTotalClaimed: "UBI (total claimed)", + bankUbiPub: "PUB", + bankUbiInhabitant: "INHABITANT", + bankUbiClaimedAmount: "CLAIMED (ECO)", + typeBankUbiResult: "BANKING - UBI", + bankNoPubConfigured: "No PUB configured. Set your PUB ID in Settings.", + shopsTitle: "Shops", + shopDescription: "Discover and manage shops in the network.", + shopTitle: "Shop", + shopFilterAll: "ALL", + shopFilterMine: "MINE", + shopFilterRecent: "RECENT", + shopFilterTop: "TOP", + shopFilterProducts: "PRODUCTS", + shopFilterPrices: "PRICES", + shopFilterFavorites: "FAVORITES", + shopUpload: "Create Shop", + shopAllSectionTitle: "Shops", + shopMineSectionTitle: "Your Shops", + shopRecentSectionTitle: "Recent Shops", + shopTopSectionTitle: "Top Shops", + shopProductsSectionTitle: "Top Products", + shopPricesSectionTitle: "Products by Price", + shopFavoritesSectionTitle: "Favorites", + shopCreateSectionTitle: "Create Shop", + shopUpdateSectionTitle: "Update Shop", + shopCreate: "Create", + shopUpdate: "Update", + shopDelete: "Delete", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Publish to Clearnet", + shopClearnetUnpublish: "Unpublish from Clearnet", + shopClearnetUrlLabel: "Clearnet URL", + shopClearnetWarning: "Publishing on Clearnet makes this shop indexable by external search engines and crawlers.", + shopAddFavorite: "Add Favorite", + shopRemoveFavorite: "Remove Favorite", + shopOpen: "OPEN", + shopClosed: "CLOSED", + shopOpenShop: "Open Shop", + shopCloseShop: "Close Shop", + shopMakePrivate: "MAKE PRIVATE (E2E)", + shopMakePublic: "MAKE PUBLIC", + shopProducts: "Products", + shopProductAdd: "Add Product", + shopProductTitle: "Product", + shopProductUpdate: "Update Product", + shopProductPrice: "Price (ECO)", + shopProductStock: "Stock", + shopProductUntitled: "Untitled Product", + shopUntitled: "Untitled Shop", + shopNoItems: "No shops found.", + shopNoProducts: "No products yet.", + shopOutOfStock: "Out of stock", + shopBuy: "Buy", + shopBuyEncryptedNote: "Your delivery details are sent encrypted, visible only to the shop owner.", + shopBuyDeliveryAddress: "Delivery address", + shopBuyContact: "Contact", + shopBuyContactPlaceholder: "email, phone, etc.", + shopBuyNotes: "Notes", + shopBuyDeliveryRequired: "Delivery address is required.", + shopOrdersTitle: "Orders", + shopOrdersDescription: "Purchase orders received by this shop.", + shopOrdersEmpty: "No orders yet.", + shopOrderProduct: "Product", + shopOrderPrice: "Price", + shopOrderBuyer: "Buyer", + shopBackToShop: "Back to Shop", + shopShareUrl: "Share URL", + shopVisitShop: "VISIT SHOP", + marketVisitItem: "VISIT ITEM", + shopStatus: "STATUS", + shopCreatedAt: "CREATED", + shopSearchPlaceholder: "Search shops...", + shopUrl: "URL", + shopLocation: "Location", + shopTags: "Tags", + shopVisibility: "Visibility", + shopImage: "Image", + shopShortDescription: "Short Description", + shopShortDescriptionPlaceholder: "Brief description of your shop", + shopTitlePlaceholder: "Name of your shop", + shopDescriptionPlaceholder: "Detailed description of your shop", + shopUrlPlaceholder: "https://your-shop-url.com", + shopLocationPlaceholder: "City, Country", + shopTagsPlaceholder: "tag1, tag2, tag3", + mapTitlePlaceholder: "Map title", + shopProductFeatured: "Featured Product", + shopSendToMarket: "Send to Market", + typeShop: "SHOPS", + typeShopProduct: "SHOP PRODUCT", + bankExchange: 'Exchange', + bankExchangeCurrentValue: 'ECOin Value (1h)', + bankTotalSupply: 'ECOin Total Supply', + bankEcoinHours: "ECOin Equivalence in Time", + bankHoursOfWork: 'hours', + bankUnitMs: "ms", + bankUnitSeconds: "seconds", + bankUnitMinutes: "minutes", + bankUnitDays: "days", + bankExchangeNoData: 'No data available', + bankExchangeIndex: 'ECOin Value (1h)', + bankInflation: 'ECOin Inflation (anual)', + bankInflationMonthly: 'ECOin Inflation (mensual)', + bankExchangeChartTitle: 'ECOin value progression over time', + bankExchangeChartValue: 'Value (ECO/h)', + bankExchangeChartSupply: 'Supply', + bankExchangeChartInflation: 'Inflation %', + bankExchangeChartEmpty: 'Not enough samples yet — revisit later', + bankCurrentSupply: 'ECOin Current Supply', + bankingSyncStatus: 'ECOin Status', + bankingSyncStatusSynced: 'Synced', + bankingSyncStatusOutdated: 'Outdated', + statsTitle: 'Statistics', + statistics: "Statistics", + statsInhabitant: "Inhabitant Stats", + statsDescription: "Discover statistics about your network.", + ALLButton: "ALL", + MINEButton: "MINE", + TOMBSTONEButton: "TOMBSTONES", + statsYou: "You", + statsUserId: "Oasis ID", + statsCreatedAt: "Created At", + statsYourContent: "Content", + statsYourOpinions: "Opinions", + statsYourTombstone: "Tombstones", + statsNetwork: "Network", + statsTotalInhabitants: "Inhabitants", + statsDiscoveredTribes: "Tribes (Public)", + statsPrivateDiscoveredTribes: "Tribes (Private)", + statsNetworkContent: "Content", + statsYourMarket: "Market", + statsYourJob: "Jobs", + statsYourProject: "Projects", + statsYourTransfer: "Transfers", + statsYourForum: "Forums", + statsNetworkOpinions: "Opinions", + statsDiscoveredMarket: "Market", + statsDiscoveredJob: "Jobs", + statsDiscoveredProject: "Projects", + statsBankingTitle: "Banking", + statsEcoWalletLabel: "ECOIN Wallet", + statsEcoWalletNotConfigured: "Not configured!", + statsTotalEcoAddresses: "Total addresses", + statsDiscoveredTransfer: "Transfers", + statsDiscoveredForum: "Forums", + statsNetworkTombstone: "Tombstones", + statsBookmark: "Bookmarks", + statsEvent: "Events", + statsTask: "Tasks", + statsVotes: "Votes", + statsMarket: "Market", + statsForum: "Forums", + statsJob: "Jobs", + statsReport: "Reports", + statsFeed: "Feeds", + statsTribe: "Tribes", + statsImage: "Images", + statsAudio: "Audios", + statsVideo: "Videos", + statsDocument: "Documents", + statsMap: "Maps", + statsShop: "Shops", + statsShopProduct: "Shop products", + statsTransfer: "Transfers", + statsAiExchange: "AI", + statsPUBs: 'PUBs', + statsPost: "Posts", + statsOasisID: "Oasis ID", + statsSize: "Total (size)", + statsBlockchainSize: "Blockchain (size)", + statsBlobsSize: "Blobs (size)", + statsActivity7d: "Activity (last 7 days)", + statsActivity7dTotal: "7-day total", + statsActivity30dTotal: "30-day total", + statsKarmaScore: "KARMA Score", + statsPublic: "Public", + statsPrivate: "Private", + day: "Day", + messages: "Messages", + statsProject: "Projects", + statsProjectsTitle: "Projects", + statsProjectsTotal: "Total projects", + statsProjectsActive: "Active", + statsProjectsCompleted: "Completed", + statsProjectsPaused: "Paused", + statsProjectsCancelled: "Cancelled", + statsProjectsGoalTotal: "Total goal", + statsProjectsPledgedTotal: "Total pledged", + statsProjectsSuccessRate: "Success rate", + statsProjectsAvgProgress: "Average progress", + statsProjectsMedianProgress: "Median progress", + statsProjectsActiveFundingAvg: "Avg. active funding", + statsJobsTitle: "Jobs", + statsJobsTotal: "Total jobs", + statsJobsOpen: "Open", + statsJobsClosed: "Closed", + statsJobsOpenVacants: "Open vacants", + statsJobsSubscribersTotal: "Total subscribers", + statsJobsAvgSalary: "Average salary", + statsJobsMedianSalary: "Median salary", + statsMarketTitle: "Market", + statsMarketTotal: "Total items", + statsMarketForSale: "For sale", + statsMarketReserved: "Reserved", + statsMarketClosed: "Closed", + statsMarketSold: "Sold", + statsMarketRevenue: "Revenue", + statsMarketAvgSoldPrice: "Avg. sold price", + statsUsersTitle: "Inhabitants", + user: "Inhabitant", + statsTombstoneTitle: "Tombstones", + statsNetworkTombstones: "Network tombstones", + statsTombstoneRatio: "Tombstone ratio (%)", + statsParliamentCandidature: "Parliament (candidatures)", + statsParliamentTerm: "Parliament terms", + statsParliamentProposal: "Parliament (proposals)", + statsParliamentRevocation: "Parliament revocations", + statsParliamentLaw: "Parliament laws", + statsCourtsCase: "Court cases", + statsCourtsEvidence: "Court evidence", + statsCourtsAnswer: "Court answers", + statsCourtsVerdict: "Court verdicts", + statsCourtsSettlement: "Court settlements", + statsCourtsSettlementProposal: "Settlement proposals", + statsCourtsSettlementAccepted: "Settlements accepted", + statsCourtsNomination: "Judge nominations", + statsCourtsNominationVote: "Nomination votes", + ai: "AI", + aiTitle: "AI", + aiDescription: "A Collective Artificial Intelligence (CAI) called '42' that learns from your network.", + aiUserQuestion: "Question", + aiResponseTitle: "Reply", + aiSubmitButton: "Send!", + aiSettingsDescription: "Set your prompt (max 128 characters) for the AI model.", + aiPrompt: "Provide an informative and precise response.", + aiConfiguration: "Set prompt", + aiPromptUsed: "Prompt", + aiClearHistory: "Clear chat history", + aiSharePrompt: "Add this answer to collective training?", + aiShareYes: "Yes", + aiShareNo: "No", + aiSharedLabel: "Added to training", + aiRejectedLabel: "Not added to training", + aiServerError: "The AI could not answer. Please try again.", + aiInputPlaceholder: "What is Oasis?", + typeAiExchange: "AI", + aiApproveTrain: "Add to collective training", + aiRejectTrain: "Do not train", + aiTrainPending: "Pending approval", + aiTrainApproved: "Approved for training", + aiTrainRejected: "Rejected for training", + aiSnippetsUsed: "Snippets used", + aiSnippetsLearned: "Snippets learned", + statsAITraining: "AI training", + aiApproveCustomTrain: "Train using this custom answer", + aiCustomAnswerPlaceholder: "Write your custom answer…", + statsAIExchanges: "Model Exchanges", + marketMineSectionTitle: "Your Items", + marketCreateSectionTitle: "Create Item", + marketUpdateSectionTitle: "Update", + marketAllSectionTitle: "Market", + marketRecentSectionTitle: "Recent Market", + marketTitle: "Market", + marketDescription: "A marketplace for exchanging goods or services in your network.", + marketFilterAll: "ALL", + marketFilterMine: "MINE", + marketFilterAuctions: "AUCTIONS", + marketFilterItems: "EXCHANGE", + marketFilterNew: "NEW", + marketFilterUsed: "USED", + marketFilterBroken: "BROKEN", + marketFilterForSale: "FOR SALE", + marketFilterSold: "SOLD", + marketFilterDiscarded: "DISCARDED", + marketFilterRecent: "RECENT", + marketFilterMyBids: "BIDS", + marketCreateButton: "Create Item", + marketItemType: "Type", + marketItemTitle: "Title", + marketItemAvailable: "Deadline", + marketItemDescription: "Description", + marketItemDescriptionPlaceholder: "Describe the item you're selling", + marketItemStatus: "Status", + marketShopLabel: "Shop", + marketItemCondition: "Condition", + marketItemPrice: "Price", + marketItemTags: "Tags", + marketItemTagsPlaceholder: "Enter tags separated by commas", + marketItemDeadline: "Deadline", + marketItemIncludesShipping: "Includes Shipping?", + marketItemHighestBid: "Highest Bid", + marketItemHighestBidder: "Highest Bidder", + marketItemStock: "Stock", + marketOutOfStock: "Out of stock", + marketItemBidTime: "Bid Time", + marketActionsUpdate: "Update", + marketUpdateButton: "Update", + marketActionsDelete: "Delete", + marketActionsSold: "Mark as Sold", + marketActionsChangeStatus: "CHANGE STATUS", + marketActionsBuy: "BUY!", + marketAuctionBids: "Current Bids", + marketPlaceBidButton: "Place Bid", + marketItemSeller: "Seller", + marketNoItems: "No items available, yet.", + marketYourBid: "Your Bid", + marketCreateFormImageLabel: "Upload media (max-size: 50MB)", + marketSearchLabel: "Search", + marketSearchPlaceholder: "Search title or tags", + marketMinPriceLabel: "Min price", + marketMaxPriceLabel: "Max price", + marketSortLabel: "Sort by", + marketSortRecent: "Most recent", + marketSortPrice: "Price", + marketSortDeadline: "Deadline", + marketSearchButton: "Search", + marketAuctionEndsIn: "Ends", + marketAuctionEnded: "Ended", + marketMyBidBadge: "You bid", + marketNoItemsMatch: "No items match your search.", + jobsTitle: "Jobs", + jobsCandidatesTitle: "Suggested candidates", + jobsCandidatesDescription: "Inhabitants whose skills match this job. Send a private message to invite them.", + jobsCandidatesPmBody: "Hi, I think your profile matches my job opening", + jobsDescription: "Discover and manage jobs in your network.", + jobsFilterRecent: "RECENT", + jobsFilterMine: "MINE", + jobsFilterAll: "ALL", + jobsFilterRemote: "REMOTE", + jobsFilterOpen: "OPEN", + jobsFilterClosed: "CLOSED", + jobsCV: "CVs", + jobsCreateJob: "Create Job", + jobsRecentTitle: "Recent Jobs", + jobsMineTitle: "Your Jobs", + jobsAllTitle: "Jobs", + jobsRemoteTitle: "Remote Jobs", + jobsOpenTitle: "Open Jobs", + jobsClosedTitle: "Closed Jobs", + jobsCVTitle: "CVs", + jobsFilterPresencial: "PRESENCIAL", + jobsFilterFreelancer: "FREELANCER", + jobsFilterEmployee: "EMPLOYEE", + jobsPresencialTitle: "Presential Jobs", + jobsFreelancerTitle: "Freelance Jobs", + jobsEmployeeTitle: "Employee Jobs", + jobTitle: "Title", + jobLocation: "Location", + jobSalary: "Salary (ECO/1h)", + jobVacants: "Vacants", + jobDescription: "Description", + jobRequirements: "Requirements", + jobLanguages: "Languages", + jobStatus: "Status", + jobStatusOPEN: "OPEN", + jobStatusCLOSED: "CLOSED", + jobSetClosed: "Set as CLOSED", + jobSubscribeButton: "Join this offer!", + jobUnsubscribeButton: "Leave this offer!", + jobTitlePlaceholder: "Enter job title", + jobDescriptionPlaceholder: "Describe the job", + jobRequirementsPlaceholder: "Enter requirements", + jobLanguagesPlaceholder: "English, Spanish, French, Basque...", + jobTasksPlaceholder: "List tasks", + jobLocationPresencial: "On-place", + jobLocationRemote: "Remote", + jobVacantsPlaceholder: "Number of positions", + jobSalaryPlaceholder: "Salary in ECO for 1 dedicated hour", + jobImage: "Upload media (max-size: 50MB)", + jobTasks: "Tasks", + jobType: "Job Type", + jobTime: "Job Time", + jobSubscribers: "Subscribers", + noSubscribers: "No subscribers", + jobsFilterTop: "TOP", + jobsTopTitle: "Top Salary Jobs", + createJobButton: "Publish Job", + viewDetailsButton: "View Details", + noJobsFound: "No job offers found.", + jobAuthor: "By", + jobTypeFreelance: "Freelancer", + jobTypeSalary: "Employee", + jobTypeExchange: "Hour exchange", + jobTypeEXCHANGE: "HOUR EXCHANGE", + jobsFilterExchange: "EXCHANGE", + jobsExchangeTitle: "Hour-exchange jobs", + jobsHoursOffered: "Hours offered", + jobsHoursRequested: "Hours requested in return", + jobsExchangeSkill: "Skill wanted in exchange", + jobsHoursOfferedPlaceholder: "e.g. 4", + jobsHoursRequestedPlaceholder: "e.g. 4", + jobsExchangeSkillPlaceholder: "e.g. carpentry, design", + jobExchangeHint: "For hour-exchange jobs, fill the fields below instead of salary.", + jobTimePartial: "Part-time", + jobTimeComplete: "Full-time", + jobsDeleteButton: "DELETE", + jobsUpdateButton: "UPDATE", + jobsFilterApplied: "APPLIED", + jobsAppliedTitle: "My applications", + jobsAppliedBadge: "Applied", + jobsFilterFavs: "Favorites", + jobsFavsTitle: "Favorites", + jobsFilterNeeds: "Needs help", + jobsNeedsTitle: "Needs help", + jobsSearchLabel: "Search", + jobsSearchPlaceholder: "Search title, tags, description...", + jobsMinSalaryLabel: "Min salary", + jobsMaxSalaryLabel: "Max salary", + jobsSortLabel: "Sort by", + jobsSortRecent: "Most recent", + jobsSortSalary: "Highest salary", + jobsSortSubscribers: "Most applicants", + jobsSearchButton: "Search", + jobsFavoriteButton: "Favorite", + jobsUnfavoriteButton: "Unfavorite", + jobsMessageAuthorButton: "PM", + jobsApplicants: "Applicants", + jobsUpdatedAt: "Updated", + jobSetOpen: "Set open", + jobNewBadge: "NEW", + jobsTagsLabel: "Tags", + jobsTagsPlaceholder: "tag1, tag2, tag3", + noJobsMatch: "No jobs match your search.", + projectsTitle: "Projects", + projectsDescription: "Create, fund, and follow community-driven projects in your network.", + projectCreateProject: "Create Project", + projectCreateButton: "Create Project", + projectUpdateButton: "UPDATE", + projectDeleteButton: "DELETE", + projectNoProjectsFound: "No projects found.", + projectFilterAll: "ALL", + projectFilterMine: "MINE", + projectFilterActive: "ACTIVE", + projectFilterPaused: "PAUSED", + projectFilterCompleted: "COMPLETED", + projectFilterFollowing: "FOLLOWING", + projectFilterRecent: "RECENT", + projectFilterTop: "TOP", + projectAllTitle: "Projects", + projectMineTitle: "Your Projects", + projectActiveTitle: "Active Projects", + projectPausedTitle: "Paused Projects", + projectCompletedTitle: "Completed Projects", + projectFollowingTitle: "Following Projects", + projectRecentTitle: "Recent Projects", + projectTopTitle: "Top Funded", + projectTitlePlaceholder: "Project name", + projectImage: "Upload media (max-size: 50MB)", + projectDescription: "Description", + projectDescriptionPlaceholder: "Tell the story and goals…", + projectGoal: "Goal (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Deadline", + projectProgress: "Starting Progress (%)", + projectStatus: "Status", + projectFunding: "Funding", + projectPledged: "Pledged", + projectSetStatus: "Set Status", + projectSetProgress: "Update Progress", + projectFollowButton: "FOLLOW", + projectUnfollowButton: "UNFOLLOW", + projectStatusACTIVE: "ACTIVE", + projectStatusPAUSED: "PAUSED", + projectStatusCOMPLETED: "COMPLETED", + projectStatusCANCELLED: "CANCELLED", + projectPledgeTitle: "Back this project", + projectPledgePlaceholder: "Amount in ECO", + projectBounties: "Bounties", + projectBountiesInputLabel: "Bounties (one per line: Title|Amount [ECO]|Description)", + projectBountiesPlaceholder: "Fix UI bug|100|Link to issue\nWrite docs|250|Outline usage examples", + projectNoBounties: "No bounties found.", + projectTitle: "Title", + projectAddBountyTitle: "New Bounty", + projectBountyTitle: "Bounty Title", + projectBountyAmount: "Amount (ECO)", + projectBountyDescription: "Description", + projectMilestoneSelect: "Select Milestone", + projectBountyCreateButton: "Create Bounty", + projectBountyStatus: "Bounty Status", + projectBountyOpen: "open", + projectBountyClaimed: "claimed", + projectBountyDone: "completed", + projectBountyClaimedBy: "claimed by", + projectBountyClaimButton: "claim", + projectBountyCompleteButton: "Mark Completed", + projectMilestones: "Milestones", + projectAddMilestoneTitle: "New milestone", + projectMilestoneTitle: "Milestone Title", + projectMilestoneTargetPercent: "Percent (%)", + projectMilestoneDueDate: "Date", + projectMilestoneCreateButton: "Create Milestone", + projectMilestoneStatus: "Milestone Status", + projectMilestoneOpen: "open", + projectMilestoneDone: "completed", + projectMilestoneDue: "due", + projectNoMilestones: "No milestones found.", + projectMilestoneMarkDone: "Mark as Done", + projectMilestoneTitlePlaceholder: "Enter milestone title", + projectMilestoneDescriptionPlaceholder: "Enter description for this milestone", + projectMilestoneDescription: "Milestone Description", + projectBudgetGoal: "Budget (Goal)", + projectBudgetAssigned: "Assigned to bounties", + projectBudgetRemaining: "Remaining", + projectBudgetOver: "⚠ Over budget: assigned exceeds goal", + projectFollowers: "Followers", + projectFollowersTitle: "Followers", + projectFollowersNone: "No followers yet.", + projectMore: "more", + projectYouFollowHint: "You follow this project", + projectBackers: "Backers", + projectBackersTitle: "Backers", + projectBackersTotal: "Total backers", + projectBackersTotalPledged: "Total pledged", + projectBackersYourPledge: "Your pledge", + projectBackersNone: "No pledges yet.", + projectNoRemainingBudget: "No remaining budget.", + projectFilterBackers: "BACKERS", + projectFilterApplied: "APPLIED", + projectAppliedTitle: "APPLIED", + projectBackersLeaderboardTitle: "Top Backers", + projectNoBackersFound: "No backers found.", + projectBackerAmount: "Total contributed", + projectBackerPledges: "Pledges", + projectBackerProjects: "Projects", + projectPledgeAmount: "Amount", + projectSelectMilestoneOrBounty: "Select Milestone or Bounty", + projectPledgeButton: "Pledge", + footerLicense: "GPLv3", + footerPackage: "Package", + footerVersion: "Version", + modulesModuleName: "Name", + modulesModuleDescription: "Description", + modulesModuleStatus: "Status", + modulesTotalModulesLabel: "Loaded Modules", + modulesEnabledModulesLabel: "Enabled", + modulesDisabledModulesLabel: "Disabled", + modulesPopularLabel: "Popular", + modulesPopularDescription: "Module to receive posts that are trending, most viewed, or most commented on.", + modulesTopicsLabel: "Topics", + modulesTopicsDescription: "Module to receive discussion categories based on shared interests.", + modulesSummariesLabel: "Summaries", + modulesSummariesDescription: "Module to receive summaries of long discussions or posts.", + modulesLatestLabel: "Latest", + modulesLatestDescription: "Module to receive the most recent posts and discussions.", + modulesThreadsLabel: "Threads", + modulesThreadsDescription: "Module to receive conversations grouped by topic or question.", + modulesMultiverseLabel: "Multiverse", + modulesMultiverseDescription: "Module to receive content from other federated peers.", + modulesInvitesLabel: "Invites", + modulesInvitesDescription: "Module to manage and apply invite codes.", + modulesWalletLabel: "Wallet", + modulesWalletDescription: "Module to manage your digital assets (ECOin).", + modulesLegacyLabel: "Legacy", + modulesLegacyDescription: "Module to manage your secret (private key) quickly and securely.", + modulesCipherLabel: "Cipher", + modulesCipherDescription: "Module to encrypt and decrypt your text symmetrically (using a shared password).", + modulesBookmarksLabel: "Bookmarks", + modulesBookmarksDescription: "Module to discover and manage bookmarks.", + modulesVideosLabel: "Videos", + modulesVideosDescription: "Module to discover and manage videos.", + modulesDocsLabel: "Documents", + modulesDocsDescription: "Module to discover and manage documents.", + modulesAudiosLabel: "Audios", + modulesAudiosDescription: "Module to discover and manage audios.", + modulesTagsLabel: "Tags", + modulesTagsDescription: "Module to discover and explore taxonomy patterns (tags).", + modulesImagesLabel: "Images", + modulesImagesDescription: "Module to discover and manage images.", + modulesTrendingLabel: "Trending", + modulesTrendingDescription: "Module to explore the most popular content.", + modulesEventsLabel: "Events", + modulesEventsDescription: "Module to discover and manage events.", + modulesTasksLabel: "Tasks", + modulesTasksDescription: "Module to discover and manage tasks.", + modulesMarketLabel: "Market", + modulesMarketDescription: "Module to exchange goods or services.", + modulesShopsLabel: "Shops", + modulesShopsDescription: "Module to manage and discover shops.", + modulesTribesLabel: "Tribes", + modulesTribesDescription: "Module to explore or create tribes (groups).", + modulesVotationsLabel: "Votations", + modulesVotationsDescription: "Module to discover and manage votations.", + modulesReportsLabel: "Reports", + modulesReportsDescription: "Module to manage and track reports related to issues, bugs, abuses, and content warnings.", + modulesOpinionsLabel: "Opinions", + modulesOpinionsDescription: "Module to discover and vote on opinions.", + modulesTransfersLabel: "Transfers", + modulesTransfersDescription: "Module to discover and manage smart-contracts (transfers).", + modulesFediverseLabel: "Fediverse", + modulesFediverseDescription: "Manage your other fediverse accounts, including sending and receiving content.", + modulesFeedLabel: "Feed", + modulesFeedDescription: "Module to discover and share short-texts (feeds).", + modulesParliamentLabel: "Parliament", + modulesParliamentDescription: "Module to elect governments and vote on laws.", + modulesCourtsLabel: "Courts", + modulesCourtsDescription: "Module to resolve conflicts and emit veredicts.", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Module to draw on a collaborative grid.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Module to generate and share the \"sound\" of your blockchain.", + modulesAgendaLabel: "Agenda", + modulesAgendaDescription: "Module to manage all your assigned items.", + modulesAILabel: "AI", + modulesAIDescription: "Module to talk with a LLM called '42'.", + modulesForumLabel: "Forums", + modulesForumDescription: "Module to discover and manage forums.", + modulesJobsLabel: "Jobs", + modulesJobsDescription: "Module to discover and manage jobs.", + modulesProjectsLabel: "Projects", + modulesProjectsDescription: "Module to explore, crowd-funding and manage projects.", + modulesBankingLabel: "Banking", + modulesBankingDescription: "Module to determine the real value of ECOIN and distribute a UBI using the common treasury.", + modulesFavoritesLabel: "Favorites", + modulesFavoritesDescription: "Module to manage your favorite content.", + fileTooLargeTitle: "File too large", + fileTooLargeMessage: "The file exceeds the maximum allowed size (50 MB). Please select a smaller file.", + goBack: "Go back", + errorPageTitle: "Something went wrong", + visibilityLabel: "Visibility", + visibilityPublic: "Public", + visibilityHidden: "Hidden", + visibilityMakePublic: "Make public", + visibilityMakeHidden: "Make hidden", + invitesInhabitantsTitle: "Inhabitants", + invitesInhabitantsFollow: "Give Support", + aiNavPlaceholder: "Where do you want to go?", + aiNavDisabled: "AI navigation is disabled.", + aiNavResultsTitle: "AI navigation suggestions", + aiNavQueryLabel: "Query", + aiNavResultsDescription: "Pick the route that best matches what you are looking for.", + aiNavResultPath: "Route", + aiNavResultDescription: "What you can do", + aiNavResultMatch: "Match", + aiNavResultsEmpty: "No matching routes. Try /search instead.", + aiNavSearchInstead: "Search instead", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Module for natural-language queries about the network's content.", + directConnect: "Direct Connect", + directConnectDescription: "Connect directly to a peer by entering their IP address, port and public key. The peer will be added as a followed connection.", + peerHost: "IP", + peerPort: "Port (default: 8008)", + peerPublicKey: "Public Key (@...ed25519)", + connectAndFollow: "Connect", + deviceSourceLabel: "Device source", + modulesPresetTitle: "Common Configurations", + modulesPreset_minimal: "Minimal", + modulesPreset_basic: "Basic", + modulesPreset_social: "Social", + modulesPreset_economy: "Economy", + modulesPreset_full: "Full", + statsCarbonFootprintTitle: "Carbon Footprint", + statsCarbonFootprintNetwork: "Network carbon footprint", + statsCarbonFootprintYours: "Your carbon footprint", + statsCarbonTombstone: "Tombstoning footprint", + feedSuccessMsg: "Feed published successfully!", + dominantOpinionLabel: "Dominant opinion", + uploadMedia: "Upload media (max-size: 50MB)", + mapsLabel: "Maps", + mapTitle: "Maps", + mapDescription: "Explore and manage offline maps in your network.", + mapMineSectionTitle: "Your Maps", + mapCreateSectionTitle: "Create Map", + mapUpdateSectionTitle: "Update Map", + mapAllSectionTitle: "Maps", + mapRecentSectionTitle: "Recent Maps", + mapFavoritesSectionTitle: "Favorites", + mapFilterAll: "ALL", + mapFilterMine: "MINE", + mapFilterRecent: "RECENT", + mapFilterFavorites: "FAVORITES", + mapUploadButton: "Upload Map", + mapCreateButton: "Create Map", + mapUpdateButton: "Update", + mapDeleteButton: "Delete", + mapAddFavoriteButton: "Add favorite", + mapRemoveFavoriteButton: "Remove favorite", + mapLatLabel: "Latitude", + mapLatPlaceholder: "e.g. 40.4168", + mapLngLabel: "Longitude", + mapLngPlaceholder: "e.g. -3.7038", + mapDescriptionLabel: "Description", + mapDescriptionPlaceholder: "Describe the map or location...", + mapTypeLabel: "Map type", + mapTypeSingle: "SINGLE (only initial location)", + mapTypeOpen: "OPEN (anyone can add markers)", + mapTypeClosed: "CLOSED (only creator adds markers)", + mapTagsLabel: "Tags", + mapTagsPlaceholder: "Enter tags separated by commas", + mapUrlLabel: "Map URL", + mapPickCoordLabel: "Or select a location on the grid:", + mapMarkersLabel: "markers", + mapMarkersTitle: "Markers", + mapMarkerDefault: "Marker", + mapMarkerLatLabel: "Marker latitude", + mapMarkerLngLabel: "Marker longitude", + mapMarkerLabelField: "Marker label", + mapMarkerLabelPlaceholder: "Describe this marker...", + markerImageLabel: "Marker Image", + mapAddMarkerTitle: "Add Marker", + mapAddMarkerButton: "Add Marker", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Apply Zoom", + mapSearchPlaceholder: "Search description, tags, author...", + mapSearchButton: "Search", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + mapUpdatedAt: "Updated", + mapNoMatch: "No maps match your search.", + noMaps: "No maps available.", + mapLocationTitle: "Map Location", + mapVisitLabel: "Visit map", + mapUrlPlaceholder: "/maps/MAP_ID", + typeMap: "MAPS", + typeMapMarker: "MAP MARKER", + modulesMapLabel: "Maps", + modulesMapDescription: "Module to manage and share offline maps.", + padsTitle: "Pads", + padTitle: "Pad", + modulesPadsLabel: "Pads", + modulesPadsDescription: "Module to manage collaborative text editors.", + padFilterAll: "ALL", + padFilterMine: "MINE", + padFilterRecent: "RECENT", + padFilterOpen: "OPEN", + padFilterClosed: "CLOSED", + padCreate: "Create Pad", + padUpdate: "Update Pad", + padDelete: "Delete Pad", + padTitleLabel: "Title", + padTitlePlaceholder: "Enter pad title...", + padStatusLabel: "Status", + padStatusOpen: "OPEN", + padStatusInviteOnly: "INVITE-ONLY", + padStatusClosed: "CLOSED", + padDeadlineLabel: "Deadline", + padTagsLabel: "Tags", + padTagsPlaceholder: "tag1, tag2, ...", + padMembersLabel: "Members", + padVisitPad: "Visit Pad", + padShareUrl: "Share URL", + padCreated: "Created", + padAuthor: "Author", + padGenerateCode: "Generate Code", + padInviteCodeLabel: "Invite Code", + padInviteCodePlaceholder: "Enter invite code...", + padValidateInvite: "Validate", + padStartEditing: "START EDITING!", + padEditorPlaceholder: "Start writing...", + padSubmitEntry: "Submit", + padNoEntries: "No entries yet.", + padAllSectionTitle: "Pads", + padMineSectionTitle: "Your Pads", + padsNoItems: "No pads found.", + padsDescription: "Manage collaborative encrypted text editors in your network.", + padVersionHistory: "Version History", + padVersionView: "View", + padRecentSectionTitle: "Recent Pads", + padOpenSectionTitle: "Open Pads", + padClosedSectionTitle: "Closed Pads", + padCreateSectionTitle: "Create New Pad", + padUpdateSectionTitle: "Update Pad", + padInviteGenerated: "Invite Code Generated", + typePad: "PADS", + padNew: "NEW", + padAddFavorite: "Add to Favorites", + padRemoveFavorite: "Remove from Favorites", + padClose: "Close Pad", + padBackToEditor: "Back to editor", + padSearchPlaceholder: "Search pads...", + + calendarsTitle: "Calendars", + calendarTitle: "Calendar", + modulesCalendarsLabel: "Calendars", + modulesCalendarsDescription: "Module to discover and manage calendars.", + typeCalendar: "CALENDARS", + calendarFilterAll: "ALL", + calendarFilterMine: "MINE", + calendarFilterOpen: "OPEN", + calendarFilterClosed: "CLOSED", + calendarFilterRecent: "RECENT", + calendarFilterFavorites: "FAVORITES", + calendarCreate: "Create Calendar", + calendarUpdate: "Update", + calendarDelete: "Delete", + calendarTitleLabel: "Title", + calendarTitlePlaceholder: "Calendar title...", + calendarStatusLabel: "Status", + calendarStatusOpen: "OPEN", + calendarStatusClosed: "CLOSED", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Tags", + calendarTagsPlaceholder: "tag1, tag2...", + calendarParticipantsLabel: "Participants", + calendarParticipantsCount: "Participants", + calendarVisitCalendar: "Visit Calendar", + calendarCreated: "Created", + calendarAuthor: "Author", + calendarJoin: "Join Calendar", + calendarGenerateInvite: "Generate invite", + calendarInviteCodePlaceholder: "Enter invite code...", + calendarValidateInvite: "Validate code", + calendarJoined: "Joined", + calendarAddDate: "Add Date", + calendarAddNote: "Add Note", + calendarDateLabel: "Date", + calendarDatePlaceholder: "Describe this date...", + calendarNoteLabel: "Note", + calendarNotePlaceholder: "Add a note...", + calendarFirstDateLabel: "Date", + calendarFirstNoteLabel: "Notes", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Description", + calendarNoDates: "No dates added yet.", + calendarNoNotes: "No notes.", + calendarsNoItems: "No calendars found.", + calendarsDescription: "Discover and manage calendars in your network.", + calendarMonthPrev: "\u2190 Prev", + calendarMonthNext: "Next \u2192", + calendarMonthLabel: "Dates", + calendarsShareUrl: "Share URL", + calendarAllSectionTitle: "Calendars", + calendarRecentSectionTitle: "Recent Calendars", + calendarFavoritesSectionTitle: "Favorites", + calendarMineSectionTitle: "Your Calendars", + calendarOpenSectionTitle: "Open Calendars", + calendarClosedSectionTitle: "Closed Calendars", + calendarCreateSectionTitle: "Create New Calendar", + calendarUpdateSectionTitle: "Update Calendar", + calendarAddFavorite: "Add to Favorites", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Remove from Favorites", + calendarSearchPlaceholder: "Search calendars...", + calendarAddEntry: "Add Entry", + calendarLeave: "Leave Calendar", + statsCalendar: "Calendars", + statsCalendarDate: "Calendar Dates", + statsCalendarNote: "Calendar Notes", + + modulesChatsLabel: "Chats", + modulesChatsDescription: "Module to discover and manage encrypted chats.", + typeChat: "CHATS", + typeChatMessage: "CHAT MESSAGE", + chatLabel: "CHATS", + chatMessageLabel: "CHAT MESSAGES", + chatsTitle: "Chats", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "Favorites", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "Description", + chatCategory: "Category", + chatStatus: "STATUS", + chatFilterAll: "ALL", + chatFilterMine: "MINE", + chatFilterRecent: "RECENT", + chatFilterFavorites: "FAVORITES", + chatFilterOpen: "OPEN", + chatFilterClosed: "CLOSED", + chatCreate: "Create Chat", + chatUpdate: "Update Chat", + chatDelete: "Delete Chat", + chatClose: "Close Chat", + chatVisitChat: "VISIT CHAT", + chatUntitled: "Untitled Chat", + chatNoItems: "No chats found.", + chatParticipants: "Participants", + chatStartChatting: "START CHATTING!", + chatGenerateCode: "Generate Code", + chatShareUrl: "Share URL", + chatCreatedAt: "CREATED", + chatSearchPlaceholder: "Search chats...", + chatStatusOpen: "OPEN", + chatStatusInviteOnly: "INVITE-ONLY", + chatStatusClosed: "CLOSED", + chatSendMessage: "Send", + chatMessagePlaceholder: "Type your message...", + chatNoMessages: "No messages yet.", + chatLeave: "Leave Chat", + chatInviteCodeLabel: "Enter invite code", + chatJoinByInvite: "Join", + chatTitlePlaceholder: "Chat title", + chatDescriptionPlaceholder: "Chat description", + chatTagsPlaceholder: "tag1, tag2, tag3", + chatImageLabel: "Select an image file (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Invite Code", + chatAuthor: "Author", + chatCreated: "Created", + chatAddFavorite: "Add to Favorites", + chatRemoveFavorite: "Remove from Favorites", + chatPM: "PM", + chatStatusLabel: "Status", + chatCategoryLabel: "Category", + chatParticipantsLabel: "Participants", + gamesTitle: "Games", + gamesDescription: "Discover and play some mini-games in your network.", + gamesFilterAll: "ALL", + gamesPlayButton: "PLAY!", + gamesBackToGames: "Back to Games", + modulesGamesLabel: "Games", + modulesGamesDescription: "Module to discover and play some games.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Module to explore the network as an interactive map of peers.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Connect PUBs to habitants through validators, shops and accumulators. Survive the CBDC threat!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Infiltrate the grid, collect confidential data, evade security drones and escape. How many levels can you clear?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "Stop the alien invasion! Shoot down waves of invaders before they reach the ground.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Break all the bricks with your paddle and ball. A classic arcade challenge.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Classic ping-pong against an AI opponent. First to 5 points wins.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "Race against time! Dodge traffic and reach the finish line before the clock runs out.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Pilot your ship through a deadly asteroid field. Shoot them down before they hit you.", + gamesRockPaperScissorsTitle: "Rock Paper Scissors", + gamesRockPaperScissorsDesc: "Rock, Paper, Scissors against an AI. Best of three rounds wins.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Classic Tic-Tac-Toe against an AI. Get three in a row to win.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Flip a coin and bet on heads or tails. How lucky are you?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Ghosts move in real time — keep moving!", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + gamesHallDate: "Date", + typeGameScore: "GAME SCORES", + gamesNewRecord: "New Record", + gameScoreLabel: "GAME SCORES", + uxModeTitle: "UX", + uxModeDescription: "Select which UX navigation mode you want for your GUI.", + uxModeMenus: "Blocks", + uxModeAINav: "AI", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsPad: "Pads", + statsPadEntry: "Pad entries", + statsGameScore: "Gaming", + chatAccessDenied: "You do not have access to the chat. Ask for an invitation to access the content.", + padAccessDenied: "You do not have access to the pad. Ask for an invitation to access the content.", + contentAccessDenied: "You do not have access to content.", + blockAccessRestricted: "Access restricted", + tribeContentAccessDenied: "Access Denied", + tribeContentAccessDeniedMsg: "This content belongs to a tribe. You must be a member to access it.", + tribeViewTribes: "View Tribes", + torrentsTitle: "Torrents", + torrentsDescription: "Explore and manage torrents in your network.", + torrentAllSectionTitle: "Torrents", + torrentMineSectionTitle: "Your Torrents", + torrentRecentSectionTitle: "Recent Torrents", + torrentTopSectionTitle: "Top Torrents", + torrentFavoritesSectionTitle: "Favorites", + torrentFilterAll: "ALL", + torrentFilterMine: "MINE", + torrentFilterRecent: "RECENT", + torrentFilterTop: "TOP", + torrentFilterFavorites: "FAVORITES", + torrentCreateSectionTitle: "Upload Torrent", + torrentUpdateSectionTitle: "Update Torrent", + torrentCreateButton: "Upload Torrent", + torrentUpdateButton: "Update", + torrentDeleteButton: "Delete", + torrentAddFavoriteButton: "Add Favorite", + torrentRemoveFavoriteButton: "Remove Favorite", + torrentFileLabel: "Select torrent file (.torrent)", + torrentTitleLabel: "Title", + torrentTitlePlaceholder: "Title", + torrentDescriptionLabel: "Description", + torrentDescriptionPlaceholder: "Description", + torrentTagsLabel: "Tags", + torrentTagsPlaceholder: "Enter tags separated by commas", + torrentSizeLabel: "Size", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "No torrent file", + noTorrents: "No torrents found.", + torrentSearchPlaceholder: "Search title, tags, author...", + torrentSearchButton: "Search", + torrentSortRecent: "Most recent", + torrentSortOldest: "Oldest", + torrentSortTop: "Most voted", + torrentNoMatch: "No matching torrents.", + torrentMessageAuthorButton: "Message Author", + torrentUpdatedAt: "Updated", + statsTorrent: "Torrents", + typeTorrent: "TORRENTS", + modulesTorrentsLabel: "Torrents", + modulesTorrentsDescription: "Module to discover and manage torrents.", + favoritesFilterTorrents: "TORRENTS", + favoritesFilterMarket: "MARKET", + favoritesFilterShopProducts: "SHOP ITEMS", + tribeSectionTorrents: "TORRENTS", + tribeCreateTorrent: "Upload Torrent", + tribeMediaTypeTorrent: "Torrent", + settingsWishTitle: "Wish", + settingsWishDesc: "Configure the wish of your Avatar. This determines how to access the whole content and your level of experiences into the network.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Private Messages", + settingsPmVisibilityDesc: "Configure the level of exposition to private messages that you want to have in the network.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "Outbound is a UX guardrail. Inbound is an attention filter (ciphertext is still replicated).", + pmBlockedNonMutual: "You can only send private messages to habitants with mutual support.", + inhabitantsPendingFollowsTitle: "Pending follow requests", + inhabitantsPendingAccept: "Accept", + inhabitantsPendingReject: "Reject", + bxEncrypted: "ENCRYPTED", + bxEncryptedHexLabel: "Ciphertext (preview)", + tribeSectionGovernance: "GOVERNANCE", + tribeSubStatusPublic: "PUBLIC", + tribeSubStatusPrivate: "PRIVATE", + tribeSubInheritedPrivate: "PRIVATE (inherited from main tribe)", + tribePadInviteRequired: "You do not have access to the pad. Ask for an invitation to access the content.", + tribeChatInviteRequired: "You do not have access to the chat. Ask for an invitation to access the content.", + tribeGovernanceDesc: "Internal governance for this tribe. Propose candidatures, debate rules, elect leaders.", + tribeGovernanceNoGov: "No active government", + tribeGovernanceNoGovDesc: "This tribe has not yet elected a government. Propose candidatures to start the process.", + tribeGovCardTitle: "Current Government", + tribeGovCycleSince: "CYCLE SINCE", + tribeGovCycleEnd: "CYCLE END", + tribeGovTimeRemaining: "TIME REMAINING", + tribeGovPopulation: "POPULATION", + tribeGovMethod: "METHOD", + tribeGovVotesReceived: "VOTES RECEIVED", + tribeGovLeader: "LEADER", + tribeGovFilterGovernment: "GOVERNMENT", + tribeGovFilterCandidatures: "CANDIDATURES", + tribeGovFilterLaws: "LAWS", + tribeGovCandidatureId: "Candidature", + tribeGovCandidatureMethod: "Method", + tribeGovCandidatureProposeBtn: "Publish Candidature", + tribeGovRuleTitle: "Rule Title", + tribeGovRuleBody: "Rule Body", + tribeGovProposals: "PROPOSALS", + tribeGovRevocations: "REVOCATIONS", + tribeGovHistorical: "HISTORICAL", + tribeGovRules: "RULES", + tribeGovernanceAlreadyPublished: "This tribe already has an open candidature in the current global parliament cycle.", + tribeGovernanceProposeInternal: "Propose internal candidature", + tribeGovernanceInternalCandidatures: "Internal candidatures", + tribeGovernanceNoCandidatures: "No open candidatures.", + tribeGovernanceAddRule: "Add rule", + tribeGovernanceNoRules: "No rules yet.", + tribeGovernanceNoLeaders: "No leaders elected yet.", + tribeGovernanceComingSoon: "Coming soon in this tribe's governance module.", + tribeGovNoProposals: "Not any proposal, yet", + tribeGovNoLaws: "Not any law, yet", + tribeGovNoRevocations: "Not any revocation, yet", + tribeGovNoHistorical: "Not any record, yet", + logsTitle: "Logs", + logsDescription: "Record your experience in the network.", + logsReadMore: "Read more", + logsViewTitle: "Log", + logsColumnType: "Type", + logsView: "View", + logsManualPrompt: "Write your log", + logsUpdateButton: "Update", + logsEditTitle: "Edit Log", + logsCreateDescription: "Record your experience...", + logsCreateTitle: "Create Log", + logsWriteButton: "Write", + logsTextPlaceholder: "Describe your experiences...", + logsTextField: "Text", + logsLabelPlaceholder: "Label...", + logsLabelField: "Write your log (label)", + logsAiDisabledWarn: "Enable the AI module in /modules to use AI-written logs.", + logsAiContextValue: "Blockchain day", + logsAiContext: "Context", + logsAiModStatus: "AImod", + logsModeApply: "Apply!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Manual", + logsModeAI: "AI", + logsColumnDelete: "Delete", + logsColumnEdit: "Edit", + logsColumnLog: "Log", + logsColumnDate: "Date", + logsDelete: "Delete", + logsEdit: "Edit", + logsFilterAlways: "ALWAYS", + logsFilterYear: "LAST YEAR", + logsFilterMonth: "LAST MONTH", + logsFilterWeek: "LAST WEEK", + logsFilterToday: "TODAY", + logsFilterRecent: "RECENT", + logsFilterAll: "ALL", + logsCreate: "Create Log", + logsExport: "Export Logs", + logsExportOne: "Export", + logsViewDetails: "View Details", + logsGenerateButton: "Generate Text", + logsSearchText: "Search in logs...", + logsSearchAnyType: "Any type", + logsSearchButton: "Search", + logsEmpty: "No logs yet.", + modulesLogsLabel: "Logs", + modulesLogsDescription: "Module to record (via AI assistant) your experiences.", + statsLogsTitle: "Logs", + statsLogsEntries: "Entries", + typeLog: "LOGS", + blockchainCycle: "Cycle", + + + } +}; diff --git a/src/client/assets/translations/oasis_es.js b/src/client/assets/translations/oasis_es.js new file mode 100644 index 0000000..099b669 --- /dev/null +++ b/src/client/assets/translations/oasis_es.js @@ -0,0 +1,3873 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + es: { + languageName: "Castellano", + shopOrderStatusPaid: "Pago recibido", + shopOrderStatusReceived: "Recibido", + shopOrderMarkPaid: "Marcar pago recibido", + shopOrderConfirmReceived: "Confirmar recibido", + shopMyOrdersTitle: "Mis pedidos", + shopPurchasesButton: "Compras", + shopMyOrdersDescription: "Tus pedidos de compra.", + shopMyOrdersEmpty: "Aún no tienes compras.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Vendedor", + shopOrderPaymentConcept: "Pago", + shopOrderInvoice: "Factura", + shopOrderInvoiceConcept: "Factura", + shopOrderStatusPending: "Pendiente", + shopOrderStatusAccepted: "Aceptado", + shopOrderStatusRejected: "Rechazado", + shopOrderStatusShipped: "Enviado", + shopOrderStatusDelivered: "Entregado", + shopOrderAccept: "Aceptar", + shopOrderReject: "Rechazar", + shopOrderMarkShipped: "Marcar enviado", + shopOrderMarkDelivered: "Marcar entregado", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Contrato", + shopOrderContractConcept: "Pedido de tienda", + shopOrdersPending: "Pedidos pendientes", + all: "Todos", + mine: "Míos", + recent: "Recientes", + anonymous: "Anónimo", + unnamed: "Anónimo", + authorLabel: "AUTOR", + delete: "Eliminar", + save: "Guardar", + vote: "Votar", + url: "URL", + price: "Precio", + priority: "Prioridad", + severity: "Gravedad", + organizer: "Organizador", + edited: "editado", + isPublic: "Público", + searchIsPublicLabel: "Público", + privacyPrivate: "PRIVADO", + privacyPublic: "PÚBLICO", + notFound: "No encontrado", + no_results: "Sin datos", + searchButton: "Buscar", + mapZoomLabel: "Zoom", + modulesTitle: "Módulos", + viewJson: "Ver JSON", + matchScore: "Puntuación de coincidencia", + preferencesLabel: "Preferencias", + inhabitantProfileTitle: "Perfil del habitante", + contentWarningLabel: "Aviso de contenido", + invalidPost: "Contenido no válido", + invalidPostHint: "Este mensaje tiene texto vacío o no válido.", + spreadBy: "Por", + spreadChron: "Difusión", + spreaded: "Difundido", + spreadMore: "más", + spreadContentUnavailable: "Contenido aún no disponible (replicación pendiente)", + filteredByTag: "Filtrado por etiqueta", + filteredByTagTitle: "Filtrado por etiqueta", + feedPublishedSuccess: "¡Publicación creada con éxito!", + tribeFeedSent: "¡Mensaje enviado con éxito!", + tribeContentEncrypted: "Contenido cifrado", + tribeTorrentsEmpty: "Aún no hay torrents.", + torrentDescription: "Descripción", + torrentDownload: "Descargar", + chatInviteMode: "Invitar", + padInviteMode: "Invitar", + noInviteMode: "Sin modo de invitación", + noDeadline: "Sin fecha límite", + noStatus: "Sin estado", + noSeverity: "Sin gravedad", + calendarDeleteDate: "Eliminar fecha", + calendarIntervalUntil: "Hasta", + bookmarkCategory: "Categoría", + bookmarkLastVisit: "Última visita", + profileClearnetBookmarksLabel: "Marcadores", + forumFilterHot: "Populares", + invitesPort: "Puerto", + currentlyUnrecheable: "¡ERROR!", + peerHostValidation: "IPv4 válida (p. ej. 192.168.1.100) o nombre de host (p. ej. pub.example.com)", + peerPortValidation: "Puerto 1-65535", + peerKeyValidation: "Clave pública SSB ed25519 (@<44 caracteres base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Baja de tu oferta de empleo", + inboxProjectFollowedTitle: "Nuevo seguidor de tu proyecto", + inboxProjectUnfollowedTitle: "Dejó de seguir tu proyecto", + pmHasFollowedYourProject: "ha seguido tu proyecto", + pmHasUnfollowedYourProject: "ha dejado de seguir tu proyecto", + pmHasUnsubscribedFromYourJobOffer: "se ha dado de baja de tu oferta de empleo", + marketDeleteButton: "Eliminar", + marketNoBids: "No se han realizado pujas, aún.", + marketAuctionBidAmount: "Importe de la puja", + marketAuctionBidTime: "Hora de la puja", + marketAuctionUser: "Pujador", + parliamentActorInPower: "TRIBU GOBERNANTE", + parliamentWinningCandidature: "Candidatura ganadora", + projectBounty: "Recompensa", + projectFollowing: "SIGUIENDO", + projectBackerAuthor: "Patrocinador", + projectBackerDate: "Fecha", + projectConfirmTransferButton: "Confirmar transferencia", + projectPendingTransfersTitle: "Transferencias pendientes", + transfersUpdateSectionTitle: "Actualizar transferencia", + taskUnassignedFrom: "Desasignado de", + taskDescriptionPlaceholder: "Descripción de la tarea", + exportPasswordLabel: "Contraseña de exportación", + importPasswordPlaceholder: "Introduce la contraseña", + searchCasesPlaceholder: "Buscar casos…", + shopBuyDeliveryAddressPlaceholder: "Dirección de entrega", + shopBuyNotesPlaceholder: "Notas", + bankTaxesUserNoteBold: "reducir directamente el impuesto ECO", + bankTaxesUserNoteIntro: "Tu impuesto ECO se deduce del excedente que la red genera por encima de tu RBU; el valor base de la RBU se fija como mínimo inamovible. El impuesto nunca se deduce de la cantidad fija que corresponde a cada habitante como RBU. Los fondos deducidos alimentan el algoritmo de redistribución de riqueza y se canalizan de vuelta a otros habitantes mediante sus reclamos de RBU. Esos otros habitantes que reciben más RBU para sus proyectos probablemente tendrán alguna tarea dedicada a ayudarte a reducir tu impuesto ECO. O pueden dedicarse a ", + bankTaxesUserNoteOutro: ", y el consenso de la red puede requerir su contribución continua.", + courtsViewDetails: "Ver", + courtsViewDetailsShort: "Detalles", + courtsThDetails: "Detalles", + courtsDetailsAnswersTitle: "Respuestas", + courtsDetailsEvidenceTitle: "Pruebas", + courtsDetailsSettlementsTitle: "Acuerdos", + courtsDetailsVerdictTitle: "Veredicto", + courtsDetailsNotPublic: "No público", + courtsEvidenceSideAccuser: "Pruebas del acusador", + courtsEvidenceSideRespondent: "Pruebas de la defensa", + courtsEvidenceSideUnknown: "Desconocido", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Apoyar el caso", + courtsSettlementAcceptBtn: "Aceptar acuerdo", + courtsSupportCount: "Apoyos", + courtsVerdictVoteTitle: "Votar el veredicto", + courtsVerdictVoteLabel: "Voto del veredicto", + courtsVerdictVoteAccept: "Aceptar", + courtsVerdictVoteReject: "Rechazar", + courtsVerdictVoteSubmit: "Enviar voto", + reportsSetStatus: "Cambiar estado", + reportsStatusClosed: "CERRADO", + statsAvgPerInhabitant: "Media por habitante", + statsCarbonMaxAnnual: "Estimación máx. anual", + statsCarbonNetwork: "Total de la red", + statsCarbonOfEstMax: "de la capacidad máx. estimada", + statsCarbonOfNetwork: "del total de la red", + statsCarbonUser: "Tu huella", + statsContentCountColumn: "Cantidad", + statsContentTypeColumn: "Tipo", + statsMsgsPerDay: "Mensajes/día (histórico)", + statsMyShare: "Tu parte de la red", + statsNetworkSpan: "Antigüedad de la red", + statsTombstoneRatioLabel: "Proporción de borrados", + statsTopTagsTitle: "Etiquetas principales", + statsTopTypesTitle: "Tipos de contenido principales", + statsTotalMsgs: "Mensajes totales", + feedViewDetails: "Ver detalles", + eventFileLabel: "Adjuntar imagen", + taskFileLabel: "Adjuntar imagen", + courtsRespondentRequired: "El ID del demandado es obligatorio", + extended: "Multiverso", + extendedDescription: [ + "Cuando apoyas a alguien, puedes descargar las publicaciones de habitantes que apoya, y esas publicaciones aparecerán aquí, ordenadas por la más reciente.", + ], + popular: "Destacadas", + popularDescription: [ + "Publicaciones de habitantes en tu red, ", + strong("ordenadas por apoyos"), + ". Selecciona el período de tiempo para obtener una lista.", + ], + week: "Semana", + month: "Mes", + year: "Año", + latest: "Últimos", + latestDescription: [ + strong("Publicaciones"), + " tuyas y de habitantes que apoyas, ordenadas por las más recientes.", + ], + topics: "Temas", + topicsDescription: [ + strong("Temas"), + " tuyos y de habitantes que apoyas, ordenados por los más recientes.", + ], + summaries: "Resúmenes", + summariesDescription: [ + strong("Temas y algunos comentarios"), + " tuyos y de habitantes que apoyas, ordenados por los más recientes.", + ], + threads: "Hilos", + threadsDescription: [ + strong("Publicaciones que tienen comentarios"), + " de habitantes que apoyas y de tu multiverso, ordenadas por las más recientes.", + ], + profile: "Avatar", + inhabitants: "Habitantes", + peersReplicatedFeeds: "Feeds replicados", + graphos: "Graphos", + graphosDescription: "Mapa interactivo de la red alrededor de ti.", + graphosViewingGraphOf: "Viendo", + graphosBackToMine: "← Mi red", + graphosShowing: "Mostrando", + graphosYou: "Tú", + graphosTotalNodes: "Nodos totales", + manualMode: "Modo Manual", + mentions: "Menciones", + mentionsDescription: [ + strong("Publicaciones que te @mencionan"), + ", ordenadas por las más recientes.", + ], + nextPage: "Siguiente", + previousPage: "Anterior", + noMentions: "No has recibido @menciones, aún.", + privateReminders: "RECORDATORIOS", + inboxFiltersLabel: "Filtros:", + inboxToggleToMutuals: "Cambiar a mutuos", + inboxToggleToWhole: "Cambiar a todos", + privateFrom: "De", + privateTo: "Para", + privateDate: "Fecha", + peers: "Nodos", + searchDescription: "Descripición", + imageSearch: "Buscar Imágenes", + searchFromLabel: "Desde", + searchToLabel: "Hasta", + searchMinOpinionsLabel: "Mín. opiniones", + searchInhabitantLabel: "Habitante (Oasis ID)", + searchQueryLabel: "Búsqueda", + searchPerPageLabel: "Resultados por página", + settings: "Configuración", + continueReading: "Continuar leyendo", + moreComments: "más comentarios", + readThread: "leer el resto del hilo", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Dibuja pixeles en un grid y colabor-ART con habitantes en tu red.', + melodyTitle: 'Melody', + melodyDescription: 'Reproduce la melodía de tu blockchain — cada bloque se convierte en una nota.', + melodyEmpty: 'Aún no hay notas — tu blockchain todavía no ha generado ningún bloque.', + melodyCompositionHelp: 'Cada bloque de tu blockchain se convierte en una nota musical según su tipo y tamaño.', + melodyStatsTitle: 'Desglose por tipo', + melodyInhabitantLabel: 'Habitante', + melodyTotalBlocks: 'Notas', + melodyType: 'Tipo', + melodyCount: 'Bloques', + melodyFilterAll: 'TODAS', + melodyFilterShare: 'Subir melodía', + melodyShareTitle: 'Comparte tu melodía', + melodyShareHelp: 'Publica una instantánea de tu melodía actual para que otros la escuchen y remezclen.', + melodyShareTitleLabel: 'Título (opcional)', + melodyShareTitlePlaceholder: 'Un fantasma de blockchain', + melodyShareSubmit: 'Publicar Melodía', + melodyNoShared: 'Aún no hay melodías de blockchain.', + melodyRegenerate: 'Regenerar', + melodyDownload: "Descargar BCS", + profileActivityTitle: 'Actividad', + profileActivityMore: 'Ver toda la actividad', + profileContentSectionTitle: 'Contenido del Avatar', + profileContentHelp: 'Elige qué módulos se mostrarán en tu avatar.', + profileSensorsHelp: 'Métricas opcionales que se muestran en tu avatar.', + profileClearnetHelp: 'Módulos que pueden ser consultados desde fuera de Oasis.', + profileHubAll: 'Todo', + profileHubTitle: 'Contenido Público', + footerPulseTitle: 'Pulso', + footerPulsePeers: 'Pares', + footerPulseInbox: 'Bandeja', + footerPulseSync: 'Última sinc.', + footerPulseEcoValue: 'ECO/h', + footerPulseLastActivity: 'Última actividad', + footerLicenseLabel: 'Licencia', + profileSwitchToOasis: 'Volver a Oasis', + profileSwitchToClearnet: 'Sumérgete en Clearnet', + profileVisibilityFediverse: "Fediverse", + profileSwitchToFediverse: "Sumérgete en el Fediverso", + coordLabel: 'Coordenadas (e.g., A3)', + coordPlaceholder: 'Introduce coordenada', + contributorsTitle: "Contribuciones", + pixeliaBy: "por", + colorLabel: 'Elige un color', + paintButton: 'Dibujar!', + invalidCoordinate: 'Coordenada incorrecta', + goToMuralButton: "Ver Mural", + totalPixels: 'Total Pixeles', + modules: "Módulos", + modulesViewTitle: "Módulos", + modulesViewDescription: "Define tu entorno ideal activando y desactivando módulos.", + inbox: "Buzón", + multiverse: "Multiverso", + fediverse: "Fediverse", + fediverseDescription: "Gestiona tus otras cuentas del fediverso, incluyendo enviar y recibir contenido.", + fediverseStatus: "Estado", + fediverseDisconnected: "Fediverse desconectado. Comprueba %LINK% o el estado de la conexión.", + fediverseSettingsTitle: "Fediverse", + fediverseInstanceLabel: "Dirección", + fediverseTokenLabel: "Token de acceso", + fediverseTokenHelp: "Configura tu token de acceso. Se usará para gestionar tu cuenta de Mastodon desde Oasis.", + fediverseConnect: "Conectar", + fediverseConnectedAs: "Conectado como", + fediverseDisconnect: "Desconectar", + fediverseEmpty: "Cuando conectes otras cuentas del fediverso desde %LINK%, podrás gestionarlas desde aquí.", + fediverseEmptyLink: "tus ajustes", + fediverseComposePlaceholder: "¿Qué estás pensando?", + fediverseReplyPlaceholder: "Escribe tu respuesta…", + fediverseAttach: "Adjuntar media", + fediversePublish: "Publicar", + fediverseReply: "Responder", + fediverseBoosted: "ha impulsado", + fediverseNoPosts: "Tu timeline está vacío.", + fediverseThread: "Hilo", + fediverseTimeline: "Cronologías", + fediverseManageTimeline: "Gestionar cronología", + fediverseFollowers: "Seguidores", + fediverseFollowing: "Siguiendo", + fediversePosts: "Publicaciones", + fediverseJoined: "Se unió", + fediverseOverview: "Resumen", + fediverseRefresh: "Actualizar", + fediverseWrite: "Escribir", + fediversePreview: "Vista previa", + fediverseEdit: "Editar", + fediverseOpenFeed: "Visitar feed", + fediverseManage: "Gestionar", + fediverseStatusNotConnected: "No conectado", + fediverseError: "No se pudo contactar con Mastodon.", + fediverseErrInstance: "URL de instancia no válida.", + fediverseErrAuth: "Token no válido o caducado.", + fediverseErrConnect: "No se pudo conectar con la instancia.", + fediverseErrToken: "Token obligatorio.", + fediverseErrPublic: "No disponible en modo público.", + fediverseErrFetch: "No se pudo cargar el timeline.", + fediverseErrPost: "No se pudo publicar.", + fediverseErrMedia: "No se pudo subir el archivo.", + fediverseErrEmpty: "Escribe algo o adjunta un archivo.", + popularLabel: "Destacadas", + topicsLabel: "Tópicos", + latestLabel: "Últimas", + summariesLabel: "Resúmenes", + threadsLabel: "Hilos", + multiverseLabel: "Multiverso", + inboxLabel: "Buzón", + invitesLabel: "Invitaciones", + walletLabel: "Cartera", + legacyLabel: "Llaves", + cipherLabel: "Cifrador", + bookmarksLabel: "Marcadores", + videosLabel: "Vídeos", + torrentsLabel: "Torrents", + docsLabel: "Documentos", + audiosLabel: "Audios", + tagsLabel: "Etiquetas", + imagesLabel: "Imágenes", + inhabitantsLabel: "Habitantes", + trendingLabel: "Tendencias", + eventsLabel: "Eventos", + tasksLabel: "Tareas", + apply: "Aplicar", + menuPersonal: "Personal", + menuContent: "Contenido", + menuBlogs: "Blogs", + menuGovernance: "Gobernanza", + menuOffice: "Oficina", + menuMultiverse: "Multiverso", + menuNetwork: "Red", + menuCreative: "Creativo", + menuEconomy: "Economía", + menuMedia: "Multimedia", + menuTools: "Herramientas", + comment: "Comentar", + subtopic: "Subtema", + json: "JSON", + createdBy: "por", + unfollow: "Dejar de soportar", + follow: "Soportar", + block: "Bloquear", + unblock: "Desbloquear", + newerPosts: "Nuevos posts", + olderPosts: "Viejos posts", + feedRangeEmpty: "El rango aplicado está vación para éste feed. Trata de ver el ", + seeFullFeed: "feed completo", + feedEmpty: "La red Oasis no ha visto nunca posts desde ésta cuenta.", + beginningOfFeed: "Éste es el comienzo del feed", + noNewerPosts: "No se han recibido posts nuevos, aún.", + relationshipNotFollowing: "No te da soporte", + relationshipTheyFollow: "Te da soporte", + relationshipMutuals: "Soporte mútuo", + relationshipFollowing: "Estás dando soporte", + relationshipYou: "Tú", + relationshipBlocking: "Estás bloqueando", + relationshipBlockedBy: "Te bloquea", + relationshipMutualBlock: "Bloqueo mutuo", + relationshipNone: "No estás dando soporte", + relationshipConflict: "Conflicto", + relationshipBlockingPost: "Post bloqueado", + viewLikes: "Ver replicas", + spreadedDescription: "Lista de posts replicados por habitante.", + totalspreads: "Replicas totales", + attachFiles: "Adjuntar ficheros", + preview: "Previsualizar", + publish: "Publicar", + contentWarningPlaceholder: "Añade un título a tu post (opcional)", + privateWarningPlaceholder: "Añade habitantes para enviar un post privado (opcional)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "RECUERDA: Debido a la tecnología blockchain, una vez has publicado algo, no puede ser editado ni borrado. Piénsalo bien!.", + ], + commentWarning: [ + "RECUERDA: Debido a la tecnología blockchain, una vez has publicado algo, no puede ser editado ni borrado. Piénsalo bien!.", + ], + commentPublic: "público", + commentPrivate: "privado", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "RECUERDA: Debido a la tecnología blockchain, una vez has publicado algo, no puede ser editado ni borrado. Piénsalo bien!.", + ], + replyLabel: ({ markdownUrl }) => [ + "RECUERDA: Debido a la tecnología blockchain, una vez has publicado algo, no puede ser editado ni borrado. Piénsalo bien!.", + ], + publishCustomInfo: ({ href }) => [ + "Si tienes experiencia, también puedes ", + a({ href }, "escribir un post avanzado"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "Si no tienes experiencia, deberías ", + a({ href }, "escribir un post normal"), + ".", + ], + publishCustom: "Escribir un post avanzado", + subtopicLabel: "Crea un subtopic para éste post", + messagePreview: "Previsualización", + mentionsMatching: "Menciones", + mentionsName: "Nombre", + mentionsRelationship: "Relación", + updateit: "OBTENER ACTUALIZACIONES!", + updateBannerText: "Hay una nueva versión de Oasis disponible.", + updateBannerAction: "Actualizar ahora →", + info: "Info", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "hace", + sendTime: "escrito ", + theme: "Tema", + legacy: "Llaves", + legacyTitle: "Llaves", + legacyDescription: "Maneja tu secreto (llave privada) de forma rápida y segura.", + legacyExportButton: "Exportar", + legacyImportButton: "Importar", + ssbLogStream: "Blockchain", + ssbLogStreamDescription: "Configura el límite de mensajes para los flujos de la blockchain.", + saveSettings: "Guardar configuración", + exportTitle: "Exportar datos", + exportDescription: "Establece una contraseña (min 32 caracteres de longitud) para cifrar tu secreto", + exportDataTitle: "Copia de Seguridad", + exportDataDescription: "Descargar tu blockchain (llave privada excluida!)", + exportDataButton: "Descargar blockchain", + pubWallet: "Cartera del PUB", + pubWalletDescription: "Establece la URL del wallet del PUB. Esta se utilizará para las transacciones del PUB (incluída la RBU).", + pubWalletConfiguration: "Guardar Configuración", + importTitle: "Importar datos", + importDescription: "Importa tu secreto cifrado (llave privada) para activar tu avatar", + importAttach: "Adjuntar fichero cifrado (.enc)", + passwordLengthInfo: "La contraseña debe tener mínimo 32 caracteres de longitud.", + passwordImport: "Escribe tu contraseña para descifrar los datos que serán guardados en tu sistema (nombre: secret)", + exportPasswordPlaceholder: "Utiliza minúsculas, mayúsculas, números y símbolos", + fileInfo: "Tu secreto cifrado será guardado en tu sistema (nombre: oasis.enc)", + themeIntro: + "Elige un tema.", + setTheme: "Establecer tema", + language: "Lenguaje", + languageDescription: + "Si quieres utilizar otro idioma, seleccionalo aquí.", + setLanguage: "Establecer lenguaje", + peerConnections: "Nodos", + peerConnectionsIntro: "Maneja todas tus conexiones con otros nodos.", + peerConnectionsTitle: "Conexiones", + online: "Conectado", + offline: "Desconectado", + discovered: 'Descubiertos', + unknown: 'Desconocidos', + lanBroadcastLabel: "Difusión LAN", + lanBroadcastActive: "Activa (multicast UDP en la LAN)", + lanBroadcastDisabled: "Desactivada (modo pub)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Reciente", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Soportado", + recommended: "Recomendado", + blocked: "Bloqueado", + noConnections: "No hay nodos conectados.", + noDiscovered: "No has descubierto nodos.", + noUnknownPeers: "Sin pares desconocidos en el grafo de amistades.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Refrescar", + peerPruneIdle: "Quitar inactivos", + peerExport: "Exportar", + peerImport: "Importar", + peerImportTitle: "Importar lista de peers", + peerImportPlaceholder: "Pega una dirección multiserver por línea, o sube un archivo .txt…", + noSupportedConnections: "No soportas nodos.", + noBlockedConnections: "No bloqueas nodos.", + noRecommendedConnections: "No recomiendas nodos.", + connectionActionIntro: + "", + startNetworking: "Iniciar red", + stopNetworking: "Parar red", + restartNetworking: "Reiniciar red", + sync: "Sincronizar red", + indexes: "Índices", + indexesDescription: + "Reconstruir índices es seguro, y puede arreglar algunos errores en tu blockchain.", + homePageTitle: "Página de inicio", + homePageDescription: "Selecciona qué módulo quieres como tu página de inicio.", + saveHomePage: "Establecer página de inicio", + invites: "Invitaciones", + invitesTitle: "Invitaciones", + invitesInvites: "Invitaciones", + invitesDescription: "Maneja y aplica códigos de invitación en tu red.", + invitesTribesTitle: "Tribus", + invitesTribeInviteCodePlaceholder: "Introduce el código de la tribu", + invitesTribeJoinButton: "Entrar en la tribu", + invitesChatsTitle: "Chats", + invitesChatInviteCodePlaceholder: "Introduce el código del chat", + invitesChatJoinButton: "Entrar en el chat", + invitesPadsTitle: "Pads", + invitesPadInviteCodePlaceholder: "Introduce el código del pad", + invitesPadJoinButton: "Entrar en el pad", + invitesCalendarsTitle: "Calendarios", + invitesCalendarInviteCodePlaceholder: "Introduce el código del calendario", + invitesCalendarJoinButton: "Entrar en el calendario", + invitesEventsTitle: "Eventos", + invitesEventInviteCodePlaceholder: "Introduce el código del evento", + invitesEventJoinButton: "Entrar en el evento", + invitesForumsTitle: "Foros", + invitesForumInviteCodePlaceholder: "Introduce el código del foro", + invitesForumJoinButton: "Entrar en el foro", + invitesMapsTitle: "Mapas", + invitesMapInviteCodePlaceholder: "Introduce el código del mapa", + invitesMapJoinButton: "Entrar en el mapa", + invitesShopsTitle: "Tiendas", + invitesShopInviteCodePlaceholder: "Introduce el código de invitación de la tienda", + invitesShopJoinButton: "Unirse a la tienda", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "Introduce el código de invitación del PUB", + invitesAcceptInvite: "Entrar en el PUB", + invitesAlreadyFederated: "Ya estás federado con este pub.", + invitesFederationsTitle: "Federaciones", + invitesAcceptedInvites: "Federadas", + invitesNoInvites: "No hay invitaciones aceptadas, aún.", + invitesNoFederatedPubs: "No hay pubs federados.", + invitesUnreachablePubs: "Inalcanzables", + invitesNoUnreachablePubs: "No hay pubs inalcanzables.", + invitesPubsRefresh: "Refrescar", + invitesPubsClearUnreachable: "Quitar inalcanzables", + invitesPubsExport: "Exportar", + invitesPubsImport: "Importar", + invitesPubsImportTitle: "Importar pubs", + invitesPubsImportPlaceholder: "Pega una dirección multiserver o código de invitación por línea, o sube un archivo .txt…", + currentlyUnreachable: "¡ERROR!", + errorDetails: "Detalles del error", + genericError: "A ocurrido un error.", + panicMode: "Modo Pánico!", + encryptData: "Establece una contraseña (min 32 caracteres de longitud) para cifrar tu blockchain", + decryptData: "Introduce contraseña para descifrar tu blockchain", + panicModeDescription: "Cifrar/Descifrar o BORRAR tu blockchain", + removeDataDescription: "CUIDADO: Éste proceso no puede ser revocado.", + encryptPanicButton: "Cifrar blockchain", + decryptPanicButton: "Descifrar blockchain", + removePanicButton: "BORRAR TODOS TUS DATOS!", + searchTitle: "Buscar", + searchDescriptionLabel: "Buscar contenido en tu red.", + searchLanguagesLabel: "Lenguajes", + searchSkillsLabel: "Habilidades", + searchPlaceholder:"Buscar contenido...", + searchDateLabel:"Fecha", + searchLocationLabel:"Localización", + searchPriceLabel:"Precio", + searchUrlLabel:"URL", + searchCategoryLabel:"Categoría", + searchStartLabel:"Fecha de comienzo", + searchEndLabel:"Fecha de fin", + searchPriorityLabel:"Prioridad", + searchStatusLabel:"Estado", + statusLabel:"Estado", + totalVotesLabel:"Votos Totales", + noResultsFound:"No se han encontrado resultados.", + votesOption:"Opiniones Votadas", + voteYesLabel:"Si", + voteNoLabel:"No", + allTypesLabel: "SIN LÍMITES", + ABSTENTIONLabel:"ABSTENCIÓN", + YESLabel:"SI", + NOLabel:"NO", + FOLLOW_MAJORITYLabel: "SEGUIR MAYORIA", + CONFUSEDLabel: "CONFUSO", + NOT_INTERESTEDLabel: "SIN INTERÉS", + StatusLabel:"Estado", + votesOptionYesLabel:"Si", + votesOptionNoLabel:"No", + votesOptionAbstentionLabel:"Abstención", + votesQuestionLabel:"Pregunta", + votesCreatedByLabel:"Creado el", + voteStatusOpen:"ABIERTO", + voteStatusClosed:"CERRADO", + imageSearchLabel: "Introduce palabras para buscar las imagenes que hayan sido etiquetadas con ellas.", + commentDescription: ({ parentUrl }) => [ + " ha comentado en ", + a({ href: parentUrl }, " hilo"), + ], + commentTitle: ({ authorName }) => [`Ha comentado en el post de @${authorName}`], + subtopicDescription: ({ parentUrl }) => [ + " creado un subtopic desde ", + a({ href: parentUrl }, " un post"), + ], + subtopicTitle: ({ authorName }) => [`Ha creado un subtopic en el post de @${authorName}`], + mysteryDescription: "publicado un post misterioso", + oasisDescription: "OASIS Red de Proyectos", + searchSubmit: "Buscar...", + postLabel: "POSTS", + aboutLabel: "HABITANTES", + feedLabel: "FEEDS", + votesLabel: "GOBIERNO", + reportLabel: "REPORTES", + imageLabel: "IMAGENES", + videoLabel: "VIDEOS", + audioLabel: "AUDIOS", + documentLabel: "DOCUMENTOS", + torrentLabel: "TORRENTS", + pdfFallbackLabel: "Documento PDF", + eventLabel: "EVENTOS", + taskLabel: "TAREAS", + transferLabel: "TRANSFERENCIAS", + curriculumLabel: "CURRÍCULUM", + bookmarkLabel: "ENLACES", + tribeLabel: "TRIBUS", + marketLabel: "MERCADO", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "CARTERAS", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Aplicar", + subjectLabel: "Asunto", + editProfile: "Editar Avatar", + profileQrAlt: "Código QR del perfil", + profileQrHint: "Escanea para copiar este Oasis ID.", + oasisIdLabel: "OasisID", + spreadLabel: "Difundir", + spreadHint: "Difunde esto a quienes te apoyan (se replica vía tu feed).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Bienvenido a Oasis — una red social libre, peer-to-peer, federada y cifrada, con una arquitectura distribuida y solo por invitación.\n\nAlgunos lugares interesantes para empezar:\n\n- /profile — Edita tu avatar, nombre, descripción y qué módulos se muestran en tu cara pública.\n- /invites — Añade un pub para federarte con el resto de la red.\n- /peers — Mira quién está conectado, reescanea tu LAN, exporta o importa listas de peers.\n- /inhabitants — Descubre y visita los avatares de otras personas.\n- /tribes — Crea o únete a grupos privados con cifrado de extremo a extremo.\n- /inbox — Lee y envía mensajes privados.\n- /activity — Mira la actividad reciente, tuya y de tu red.\n- /publish — Escribe un post o comparte una imagen, audio, vídeo o documento.\n\nAlgunos lugares interesantes para conectar:\n\n- /fediverse — Gestiona tus otras cuentas del fediverso, incluyendo enviar y recibir contenido.\n\nEste mensaje se envió de forma privada desde ti hacia ti, por eso no hizo falta conexión para recibirlo.", + profileVisibilityTitle: "Visibilidad del perfil público", + profileVisibilityHint: "Marca qué campos ven otros habitantes en tu perfil. Por defecto solo se muestra Karma.", + profileSensorsSectionTitle: "Sensores", + profileVisibilityActivity: "Nivel de actividad", + profileVisibilityDevice: "Dispositivo", + profileDeviceLockedHint: "Este sensor está siempre activo: permite a otros ver desde qué dispositivo te conectas y no se puede desactivar.", + profileVisibilityKarma: "Puntuación KARMA", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "Cartera ECOIN", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "Llave GPG", + profileVisibilityClearnet: "Publicar mi perfil", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Tiendas", + profileClearnetJobsLabel: "Empleo", + profileClearnetEventsLabel: "Eventos", + profileClearnetProjectsLabel: "Proyectos", + profileClearnetPostsLabel: "Blogs", + profileClearnetAudiosLabel: "Audios", + profileClearnetVideosLabel: "Vídeos", + profileClearnetImagesLabel: "Imágenes", + profileClearnetDocumentsLabel: "Documentos", + profileClearnetTorrentsLabel: "Torrents", + editProfileDescription: + "", + profileName: "Nombre", + profileImage: "Imágen Avatar", + profileGpgKey: "Clave Pública GPG (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Descargar", + profileGpgRemove: "Eliminar", + profileDescription: "Descripción", + hashtagDescription: + "Posts de habitantes de tu red que referencian éste #hashtag, ordenados por el más reciente.", + rebuildName: "Reconstruir blockchain", + wallet: "Cartera", + walletAddress: "Dirección", + walletAmount: "Cantidad", + walletAddressLine: ({ address }) => `Dirección: ${address}`, + walletAmountLine: ({ amount }) => `Cantidad: ${amount} ECO`, + walletBack: "Volver", + walletBalanceTitle: "Saldo", + walletWalletSendTitle: "Enviar", + walletReceiveTitle: "Recibir", + walletHistoryTitle: "Historial", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Conf.", + walletConfirm: "Confirmar", + walletDescription: "Administra tus activos digitales, incluido el envío y la recepción de ECOin, la visualización del saldo y el acceso al historial de transacciones.", + walletDate: "Fecha", + walletFee: "Tasa (Cuanto mayor sea la tasa, más rápido se procesará tu transacción)", + walletFeeLine: ({ fee }) => `Tasa: ECO ${fee}`, + walletHistory: "Historial", + walletReceive: "Recibir", + walletReset: "Resetear", + walletSend: "Enviar", + walletStatus: "Estado", + walletDisconnected: [ + "Tienes la ", + strong("cartera de ECOin desconectada"), + ". Revisa ", + a({ href: '/settings#wallet' }, "tu configuración"), + " o el estado de tu conexión.", + ], + walletSentToLine: ({ destination, amount }) => `Enviar ECO ${amount} a ${destination}`, + walletSettingsTitle: "Cartera", + walletSettingsDescription: "Integrar Oasis con tu cartera de ECOin.", + walletSettingsDocLink: "Guía de instalación de ECOin", + walletStatusMessages: { + invalid_amount: "Cantidad inválida", + invalid_dest: "Dirección de destino inválida", + invalid_fee: "Tasa inválida", + validation_errors: "Errores en la validación", + send_tx_success: "Transacción realizada", + }, + walletTitle: "Cartera", + walletTotalCostLine: ({ totalCost }) => `Coste total: ECO ${totalCost}`, + walletTransactionId: "ID de transacción", + walletTxId: "ID Tx", + walletType: "Tipo", + walletUser: "Usuario", + walletPass: "Contraseña", + walletConfiguration: "Configurar cartera", + cipher: "Cifrador", + cipherTitle: "Cifrador", + cipherDescription: "Encripta y desencripta tu texto de forma simétrica (usando una contraseña compartida).", + randomPassword: "Contraseña Aleatoria", + cipherEncryptTitle: "Encriptar Texto", + cipherEncryptDescription: "Introduce el texto para encriptar", + cipherTextLabel: "Texto para Encriptar", + cipherTextPlaceholder: "Introduce el texto para encriptar...", + cipherPasswordLabel: "Establece una contraseña (mínimo 32 caracteres) para encriptar tu texto", + cipherPasswordDecryptLabel: "Establece una contraseña (mínimo 32 caracteres) para desencriptar tu texto", + cipherPasswordPlaceholder: "Introduce una contraseña...", + cipherEncryptButton: "Encriptar", + cipherDecryptTitle: "Desencriptar Texto", + cipherDecryptDescription: "Introduce el texto para desencriptar", + cipherEncryptedMessageLabel: "Texto Encriptado", + cipherDecryptedMessageLabel: "Texto Desencriptado", + cipherPasswordUsedLabel: "Contraseña usada para encriptar (guárdala!)", + cipherEncryptedTextPlaceholder: "Introduce el texto encriptado...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "Introduce el vector de inicialización...", + cipherDecryptButton: "Desencriptar", + password: "Contraseña", + text: "Texto", + encryptedText: "Texto Encriptado", + iv: "Vector de Inicialización (IV)", + encryptTitle: "Encripta tu texto", + encryptDescription: "Introduce el texto que deseas encriptar y proporciona una contraseña.", + encryptButton: "Encriptar", + decryptTitle: "Desencripta tu texto", + decryptDescription: "Introduce el texto encriptado y proporciona la misma contraseña utilizada para la encriptación.", + decryptButton: "Desencriptar", + passwordLengthError: "La contraseña debe tener al menos 32 caracteres.", + missingFieldsError: "Texto, contraseña o IV no proporcionados.", + encryptionError: "Error al encriptar el texto.", + decryptionError: "Error al desencriptar el texto.", + bookmarkTitle: "Marcadores", + bookmarkDescription: "Descubre y gestiona marcadores en tu red.", + bookmarkAllSectionTitle: "Marcadores", + bookmarkMineSectionTitle: "Tus Marcadores", + bookmarkRecentSectionTitle: "Marcadores recientes", + bookmarkTopSectionTitle: "Marcadores top", + bookmarkFavoritesSectionTitle: "Favoritos", + bookmarkCreateSectionTitle: "Crear marcador", + bookmarkUpdateSectionTitle: "Actualizar marcador", + bookmarkFilterAll: "TODOS", + bookmarkFilterMine: "MÍOS", + bookmarkFilterTop: "TOP", + bookmarkFilterFavorites: "FAVORITOS", + bookmarkFilterRecent: "RECIENTES", + bookmarkCreateButton: "Crear marcador", + bookmarkUpdateButton: "Actualizar", + bookmarkDeleteButton: "Eliminar", + bookmarkAddFavoriteButton: "Añadir favorito", + bookmarkRemoveFavoriteButton: "Quitar favorito", + bookmarkUrlLabel: "Enlace", + bookmarkUrlPlaceholder: "https://ejemplo.com", + bookmarkDescriptionLabel: "Descripción", + bookmarkDescriptionPlaceholder: "Opcional", + bookmarkTagsLabel: "Etiquetas", + bookmarkTagsPlaceholder: "Introduce tags separadas por comas", + bookmarkCategoryLabel: "Categoría", + bookmarkCategoryPlaceholder: "Opcional", + bookmarkLastVisitLabel: "Última visita", + bookmarkSearchPlaceholder: "Buscar URL, tags, categoría, autor...", + bookmarkSortRecent: "Más recientes", + bookmarkSortOldest: "Más antiguos", + bookmarkSortTop: "Más votados", + bookmarkSearchButton: "Buscar", + bookmarkUpdatedAt: "Actualizado", + bookmarkNoMatch: "No hay marcadores que coincidan con tu búsqueda.", + noBookmarks: "No hay marcadores disponibles.", + noUrl: "Sin enlace", + noCategory: "Sin categoría", + noLastVisit: "Sin última visita", + videoTitle: "Vídeos", + videoDescription: "Explora y gestiona contenido de vídeo en tu red.", + videoPluginTitle: "Título", + videoPluginDescription: "Descripción", + videoMineSectionTitle: "Tus vídeos", + videoCreateSectionTitle: "Subir vídeo", + videoUpdateSectionTitle: "Actualizar vídeo", + videoAllSectionTitle: "Vídeos", + videoRecentSectionTitle: "Vídeos recientes", + videoTopSectionTitle: "Vídeos más votados", + videoFavoritesSectionTitle: "Favoritos", + videoFilterAll: "TODOS", + videoFilterMine: "MÍOS", + videoFilterRecent: "RECIENTES", + videoFilterTop: "TOP", + videoFilterFavorites: "FAVORITOS", + videoCreateButton: "Subir vídeo", + videoUpdateButton: "Actualizar", + videoDeleteButton: "Eliminar", + videoAddFavoriteButton: "Añadir a favoritos", + videoRemoveFavoriteButton: "Quitar de favoritos", + videoFileLabel: "Selecciona un archivo de vídeo (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Etiquetas", + videoTagsPlaceholder: "Introduce etiquetas separadas por comas", + videoTitleLabel: "Título", + videoTitlePlaceholder: "Opcional", + videoDescriptionLabel: "Descripción", + videoDescriptionPlaceholder: "Opcional", + videoNoFile: "No se ha proporcionado ningún archivo de vídeo", + noVideos: "No hay vídeos disponibles.", + videoSearchPlaceholder: "Buscar por título, etiquetas, autor...", + videoSortRecent: "Más recientes", + videoSortOldest: "Más antiguos", + videoSortTop: "Más votados", + videoSearchButton: "Buscar", + videoMessageAuthorButton: "MP", + videoUpdatedAt: "Actualizado", + videoNoMatch: "Ningún vídeo coincide con tu búsqueda.", + documentTitle: "Documentos", + documentDescription: "Descubre y gestiona documentos en tu red.", + documentAllSectionTitle: "Documentos", + documentMineSectionTitle: "Tus documentos", + documentRecentSectionTitle: "Documentos recientes", + documentTopSectionTitle: "Documentos top", + documentFavoritesSectionTitle: "Favoritos", + documentCreateSectionTitle: "Subir documento", + documentUpdateSectionTitle: "Editar documento", + documentFilterAll: "TODO", + documentFilterMine: "MÍOS", + documentFilterRecent: "RECIENTES", + documentFilterTop: "TOP", + documentFilterFavorites: "FAVORITOS", + documentCreateButton: "Subir documento", + documentUpdateButton: "Actualizar", + documentDeleteButton: "Eliminar", + documentAddFavoriteButton: "Añadir a favoritos", + documentRemoveFavoriteButton: "Quitar de favoritos", + documentMessageAuthorButton: "MP", + documentFileLabel: "Subir documento (.pdf)", + documentTagsLabel: "Etiquetas", + documentTagsPlaceholder: "Introduce etiquetas separadas por comas", + documentTitleLabel: "Título", + documentTitlePlaceholder: "Opcional", + documentDescriptionLabel: "Descripción", + documentDescriptionPlaceholder: "Opcional", + documentNoFile: "Sin archivo.", + noDocuments: "No hay documentos disponibles.", + documentSearchPlaceholder: "Buscar título, etiquetas, descripción, autor...", + documentSortRecent: "Más recientes", + documentSortOldest: "Más antiguos", + documentSortTop: "Más votados", + documentSearchButton: "Buscar", + documentNoMatch: "No hay documentos que coincidan con tu búsqueda.", + documentUpdatedAt: "Actualizado", + audioTitle: "Audios", + audioDescription: "Explora y gestiona contenido de audio en tu red.", + audioPluginTitle: "Título", + audioPluginDescription: "Descripción", + audioMineSectionTitle: "Tus audios", + audioCreateSectionTitle: "Subir audio", + audioUpdateSectionTitle: "Actualizar audio", + audioAllSectionTitle: "Audios", + audioRecentSectionTitle: "Audios recientes", + audioTopSectionTitle: "Audios más votados", + audioFilterAll: "TODOS", + audioFilterMine: "MÍOS", + audioFilterRecent: "RECIENTES", + audioFilterTop: "TOP", + audioFilterBlockchain: "BLOCKCHAIN", + audioCreateButton: "Subir audio", + audioUpdateButton: "Actualizar", + audioDeleteButton: "Eliminar", + audioAddFavoriteButton: "Añadir a favoritos", + audioRemoveFavoriteButton: "Quitar de favoritos", + audioFileLabel: "Selecciona un archivo de audio (.mp3, .wav, .ogg)", + audioTagsLabel: "Etiquetas", + audioTagsPlaceholder: "Introduce etiquetas separadas por comas", + audioTitleLabel: "Título", + audioTitlePlaceholder: "Opcional", + audioDescriptionLabel: "Descripción", + audioDescriptionPlaceholder: "Opcional", + audioNoFile: "No se ha proporcionado ningún archivo de audio", + noAudios: "No hay audios disponibles.", + audioSearchPlaceholder: "Buscar por título, etiquetas, autor...", + audioSortRecent: "Más recientes", + audioSortOldest: "Más antiguos", + audioSortTop: "Más votados", + audioSearchButton: "Buscar", + audioMessageAuthorButton: "MP", + audioUpdatedAt: "Actualizado", + audioNoMatch: "Ningún audio coincide con tu búsqueda.", + audioFavoritesSectionTitle: "Favoritos", + audioFilterFavorites: "FAVORITOS", + favoritesTitle: "Favoritos", + favoritesDescription: "Todos tus elementos marcados como favoritos en un solo lugar.", + favoritesFilterAll: "TODOS", + favoritesFilterRecent: "RECIENTES", + favoritesFilterAudios: "AUDIOS", + favoritesFilterBookmarks: "MARCADORES", + favoritesFilterDocuments: "DOCUMENTOS", + favoritesFilterImages: "IMÁGENES", + favoritesFilterMaps: "MAPAS", + favoritesFilterPads: "PADS", + favoritesFilterChats: "CHATS", + favoritesFilterCalendars: "CALENDARIOS", + favoritesFilterVideos: "VÍDEOS", + favoritesRemoveButton: "Quitar de favoritos", + favoritesNoItems: "Todavía no hay favoritos.", + yourContacts: "Tus Contactos", + allInhabitants: "Habitantes", + allCVs: "Todos los CVs", + discoverPeople: "Descubre habitantes en tu red.", + allInhabitantsButton: "TODOS", + contactsButton: "SOPORTES", + CVsButton: "CVs", + matchSkills: "Coincidir Habilidades", + matchSkillsButton: "COINCIDIR HABILIDADES", + suggestedButton: "SUGERIDOS", + searchInhabitantsPlaceholder: "FILTRAR habitantes POR NOMBRE …", + filterLocation: "FILTRAR habitantes POR UBICACIÓN …", + filterLanguage: "FILTRAR habitantes POR IDIOMA …", + filterSkills: "FILTRAR habitantes POR HABILIDADES …", + applyFilters: "Aplicar Filtros", + locationLabel: "Ubicación", + languagesLabel: "Idiomas", + skillsLabel: "Habilidades", + commonSkills: "Habilidades Comunes", + mutualFollowers: "Seguidores Mutuos", + suggestedFollowsYou: "Te sigue", + latestInteractions: "Últimas Interacciones", + viewAvatar: "Ver Avatar", + viewCV: "Ver CV", + suggestedSectionTitle: "Sugeridos", + topkarmaSectionTitle: "Mejor Karma", + topecoSectionTitle: "Mejor Eco", + topactivitySectionTitle: "Top Actividad", + blockedSectionTitle: "Bloqueados", + gallerySectionTitle: "GALERÍA", + blockedButton: "BLOQUEADOS", + blockedLabel: "Usuario Bloqueado", + inhabitantviewDetails: "Ver Detalles", + keepReading: "Seguir leyendo...", + oasisId: "ID", + noInhabitantsFound: "No se encontraron habitantes, aún.", + inhabitantActivityLevel: "Nivel Actividad", + lifespanLabel: "Vigencia", + deviceLabel: "Dispositivo", + parliamentTitle: "Parlamento", + parliamentDescription: "Explora formas de gobierno y políticas de gestión colectiva.", + parliamentFilterGovernment: "GOBIERNO", + parliamentFilterCandidatures: "CANDIDATURAS", + parliamentFilterProposals: "PROPUESTAS", + parliamentFilterLaws: "LEYES", + parliamentFilterHistorical: "HISTÓRICO", + parliamentFilterLeaders: "LÍDERES", + parliamentFilterRules: "REGLAS", + parliamentGovernmentCard: "Gobierno actual", + parliamentActorInPowerInhabitant: 'HABITANTE EN EL PODER', + parliamentActorInPowerTribe: 'TRIBU EN EL PODER', + parliamentHistoricalGovernmentsTitle: 'GOBIERNOS', + parliamentHistoricalElectionsTitle: 'CICLOS ELECTORALES', + parliamentHistoricalLawsTitle: 'HISTÓRICO', + parliamentHistoricalLeadersTitle: 'LÍDERES', + parliamentThCycles: 'CICLOS', + parliamentThTimesInPower: 'MANDATOS', + parliamentThTotalCandidatures: 'CANDIDATURAS', + parliamentThProposed: 'LEYES PROPUESTAS', + parliamentThApproved: 'LEYES APROBADAS', + parliamentThDeclined: 'LEYES RECHAZADAS', + parliamentThDiscarded: 'LEYES DESCARTADAS', + parliamentMembers: "MIEMBROS", + parliamentLegSince: "INICIO DEL CICLO", + parliamentLegEnd: "FIN DEL CICLO", + parliamentPoliciesProposal: "PROPUESTAS DE LEYES", + parliamentPoliciesApproved: "LEYES APROBADAS", + parliamentPoliciesDeclined: "LEYES RECHAZADAS", + parliamentPoliciesDiscarded: "LEYES DESCARTADAS", + parliamentEfficiency: "% EFICIENCIA", + parliamentFilterRevocations: 'REVOCACIONES', + parliamentRevocationFormTitle: 'Revocar Ley', + parliamentRevocationLaw: 'Ley', + parliamentRevocationTitle: 'Título', + parliamentRevocationReasons: 'Motivos', + parliamentRevocationPublish: 'Publicar Revocación', + parliamentCurrentRevocationsTitle: 'Revocaciones Actuales', + parliamentFutureRevocationsTitle: 'Revocaciones Futuras', + parliamentPoliciesRevocated: 'LEYES REVOCADAS', + parliamentPoliciesTitle: 'HISTÓRICO', + parliamentLawsTitle: 'LEYES APROBADAS', + parliamentRulesRevocations: 'Cualquier ley aprobada puede ser revocada utilizando el método de gobierno vigente.', + parliamentNoStableGov: "Aún no hay un gobierno elegido.", + parliamentNoGovernments: "Todavía no hay gobiernos en el histórico.", + parliamentCandidatureFormTitle: "Proponer Candidatura", + parliamentCandidatureIdPh: "Oasis ID (@...) o nombre de la tribu", + parliamentCandidatureSlogan: "Lema (máx. 140 car.)", + parliamentCandidatureSloganPh: "Un lema corto", + parliamentCandidatureMethod: "Método", + parliamentCandidatureProposeBtn: "Publicar Candidatura", + parliamentThDate: "Fecha de propuesta", + parliamentThSlogan: "Lema", + parliamentThSince: "Perfil desde", + parliamentThVotes: "Votos recibidos", + parliamentThVoteAction: "Votar", + parliamentTypeUser: "Habitante", + parliamentTypeTribe: "Tribu", + parliamentVoteBtn: "Votar", + parliamentProposalFormTitle: "Proponer Ley", + parliamentProposalDescription: "Descripción (≤1000)", + parliamentProposalPublish: "Publicar Propuesta", + parliamentFinalize: "Finalizar", + parliamentDeadline: "Fecha límite", + parliamentNoProposals: "Aún no hay propuestas de ley.", + parliamentNoLaws: "Aún no hay leyes aprobadas.", + parliamentMethodDEMOCRACY: "Democracia", + parliamentMethodMAJORITY: "Mayoría (80%)", + parliamentMethodMINORITY: "Minoría (20%)", + parliamentMethodDICTATORSHIP: "Dictadura", + parliamentMethodKARMATOCRACY: "Karmatocracia", + parliamentThId: "ID", + parliamentThProposalDate: "Fecha de propuesta", + parliamentThMethod: "Método", + parliamentThKarma: "Karma", + parliamentThSupports: "Apoyos", + parliamentThVote: "Votar", + parliamentCurrentProposalsTitle: "Propuestas actuales", + parliamentVotesSlashTotal: "Votos/Total", + parliamentVotesNeeded: "Votos Necesarios", + parliamentFutureLawsTitle: "Futuras leyes", + parliamentNoFutureLaws: "Aún no hay futuras leyes.", + parliamentVoteAction: "Votar", + parliamentLeadersTitle: "Líderes", + parliamentThLeader: "AVATAR", + parliamentPopulation: "Población", + parliamentThType: "Tipo", + parliamentThInPower: "Gobiernos", + parliamentThPresented: "CANDIDATURAS", + parliamentNoLeaders: "Aún no hay líderes.", + parliamentCandidaturesListTitle: "Lista de Candidaturas", + parliamentTimeRemaining: "Tiempo restante", + parliamentNoLeader: "Aún no hay candidatura ganadora.", + parliamentElectionsStatusTitle: "Próximo Gobierno", + parliamentProposalDeadlineLabel: "Fecha límite", + parliamentProposalTimeLeft: "Tiempo restante", + parliamentProposalOnTrack: "Con apoyo suficiente", + parliamentProposalOffTrack: "Apoyo insuficiente", + parliamentRulesTitle: "Cómo funciona el Parlamento", + parliamentRulesIntro: "Las elecciones se resuelven cada 2 meses; las candidaturas son continuas y se reinician cuando se elige un gobierno.", + parliamentRulesCandidates: "Cualquier habitante puede proponerse a sí mismo, a otro habitante o a cualquier tribu. Cada habitante puede proponer hasta 3 candidaturas por ciclo; se rechazan duplicados en el mismo ciclo.", + parliamentRulesElection: "Gana la candidatura con más votos en el momento de la resolución.", + parliamentRulesTies: "Desempate: mayor karma del habitante; si persiste, perfil más antiguo; si persiste, propuesta más temprana; después, orden lexicográfico por ID.", + parliamentRulesFallback: "Si nadie vota, gana la última candidatura propuesta. Si no hay candidaturas, se selecciona una tribu al azar.", + parliamentRulesTerm: "La pestaña Gobierno muestra el gobierno actual y sus estadísticas.", + parliamentRulesMethods: "Formas: Anarquía (mayoría simple), Democracia (50%+1), Mayoría (80%), Minoría (20%), Karmatocracia (propuestas con mayor karma), Dictadura (aprobación instantánea).", + parliamentRulesAnarchy: "Anarquía es el modo por defecto: si al resolver no se elige ninguna candidatura, se proclama la Anarquía. En Anarquía, cualquier habitante puede proponer leyes.", + parliamentRulesProposals: "Si eres el habitante gobernante o miembro de la tribu gobernante, puedes publicar propuestas de ley. Los métodos no dictatoriales crean una votación pública.", + parliamentRulesLimit: "Cada habitante puede publicar como máximo 3 propuestas de ley por ciclo.", + parliamentRulesLaws: "Cuando una propuesta alcanza su umbral, se convierte en Ley y aparece en la pestaña Leyes con su fecha de entrada en vigor.", + parliamentRulesHistorical: "En Histórico se puede ver cada ciclo de gobierno que ha habido y datos sobre su gestión.", + parliamentRulesLeaders: "En Líderes se puede ver un ranking de habitantes/tribus que han gobernado (o se han presentado), ordenados por eficacia.", + parliamentProposalVoteStatusLabel: "Estado de la votación", + parliamentProposalOnTrackYes: "Umbral alcanzado", + parliamentProposalOnTrackNo: "Por debajo del umbral", + courtsTitle: "Tribunales", + courtsDescription: "Explora formas de resolución de conflictos y de gestión colectiva de la justicia.", + courtsFilterCases: "CASOS", + courtsFilterMyCases: "MÍOS", + courtsFilterJudges: "JUECES", + courtsFilterHistory: "HISTORIAL", + courtsFilterRules: "REGLAS", + courtsCaseFormTitle: "Abrir caso", + courtsCaseRespondent: "Acusado / Parte demandada", + courtsCaseRespondentPh: "ID de Oasis (@...) o nombre de Tribu", + courtsCaseMediatorsAccuser: "Mediadores (acusación)", + courtsCaseMethod: "Método de resolución", + courtsCaseDescription: "Descripción (máx. 1000 caracteres)", + courtsCaseEvidenceTitle: "Pruebas del caso", + courtsCaseEvidenceHelp: "Adjunta imágenes, audios, documentos (PDF) o vídeos que apoyen tu caso.", + courtsCaseSubmit: "Presentar caso", + courtsNominateJudge: "Nominar juez", + courtsJudgeId: "Juez", + courtsJudgeIdPh: "ID de Oasis (@...) o nombre de Habitante", + courtsNominateBtn: "Nominar", + courtsAddEvidence: "Añadir pruebas", + courtsEvidenceText: "Texto", + courtsEvidenceLink: "Enlace", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "Adjuntar", + courtsAnswerText: "Resumen de la respuesta", + courtsAnswerSubmit: "Enviar respuesta", + courtsVerdictTitle: "Emitir veredicto", + courtsVerdictOrdersPh: "Acciones, plazos, pasos restaurativos...", + courtsIssueVerdict: "Emitir veredicto", + courtsMediationPropose: "Proponer acuerdo", + courtsSettlementProposeBtn: "Proponer", + courtsNominationsTitle: "Nominaciones a la judicatura", + courtsThJudge: "Juez", + courtsThDate: "Fecha", + courtsThVote: "Votar", + courtsNoNominations: "Todavía no hay nominaciones.", + courtsRespondentInvalid: "Debe ser un ID SSB válido (@...ed25519)", + courtsCaseMediatorsRespondentTitle: "Añadir mediadores de la defensa", + courtsCaseMediatorsRespondent: "Mediadores (defensa)", + courtsMediatorsAccuserLabel: "Mediadores (acusación)", + courtsMediatorsRespondentLabel: "Mediadores (defensa)", + courtsMediatorsSubmit: "Guardar mediadores", + courtsNoMyCases: "Aún no tienes conflictos.", + courtsNoHistory: "Todavía no hay juicios registrados.", + courtsMethodDICTATOR: "Dictador", + courtsMethodPOPULAR: "Popular", + courtsMethodKARMATOCRACY: "Karmatocracia", + courtsRulesTitle: "Cómo funcionan los Tribunales", + courtsRulesIntro: "Los Tribunales son un proceso gestionado por la comunidad para resolver conflictos y promover la justicia restaurativa. Se priorizan el diálogo, las pruebas claras y los remedios proporcionales.", + courtsRulesLifecycle: "Proceso: 1) Abrir caso 2) Seleccionar método 3) Presentar pruebas 4) Audiencia y deliberación 5) Veredicto y remedio 6) Cumplimiento y cierre 7) Apelación (si procede).", + courtsRulesRoles: "Acusación: abre el caso. Defensa: persona o tribu acusada. Método: mecanismo elegido por la comunidad para facilitar, evaluar las pruebas y emitir veredicto. Testigo: aporta testimonio o pruebas. Mediadores: personas neutrales invitadas por la acusación y/o la defensa, con acceso a todos los detalles, que ayudan a desescalar el conflicto y co-crear acuerdos.", + courtsRulesEvidence: "Descripción de hasta 1000 caracteres. Adjunta imágenes, audio, vídeo y documentos PDF relevantes y legales. No compartas datos privados sensibles sin consentimiento.", + courtsRulesDeliberation: "Las audiencias pueden ser públicas o privadas. Los jueces garantizan el respeto, piden aclaraciones y pueden descartar material irrelevante o ilegal.", + courtsRulesVerdict: "Se prioriza la restauración: disculpas, acuerdos de mediación, moderación de contenido, restricciones temporales u otras medidas proporcionales. El razonamiento debe quedar registrado.", + courtsRulesAppeals: "Apelación: permitida cuando hay nuevas pruebas o un error procesal claro. Debe presentarse en un plazo de 7 días salvo que se indique lo contrario.", + courtsRulesPrivacy: "Respeta la privacidad y la seguridad. El doxing, el odio o las amenazas se eliminan. Los jueces pueden editar o sellar partes del expediente para proteger a personas en riesgo.", + courtsRulesMisconduct: "El acoso, la manipulación o las pruebas fabricadas pueden llevar a una resolución negativa inmediata.", + courtsRulesGlossary: "Caso: registro de un conflicto. Pruebas: materiales que respaldan las afirmaciones. Veredicto: decisión con remedio. Apelación: solicitud para revisar el veredicto.", + courtsFilterActions: "ACCIONES", + courtsNoActions: "No hay acciones pendientes para tu rol.", + courtsCaseTitlePlaceholder: "Breve descripción del conflicto", + courtsCaseSeverity: "Severidad", + courtsCaseSeverityNone: "Sin etiqueta de severidad", + courtsCaseSeverityLOW: "Baja", + courtsCaseSeverityMEDIUM: "Media", + courtsCaseSeverityHIGH: "Alta", + courtsCaseSeverityCRITICAL: "Crítica", + courtsCaseSubject: "Tema", + courtsCaseSubjectNone: "Sin etiqueta de tema", + courtsCaseSubjectBEHAVIOUR: "Comportamiento", + courtsCaseSubjectCONTENT: "Contenido", + courtsCaseSubjectGOVERNANCE: "Gobernanza / normas", + courtsCaseSubjectFINANCIAL: "Finanzas / recursos", + courtsCaseSubjectOTHER: "Otro", + courtsHiddenRespondent: "Oculto (solo visible para los roles implicados).", + courtsThRole: "Rol", + courtsRoleAccuser: "Acusación", + courtsRoleDefence: "Defensa", + courtsRoleMediator: "Mediador", + courtsRoleJudge: "Juez", + courtsRoleDictator: "Dictador", + courtsAssignJudgeTitle: "Elegir juez", + courtsAssignJudgeBtn: "Elegir juez", + trendingTitle: "Tendencias", + exploreTrending: "Explora el contenido más popular en tu red.", + bookmarkButton: "MARCADORES", + transferButton: "TRANSFERENCIAS", + eventButton: "EVENTOS", + taskButton: "TAREAS", + votesButton: "GOBIERNO", + reportButton: "INFORMES", + feedButton: "FEED", + marketButton: "MERCADO", + imageButton: "IMÁGENES", + audioButton: "AUDIOS", + videoButton: "VIDEOS", + documentButton: "DOCUMENTOS", + torrentButton: "TORRENTS", + noTrendingFound: "No se encontraron contenidos populares.", + noContentMessage: "No hay contenido popular disponible, aún.", + trendingDescription: "Descripción", + trendingDate: "Fecha", + trendingLocation: "Ubicación", + trendingPrice: "Precio", + trendingUrl: "URL", + trendingCategory: "Categoría", + trendingStart: "Inicio", + trendingEnd: "Fin", + trendingPriority: "Prioridad", + trendingStatus: "Estado", + trendingFrom: "Desde", + trendingTo: "Hasta", + trendingConcept: "Concepto", + trendingAmount: "Cantidad", + trendingDeadline: "Plazo", + trendingItemStatus: "Estado del Artículo", + trendingTotalVotes: "Total de Votos", + trendingTotalOpinions: "Total de Opiniones", + trendingNoContentMessage: "No hay tendencias disponibles, aún.", + trendingAuthor: "Por", + trendingCreatedAtLabel: "Creado el", + trendingTotalCount: "Total de Contadores", + tasksTitle: "Tareas", + tasksDescription: "Descubre y gestiona tareas en tu red.", + taskTitleLabel: "Título", + taskDescriptionLabel: "Descripción", + taskStartTimeLabel: "Fecha de Inicio", + taskEndTimeLabel: "Fecha de Fin", + taskPriorityLabel: "Prioridad", + taskPrioritySelect: "Seleccionar prioridad", + taskPriorityUrgent: "Urgente", + taskPriorityHigh: "Alta", + taskPriorityMedium: "Media", + taskPriorityLow: "Baja", + taskLocationLabel: "Ubicación", + taskTagsLabel: "Etiquetas", + taskVisibilityLabel: "Visibilidad", + taskPublic: "público", + taskPrivate: "privado", + taskCreatedAt: "Creado el", + taskBy: "Por", + taskStatus: "Estado", + taskStatusOpen: "Abierta", + taskStatusInProgress: "En Progreso", + taskStatusClosed: "Cerrada", + taskAssignedTo: "Asignada a", + taskAssignees: "Asignados", + taskAssignButton: "Asignar a Mí", + taskUnassignButton: "Desasignar", + taskCreateButton: "Crear Tarea", + taskUpdateButton: "Actualizar", + taskDeleteButton: "Eliminar", + taskFilterAll: "TODOS", + taskFilterMine: "MÍAS", + taskFilterOpen: "ABIERTAS", + taskFilterInProgress: "EN-PROCESO", + taskFilterClosed: "CERRADAS", + taskFilterAssigned: "ASIGNADAS", + taskFilterArchived: "ARCHIVADAS", + taskFilterUrgent: "URGENTES", + taskFilterHigh: "ALTAS", + taskFilterMedium: "MEDIAS", + taskFilterLow: "BAJAS", + taskAllSectionTitle: "Tareas", + taskMineSectionTitle: "Tus Tareas", + taskCreateSectionTitle: "Crear Tarea", + taskUpdateSectionTitle: "Actualizar", + taskOpenTitle: "Tareas Abiertas", + taskInProgressTitle: "Tareas en Progreso", + taskClosedTitle: "Tareas Cerradas", + taskAssignedTitle: "Tareas Asignadas", + taskArchivedTitle: "Tareas Archivadas", + taskPublicTitle: "Tareas Públicas", + taskPrivateTitle: "Tareas Privadas", + notasks: "No hay tareas disponibles.", + noLocation: "No se especificó ubicación", + taskSetStatus: "Establecer estado", + eventTitle: "Eventos", + eventDateLabel: "Fecha", + eventsTitle: "Eventos", + eventsDescription: "Descubre y gestiona eventos en tu red.", + eventDescription: "Descripción", + eventPrice: "Precio", + eventStatus: "Estado", + eventOrganizer: "Organizador", + eventAllSectionTitle: "Eventos", + eventMineSectionTitle: "Tus Eventos", + eventArchivedTitle: "Eventos Archivados", + eventCreateSectionTitle: "Crear Evento", + eventUpdateSectionTitle: "Actualizar Evento", + eventDeleteButton: "Eliminar", + eventCreateButton: "Crear Evento", + eventAddToCalendar: "Añadir al Calendario", + eventVisitCalendar: "Visitar Calendario", + viewTask: "Ver Tarea", + viewReport: "Ver Reporte", + viewTransfer: "Ver Transferencia", + viewItem: "Ver Item", + viewShop: "Ver Tienda", + viewProject: "Ver Proyecto", + viewVotation: "Ver Votación", + voteCastTitle: "Emitir Voto", + voteResults: "Resultados", + eventTitleLabel: "Título", + eventDescriptionLabel: "Descripción", + eventDescriptionPlaceholder: "Introduce la descripción del evento...", + eventUpdateButton: "Actualizar", + eventAttendeesLabel: "Asistentes", + eventAttendeesPlaceholder: "Introduce los asistentes, separados por comas...", + eventTagsLabel: "Etiquetas", + eventTagsPlaceholder: "Introduce las etiquetas del evento, separadas por comas...", + eventTags: "Etiquetas", + eventPriceLabel: "Precio", + eventUrlLabel: "URL", + eventAttendees: "Asistentes", + noAttendees: "Aún no hay asistentes", + eventCreatedAt: "Creado el", + eventLocation: "Ubicación", + eventLocationLabel: "Ubicación", + eventNoLocation: "No se especificó ubicación", + eventNoURL: "No se especificó URL", + eventBy: "Por", + noevents: "No hay eventos disponibles.", + eventDate: "Fecha", + eventDateFormat: "DD:MM:YYYY HH:mm", + eventAttendButton: "Asistir al Evento", + eventUnattendButton: "Dejar de asistir al Evento", + eventCreatedBy: "Creado por", + eventAttendeesCount: "Número de Asistentes", + eventCreatedByYou: "Tú creaste este evento", + eventFilterAll: "TODOS", + eventFilterMine: "MIOS", + eventFilterToday: "HOY", + eventFilterWeek: "ESTA SEMANA", + eventFilterMonth: "ESTE MES", + eventFilterYear: "ESTE AÑO", + eventFilterArchived: "ARCHIVADOS", + eventTodayTitle: "Eventos de Hoy", + eventThisWeekTitle: "Eventos de Esta Semana", + eventThisMonthTitle: "Eventos de Este Mes", + eventThisYearTitle: "Eventos de Este Año", + eventPrivacyLabel: "Visibilidad", + eventPublic: "Público", + eventPrivate: "Privado", + eventPublicTitle: "Eventos Públicos", + eventNoPrice: "Evento Gratuito", + eventNoImage: "No se ha subido ninguna imagen", + eventAttendConfirmation: "Ahora estás asistiendo a este evento", + eventUnattendConfirmation: "Ya no estás asistiendo a este evento", + eventAttended: "Asisto", + eventUnattended: "No asisto", + eventStatusOpen: "Abierto", + eventStatusClosed: "Cerrado", + tagsTitle: "Etiquetas", + tagsDescription: "Descubre y explora patrones de taxonomía en tu red.", + tagsAllSectionTitle: "Etiquetas", + tagsTopSectionTitle: "Etiquetas Principales", + tagsCloudSectionTitle: "Nube de Etiquetas", + tagsFilterAll: "TODOS", + tagsFilterTop: "TOP", + tagsFilterCloud: "NUBE", + tagsNoItems: "No hay etiquetas disponibles.", + tagsTableHeaderTag: "ETIQUETA/ENLACE:", + tagsTableHeaderCount: "CONTADOR:", + transfersTitle: "Transferencias", + transfersDescription: "Descubre y gestiona transferencias en tu red.", + transfersFrom: "De", + transfersTo: "A", + transfersFilterAll: "TODOS", + transfersFilterMine: "MÍAS", + transfersFilterUBI: "RBU", + transfersFilterMarket: "MERCADO", + transfersFilterTop: "TOP", + transfersFilterPending: "PENDIENTES", + transfersFilterUnconfirmed: "NO CONFIRMADAS", + transfersFilterClosed: "CERRADAS", + transfersFilterDiscarded: "DESCARTADAS", + transfersFilterEconomic: "ECONÓMICA", + transfersFilterTime: "TIEMPO", + transfersFilterTrust: "CONFIANZA", + transfersCategory: "Categoría", + transfersCategoryEconomic: "Económica", + transfersCategoryTime: "Tiempo", + transfersCategoryTrust: "Confianza", + transfersCategoryApply: "Aplicar", + transfersUnitEco: "ECO", + transfersUnitHours: "h", + transfersUnitTrust: "confianza", + transfersEconomicSectionTitle: "Transferencias económicas", + transfersTimeSectionTitle: "Transferencias de tiempo", + transfersTrustSectionTitle: "Transferencias de confianza", + transfersCreateButton: "Crear Transferencia", + transfersUpdateButton: "Actualizar", + transfersDeleteButton: "Eliminar", + transfersToUser: "ID de Oasis", + transfersToUserValidation: "ID de Oasis válido, ej. @…=.ed25519", + transfersConcept: "Concepto", + transfersAmount: "Cantidad", + transfersDeadline: "Plazo", + transfersTags: "Etiquetas", + transfersStatus: "Estado", + transfersStatusUnconfirmed: "NO CONFIRMADA", + transfersStatusClosed: "CERRADA", + transfersStatusDiscarded: "DESCARTADA", + transfersCreatedAt: "Creado el", + transfersConfirmations: "CONFIRMACIONES", + transfersContainingBlock: "Bloque contenedor", + transfersExportContract: "Crear contrato", + transfersConfirmButton: "Confirmar Transferencia", + transfersNoItems: "No se encontraron transferencias.", + transfersMineSectionTitle: "Tus Transferencias", + transfersUBISectionTitle: "Transferencias RBU", + transfersMarketSectionTitle: "Transferencias del Mercado", + transfersTopSectionTitle: "Transferencias Principales", + transfersPendingSectionTitle: "Transferencias Pendientes", + transfersUnconfirmedSectionTitle: "Transferencias No Confirmadas", + transfersClosedSectionTitle: "Transferencias Cerradas", + transfersDiscardedSectionTitle: "Transferencias Descartadas", + transfersCreateSectionTitle: "Crear transferencia", + transfersAllSectionTitle: "Transferencias", + transfersFilterFavs: "Favoritos", + transfersFavsSectionTitle: "Transferencias favoritas", + transfersSearchLabel: "Buscar", + transfersSearchPlaceholder: "Buscar por concepto, tags, usuarios...", + transfersMinAmountLabel: "Importe mín.", + transfersMaxAmountLabel: "Importe máx.", + transfersSortLabel: "Ordenar por", + transfersSortRecent: "Más recientes", + transfersSortAmount: "Mayor importe", + transfersSortDeadline: "Deadline más próximo", + transfersSearchButton: "Buscar", + transfersFavoriteButton: "Favorito", + transfersUnfavoriteButton: "Quitar favorito", + transfersMessageUserButton: "Mensaje", + transfersExpiringSoonBadge: "CADUCA PRONTO", + transfersExpiredBadge: "CADUCADA", + transfersUpdatedAt: "Actualizado", + transfersNoMatch: "No hay transferencias que coincidan con tu búsqueda.", + votationsTitle: "Votaciones", + votationsDescription: "Descubre y gestiona votaciones en tu red.", + voteMineSectionTitle: "Tus Votaciones", + voteCreateSectionTitle: "Crear Votación", + voteUpdateSectionTitle: "Actualizar", + voteOpenTitle: "Votaciones Abiertas", + voteClosedTitle: "Votaciones Cerradas", + voteAllSectionTitle: "Gobierno", + voteCreateButton: "Crear Votación", + voteUpdateButton: "Actualizar", + voteDeleteButton: "Eliminar", + voteOptionYes: "SÍ", + voteOptionNo: "NO", + voteOptionAbstention: "ABSTENCIÓN", + voteConfused: "CONFUNDIDO", + voteFollowMajority: "SEGUIR MAYORÍA", + voteNotInterested: "SIN INTERÉS", + voteFilterAll: "TODOS", + voteFilterMine: "MÍAS", + voteFilterOpen: "ABIERTAS", + voteFilterClosed: "CERRADAS", + voteQuestionLabel: "Pregunta", + voteDeadlineLabel: "Plazo", + voteOptionsLabel: "Tu voto", + voteBreakdown: "Desglose", + voteFinalResult: "RESULTADO", + voteNoQuorum: "SIN QUÓRUM", + voteTagsLabel: "Etiquetas", + voteDeadline: "Plazo", + voteStatus: "Estado", + voteTags: "Etiquetas", + voteOpinions: "Opiniones", + novotes: "No hay propuestas de votación disponibles.", + voteBy: "Por", + voteCreatedAt: "Creado el", + voteNoQuestion: "No se proporcionó pregunta", + voteUnknownCreator: "Creador Desconocido", + voteUnknownDate: "Fecha Desconocida", + errorVoteNotFound: "Votación no encontrada", + errorAlreadyVoted: "Ya has opnado.", + errorVoteClosedCannotEdit: "No puedes editar una votación cerrada", + errorVoteDeadlinePassed: "El plazo para esta votación ha pasado", + errorRetrievingVote: "Error al recuperar votación", + errorCreatingVote: "Error al crear votación", + errorVoteAlreadyVoted: "No se puede editar después de haber opinado", + errorDeletingOldVote: "Error al eliminar opinión anterior", + errorCreatingUpdatedVote: "Error al crear opinión actualizada", + errorCreatingTombstone: "Error al crear lápida", + voteDetailSectionTitle: 'Detalles de la votación', + voteCommentsLabel: 'Comentarios', + voteCommentsForumButton: 'Abrir conversación', + voteCommentsSectionTitle: 'Conversación abierta', + voteNoCommentsYet: 'Todavía no hay comentarios. Sé la primera persona en opinar.', + voteNewCommentPlaceholder: 'Escribe aquí tu comentario…', + voteNewCommentButton: 'Publicar comentario', + voteNewCommentLabel: 'Añade un comentario', + cvTitle: "CV", + cvLabel: "Currículum Vitae (CV)", + cvEditSectionTitle: "Editar CV", + cvCreateSectionTitle: "Crear CV", + cvDescription: "Gestiona y comparte tus habilidades profesionales e información.", + cvNameLabel: "Nombre Completo", + cvDescriptionLabel: "Resumen", + cvPhotoLabel: "Foto", + cvPersonalExperiencesLabel: "Experiencias Personales", + cvPersonalSkillsLabel: "Habilidades Personales (separadas por comas)", + cvOasisExperiencesLabel: "Experiencias de Contribución en Oasis", + cvOasisSkillsLabel: "Habilidades de Contribución en Oasis (separadas por comas)", + cvEducationExperiencesLabel: "Experiencias Educativas", + cvEducationalSkillsLabel: "Habilidades Educativas (separadas por comas)", + cvProfessionalExperiencesLabel: "Experiencias Profesionales", + cvProfessionalSkillsLabel: "Habilidades Profesionales (separadas por comas)", + cvLanguagesLabel: "Idiomas", + cvLocationLabel: "Ubicación", + cvStatusLabel: "Estado", + cvPreferencesLabel: "Preferencias", + cvOasisContributorLabel: "Contribuyente de Oasis", + cvPersonal: "Personal", + cvOasis: "Contribuyente de Oasis (opcional)", + cvOasisContributorView: "Contribución en Oasis", + cvEducational: "Educativo (opcional)", + cvEducationalView: "Educativo", + cvProfessional: "Profesional (opcional)", + cvProfessionalView: "Profesional", + cvAvailability: "Disponibilidad (opcional)", + cvAvailabilityView: "Disponibilidad", + cvUpdateButton: "Actualizar", + cvCreateButton: "Crear CV", + cvContactLabel: "Contacto", + cvCreatedAt: "Creado el", + cvUpdatedAt: "Actualizado el", + cvEditButton: "Actualizar", + cvDeleteButton: "Eliminar", + cvNoCV: "No se encontró ningún CV.", + blogSubject: "Asunto", + blogMessage: "Cuerpo limitado a 8096 caracteres.", + blogImage: "Subir contenido multimedia (max: 50MB)", + blogPublish: "Vista previa", + noPopularMessages: "No se han publicado mensajes populares, aún", + forumTitle: "Foros", + forumCategoryLabel: "Categoría", + forumTitleLabel: "Título", + forumTitlePlaceholder: "Título del foro...", + forumCreateButton: "Crear foro", + forumCreateSectionTitle: "Crear foro", + forumDescription: "Habla abiertamente con habitantes de tu red.", + forumFilterAll: "TODOS", + forumFilterMine: "MÍAS", + forumFilterRecent: "RECIENTES", + forumFilterTop: "TOP", + forumMineSectionTitle: "Tus Foros", + forumRecentSectionTitle: "Foros Recientes", + forumAllSectionTitle: "Foros", + forumDeleteButton: "Borrar", + forumParticipants: "participantes", + forumMessages: "mensajes", + forumLastMessage: "Último mensaje", + forumMessageLabel: "Mensaje", + forumMessagePlaceholder: "Escribe tu mensaje...", + forumSendButton: "Enviar", + forumVisitForum: "Visitar Foro", + noForums: "No hay foros disponibles, aún.", + forumVisitButton: "Visitar foro", + forumCatGENERAL: "General", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "Política", + forumCatTECH: "Tecnología", + forumCatSCIENCE: "Ciencia", + forumCatMUSIC: "Música", + forumCatART: "Arte", + forumCatGAMING: "Videojuegos", + forumCatBOOKS: "Libros", + forumCatFILMS: "Cine", + forumCatPHILOSOPHY: "Filosofía", + forumCatSOCIETY: "Sociedad", + forumCatPRIVACY: "Privacidad", + forumCatCYBERWARFARE: "Ciberguerra", + forumCatSURVIVALISM: "Supervivencia", + imageTitle: "Imágenes", + imageDescription: "Explora y gestiona contenido de imágenes en tu red.", + imagePluginTitle: "Título", + imagePluginDescription: "Descripción", + imageMineSectionTitle: "Tus imágenes", + imageCreateSectionTitle: "Subir imagen", + imageUpdateSectionTitle: "Actualizar imagen", + imageAllSectionTitle: "Imágenes", + imageRecentSectionTitle: "Imágenes recientes", + imageTopSectionTitle: "Top imágenes", + imageFavoritesSectionTitle: "Favoritos", + imageGallerySectionTitle: "Galería", + imageMemeSectionTitle: "Memes", + imageFilterAll: "TODAS", + imageFilterMine: "MÍAS", + imageFilterRecent: "RECIENTES", + imageFilterTop: "TOP", + imageFilterFavorites: "FAVORITOS", + imageFilterGallery: "GALERÍA", + imageFilterMeme: "MEMES", + imageCreateButton: "Subir imagen", + imageUpdateButton: "Actualizar", + imageDeleteButton: "Eliminar", + imageAddFavoriteButton: "Añadir favorito", + imageRemoveFavoriteButton: "Quitar favorito", + imageFileLabel: "Selecciona un archivo de imagen (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Etiquetas", + imageTagsPlaceholder: "Introduce etiquetas separadas por comas", + imageTitleLabel: "Título", + imageTitlePlaceholder: "Opcional", + imageDescriptionLabel: "Descripción", + imageDescriptionPlaceholder: "Opcional", + imageMemeLabel: "¿MEME?", + imageNoFile: "No se ha proporcionado ningún archivo de imagen", + noImages: "No hay imágenes disponibles.", + imageSearchPlaceholder: "Buscar por título, etiquetas, descripción, autor...", + imageSortRecent: "Más recientes", + imageSortOldest: "Más antiguas", + imageSortTop: "Más votadas", + imageSearchButton: "Buscar", + imageMessageAuthorButton: "Mensaje", + imageUpdatedAt: "Actualizado", + imageNoMatch: "Ninguna imagen coincide con tu búsqueda.", + feedTitle: "Feed", + feedDetailTitle: "Feed", + feedOpenDiscussion: "Abrir discusión", + feedPostComment: "Publicar comentario", + noComments: "Aún no hay comentarios", + createFeedTitle: "Crear Feed", + createFeedButton: "Enviar Feed!", + feedPlaceholder: "¿Qué está pasando? (máximo 280 caracteres)", + TODAYButton: "HOY", + CREATEButton: "Crear Feed", + totalOpinions: "Total de Opiniones", + moreVoted: "Más Votado", + noFeedsFound: "No se encontraron feeds.", + createdAtLabel: "Creado el", + FeedshareYourOpinions: "Descubre y comparte textos breves en tu red.", + refeedButton: "Realimentar", + alreadyRefeeded: "Ya has realimentado esto.", + activityTitle: "Actividad", + yourActivity: "Tu actividad", + globalActivity: "Actividad global", + activityList: "Actividad", + activityDesc: "Consulta la actividad más reciente de tu red.", + allButton: "TODO", + mineButton: "MÍAS", + noActions: "No hay actividad disponible.", + from: "De", + to: "A", + amount: "Cantidad", + concept: "Concepto", + description: "Descripción", + meme: "Meme", + activityContact: "Contacto", + activityBy: "Nombre", + activityPixelia: "Nuevo píxel añadido", + viewImage: "Ver imagen", + playAudio: "Reproducir audio", + playVideo: "Reproducir vídeo", + typeRecent: "RECIENTE", + errorActivity: "Error al recuperar la actividad", + typePost: "BLOGS", + recentButton: "RECIENTE", + typeTribe: "TRIBUS", + typeLarp: "L.A.R.P.", + typeInhabitants: "HABITANTES", + activityProfileUpdated: "actualizó su perfil", + typeAbout: "HABITANTES", + typeCurriculum: "CVS", + typeImage: "IMÁGENES", + typeBookmark: "MARCADORES", + typeDocument: "DOCUMENTOS", + typeVotes: "VOTACIONES", + typeAudio: "AUDIOS", + typeMarket: "MERCADO", + typeJob: "TRABAJOS", + typeProject: "PROYECTOS", + typeVideo: "VÍDEOS", + typeVote: "DIFUSIÓN", + typeEvent: "EVENTOS", + typeTransfer: "TRANSFERENCIAS", + typeTask: "TAREAS", + typePixelia: "PIXELIA", + typeForum: "FOROS", + typeReport: "REPORTES", + typeFeed: "FEEDS", + typeContact: "CONTACTO", + typePub: "PUB", + typeTombstone: "ELIMINADO", + typeBanking: "BANCA", + typeBankWallet: "BANCA/MONEDERO", + typeBankClaim: "BANCA/UBI", + typeKarmaScore: "KARMA", + typeParliament: "PARLAMENTO", + typeSpread: "RÉPLICAS", + typeParliamentCandidature: "Parlamento · Candidatura", + typeParliamentTerm: "Parlamento · Mandato", + typeParliamentProposal:"Parlamento · Propuesta", + typeParliamentRevocation:"Parlamento · Revocación", + typeParliamentLaw: "Parlamento · Nueva ley", + typeCourts: "TRIBUNALES", + typeCourtsCase: "Tribunales · Caso", + typeCourtsEvidence: "Tribunales · Prueba", + typeCourtsAnswer: "Tribunales · Respuesta", + typeCourtsVerdict: "Tribunales · Veredicto", + typeCourtsSettlement: "Tribunales · Acuerdo", + typeCourtsSettlementProposal: "Tribunales · Propuesta de acuerdo", + typeCourtsSettlementAccepted: "Tribunales · Acuerdo aceptado", + typeCourtsNomination: "Tribunales · Nominación", + typeCourtsNominationVote: "Tribunales · Votación de nominación", + activitySupport: "Nueva alianza forjada", + activityJoin: "Nuevo PUB unido", + question: "Pregunta", + deadline: "Fecha límite", + status: "Estado", + votes: "Votos", + totalVotes: "Votos totales", + voteTotalVotes: "Votos totales", + name: "Nombre", + skills: "Habilidades", + tags: "Etiquetas", + title: "Título", + date: "Fecha", + category: "Categoría", + attendees: "Asistentes", + activitySpread: "->", + visitLink: "Visitar enlace", + viewDocument: "Ver documento", + location: "Ubicación", + contentWarning: "Asunto", + personName: "Nombre del habitante", + bankWalletConnected: "Monedero ECOin", + bankUbiReceived: "UBI recibido", + bankTx: "Tx", + bankEpochShort: "Época", + viewDetails: "Ver detalles", + link: "Enlace", + activityProjectFollow: "%OASIS% ahora está %ACTION% este proyecto %PROJECT%", + activityProjectUnfollow: "%OASIS% ahora está %ACTION% este proyecto %PROJECT%", + activityProjectPledged: "%OASIS% ha %ACTION% %AMOUNT% al proyecto %PROJECT%", + following: "SIGUIENDO", + unfollowing: "DEJANDO DE SEGUIR", + pledged: "APORTADO", + parliamentCandidatureId: "Candidatura", + parliamentGovMethod: "Método", + parliamentVotesReceived: "Votos recibidos", + parliamentMethodANARCHY: "Anarquía", + parliamentMethodVOTE: "Votación comunitaria", + parliamentMethodRANKED: "Voto preferencial", + parliamentMethodPLURALITY: "Pluralidad", + parliamentMethodCOUNCIL: "Consejo", + parliamentMethodJURY: "Jurado", + parliamentAnarchy: "ANARQUÍA", + parliamentElectionsStart: "Inicio de elecciones", + parliamentElectionsEnd: "Fin de elecciones", + parliamentCurrentLeader: "Candidatura ganadora", + parliamentProposalTitle: "Título", + parliamentOpenVote: "Votación abierta", + parliamentStatus: "Estado", + parliamentLawQuestion: "Pregunta", + parliamentLawMethod: "Método", + parliamentLawProposer: "Proponente", + parliamentLawEnacted: "Promulgada", + parliamentLawVotes: "Votos", + createdAt: "Creado el", + courtsCaseTitle: "Caso", + courtsMethod: "Método", + courtsMethodJUDGE: "Juez", + courtsMethodJUDGES: "Panel de jueces", + courtsMethodSINGLE_JUDGE: "Juez único", + courtsMethodJURY: "Jurado", + courtsMethodCOUNCIL: "Consejo", + courtsMethodCOMMUNITY: "Comunidad", + courtsMethodARBITRATION: "Arbitraje", + courtsMethodVOTE: "Votación comunitaria", + courtsAccuser: "Acusación", + courtsRespondent: "Defensa", + courtsThStatus: "Estado", + courtsThAnswerBy: "Responder antes de", + courtsThEvidenceBy: "Pruebas hasta", + courtsThDecisionBy: "Decisión antes de", + courtsVotesNeeded: "Votos necesarios", + courtsVotesSlashTotal: "SÍ/TOTAL", + courtsOpenVote: "Votación abierta", + courtsAnswerTitle: "Respuesta", + courtsStanceADMIT: "Admite", + courtsStanceDENY: "Niega", + courtsStancePARTIAL: "Parcial", + courtsStanceCOUNTERCLAIM: "Reconvención", + courtsStanceNEUTRAL: "Neutral", + courtsVerdictResult: "Resultado", + courtsVerdictOrders: "Órdenes", + courtsSettlementText: "Acuerdo", + courtsSettlementAccepted: "Aceptado", + courtsSettlementPending: "Pendiente", + courtsJudge: "Juez", + courtsThSupports: "Apoyos", + courtsFilterOpenCase: 'Abrir caso', + courtsEvidenceFileLabel: 'Archivo de prueba (imagen, audio, vídeo o PDF)', + courtsCaseMediators: 'Mediadores', + courtsCaseMediatorsPh: 'IDs de Oasis de mediadores, separados por comas', + courtsMediatorsLabel: 'Mediadores', + courtsThCase: 'Caso', + courtsThCreatedAt: 'Fecha de inicio', + courtsThActions: 'Acciones', + courtsPublicPrefLabel: 'Visibilidad tras la resolución', + courtsPublicPrefYes: 'Acepto que este caso sea totalmente público', + courtsPublicPrefNo: 'Prefiero mantener privados los detalles', + courtsPublicPrefSubmit: 'Guardar preferencia de visibilidad', + courtsMethodMEDIATION: 'Mediación', + courtsNoCases: 'No hay casos.', + reportsTitle: "Informes", + reportsDescription: "Gestiona y realiza un seguimiento de los informes relacionados con problemas, errores, abusos y advertencias de contenido en tu red.", + reportsFilterAll: "TODOS", + reportsFilterMine: "MIOS", + reportsFilterFeatures: "FUNCIONES", + reportsFilterBugs: "ERRORES", + reportsFilterAbuse: "ABUSOS", + reportsFilterContent: "CONTENIDO", + reportsFilterConfirmed: "CONFIRMADOS", + reportsFilterResolved: "RESUELTOS", + reportsFilterOpen: "ABIERTOS", + reportsFilterUnderReview: "EN REVISIÓN", + reportsFilterInvalid: "INVALIDOS", + reportsCreateButton: "Crear Informe", + reportsTitleLabel: "Título", + reportsDescriptionLabel: "Descripción", + reportsDescriptionPlaceholder: "Por favor, proporciona una descripción detallada.", + reportsCategory: "Categoría", + reportsCategoryLabel: "Categoría", + reportsCategoryFeatures: "Funciones", + reportsCategoryBugs: "Errores", + reportsCategoryAbuse: "Abuso", + reportsCategoryContent: "Problemas de Contenido", + reportsUpdateButton: "Actualizar", + reportsDeleteButton: "Eliminar", + reportsDateLabel: "Fecha", + reportsUploadFile: "Subir contenido multimedia (max: 50MB)", + reportsMineSectionTitle: "Tus Informes", + reportsFeaturesSectionTitle: "Solicitudes de Funciones", + reportsBugsSectionTitle: "Errores", + reportsAbuseSectionTitle: "Informes de Abuso", + reportsContentSectionTitle: "Problemas de Contenido", + reportsAllSectionTitle: "Informes", + reportsNoItems: "No hay informes disponibles.", + reportsValidationTitle: "Por favor, ingresa un título válido.", + reportsValidationDescription: "La descripción no puede estar vacía.", + reportsValidationCategory: "Por favor, selecciona una categoría.", + reportsCreatedAt: "Creado el", + reportsCreatedBy: "Por", + reportsSeverity: "Gravedad", + reportsSeverityLow: "Baja", + reportsSeverityMedium: "Media", + reportsSeverityHigh: "Alta", + reportsSeverityCritical: "Crítica", + reportsStatus: "Estado", + reportsStatusOpen: "Abierto", + reportsStatusUnderReview: "En Revisión", + reportsStatusResolved: "Resuelto", + reportsStatusInvalid: "Inválido", + reportsUpdateStatusButton: "Actualizar Estado", + reportsAnonymityOption: "Enviar de forma anónima", + reportsAnonymousAuthor: "Anónimo", + reportsConfirmButton: "CONFIRMAR INFORME!", + reportsConfirmations: "Confirmaciones", + reportsConfirmedSectionTitle: "Informes Confirmados", + reportsCreateTaskButton: "CREAR TAREA", + reportsOpenSectionTitle: "Informes Abiertos", + reportsUnderReviewSectionTitle: "Informes en Revisión", + reportsResolvedSectionTitle: "Informes Resueltos", + reportsInvalidSectionTitle: "Informes Inválidos", + reportsTemplateSectionTitle: 'Plantilla de reporte', + reportsBugTemplateTitle: 'Datos para reproducir (Bugs)', + reportsFeatureTemplateTitle: 'Detalles (Features)', + reportsAbuseTemplateTitle: 'Detalles (Abuso)', + reportsContentTemplateTitle: 'Detalles (Problemas de Contenido)', + reportsStepsToReproduceLabel: 'Pasos para reproducir', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'Resultado esperado', + reportsExpectedBehaviorPlaceholder: '¿Qué debería pasar?', + reportsActualBehaviorLabel: 'Resultado actual', + reportsActualBehaviorPlaceholder: '¿Qué pasa realmente?', + reportsEnvironmentLabel: 'Entorno', + reportsEnvironmentPlaceholder: 'Versión, dispositivo, OS, navegador, configuración, logs, etc.', + reportsReproduceRateLabel: 'Frecuencia', + reportsReproduceRateAlways: 'Siempre', + reportsReproduceRateOften: 'A menudo', + reportsReproduceRateSometimes: 'A veces', + reportsReproduceRateRarely: 'Rara vez', + reportsReproduceRateUnable: 'No se puede reproducir', + reportsReproduceRateUnknown: 'No especificado', + reportsProblemStatementLabel: 'Problema / necesidad', + reportsProblemStatementPlaceholder: '¿Qué problema resuelve esta feature?', + reportsUserStoryLabel: 'Historia de habitante', + reportsUserStoryPlaceholder: 'Como , quiero , para .', + reportsAcceptanceCriteriaLabel: 'Criterios de aceptación', + reportsAcceptanceCriteriaPlaceholder: '- Dado...\n- Cuando...\n- Entonces...', + reportsWhatHappenedLabel: '¿Qué ocurrió?', + reportsWhatHappenedPlaceholder: 'Describe el incidente con contexto y fechas aproximadas.', + reportsReportedUserLabel: 'Habitante / entidad reportada', + reportsReportedUserPlaceholder: '@habitante, feed, ID, enlace, etc.', + reportsEvidenceLinksLabel: 'Evidencia / enlaces', + reportsEvidenceLinksPlaceholder: 'Pega enlaces, IDs de mensajes, capturas (si aplica), etc.', + reportsContentLocationLabel: 'Dónde está el contenido', + reportsContentLocationPlaceholder: 'Enlace, ID, canal, hilo, autor, etc.', + reportsWhyInappropriatePlaceholder: 'Explica el motivo y el impacto.', + reportsRequestedActionLabel: 'Acción solicitada', + reportsRequestedActionPlaceholder: 'Eliminar, ocultar, marcar, advertir, etc.', + tribesTitle: "Tribus", + tribeAllSectionTitle: "Tribus", + tribeMineSectionTitle: "Tus Tribus", + tribeCreateSectionTitle: "Crear Tribu", + tribeUpdateSectionTitle: "Actualizar Tribu", + tribeGallerySectionTitle: "Galería de Tribus", + tribeRecentSectionTitle: "Tribus Recientes", + tribeTopSectionTitle: "Tribus Populares", + tribeviewTribeButton: "Visitar Tribu", + tribeviewSubTribeButton: "Visitar Sub-Tribu", + tribeRootLabel: "RAÍZ", + tribeDescription: "Explora o crea tribus en tu red.", + tribeFilterAll: "TODOS", + tribeFilterMine: "MÍAS", + tribeFilterMembership: "MIEMBROS", + tribeFilterRecent: "RECIENTES", + tribeFilterTop: "TOP", + tribeFilterSubtribes: "SUB-TRIBUS", + tribeFilterGallery: "GALERÍA", + tribeMainTribeLabel: "TRIBU PRINCIPAL", + tribeCreateButton: "Crear Tribu", + tribeUpdateButton: "Actualizar", + tribeDeleteButton: "Eliminar", + tribeImageLabel: "Subir contenido multimedia (max: 50MB)", + tribeTitleLabel: "Título", + searchTribesPlaceholder: "FILTRAR tribus POR NOMBRE …", + tribeTitlePlaceholder: "Nombre de la tribu", + tribeDescriptionLabel: "Descripción", + tribeDescriptionPlaceholder: "Describe esta tribu", + tribeLocationLabel: "Ubicación", + tribeLocationPlaceholder: "¿Dónde está ubicada esta tribu?", + tribeTagsLabel: "Etiquetas", + tribeTagsPlaceholder: "Introduce etiquetas separadas por comas", + tribeInviteMode: "Modo de invitación", + tribeModeLabel: "MODO", + tribeIsAnonymousLabel: "ESTADO", + tribeMembersCount: "Miembros", + tribeInviteCodePlaceholder: "Introduce el código de invitación", + tribeJoinByCodeButton: "Unirse con código", + tribeJoinButton: "UNIRSE", + tribeEnterInvite: "INTRODUCIR INVITACIÓN", + tribeLeaveButton: "DEJAR", + tribeYes: "SÍ", + tribeNo: "NO", + tribePublic: "PÚBLICA", + tribePrivate: "PRIVADA", + tribeGenerateInvite: "INVITACIÓN ÚNICA", + tribeOpenInvitation: "INVITACIÓN ABIERTA", + tribeJoinOpen: "UNIRSE A LA TRIBU", + tribeRemoveInvitation: "ELIMINAR INVITACIÓN", + tribeCreatedAt: "Creado el", + tribeAuthor: "Por", + tribeAuthorLabel: "AUTOR", + tribeStrict: "Estricto", + tribeOpen: "Abierta", + tribeFeedFilterRECENT: "RECIENTES", + tribeFeedFilterMINE: "MIOS", + tribeFeedFilterALL: "TODOS", + tribeFeedFilterTOP: "TOP", + tribeFeedRefeeds: "Redifusiones", + tribeFeedRefeed: "Redifundir", + tribeFeedMessagePlaceholder: "Escribe un feed…", + tribeFeedSend: "Enviar", + tribeFeedEmpty: "No hay mensajes de feed disponibles, aún.", + noTribes: "No se encontraron tribus, aún.", + tribeNotFound: "Tribu no encontrada!", + createTribeTitle: "Crear Tribu", + updateTribeTitle: "Actualizar Tribu", + tribeSectionOverview: "Resumen", + tribeSectionInhabitants: "Habitantes", + tribeSectionVotations: "VOTACIONES", + tribeSectionEvents: "EVENTOS", + tribeSectionReports: "Reportes", + tribeSectionTasks: "TAREAS", + tribeSectionFeed: "FEED", + tribeSectionForum: "FORO", + tribeSectionMarket: "Mercado", + tribeSectionJobs: "Empleos", + tribeSectionProjects: "Proyectos", + tribeSectionMedia: "Multimedia", + tribeSectionImages: "IMÁGENES", + tribeSectionAudios: "AUDIOS", + tribeSectionVideos: "VÍDEOS", + tribeSectionDocuments: "DOCUMENTOS", + tribeSectionBookmarks: "MARCADORES", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "No hay habitantes en esta tribu, aún.", + tribeEventCreate: "Crear Evento", + tribeEventsEmpty: "No hay eventos, aún.", + tribeEventTitle: "Título", + tribeEventDescription: "Descripción", + tribeEventDate: "Fecha", + tribeEventLocation: "Ubicación", + tribeEventAttend: "ASISTIR", + tribeEventUnattend: "NO ASISTIR", + tribeEventAttendees: "Asistentes", + tribeTaskCreate: "Crear Tarea", + tribeTasksEmpty: "No hay tareas, aún.", + tribeTaskTitle: "Título", + tribeTaskDescription: "Descripción", + tribeTaskPriority: "Prioridad", + tribeTaskDeadline: "Fecha límite", + tribeTaskAssignees: "Asignados", + tribeTaskStatusInProgress: "EN PROGRESO", + tribeTaskStatusClosed: "CERRAR", + tribeTaskAssign: "ASIGNAR", + tribeTaskUnassign: "DESASIGNAR", + tribeReportCreate: "Crear Reporte", + tribeReportsEmpty: "No hay reportes, aún.", + tribeReportTitle: "Título", + tribeReportDescription: "Descripción", + tribeReportCategory: "Categoría", + tribeVotationCreate: "Crear Votación", + tribeVotationsEmpty: "No hay votaciones, aún.", + tribeVotationTitle: "Título", + tribeVotationDescription: "Descripción", + tribeVotationOptions: "Opciones", + tribeVotationDeadline: "Fecha límite", + tribeVotationVote: "VOTAR", + tribeVotationResults: "Votos", + tribeVotationClose: "CERRAR VOTACIÓN", + tribeVotationOptionPlaceholder: "Opción", + tribeForumCreate: "Crear Forum", + tribeForumEmpty: "No hay hilos, aún.", + tribeForumTitle: "Título", + tribeForumText: "Mensaje", + tribeForumCategory: "Categoría", + tribeForumReply: "Responder", + tribeForumReplies: "Respuestas", + tribeMarketCreate: "Crear Anuncio", + tribeMarketEmpty: "No hay anuncios, aún.", + tribeMarketTitle: "Título", + tribeMarketDescription: "Descripción", + tribeMarketPrice: "Precio", + tribeMarketImage: "Imagen", + tribeMarketCategory: "Categoría", + tribeJobCreate: "Crear Empleo", + tribeJobsEmpty: "No hay empleos, aún.", + tribeJobTitle: "Título", + tribeJobDescription: "Descripción", + tribeJobLocation: "Ubicación", + tribeJobSalary: "Salario", + tribeJobDeadline: "Fecha límite", + tribeProjectCreate: "Crear Proyecto", + tribeProjectsEmpty: "No hay proyectos, aún.", + tribeProjectTitle: "Título", + tribeProjectDescription: "Descripción", + tribeProjectGoal: "Objetivo", + tribeProjectFunded: "Financiado", + tribeProjectDeadline: "Fecha límite", + tribeMediaUpload: "Subir Media", + readDocument: "Leer Documento", + tribeCreateImage: "Crear Imagen", + tribeCreateAudio: "Crear Audio", + tribeCreateVideo: "Crear Video", + tribeCreateDocument: "Crear Documento", + tribeCreateBookmark: "Crear Marcador", + tribeMediaEmpty: "No hay media, aún.", + tribeMediaTitle: "Título", + tribeMediaDescription: "Descripción", + tribeMediaType: "Tipo", + tribeMediaTypeImage: "Imagen", + tribeMediaTypeVideo: "Vídeo", + tribeMediaTypeAudio: "Audio", + tribeMediaTypeDocument: "Documento", + tribeMediaTypeBookmark: "Marcador", + tribeContentDelete: "ELIMINAR", + tribeInviteCodeText: "Código de invitación: ", + oasisVersionLabel: "Versión de Oasis", + tribeInviteCodeHint: "Comparte este código con quien quieras invitar. Puede unirse desde /invites → Tribes.", + tribeGroupTribe: "Tribu", + tribeGroupOffice: "Oficina", + tribeGroupNetwork: "Red", + tribeGroupEconomy: "Economía", + tribeGroupMedia: "Multimedia", + tribeStatusOpen: "ABIERTO", + tribeStatusClosed: "CERRADO", + tribeStatusInProgress: "EN PROGRESO", + tribePriorityLow: "BAJA", + tribePriorityMedium: "MEDIA", + tribePriorityHigh: "ALTA", + tribePriorityCritical: "CRÍTICA", + tribeTaskFilterAll: "TODOS", + tribeMediaFilterAll: "TODOS", + tribeReportCatBug: "ERROR", + tribeReportCatAbuse: "ABUSO", + tribeReportCatContent: "CONTENIDO", + tribeReportCatOther: "OTRO", + tribeForumCatGeneral: "GENERAL", + tribeForumCatProposal: "PROPUESTA", + tribeForumCatQuestion: "PREGUNTA", + tribeForumCatAnnouncement: "ANUNCIO", + tribeMarketCatGoods: "BIENES", + tribeMarketCatServices: "SERVICIOS", + tribeMarketCatFood: "ALIMENTACIÓN", + tribeMarketCatOther: "OTRO", + tribeStatusLabel: "Estado", + tribeSubTribes: "SUB-TRIBUS", + tribeSubTribesCreate: "Crear Sub-Tribu", + tribeSubTribesStrictDenied: "El modo estricto de la tribu no te permite generar nuevas sub-tribus. Ponte en contacto con el administrador.", + tribeSubTribesEmpty: "No se han creado sub-tribus, aún.", + tribeActivityJoined: "UNIDO", + tribeActivityLeft: "SALIDO", + tribeActivityFeed: "FEED", + tribeActivityRefeed: "REFEED", + tribeGroupAnalytics: "Analíticas", + tribeGroupCreative: "Creativo", + tribeSectionActivity: "ACTIVIDAD", + tribeSectionTrending: "TENDENCIAS", + tribeSectionOpinions: "OPINIONES", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "ETIQUETAS", + tribeSectionSearch: "BUSCAR", + tribeActivityEmpty: "Sin actividad aún.", + tribeActivityCreated: "creó", + tribeActivityPosted: "publicó", + tribeActivityReplied: "respondió", + tribeTrendingEmpty: "Sin contenido en tendencia aún.", + tribeTrendingPeriodDay: "Hoy", + tribeTrendingPeriodWeek: "Esta Semana", + tribeTrendingPeriodAll: "Todo", + tribeTrendingEngagement: "interacción", + tribeOpinionsEmpty: "Sin opiniones aún.", + tribeOpinionsCast: "Votar", + tribeOpinionsRankings: "Clasificaciones", + tribeOpinionsAlreadyVoted: "Ya has votado", + tribeTopCategory: "Más votado", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Lienzo colaborativo de pixel art.", + tribePixeliaPaint: "Pintar", + tribePixeliaContributors: "contribuidores", + tribePixeliaTotalPixels: "píxeles pintados", + tribeTagsEmpty: "No se encontraron etiquetas.", + tribeTagsCloud: "Nube de Etiquetas", + tribeTagsContentWith: "Contenido con etiqueta", + tribeSearchPlaceholder: "Buscar contenido de la tribu...", + tribeSearchEmpty: "Sin resultados.", + tribeSearchResults: "Resultados", + tribeSearchMinChars: "Introduce al menos 2 caracteres para buscar.", + agendaTitle: "Agenda", + agendaDescription: "Aquí puedes encontrar todos tus elementos asignados.", + agendaFilterAll: "TODOS", + agendaFilterToday: "HOY", + agendaFilterUpcoming: "PRÓXIMO", + agendaFilterOverdue: "VENCIDOS", + agendaFilterOpen: "ABIERTOS", + agendaFilterClosed: "CERRADOS", + agendaFilterTasks: "TAREAS", + agendaFilterMarket: "MERCADO", + agendaFilterTribes: "TRIBUS", + agendaFilterEvents: "EVENTOS", + agendaFilterReports: "INFORMES", + agendaFilterTransfers: "TRANSFERENCIAS", + agendaFilterJobs: "TRABAJOS", + agendaFilterProjects: "PROYECTOS", + agendaFilterCalendars: "CALENDARIOS", + agendaNoItems: "No se encontraron asignaciones.", + agendaDiscardButton: "Descartar", + agendaRestoreButton: "Restaurar", + agendaAuthor: "Por", + agendaCreatedAt: "Creado el", + agendaTitleLabel: "Título", + agendaMembersCount: "Miembros", + agendaDescriptionLabel: "Descripción", + agendaStatus: "Estado", + agendaVisibility: "Visibilidad", + agendaEventDate: "Fecha del Evento", + agendaEventLocation: "Ubicación", + agendaEventPrice: "Precio", + agendaEventUrl: "URL", + agendaTaskStart: "Hora de Inicio", + agendaLocationLabel: "Ubicación", + agendaYes: "SÍ", + agendaNo: "NO", + agendaInviteModeLabel: "Modo de invitación", + agendaAnonymousLabel: "Modo Anónimo", + agendaMembersLabel: "Miembros", + agendareportCategory: "Categoría", + agendareportSeverity: "Gravedad", + agendareportStatus: "Estado", + agendareportDescription: "Descripción", + agendaTaskEnd: "Hora de Fin", + agendaTaskPriority: "Prioridad", + agendaTransferFrom: "De", + agendaTransferTo: "A", + agendaTransferConcept: "Concepto", + agendaTransferAmount: "Cantidad", + agendaTransferDeadline: "Plazo", + opinionsTitle: "Opiniones", + shareYourOpinions: "Descubre y vota por opiniones en tu red.", + author: "Por", + voteNow: "Vota ahora", + alreadyVoted: "Ya has opinado.", + noOpinionsFound: "No se encontraron opiniones.", + RECENTButton: "RECIENTES", + TOPButton: "TOP", + interestingButton: "INTERESANTE", + necessaryButton: "NECESARIO", + funnyButton: "DIVERTIDO", + disgustingButton: "ASQUEROSO", + sensibleButton: "SENSIBLE", + propagandaButton: "PROPAGANDA", + adultOnlyButton: "SOLO ADULTOS", + boringButton: "ABURRIDO", + confusingButton: "CONFUSO", + usefulButton: "UTIL", + informativeButton: "INFORMATIVO", + wellResearchedButton: "BIEN INVESTIGADO", + accurateButton: "CONCISO", + needsSourcesButton: "NECESITA FUENTES", + wrongButton: "INCORRECTO", + lowQualityButton: "BAJA CALIDAD", + creativeButton: "CREATIVO", + insightfulButton: "PERSPECTIVO", + actionableButton: "ACCIONABLE", + inspiringButton: "INSPIRADOR", + loveButton: "AMOR", + clearButton: "CLARO", + upliftingButton: "EMPODERANTE", + unnecessaryButton: "INNECESARIO", + rejectedButton: "RECHAZADO", + misleadingButton: "ENGAÑOSO", + offTopicButton: "FUERA DE TEMA", + duplicateButton: "DUPLICADO", + clickbaitButton: "CEBO DE CLICS", + spamButton: "SPAM", + trollButton: "TROLL", + nsfwButton: "NSFW", + violentButton: "VIOLENTO", + toxicButton: "TOXICO", + harassmentButton: "ACOSO", + hateButton: "ODIO", + scamButton: "ESTAFA", + triggeringButton: "PROVOCADOR", + opinionsCreatedAt: "Creado en", + opinionsTotalCount: "Total de opiniones", + voteInteresting: "Interesante", + voteNecessary: "Necesario", + voteUseful: "Útil", + voteInformative: "Informativo", + voteWellResearched: "Bien investigado", + voteNeedsSources: "Necesita fuentes", + voteWrong: "Incorrecto", + voteLowQuality: "Baja calidad", + voteLove: "Amor", + voteClear: "Claro", + voteMisleading: "Engañoso", + voteOffTopic: "Fuera de tema", + voteDuplicate: "Duplicado", + voteClickbait: "Cebo de clics", + votePropaganda: "Propaganda", + voteFunny: "Divertido", + voteInspiring: "Inspirador", + voteUplifting: "Empoderante", + voteUnnecessary: "Innecesario", + voteRejected: "Rechazado", + voteConfusing: "Confuso", + voteTroll: "Troll", + voteNsfw: "NSFW", + voteViolent: "Violento", + voteToxic: "Tóxico", + voteHarassment: "Acoso", + voteHate: "Odio", + voteScam: "Estafa", + voteTriggering: "Provocador", + voteInsightful: "Perspicaz", + voteAccurate: "Conciso", + voteActionable: "Accionable", + voteCreative: "Creativo", + voteSpam: "Spam", + voteAdultOnly: "Solo adultos", + publishBlog: "Publicar Blog", + privateMessage: "MP", + pmSendTitle: "Mensajes Privados", + pmSend: "Enviar!", + pmDescription: "Usa este formulario para enviar un mensaje cifrado a otros habitantes.", + pmRecipients: "Destinatarios", + pmRecipientsHint: "Introduce los IDs de Oasis separados por comas", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Asunto", + pmSubjectHint: "Introduce el asunto del mensaje", + pmText: "Mensaje", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Descifrar", + pmCrypterBadKey: "¡La clave compartida es incorrecta!", + pmCrypterTooLong: "El mensaje es demasiado largo para cifrarlo con el Crypter. Acórtalo e inténtalo de nuevo.", + pmCrypterCipherLabel: "Mensaje recifrado", + pmCrypterCharsLabel: "caracteres", + pmInvalidRecipients: "Oasis ID no válido (debería ser como @…=.ed25519).", + pmEncryptionLabel: "Cifrado", + pmFile: "Archivo adjunto", + private: "Privados", + privateDescription: "Tus mensajes cifrados.", + privateInbox: "BUZÓN", + privateSent: "ENVIADOS", + privateDelete: "Borrar", + pmCreateButton: "Escribir MP", + noPrivateMessages: "No hay mensajes privados.", + pmReply: "Responder", + pmReplies: "respuestas", + pmNew: "nuevas", + pmMarkRead: "Marcar como leido", + inReplyTo: "EN RESPUESTA A", + pmPreview: "Previsualizar", + pmPreviewTitle: "Vista previa", + performed: "→", + pmFromLabel: "De:", + pmToLabel: "Para:", + pmInvalidMessage: "Mensaje no válido", + pmNoSubject: "(sin asunto)", + pmSubjectLabel: "Asunto:", + pmBodyLabel: "Cuerpo", + pmBotJobs: "42-EmpleosBOT", + pmBotProjects: "42-ProyectosBOT", + pmBotMarket: "42-MercadoBOT", + inboxJobSubscribedTitle: "Nueva suscripción a tu oferta de trabajo", + pmInhabitantWithId: "Habitante con ID OASIS:", + pmHasSubscribedToYourJobOffer: "se ha suscrito a tu oferta de trabajo", + inboxProjectCreatedTitle: "Nuevo proyecto creado", + pmHasCreatedAProject: "ha creado un proyecto", + inboxMarketItemSoldTitle: "Artículo vendido", + pmYourItem: "Tu artículo", + pmHasBeenSoldTo: "se ha vendido a", + pmFor: "por", + inboxProjectPledgedTitle: "Nueva aportación a tu proyecto", + pmHasPledged: "ha aportado", + pmToYourProject: "a tu proyecto", + blockchain: 'Explorador', + blockchainTitle: 'Explorador', + blockchainDescription: 'Explora y visualiza los bloques de la cadena.', + blockchainNoBlocks: 'No se encontraron bloques en la cadena.', + blockchainStatsTitle: 'Estadísticas', + blockchainStatsTypeBreakdown: 'Desglose por tipo', + blockchainStatsTopAuthors: 'Autores principales', + blockchainStatsCount: 'Bloques', + blockchainBlockID: 'ID del Bloque', + blockchainBlockAuthor: 'Autoría', + blockchainBlockCarbon: 'Huella de carbono', + blockchainBlockType: 'Tipo', + blockchainBlockTimestamp: 'Marca de tiempo', + blockchainBlockContent: 'Bloque', + blockchainBlockURL: 'URL:', + blockchainContent: 'Bloque', + blockchainContentPreview: 'Vista previa del contenido del bloque', + blockchainLatestDatagram: 'Último Datagrama', + blockchainDatagram: 'Datagrama', + blockchainDetails: 'Ver detalles del bloque', + blockchainViewBlockexplorer: "Ver en el blockexplorer", + blockchainBlockInfo: 'Información del Bloque', + blockchainBlockDetails: 'Detalles del bloque seleccionado', + blockchainBack: 'Volver al Blockexplorer', + banking: 'Banking', + bankingTitle: 'Banking', + bankingDescription: 'Explora el valor actual de ECOin y la asignación de RBU correspondiente, distribuida semanalmente en función de la participación y la confianza.', + bankOverview: 'Resumen', + bankEpochs: 'Épocas', + bankRules: 'Reglas', + pending: 'Pendientes', + closed: 'Cerradas', + bankBack: 'Volver a Banking', + bankViewTx: 'Ver Tx', + bankClaimNow: 'Reclamar', + bankClaimUBI: '¡Reclamar RBU!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'No hay asignaciones RBU pendientes para esta época.', + bankEpoch: 'Época', + bankPool: 'Fondo (esta época)', + bankWeightsSum: 'Suma de pesos', + bankAllocations: 'Asignaciones', + bankNoAllocations: 'No se encontraron asignaciones.', + bankNoEpochs: 'No se encontraron épocas.', + bankEpochAllocations: 'Asignaciones de la época', + bankAllocId: 'ID de asignación', + bankAllocDate: 'Fecha', + bankAllocConcept: 'Concepto', + bankAllocFrom: 'De', + bankAllocTo: 'Para', + bankAllocAmount: 'Cantidad', + bankAllocStatus: 'Estado', + bankEpochId: 'ID de época', + bankRuleHash: 'Hash del snapshot de reglas', + bankViewEpoch: 'Ver época', + bankUserBalance: 'Tu saldo', + ecoWalletNotConfigured: 'Cartera de ECOin no configurada', + editWallet: 'Editar cartera', + addWallet: 'Añadir cartera', + bankAddresses: 'Direcciones', + bankNoAddresses: 'No se encontraron direcciones.', + bankUser: 'ID de Oasis', + bankAddress: 'Dirección', + bankAddAddressTitle: 'Añadir dirección de ECOIN', + bankAddAddressUser: 'ID de Oasis', + bankAddAddressAddress: 'Dirección de ECOIN', + bankAddAddressSave: 'Guardar', + bankAddressAdded: 'Dirección añadida', + bankAddressUpdated: 'Dirección actualizada', + bankAddressExists: 'La dirección ya existe', + bankAddressInvalid: 'Dirección no válida', + bankAddressDeleted: 'Dirección eliminada', + bankAddressNotFound: 'Dirección no encontrada', + bankAddressForbidden: 'Solo puedes configurar tu propia dirección de cobro', + bankAddressTotal: 'Total de Direcciones', + bankAddressSearch: 'Buscar @habitante o dirección', + bankAddressActions: 'Acciones', + bankAddressDelete: 'Eliminar', + bankAddressSource: 'Fuente', + bankAddressDeleteConfirm: 'Eliminar esta dirección?', + search: 'Buscar!', + bankLocal: 'Local', + bankFromOasis: 'Oasis', + bankMyAddress: 'Tu dirección', + bankRemoveMyAddress: 'Eliminar mi dirección', + bankNotRemovableOasis: 'Las direcciones no se pueden eliminar localmente', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "SIN FONDOS!", + bankUbiAvailableOk: "¡DISPONIBLE!", + bankUbiAvailability: "Disponibilidad de UBI", + bankAlreadyClaimedThisMonth: "Ya reclamado este mes", + bankUbiThisMonth: "UBI (este mes)", + bankUbiLastClaimed: "UBI (última reclamada)", + bankUbiNeverClaimed: "Nunca reclamado", + bankUbiTotalClaimed: "UBI (total reclamado)", + bankUbiPub: "PUB", + bankUbiInhabitant: "HABITANTE", + bankUbiClaimedAmount: "RECLAMADO (ECO)", + typeBankUbiResult: "BANCARIO - UBI", + bankNoPubConfigured: "Sin PUB configurado. Establece tu ID de PUB en Ajustes.", + shopsTitle: "Tiendas", + shopDescription: "Descubre y gestiona tiendas en la red.", + shopTitle: "Tienda", + shopFilterAll: "TODAS", + shopFilterMine: "MÍAS", + shopFilterRecent: "RECIENTES", + shopFilterTop: "TOP", + shopFilterProducts: "PRODUCTOS", + shopFilterPrices: "PRECIOS", + shopFilterFavorites: "FAVORITAS", + shopUpload: "Crear Tienda", + shopAllSectionTitle: "Tiendas", + shopMineSectionTitle: "Mis Tiendas", + shopRecentSectionTitle: "Tiendas Recientes", + shopTopSectionTitle: "Tiendas Destacadas", + shopProductsSectionTitle: "Productos Destacados", + shopPricesSectionTitle: "Productos por Precio", + shopFavoritesSectionTitle: "Favoritas", + shopCreateSectionTitle: "Crear Tienda", + shopUpdateSectionTitle: "Actualizar Tienda", + shopCreate: "Crear", + shopUpdate: "Actualizar", + shopDelete: "Eliminar", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Publicar en Clearnet", + shopClearnetUnpublish: "Quitar de Clearnet", + shopClearnetUrlLabel: "URL en Clearnet", + shopClearnetWarning: "Publicar en Clearnet hace que esta tienda sea indexable por buscadores y rastreadores externos.", + shopAddFavorite: "Añadir Favorito", + shopRemoveFavorite: "Quitar Favorito", + shopOpen: "ABIERTA", + shopClosed: "CERRADA", + shopOpenShop: "Abrir Tienda", + shopCloseShop: "Cerrar Tienda", + shopMakePrivate: "HACER PRIVADA (E2E)", + shopMakePublic: "HACER PÚBLICA", + shopProducts: "Productos", + shopProductAdd: "Añadir Producto", + shopProductTitle: "Producto", + shopProductUpdate: "Actualizar Producto", + shopProductPrice: "Precio (ECO)", + shopProductStock: "Stock", + shopProductUntitled: "Producto sin título", + shopUntitled: "Tienda sin título", + shopNoItems: "No se encontraron tiendas.", + shopNoProducts: "Aún no hay productos.", + shopOutOfStock: "Agotado", + shopBuy: "Comprar", + shopBuyEncryptedNote: "Tus datos de entrega se envían cifrados; solo el dueño de la tienda puede verlos.", + shopBuyDeliveryAddress: "Dirección de entrega", + shopBuyContact: "Contacto", + shopBuyContactPlaceholder: "correo, teléfono, etc.", + shopBuyNotes: "Notas", + shopBuyDeliveryRequired: "La dirección de entrega es obligatoria.", + shopOrdersTitle: "Pedidos", + shopOrdersDescription: "Pedidos recibidos por esta tienda.", + shopOrdersEmpty: "Aún no hay pedidos.", + shopOrderProduct: "Producto", + shopOrderPrice: "Precio", + shopOrderBuyer: "Comprador", + shopBackToShop: "Volver a la Tienda", + shopShareUrl: "URL para compartir", + shopVisitShop: "VISITAR TIENDA", + marketVisitItem: "VER ARTÍCULO", + shopStatus: "ESTADO", + shopCreatedAt: "CREADO", + shopSearchPlaceholder: "Buscar tiendas...", + shopUrl: "URL", + shopLocation: "Ubicación", + shopTags: "Etiquetas", + shopVisibility: "Visibilidad", + shopImage: "Imagen", + shopShortDescription: "Descripcion Corta", + shopShortDescriptionPlaceholder: "Descripcion breve para las tarjetas (max 160 caracteres)", + shopTitlePlaceholder: "Nombre de tu tienda", + shopDescriptionPlaceholder: "Descripcion detallada de tu tienda", + shopUrlPlaceholder: "https://url-de-tu-tienda.com", + shopLocationPlaceholder: "Ciudad, Pais", + shopTagsPlaceholder: "etiqueta1, etiqueta2, etiqueta3", + mapTitlePlaceholder: "Titulo del mapa", + shopProductFeatured: "Producto Destacado", + shopSendToMarket: "Send to Market", + typeShop: "TIENDAS", + typeShopProduct: "PRODUCTO DE TIENDA", + bankExchange: 'Intercambio', + bankExchangeCurrentValue: 'Valor de ECOin (1h)', + bankTotalSupply: 'Suministro Total de ECOin', + bankHoursOfWork: 'horas', + bankUnitMs: "ms", + bankUnitSeconds: "segundos", + bankUnitMinutes: "minutos", + bankUnitDays: "días", + bankExchangeNoData: 'No hay datos disponibles', + bankExchangeIndex: 'Valor de ECOin (1h)', + bankInflation: 'Inflación de ECOin (anual)', + bankInflationMonthly: 'Inflación de ECOin (mensual)', + bankExchangeChartTitle: 'Progresión del valor de ECOin a lo largo del tiempo', + bankExchangeChartValue: 'Valor (ECO/h)', + bankExchangeChartSupply: 'Suministro', + bankExchangeChartInflation: 'Inflación %', + bankExchangeChartEmpty: 'Aún no hay muestras suficientes — vuelve más tarde', + bankCurrentSupply: 'Suministro Actual de ECOin', + bankingSyncStatus: 'Estado de ECOin', + bankingSyncStatusSynced: 'Sincronizado', + bankingSyncStatusOutdated: 'Desactualizado', + statsTitle: 'Estadísticas', + statistics: "Estadísticas", + statsInhabitant: "Estadísticas del Habitante", + statsDescription: "Descubre estadísticas sobre tu red.", + ALLButton: "TODO", + MINEButton: "MÍAS", + TOMBSTONEButton: "ELIMINADOS", + statsYou: "Tú", + statsUserId: "ID de Oasis", + statsCreatedAt: "Creado el", + statsYourContent: "Contenido", + statsYourOpinions: "Opiniones", + statsYourTombstone: "Eliminados", + statsNetwork: "Red", + statsTotalInhabitants: "Habitantes", + statsDiscoveredTribes: "Tribus (Públicas)", + statsPrivateDiscoveredTribes: "Tribus (Privadas)", + statsNetworkContent: "Contenido", + statsYourMarket: "Mercado", + statsYourJob: "Empleos", + statsYourProject: "Proyectos", + statsYourTransfer: "Transferencias", + statsYourForum: "Foros", + statsNetworkOpinions: "Opiniones", + statsDiscoveredMarket: "Mercado", + statsDiscoveredJob: "Empleos", + statsDiscoveredProject: "Proyectos", + statsBankingTitle: "Banca", + statsEcoWalletLabel: "Billetera ECOIN", + statsEcoWalletNotConfigured: "¡No configurada!", + statsTotalEcoAddresses: "Direcciones totales", + statsDiscoveredTransfer: "Transferencias", + statsDiscoveredForum: "Foros", + statsNetworkTombstone: "Eliminados", + statsBookmark: "Marcadores", + statsEvent: "Eventos", + statsTask: "Tareas", + statsVotes: "Votos", + statsMarket: "Mercado", + statsForum: "Foros", + statsJob: "Empleos", + statsReport: "Reportes", + statsFeed: "Publicaciones", + statsTribe: "Tribus", + statsImage: "Imágenes", + statsAudio: "Audios", + statsVideo: "Vídeos", + statsDocument: "Documentos", + statsMap: "Mapas", + statsShop: "Tiendas", + statsShopProduct: "Productos de tienda", + statsTransfer: "Transferencias", + statsAiExchange: "IA", + statsPUBs: 'PUBs', + statsPost: "Publicaciones", + statsOasisID: "ID de Oasis", + statsSize: "Total (tamaño)", + statsBlockchainSize: "Blockchain (tamaño)", + statsBlobsSize: "Blobs (tamaño)", + statsActivity7d: "Actividad (últimos 7 días)", + statsActivity7dTotal: "Total 7 días", + statsActivity30dTotal: "Total 30 días", + statsKarmaScore: "Puntuación KARMA", + statsPublic: "Público", + statsPrivate: "Privado", + day: "Día", + messages: "Mensajes", + statsProject: "Proyectos", + statsProjectsTitle: "Proyectos", + statsProjectsTotal: "Total de proyectos", + statsProjectsActive: "Activos", + statsProjectsCompleted: "Completados", + statsProjectsPaused: "Pausados", + statsProjectsCancelled: "Cancelados", + statsProjectsGoalTotal: "Meta total", + statsProjectsPledgedTotal: "Aportación total", + statsProjectsSuccessRate: "Tasa de éxito", + statsProjectsAvgProgress: "Progreso medio", + statsProjectsMedianProgress: "Progreso mediano", + statsProjectsActiveFundingAvg: "Financiación activa media", + statsJobsTitle: "Empleos", + statsJobsTotal: "Total de empleos", + statsJobsOpen: "Abiertos", + statsJobsClosed: "Cerrados", + statsJobsOpenVacants: "Vacantes abiertas", + statsJobsSubscribersTotal: "Suscriptores totales", + statsJobsAvgSalary: "Salario medio", + statsJobsMedianSalary: "Salario mediano", + statsMarketTitle: "Mercado", + statsMarketTotal: "Artículos totales", + statsMarketForSale: "En venta", + statsMarketReserved: "Reservados", + statsMarketClosed: "Cerrados", + statsMarketSold: "Vendidos", + statsMarketRevenue: "Ingresos", + statsMarketAvgSoldPrice: "Precio medio vendido", + statsUsersTitle: "Habitantes", + user: "Habitante", + statsTombstoneTitle: "Eliminados", + statsNetworkTombstones: "Eliminados en la red", + statsTombstoneRatio: "Ratio de eliminados (%)", + bankingUserEngagementScore: "Puntuación de KARMA", + statsParliamentCandidature: "Candidaturas de parlamento", + statsParliamentTerm: "Mandatos de parlamento", + statsParliamentProposal: "Propuestas de parlamento", + statsParliamentRevocation: "Revocaciones de parlamento", + statsParliamentLaw: "Leyes de parlamento", + statsCourtsCase: "Casos judiciales", + statsCourtsEvidence: "Evidencias judiciales", + statsCourtsAnswer: "Respuestas judiciales", + statsCourtsVerdict: "Veredictos judiciales", + statsCourtsSettlement: "Acuerdos judiciales", + statsCourtsSettlementProposal: "Propuestas de acuerdo", + statsCourtsSettlementAccepted: "Acuerdos aceptados", + statsCourtsNomination: "Nominaciones de jueces", + statsCourtsNominationVote: "Votos de nominación", + ai: "IA", + aiTitle: "IA", + aiDescription: "Una Inteligencia Artificial Colectiva (IAC) llamada '42' que aprende de tu red.", + aiUserQuestion: "Pregunta", + aiResponseTitle: "Respuesta", + aiSubmitButton: "Enviar!", + aiSettingsDescription: "Configura tu prompt (máx 128 caracteres) para el modelo de IA.", + aiPrompt: "Proporciona una respuesta informativa y precisa.", + aiConfiguration: "Configurar prompt", + aiPromptUsed: "Indicación", + aiClearHistory: "Borrar historial de chat", + aiSharePrompt: "Añadir esta respuesta al entrenamiento colectivo?", + aiShareYes: "Sí", + aiShareNo: "No", + aiSharedLabel: "Añadida al entrenamiento", + aiRejectedLabel: "No añadida al entrenamiento", + aiServerError: "La IA no ha podido responder. Inténtalo de nuevo.", + aiInputPlaceholder: "¿Qué es Oasis?", + typeAiExchange: "IA", + aiApproveTrain: "Añadir al entrenamiento colectivo", + aiRejectTrain: "No entrenar", + aiTrainPending: "Pendiente de aprobación", + aiTrainApproved: "Aprobado para entrenamiento", + aiTrainRejected: "Rechazado para entrenamiento", + aiSnippetsUsed: "Fragmentos usados", + aiSnippetsLearned: "Fragmentos aprendidos", + statsAITraining: "Entrenamiento de IA", + aiApproveCustomTrain: "Entrenar con esta respuesta personalizada", + aiCustomAnswerPlaceholder: "Escribe tu respuesta personalizada…", + statsAIExchanges: "Intercambio de Modelos", + marketMineSectionTitle: "Tus artículos", + marketCreateSectionTitle: "Crear artículo", + marketUpdateSectionTitle: "Actualizar", + marketAllSectionTitle: "Mercado", + marketRecentSectionTitle: "Mercado reciente", + marketTitle: "Mercado", + marketDescription: "Un mercado para intercambiar bienes o servicios en tu red.", + marketFilterAll: "TODOS", + marketFilterMine: "MÍOS", + marketFilterAuctions: "SUBASTAS", + marketFilterItems: "INTERCAMBIO", + marketFilterNew: "NUEVO", + marketFilterUsed: "USADO", + marketFilterBroken: "ROTO", + marketFilterForSale: "EN VENTA", + marketFilterSold: "VENDIDO", + marketFilterDiscarded: "DESCARTADO", + marketFilterRecent: "RECIENTE", + marketFilterMyBids: "PUJAS", + marketCreateButton: "Crear artículo", + marketItemType: "Tipo", + marketItemTitle: "Título", + marketItemAvailable: "Fecha límite", + marketItemDescription: "Descripción", + marketItemDescriptionPlaceholder: "Describe el artículo que estás vendiendo", + marketItemStatus: "Estado", + marketShopLabel: "Tienda", + marketItemCondition: "Condición", + marketItemPrice: "Precio", + marketItemTags: "Etiquetas", + marketItemTagsPlaceholder: "Introduce etiquetas separadas por comas", + marketItemDeadline: "Fecha límite", + marketItemIncludesShipping: "¿Incluye envío?", + marketItemHighestBid: "Puja más alta", + marketItemHighestBidder: "Mejor postor", + marketItemStock: "Existencias", + marketOutOfStock: "Sin stock", + marketItemBidTime: "Fecha de la puja", + marketActionsUpdate: "Actualizar", + marketUpdateButton: "Actualizar", + marketActionsDelete: "Eliminar", + marketActionsSold: "Marcar como vendido", + marketActionsChangeStatus: "CAMBIAR ESTADO", + marketActionsBuy: "¡COMPRAR!", + marketAuctionBids: "Pujas actuales", + marketPlaceBidButton: "Pujar", + marketItemSeller: "Vendedor", + marketNoItems: "Aún no hay artículos disponibles.", + marketYourBid: "Tu puja", + marketCreateFormImageLabel: "Subir contenido multimedia (max: 50MB)", + marketSearchLabel: "Buscar", + marketSearchPlaceholder: "Buscar por título o etiquetas", + marketMinPriceLabel: "Precio mínimo", + marketMaxPriceLabel: "Precio máximo", + marketSortLabel: "Ordenar por", + marketSortRecent: "Más recientes", + marketSortPrice: "Precio", + marketSortDeadline: "Fecha límite", + marketSearchButton: "Buscar", + marketAuctionEndsIn: "Termina", + marketAuctionEnded: "Terminó", + marketMyBidBadge: "Has pujado", + marketNoItemsMatch: "No hay artículos que coincidan con tu búsqueda.", + jobsTitle: "Laboral", + jobsCandidatesTitle: "Candidatos sugeridos", + jobsCandidatesDescription: "Habitantes cuyas competencias encajan con este empleo. Envíales un mensaje privado para invitarles.", + jobsCandidatesPmBody: "Hola, creo que tu perfil encaja con mi oferta laboral", + jobsDescription: "Descubre y gestiona ofertas de trabajo en tu red.", + jobsFilterRecent: "RECIENTES", + jobsFilterMine: "MIOS", + jobsFilterAll: "TODOS", + jobsFilterRemote: "REMOTOS", + jobsFilterOpen: "ABIERTOS", + jobsFilterClosed: "CERRADOS", + jobsCV: "CVs", + jobsCreateJob: "Publicar Trabajo", + jobsRecentTitle: "Trabajos Recientes", + jobsMineTitle: "Tus Trabajos", + jobsAllTitle: "Ofertas de Trabajo", + jobsRemoteTitle: "Trabajos Remotos", + jobsOpenTitle: "Trabajos Abiertos", + jobsClosedTitle: "Trabajos Cerrados", + jobsCVTitle: "CVs", + jobsFilterPresencial: "PRESENCIAL", + jobsFilterFreelancer: "FREELANCE", + jobsFilterEmployee: "EMPLEADO", + jobsPresencialTitle: "Trabajos Presenciales", + jobsFreelancerTitle: "Trabajos Freelance", + jobsEmployeeTitle: "Trabajos de Empleado", + jobTitle: "Título", + jobLocation: "Ubicación", + jobSalary: "Salario (ECO/1h)", + jobVacants: "Vacantes", + jobDescription: "Descripción", + jobRequirements: "Requisitos", + jobLanguages: "Idiomas", + jobStatus: "Estado", + jobStatusOPEN: "ABIERTA", + jobStatusCLOSED: "CERRADA", + jobSetClosed: "Marcar como CERRADA", + jobSubscribeButton: "Unirse a esta oferta!", + jobUnsubscribeButton: "Salir de esta oferta!", + jobTitlePlaceholder: "Introduce el título del trabajo", + jobDescriptionPlaceholder: "Describe el puesto", + jobRequirementsPlaceholder: "Introduce los requisitos", + jobLanguagesPlaceholder: "Inglés, Francés, Español, Euskera...", + jobTasksPlaceholder: "Lista de tareas", + jobLocationPresencial: "Presencial", + jobLocationRemote: "Remoto", + jobVacantsPlaceholder: "Número de vacantes", + jobSalaryPlaceholder: "Salario en ECO por 1 hora dedicada", + jobImage: "Subir contenido multimedia (max: 50MB)", + jobTasks: "Tareas", + jobType: "Tipo de Trabajo", + jobTime: "Tiempo de Trabajo", + jobSubscribers: "Suscriptores", + noSubscribers: "Sin suscriptores", + jobsFilterTop: "TOP", + jobsTopTitle: "Trabajos Mejor Pagados", + createJobButton: "Publicar Trabajo", + viewDetailsButton: "Ver Detalles", + noJobsFound: "No se encontraron ofertas de trabajo.", + jobAuthor: "Por", + jobTypeFreelance: "Autónomo", + jobTypeSalary: "Empleado", + jobTypeExchange: "Intercambio de horas", + jobTypeEXCHANGE: "INTERCAMBIO DE HORAS", + jobsFilterExchange: "INTERCAMBIO", + jobsExchangeTitle: "Trabajos por intercambio de horas", + jobsHoursOffered: "Horas ofrecidas", + jobsHoursRequested: "Horas pedidas a cambio", + jobsExchangeSkill: "Habilidad solicitada a cambio", + jobsHoursOfferedPlaceholder: "p. ej. 4", + jobsHoursRequestedPlaceholder: "p. ej. 4", + jobsExchangeSkillPlaceholder: "p. ej. carpintería, diseño", + jobExchangeHint: "Para trabajos de intercambio de horas, rellena los campos siguientes en vez del salario.", + jobTimePartial: "Parcial", + jobTimeComplete: "Completo", + jobsDeleteButton: "ELIMINAR", + jobsUpdateButton: "ACTUALIZAR", + jobsFilterApplied: "CANDIDATURA", + jobsAppliedTitle: "Mis candidaturas", + jobsAppliedBadge: "Inscrito", + jobsFilterFavs: "Favoritos", + jobsFavsTitle: "Favoritos", + jobsFilterNeeds: "Necesita ayuda", + jobsNeedsTitle: "Necesita ayuda", + jobsSearchLabel: "Buscar", + jobsSearchPlaceholder: "Buscar por título, tags, descripción...", + jobsMinSalaryLabel: "Salario mín.", + jobsMaxSalaryLabel: "Salario máx.", + jobsSortLabel: "Ordenar por", + jobsSortRecent: "Más recientes", + jobsSortSalary: "Mayor salario", + jobsSortSubscribers: "Más candidatos", + jobsSearchButton: "Buscar", + jobsFavoriteButton: "Favorito", + jobsUnfavoriteButton: "Quitar favorito", + jobsMessageAuthorButton: "MP", + jobsApplicants: "Candidatos", + jobsUpdatedAt: "Actualizado", + jobSetOpen: "Marcar abierto", + jobNewBadge: "NUEVO", + jobsTagsLabel: "Etiquetas", + jobsTagsPlaceholder: "etiqueta1, etiqueta2, etiqueta3", + noJobsMatch: "No hay ofertas que coincidan con tu búsqueda.", + projectsTitle: "Proyectos", + projectsDescription: "Crea, financia y sigue proyectos impulsados por la comunidad en tu red.", + projectCreateProject: "Crear Proyecto", + projectCreateButton: "Crear Proyecto", + projectUpdateButton: "ACTUALIZAR", + projectDeleteButton: "ELIMINAR", + projectNoProjectsFound: "No se encontraron proyectos.", + projectFilterAll: "TODOS", + projectFilterMine: "MIS PROYECTOS", + projectFilterActive: "ACTIVOS", + projectFilterPaused: "PONDERADOS", + projectFilterCompleted: "COMPLETADOS", + projectFilterFollowing: "SEGUIDOS", + projectFilterRecent: "RECIENTES", + projectFilterTop: "TOP", + projectAllTitle: "Proyectos", + projectMineTitle: "Tus Proyectos", + projectActiveTitle: "Proyectos Activos", + projectPausedTitle: "Proyectos Pausados", + projectCompletedTitle: "Proyectos Completados", + projectFollowingTitle: "Proyectos Seguidos", + projectRecentTitle: "Proyectos Recientes", + projectTopTitle: "Mejor Financiados", + projectTitlePlaceholder: "Nombre del proyecto", + projectImage: "Subir contenido multimedia (max: 50MB)", + projectDescription: "Descripción", + projectDescriptionPlaceholder: "Cuenta la historia y los objetivos…", + projectGoal: "Meta (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Fecha límite", + projectProgress: "Progreso inicial (%)", + projectStatus: "Estado", + projectFunding: "Financiamiento", + projectPledged: "Prometido", + projectSetStatus: "Establecer estado", + projectSetProgress: "Actualizar progreso", + projectFollowButton: "SEGUIR", + projectUnfollowButton: "DEJAR DE SEGUIR", + projectStatusACTIVE: "ACTIVO", + projectStatusPAUSED: "PAUSADO", + projectStatusCOMPLETED: "COMPLETADO", + projectStatusCANCELLED: "CANCELADO", + projectPledgeTitle: "Apoya este proyecto", + projectPledgePlaceholder: "Cantidad en ECO", + projectBounties: "Recompensas", + projectBountiesInputLabel: "Recompensas (una por línea: Título|Cantidad [ECO]|Descripción)", + projectBountiesPlaceholder: "Arreglar error en UI|100|Enlace al problema\nEscribir documentación|250|Ejemplos de uso", + projectNoBounties: "No se encontraron recompensas.", + projectTitle: "Título", + projectAddBountyTitle: "Añadir nueva recompensa", + projectBountyTitle: "Título de la recompensa", + projectBountyAmount: "Cantidad (ECO)", + projectBountyDescription: "Descripción", + projectMilestoneSelect: "Seleccionar hito", + projectBountyCreateButton: "Crear recompensa", + projectBountyStatus: "Estado de la recompensa", + projectBountyOpen: "abierta", + projectBountyClaimed: "reclamada", + projectBountyDone: "completada", + projectBountyClaimedBy: "reclamada por", + projectBountyClaimButton: "reclamar", + projectBountyCompleteButton: "Marcar como completada", + projectMilestones: "Hitos", + projectAddMilestoneTitle: "Nuevo hito", + projectMilestoneTitle: "Título del hito", + projectMilestoneTargetPercent: "Porcentaje (%)", + projectMilestoneDueDate: "Fecha", + projectMilestoneCreateButton: "Crear hito", + projectMilestoneStatus: "Estado del hito", + projectMilestoneOpen: "abierto", + projectMilestoneDone: "completado", + projectMilestoneDue: "debido", + projectNoMilestones: "No se encontraron hitos.", + projectMilestoneMarkDone: "Marcar como hecho", + projectMilestoneTitlePlaceholder: "Ingresa el título del hito", + projectMilestoneDescriptionPlaceholder: "Ingresa la descripción de este hito", + projectMilestoneDescription: "Descripción del hito", + projectBudgetGoal: "Presupuesto (Objetivo)", + projectBudgetAssigned: "Asignado a recompensas", + projectBudgetRemaining: "Restante", + projectBudgetOver: "⚠ Presupuesto excedido: lo asignado supera el objetivo", + projectFollowers: "Seguidores", + projectFollowersTitle: "Seguidores", + projectFollowersNone: "Aún no hay seguidores.", + projectMore: "más", + projectYouFollowHint: "Sigues este proyecto", + projectBackers: "Patrocinadores", + projectBackersTitle: "Patrocinadores", + projectBackersTotal: "Total de patrocinadores", + projectBackersTotalPledged: "Total aportado", + projectBackersYourPledge: "Tu aportación", + projectBackersNone: "Aún no hay aportaciones.", + projectNoRemainingBudget: "No queda presupuesto.", + projectFilterBackers: "MECENAS", + projectFilterApplied: "APORTANDO", + projectAppliedTitle: "APORTANDO", + projectBackersLeaderboardTitle: "Mecenas destacados", + projectNoBackersFound: "No hay mecenas.", + projectBackerAmount: "Total aportado", + projectBackerPledges: "Aportaciones", + projectBackerProjects: "Proyectos", + projectPledgeAmount: "Cantidad", + projectSelectMilestoneOrBounty: "Seleccionar Hito o Recompensa", + projectPledgeButton: "Aportar", + footerLicense: "GPLv3", + footerPackage: "Paquete", + footerVersion: "Versión", + modulesModuleName: "Nombre", + modulesModuleDescription: "Descripción", + modulesModuleStatus: "Estado", + modulesTotalModulesLabel: "Módulos Cargados", + modulesEnabledModulesLabel: "Habilitados", + modulesDisabledModulesLabel: "Deshabilitados", + modulesPopularLabel: "Popular", + modulesPopularDescription: "Módulo para recibir publicaciones que son tendencia, más vistas o más comentadas.", + modulesTopicsLabel: "Temas", + modulesTopicsDescription: "Módulo para recibir categorías de discusión basadas en intereses compartidos.", + modulesSummariesLabel: "Resúmenes", + modulesSummariesDescription: "Módulo para recibir resúmenes de discusiones o publicaciones largas.", + modulesLatestLabel: "Últimos", + modulesLatestDescription: "Módulo para recibir las publicaciones y discusiones más recientes.", + modulesThreadsLabel: "Hilos", + modulesThreadsDescription: "Módulo para recibir conversaciones agrupadas por tema o pregunta.", + modulesMultiverseLabel: "Multiverso", + modulesMultiverseDescription: "Módulo para recibir contenido de otros pares federados.", + modulesInvitesLabel: "Invitaciones", + modulesInvitesDescription: "Módulo para gestionar y aplicar códigos de invitación.", + modulesWalletLabel: "Cartera", + modulesWalletDescription: "Módulo para gestionar tus activos digitales (ECOin).", + modulesLegacyLabel: "Legado", + modulesLegacyDescription: "Módulo para gestionar tu secreto (clave privada) de forma rápida y segura.", + modulesCipherLabel: "Cifrado", + modulesCipherDescription: "Módulo para encriptar y desencriptar tu texto de forma simétrica (usando una contraseña compartida).", + modulesBookmarksLabel: "Marcadores", + modulesBookmarksDescription: "Módulo para descubrir y gestionar marcadores.", + modulesVideosLabel: "Videos", + modulesVideosDescription: "Módulo para descubrir y gestionar videos.", + modulesDocsLabel: "Documentos", + modulesDocsDescription: "Módulo para descubrir y gestionar documentos.", + modulesAudiosLabel: "Audios", + modulesAudiosDescription: "Módulo para descubrir y gestionar audios.", + modulesTagsLabel: "Etiquetas", + modulesTagsDescription: "Módulo para descubrir y explorar patrones de taxonomía (etiquetas).", + modulesImagesLabel: "Imágenes", + modulesImagesDescription: "Módulo para descubrir y gestionar imágenes.", + modulesTrendingLabel: "Tendencias", + modulesTrendingDescription: "Módulo para explorar el contenido más popular.", + modulesEventsLabel: "Eventos", + modulesEventsDescription: "Módulo para descubrir y gestionar eventos.", + modulesTasksLabel: "Tareas", + modulesTasksDescription: "Módulo para descubrir y gestionar tareas.", + modulesMarketLabel: "Mercado", + modulesMarketDescription: "Módulo para intercambiar bienes o servicios.", + modulesShopsLabel: "Tiendas", + modulesShopsDescription: "Módulo para gestionar y descubrir tiendas.", + modulesTribesLabel: "Tribus", + modulesTribesDescription: "Módulo para explorar o crear tribus (grupos).", + modulesVotationsLabel: "Votaciones", + modulesVotationsDescription: "Módulo para descubrir y gestionar votaciones.", + modulesParliamentLabel: "Parlamento", + modulesParliamentDescription: "Módulo para elegir gobiernos y votar leyes.", + modulesCourtsLabel: "Juzgados", + modulesCourtsDescription: "Módulo para resolver conflictos y emitir veredictos.", + modulesReportsLabel: "Informes", + modulesReportsDescription: "Módulo para gestionar y hacer un seguimiento de informes relacionados con problemas, errores, abusos y advertencias de contenido.", + modulesOpinionsLabel: "Opiniones", + modulesOpinionsDescription: "Módulo para descubrir y votar opiniones.", + modulesTransfersLabel: "Transferencias", + modulesTransfersDescription: "Módulo para descubrir y gestionar contratos inteligentes (transferencias).", + modulesFediverseLabel: "Fediverse", + modulesFediverseDescription: "Gestiona tus otras cuentas del fediverso, incluyendo enviar y recibir contenido.", + modulesFeedLabel: "Feed", + modulesFeedDescription: "Módulo para descubrir y compartir textos breves (feeds).", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Módulo para dibujar en una cuadrícula colaborativa.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Módulo para generar y compartir el \"sonido\" de tu blockchain.", + modulesAgendaLabel: "Agenda", + modulesAgendaDescription: "Módulo para gestionar todos tus elementos asignados.", + modulesAILabel: "AI", + modulesAIDescription: "Módulo para hablar con un LLM llamado '42'.", + modulesForumLabel: "Foros", + modulesForumDescription: "Módulo para descubrir y gestionar foros.", + modulesJobsLabel: "Trabajos", + modulesJobsDescription: "Modulo para descubrir y gestionar ofertas de trabajo.", + modulesProjectsLabel: "Proyectos", + modulesProjectsDescription: "Módulo para explorar, financiar y gestionar proyectos.", + modulesBankingLabel: "Banca", + modulesBankingDescription: "Módulo para conocer el valor real de ECOIN y distribuir una RBU utilizando la tesorería común.", + modulesFavoritesLabel: "Favoritos", + modulesFavoritesDescription: "Módulo para gestionar tu contenido favorito.", + fileTooLargeTitle: "Archivo demasiado grande", + fileTooLargeMessage: "El archivo supera el tamaño máximo permitido (50 MB). Por favor, selecciona un archivo más pequeño.", + goBack: "Volver", + errorPageTitle: "Ha ocurrido un error", + visibilityLabel: "Visibilidad", + visibilityPublic: "Pública", + visibilityHidden: "Oculta", + visibilityMakePublic: "Hacer pública", + visibilityMakeHidden: "Hacer oculta", + invitesInhabitantsTitle: "Habitantes", + invitesInhabitantsFollow: "Seguir", + aiNavPlaceholder: "¿A dónde quieres ir?", + aiNavDisabled: "La navegación con IA está desactivada.", + aiNavResultsTitle: "Sugerencias de navegación", + aiNavQueryLabel: "Consulta", + aiNavResultsDescription: "Elige la ruta que mejor se ajuste a lo que buscas.", + aiNavResultPath: "Ruta", + aiNavResultDescription: "Qué puedes hacer", + aiNavResultMatch: "Coincidencia", + aiNavResultsEmpty: "No hay rutas coincidentes. Prueba con /search.", + aiNavSearchInstead: "Buscar en su lugar", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Módulo para realizar peticiones en lenguaje natural sobre el contenido de la red.", + directConnect: "Conexión Directa", + directConnectDescription: "Conéctate directamente a un nodo introduciendo su dirección IP, puerto y clave pública. El nodo se añadirá como conexión seguida.", + peerHost: "IP", + peerPort: "Puerto (por defecto: 8008)", + peerPublicKey: "Clave Pública (@...ed25519)", + connectAndFollow: "Conectar", + deviceSourceLabel: "Dispositivo", + modulesPresetTitle: "Configuraciones Comunes", + modulesPreset_minimal: "Mínimo", + modulesPreset_basic: "Básico", + modulesPreset_social: "Social", + modulesPreset_economy: "Economía", + modulesPreset_full: "Completo", + statsCarbonFootprintTitle: "Huella de Carbono", + statsCarbonFootprintNetwork: "Huella de carbono de la red", + statsCarbonFootprintYours: "Tu huella de carbono", + statsCarbonTombstone: "Huella del tombstoning", + feedSuccessMsg: "¡Feed publicado correctamente!", + dominantOpinionLabel: "Opinión predominante", + uploadMedia: "Subir contenido multimedia (max: 50MB)", + invitesUnfollow: "Dejar de seguir", + invitesFollow: "Seguir", + invitesUnfollowedInvites: "No federadas", + invitesNoUnfollowed: "No hay pubs no federados.", + reportsWhyInappropriateLabel: "¿Por qué es inapropiado?", + blockchainContentDeleted: "Este contenido ha sido eliminado", + visitContent: "Visitar contenido", + bankEcoinHours: "Equivalencia ECOin en tiempo", + mapsLabel: "Mapas", + mapTitle: "Mapas", + mapDescription: "Explora y gestiona mapas offline en tu red.", + mapMineSectionTitle: "Tus Mapas", + mapCreateSectionTitle: "Crear Mapa", + mapUpdateSectionTitle: "Actualizar Mapa", + mapAllSectionTitle: "Mapas", + mapRecentSectionTitle: "Mapas Recientes", + mapFavoritesSectionTitle: "Favoritos", + mapFilterAll: "TODOS", + mapFilterMine: "MÍOS", + mapFilterRecent: "RECIENTES", + mapFilterFavorites: "FAVORITOS", + mapUploadButton: "Crear Mapa", + mapCreateButton: "Crear Mapa", + mapUpdateButton: "Actualizar", + mapDeleteButton: "Eliminar", + mapAddFavoriteButton: "Añadir favorito", + mapRemoveFavoriteButton: "Quitar favorito", + mapLatLabel: "Latitud", + mapLatPlaceholder: "ej. 40.4168", + mapLngLabel: "Longitud", + mapLngPlaceholder: "ej. -3.7038", + mapDescriptionLabel: "Descripción", + mapDescriptionPlaceholder: "Describe el mapa o ubicación...", + mapTypeLabel: "Tipo de mapa", + mapTypeSingle: "ÚNICO (solo ubicación inicial)", + mapTypeOpen: "ABIERTO (cualquiera añade marcadores)", + mapTypeClosed: "CERRADO (solo el creador añade marcadores)", + mapTagsLabel: "Etiquetas", + mapTagsPlaceholder: "Introduce etiquetas separadas por comas", + mapUrlLabel: "URL del mapa", + mapPickCoordLabel: "O selecciona una ubicación en la cuadrícula:", + mapMarkersLabel: "marcadores", + mapMarkersTitle: "Marcadores", + mapMarkerDefault: "Marcador", + mapMarkerLatLabel: "Latitud del marcador", + mapMarkerLngLabel: "Longitud del marcador", + mapMarkerLabelField: "Etiqueta del marcador", + mapMarkerLabelPlaceholder: "Describe este marcador...", + markerImageLabel: "Imagen del Marker", + mapAddMarkerTitle: "Añadir Marcador", + mapAddMarkerButton: "Añadir Marcador", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Aplicar Zoom", + mapSearchPlaceholder: "Buscar descripción, etiquetas, autor...", + mapSearchButton: "Buscar", + mapClickToCreate: "Haz click en el mapa para seleccionar ubicación", + mapClickToAddMarker: "Haz click en el mapa para añadir un marcador", + mapUpdatedAt: "Actualizado", + mapNoMatch: "Ningún mapa coincide con tu búsqueda.", + noMaps: "No hay mapas disponibles.", + mapLocationTitle: "Ubicación", + mapVisitLabel: "Visitar mapa", + typeMap: "MAPAS", + typeMapMarker: "MARCADOR DE MAPA", + modulesMapLabel: "Mapas", + modulesMapDescription: "Módulo para gestionar y compartir mapas offline.", + padsTitle: "Pads", + padTitle: "Pad", + modulesPadsLabel: "Pads", + modulesPadsDescription: "Módulo para gestionar editores de texto colaborativos.", + padFilterAll: "TODOS", + padFilterMine: "MÍO", + padFilterRecent: "RECIENTE", + padFilterOpen: "ABIERTO", + padFilterClosed: "CERRADO", + padCreate: "Crear Pad", + padUpdate: "Actualizar Pad", + padDelete: "Eliminar Pad", + padTitleLabel: "Título", + padTitlePlaceholder: "Escribe el título del pad...", + padStatusLabel: "Estado", + padStatusOpen: "ABIERTO", + padStatusInviteOnly: "SOLO INVITADOS", + padStatusClosed: "CERRADO", + padDeadlineLabel: "Fecha límite", + padTagsLabel: "Etiquetas", + padTagsPlaceholder: "etiqueta1, etiqueta2, ...", + padMembersLabel: "Miembros", + padVisitPad: "Visitar Pad", + padShareUrl: "Compartir URL", + padCreated: "Creado", + padAuthor: "Autor", + padGenerateCode: "Generar Código", + padInviteCodeLabel: "Código de Invitación", + padInviteCodePlaceholder: "Introduce el código de invitación...", + padValidateInvite: "Validar", + padStartEditing: "¡EMPEZAR A EDITAR!", + padEditorPlaceholder: "Empieza a escribir...", + padSubmitEntry: "Enviar", + padNoEntries: "Sin entradas aún.", + padAllSectionTitle: "Todos los Pads", + padMineSectionTitle: "Mis Pads", + padsDescription: "Gestiona editores de texto cifrados colaborativos en tu red.", + padRecentSectionTitle: "Pads Recientes", + padOpenSectionTitle: "Pads Abiertos", + padClosedSectionTitle: "Pads Cerrados", + padCreateSectionTitle: "Crear Nuevo Pad", + padUpdateSectionTitle: "Actualizar Pad", + padInviteGenerated: "Código de Invitación Generado", + typePad: "PADS", + padNew: "NUEVO", + padAddFavorite: "Añadir a Favoritos", + padRemoveFavorite: "Quitar de Favoritos", + padClose: "Cerrar Pad", + padBackToEditor: "Volver al editor", + padSearchPlaceholder: "Buscar pads...", + + calendarsTitle: "Calendarios", + calendarTitle: "Calendario", + modulesCalendarsLabel: "Calendarios", + modulesCalendarsDescription: "Módulo para descubrir y gestionar calendarios.", + typeCalendar: "CALENDARIOS", + calendarFilterAll: "TODOS", + calendarFilterMine: "MÍOs", + calendarFilterOpen: "ABIERTOS", + calendarFilterClosed: "CERRADOS", + calendarFilterRecent: "RECIENTES", + calendarFilterFavorites: "FAVORITOS", + calendarCreate: "Crear Calendario", + calendarUpdate: "Actualizar", + calendarDelete: "Eliminar", + calendarTitleLabel: "Título", + calendarTitlePlaceholder: "Título del calendario...", + calendarStatusLabel: "Estado", + calendarStatusOpen: "ABIERTO", + calendarStatusClosed: "CERRADO", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Etiquetas", + calendarTagsPlaceholder: "etiqueta1, etiqueta2...", + calendarParticipantsLabel: "Participantes", + calendarParticipantsCount: "Participantes", + calendarVisitCalendar: "Ver Calendario", + calendarCreated: "Creado", + calendarAuthor: "Autor", + calendarJoin: "Unirse al Calendario", + calendarGenerateInvite: "Generar invitación", + calendarInviteCodePlaceholder: "Introduce código...", + calendarValidateInvite: "Validar código", + calendarJoined: "Unido", + calendarAddDate: "Añadir Fecha", + calendarAddNote: "Añadir Nota", + calendarDateLabel: "Fecha", + calendarDatePlaceholder: "Describe esta fecha...", + calendarNoteLabel: "Nota", + calendarNotePlaceholder: "Añadir una nota...", + calendarFirstDateLabel: "Fecha", + calendarFirstNoteLabel: "Notas", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Descripción", + calendarNoDates: "No hay fechas añadidas.", + calendarNoNotes: "Sin notas.", + calendarsNoItems: "No se encontraron calendarios.", + calendarsDescription: "Descubre y gestiona calendarios en tu red.", + calendarMonthPrev: "\u2190 Anterior", + calendarMonthNext: "Siguiente \u2192", + calendarMonthLabel: "Fechas", + calendarsShareUrl: "URL para compartir", + calendarAllSectionTitle: "Calendarios", + calendarRecentSectionTitle: "Calendarios Recientes", + calendarFavoritesSectionTitle: "Favoritos", + calendarMineSectionTitle: "Tus Calendarios", + calendarOpenSectionTitle: "Calendarios Abiertos", + calendarClosedSectionTitle: "Calendarios Cerrados", + calendarCreateSectionTitle: "Crear Nuevo Calendario", + calendarUpdateSectionTitle: "Actualizar Calendario", + calendarAddFavorite: "Añadir a Favoritos", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Quitar de Favoritos", + calendarSearchPlaceholder: "Buscar calendarios...", + calendarAddEntry: "Añadir Entrada", + calendarLeave: "Salir del Calendario", + statsCalendar: "Calendarios", + statsCalendarDate: "Fechas de Calendario", + statsCalendarNote: "Notas de Calendario", + + modulesChatsLabel: "Chats", + modulesChatsDescription: "Módulo para descubrir y gestionar chats cifrados.", + typeChat: "CHATS", + typeChatMessage: "MENSAJE DE CHAT", + chatLabel: "CHATS", + chatMessageLabel: "MENSAJES DE CHAT", + chatsTitle: "Chats", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "Favoritos", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "Descripción", + chatCategory: "Categoría", + chatStatus: "ESTADO", + chatFilterAll: "TODOS", + chatFilterMine: "MÍO", + chatFilterRecent: "RECIENTE", + chatFilterFavorites: "FAVORITOS", + chatFilterOpen: "ABIERTO", + chatFilterClosed: "CERRADO", + chatCreate: "Crear Chat", + chatUpdate: "Actualizar Chat", + chatDelete: "Eliminar Chat", + chatClose: "Cerrar Chat", + chatVisitChat: "VER CHAT", + chatUntitled: "Chat sin título", + chatNoItems: "No se encontraron chats.", + chatParticipants: "Participantes", + chatStartChatting: "¡EMPEZAR A CHATEAR!", + chatGenerateCode: "Generar Código", + chatShareUrl: "Compartir URL", + chatCreatedAt: "CREADO", + chatSearchPlaceholder: "Buscar chats...", + chatStatusOpen: "ABIERTO", + chatStatusInviteOnly: "SOLO INVITADOS", + chatStatusClosed: "CERRADO", + chatSendMessage: "Enviar", + chatMessagePlaceholder: "Escribe tu mensaje...", + chatNoMessages: "Sin mensajes aún.", + chatLeave: "Salir del Chat", + chatInviteCodeLabel: "Introduce el código de invitación", + chatJoinByInvite: "Unirse", + chatTitlePlaceholder: "Título del chat", + chatDescriptionPlaceholder: "Descripción del chat", + chatTagsPlaceholder: "etiqueta1, etiqueta2", + chatImageLabel: "Selecciona un archivo de imagen (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Código de Invitación", + chatAuthor: "Autor", + chatCreated: "Creado", + chatAddFavorite: "Añadir a Favoritos", + chatRemoveFavorite: "Quitar de Favoritos", + chatPM: "MP", + chatStatusLabel: "Estado", + chatCategoryLabel: "Categoría", + chatParticipantsLabel: "Participantes", + gamesTitle: "Juegos", + gamesDescription: "Descubre y juega algunos juegos.", + gamesFilterAll: "TODOS", + gamesPlayButton: "¡JUGAR!", + gamesBackToGames: "Volver a Juegos", + modulesGamesLabel: "Juegos", + modulesGamesDescription: "Módulo para descubrir y jugar algunos juegos.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Módulo para explorar la red como un mapa interactivo de pares.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Conecta PUBs a habitantes mediante validadores, tiendas y acumuladores. ¡Sobrevive a la amenaza CBDC!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Infiltra la cuadricula, recolecta datos confidenciales, evade los drones de seguridad y escapa. ¿Cuantos niveles puedes superar?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "¡Detén la invasión alienígena! Destruye oleadas de invasores antes de que lleguen al suelo.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Rompe todos los ladrillos con tu paleta y pelota. Un clásico desafío arcade.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Ping-pong clásico contra una IA. El primero en llegar a 5 puntos gana.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "¡Corre contra el tiempo! Esquiva el tráfico y llega a la meta antes de que se acabe el tiempo.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Pilota tu nave por un campo de asteroides mortal. Dispárales antes de que te alcancen.", + gamesRockPaperScissorsTitle: "Piedra Papel Tijeras", + gamesRockPaperScissorsDesc: "Piedra, papel o tijera contra una IA. Gana el mejor de tres rondas.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Tres en raya clásico contra la IA. Consigue tres en línea para ganar.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Lanza una moneda y apuesta cara o cruz. ¿Cuánta suerte tienes?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + chatAccessDenied: "No tienes acceso a este chat. Solicita una invitación para acceder al contenido.", + padAccessDenied: "No tienes acceso a este pad. Solicita una invitación para acceder al contenido.", + contentAccessDenied: "No tienes acceso a este contenido.", + blockAccessRestricted: "Acceso restringido", + tribeContentAccessDenied: "Acceso Denegado", + tribeContentAccessDeniedMsg: "Este contenido pertenece a una tribu. Debes ser miembro para acceder.", + tribeViewTribes: "Ver Tribus", + torrentsTitle: "Torrents", + torrentsDescription: "Explora y gestiona torrents en tu red.", + torrentAllSectionTitle: "Torrents", + torrentMineSectionTitle: "Tus Torrents", + torrentRecentSectionTitle: "Torrents Recientes", + torrentTopSectionTitle: "Torrents Destacados", + torrentFavoritesSectionTitle: "Favoritos", + torrentFilterAll: "TODOS", + torrentFilterMine: "MÍOS", + torrentFilterRecent: "RECIENTES", + torrentFilterTop: "DESTACADOS", + torrentFilterFavorites: "FAVORITOS", + torrentCreateSectionTitle: "Subir Torrent", + torrentUpdateSectionTitle: "Actualizar Torrent", + torrentCreateButton: "Subir Torrent", + torrentUpdateButton: "Actualizar", + torrentDeleteButton: "Eliminar", + torrentAddFavoriteButton: "Añadir Favorito", + torrentRemoveFavoriteButton: "Quitar Favorito", + torrentFileLabel: "Seleccionar archivo torrent (.torrent)", + torrentTitleLabel: "Título", + torrentTitlePlaceholder: "Título", + torrentDescriptionLabel: "Descripción", + torrentDescriptionPlaceholder: "Descripción", + torrentTagsLabel: "Etiquetas", + torrentTagsPlaceholder: "Introduce etiquetas separadas por comas", + torrentSizeLabel: "Tamaño", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "Sin archivo torrent", + noTorrents: "No se encontraron torrents.", + torrentSearchPlaceholder: "Buscar título, etiquetas, autor...", + torrentSearchButton: "Buscar", + torrentSortRecent: "Más recientes", + torrentSortOldest: "Más antiguos", + torrentSortTop: "Más votados", + torrentNoMatch: "No se encontraron torrents coincidentes.", + torrentMessageAuthorButton: "Enviar Mensaje al Autor", + torrentUpdatedAt: "Actualizado", + statsTorrent: "Torrents", + typeTorrent: "TORRENTS", + modulesTorrentsLabel: "Torrents", + modulesTorrentsDescription: "Módulo para descubrir y gestionar torrents.", + favoritesFilterTorrents: "TORRENTS", + favoritesFilterMarket: "MERCADO", + favoritesFilterShopProducts: "ARTÍCULOS DE TIENDA", + tribeSectionTorrents: "TORRENTS", + tribeCreateTorrent: "Subir Torrent", + tribeMediaTypeTorrent: "Torrent", + settingsWishTitle: "Deseo", + settingsWishDesc: "Configura el deseo de tu Avatar. Determina cómo accedes a todo el contenido y tu experiencia en la red Oasis.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Mensajes Privados", + settingsPmVisibilityDesc: "Configura el nivel de exposición a mensajes privados que deseas tener en la red.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "El envío es una barrera de UX. La recepción es un filtro de atención (el cifrado ya está replicado).", + pmBlockedNonMutual: "Solo puedes enviar mensajes privados a habitantes con apoyo mutuo.", + inhabitantsPendingFollowsTitle: "Solicitudes de apoyo pendientes", + inhabitantsPendingAccept: "Aceptar", + inhabitantsPendingReject: "Rechazar", + bxEncrypted: "CIFRADO", + bxEncryptedHexLabel: "Texto cifrado (vista previa)", + tribeSectionGovernance: "GOBIERNO", + tribeSubStatusPublic: "PÚBLICA", + tribeSubStatusPrivate: "PRIVADA", + tribeSubInheritedPrivate: "PRIVADA (heredada de la tribu principal)", + tribePadInviteRequired: "No tienes acceso al pad. Pide una invitación para acceder al contenido.", + tribeChatInviteRequired: "No tienes acceso al chat. Pide una invitación para acceder al contenido.", + tribeGovernanceDesc: "Gobierno interno de esta tribu.", + tribeGovernanceNoGov: "Sin gobierno activo", + tribeGovernanceNoGovDesc: "Esta tribu aún no tiene gobierno. Propón candidaturas para iniciar el proceso.", + tribeGovCardTitle: "Gobierno Actual", + tribeGovCycleSince: "CICLO INICIO", + tribeGovCycleEnd: "CICLO FIN", + tribeGovTimeRemaining: "TIEMPO RESTANTE", + tribeGovPopulation: "POBLACIÓN", + tribeGovMethod: "MÉTODO", + tribeGovVotesReceived: "VOTOS RECIBIDOS", + tribeGovLeader: "LÍDER", + tribeGovFilterGovernment: "GOBIERNO", + tribeGovFilterCandidatures: "CANDIDATURAS", + tribeGovFilterLaws: "LEYES", + tribeGovCandidatureId: "Candidatura", + tribeGovCandidatureMethod: "Método", + tribeGovCandidatureProposeBtn: "Publicar Candidatura", + tribeGovRuleTitle: "Título de Regla", + tribeGovRuleBody: "Cuerpo de Regla", + tribeGovProposals: "PROPUESTAS", + tribeGovRevocations: "REVOCACIONES", + tribeGovHistorical: "HISTÓRICO", + tribeGovRules: "REGLAS", + tribeGovernanceAlreadyPublished: "Esta tribu ya tiene una candidatura abierta en el ciclo actual del parlamento global.", + tribeGovernanceProposeInternal: "Proponer candidatura interna", + tribeGovernanceInternalCandidatures: "Candidaturas internas", + tribeGovernanceNoCandidatures: "Sin candidaturas abiertas.", + tribeGovernanceAddRule: "Añadir regla", + tribeGovernanceNoRules: "Sin reglas todavía.", + tribeGovernanceNoLeaders: "Sin líderes elegidos todavía.", + tribeGovernanceComingSoon: "Próximamente en el módulo de gobierno de esta tribu.", + tribeGovNoProposals: "Aún no hay ninguna propuesta", + tribeGovNoLaws: "Aún no hay ninguna ley", + tribeGovNoRevocations: "Aún no hay ninguna revocación", + tribeGovNoHistorical: "Aún no hay ningún registro", + logsTitle: "Bitácora", + logsDescription: "Registra tu experiencia en la red.", + logsReadMore: "Leer más", + logsViewTitle: "Bitácora", + logsColumnType: "Tipo", + logsView: "Ver", + logsManualPrompt: "Escribe tu bitácora", + logsUpdateButton: "Actualizar", + logsEditTitle: "Editar entrada", + logsCreateDescription: "Registra tu experiencia...", + logsCreateTitle: "Crear entrada", + logsWriteButton: "Escribir", + logsTextPlaceholder: "Describe tus experiencias...", + logsTextField: "Texto", + logsLabelPlaceholder: "Etiqueta...", + logsLabelField: "Escribe tu bitácora (etiqueta)", + logsAiDisabledWarn: "Activa el módulo de IA en /modules para usar entradas escritas por IA.", + logsAiContextValue: "Día de blockchain", + logsAiContext: "Contexto", + logsAiModStatus: "AImod", + logsModeApply: "¡Aplicar!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Manual", + logsModeAI: "IA", + logsColumnDelete: "Eliminar", + logsColumnEdit: "Editar", + logsColumnLog: "Bitácora", + logsColumnDate: "Fecha", + logsDelete: "Eliminar", + logsEdit: "Editar", + logsFilterAlways: "SIEMPRE", + logsFilterYear: "ÚLTIMO AÑO", + logsFilterMonth: "ÚLTIMO MES", + logsFilterWeek: "ÚLTIMA SEMANA", + logsFilterToday: "HOY", + logsFilterRecent: "RECIENTES", + logsFilterAll: "TODOS", + logsCreate: "Crear entrada", + logsExport: "Exportar bitácora", + logsExportOne: "Exportar", + logsViewDetails: "Ver detalles", + logsGenerateButton: "Generar texto", + logsSearchText: "Buscar en bitácoras...", + logsSearchAnyType: "Cualquier tipo", + logsSearchButton: "Buscar", + logsEmpty: "Aún no hay entradas.", + modulesLogsLabel: "Bitácora", + modulesLogsDescription: "Módulo para registrar (vía asistente de IA) tus experiencias.", + statsLogsTitle: "Bitácora", + statsLogsEntries: "Entradas", + typeLog: "BITÁCORA", + blockchainCycle: "Ciclo", + + larpTitle: "L.A.R.P.", + larpDescription: "Una capa de juego de rol en vivo para la experimentación colaborativa.", + larpAllHouses: "Casas:", + larpFilterRuling: "RIGE", + larpFilterHouses: "CASAS", + larpFilterRules: "FAQ", + larpRulesTitle: "FAQ del L.A.R.P.", + larpRulesEntryTitle: "Casa de inicio", + larpRulesEntryText: "Todo habitante comienza en ACADEMIA por defecto. Desde ACADEMIA, elige una casa en el panel \"Unirse a una casa\": esto abre su test de entrada. Responde las 10 preguntas y envía. Si superas el umbral (7/10), serás admitido automáticamente en esa casa; si no, serás rechazado y permanecerás en ACADEMIA. En cualquier caso el sistema registra tu OasisID y el ciclo del intento e impide otro intento hasta que pase el enfriamiento de 30 ciclos.", + larpRulesLeaveText: "Los miembros de cualquier casa pueden volver a ACADEMIA en cualquier momento desde la página de su casa; al hacerlo se restablece su pertenencia, pero no se anula el enfriamiento del test.", + larpRulesGovernanceTitle: "Muro de gobierno", + larpRulesGovernanceText: "Durante su ciclo, la casa gobernante luce la insignia \"Rige\" y es la única cuyo Muro se muestra públicamente bajo la pestaña RIGE.", + larpRulesSignTitle: "Emblema L.A.R.P.", + larpRulesSignText: "Los habitantes pueden activarlo (en /profile/edit > Sensores) para mostrar la imagen de su casa actual como un sello en su perfil, en su tarjeta de autor y de habitante. El sello enlaza a la página de la casa.", + larpPostsTitle: "Muro", + larpPostsEmpty: "Aún no hay publicaciones.", + larpPostLabel: "Nueva publicación", + larpPostPlaceholder: "¿Qué tiene que decir esta casa?", + larpPostPublic: "Pública (visible para cualquiera, si no solo miembros)", + larpPostSubmit: "Publicar", + larpPostMembersOnly: "Solo miembros", + larpCycleLabel: "Ciclo:", + bankRulesArchTaxFormula: "ARCH Tax(usuario) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "El impuesto ARCH refleja el coste a largo plazo de hacer crecer y mantener el archivo de la red: cuanto más antiguos sean tus datos en la red, mayor será tu parte del coste de mantenimiento.", + bankRulesChipFormula: "band = ratio / reducer, donde ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). alto ≥ 0.66, medio ≥ 0.33, en otro caso bajo.", + bankRulesChipNote: "El chip de cada elemento se colorea respecto al mensaje más grande visto en la red y se suaviza por el número de habitantes activos — una red más grande distribuye la carga y empuja más elementos a la banda verde.", + bankRulesEcoTaxFormula: "ECO Tax(usuario) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "El impuesto ECO se deduce del UBI bruto cuando se calcula la asignación del epoch. Los ECO deducidos no se redistribuyen en este epoch — permanecen en el saldo del pub y alimentan el pool del próximo epoch a través de los topes reserveMin / alpha.", + bankRulesKarmaFormula: "karma = log10(1 + actions) + reputation", + bankRulesKarmaNote: "El karma pondera tu participación en la red de cara a la asignación de UBI.", + bankRulesNetFormula: "net_UBI = floor + max(0, gross_UBI − floor) − ECO Tax − ARCH Tax", + bankRulesPoolFormula: "pool = min(pubBal − reserveMin, capPerEpoch, alpha · pubBal)", + bankRulesShareFormula: "share = clamp(1 + karma/100, w_min, w_max) / sum(weights)", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankTaxesArchTaxFootprintNote: "El impuesto ARCH refleja el coste de hacer crecer y mantener el archivo de la red. Se calcula a partir del tiempo transcurrido entre tu primer bloque publicado y el bloque más nuevo de la red — cuanto más viva tu historia en el archivo, mayor será tu parte del coste de mantenimiento.", + bankTaxesArchTaxNoteBold: "reducir el impuesto ARCH directamente", + bankTaxesArchTaxNoteIntro: "Tu impuesto ARCH se deduce del excedente que la red genera sobre tu UBI; el valor base del UBI es un mínimo inamovible. El impuesto nunca se deduce del importe fijo que corresponde a cada habitante como UBI. Los fondos deducidos alimentan el algoritmo de redistribución de la riqueza y se canalizan a otros habitantes a través de sus reclamaciones de UBI. Los habitantes más nuevos y los que se dediquen específicamente a tareas de archivo, replicación y mantenimiento probablemente tengan alguna tarea dedicada a mantener sano el archivo de la red en tu nombre. O pueden dedicarse a ", + bankTaxesArchTaxNoteOutro: " mediante acuerdos de poda, deduplicación de datos redundantes e infraestructura de mantenimiento compartida, y su contribución continua puede ser requerida por consenso de la red.", + bankTaxesUserNoteChips: "El chip de impuesto ECO mostrado junto a cada elemento cambia de color (verde / amarillo / rojo) según el tamaño del elemento en relación con el bloque más grande visto en la red, suavizado por el número de habitantes activos — una red solar-punk más grande distribuye la carga y empuja más elementos a la banda verde.", + bankTaxesUserNoteOtherParams: "El impuesto ECO se deriva actualmente de la huella de carbono de cada bloque, pero puede incorporar otros parámetros con el tiempo — energía gastada en replicación, almacenamiento redundante entre peers, ancho de banda de blobs, coste computacional de descifrado, huella de minería de transacciones asociadas, o cualquier otra carga medible que la red acuerde valorar.", + gamesHallDate: "Fecha", + gamesNewRecord: "¡Nuevo récord!", + indexingEditProfileLink: "Editar perfil", + indexingMessage: "El servidor SSB está indexando. Vuelve en un momento.", + indexingRefreshNote: "Esta página se recargará automáticamente.", + indexingTitle: "Indexando", + padsNoItems: "Aún no hay pads.", + padVersionHistory: "Historial", + padVersionView: "Ver versión", + statsChat: "Chats", + statsChatMessage: "Mensajes de chat", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Entradas de pad", + typeGameScore: "PUNTUACIONES", + bankTaxesLookupNote: "Introduce un ID de bloque para buscarlo en tu feed local.", + bankTaxesUserNoteChipsClick: "Haz clic en cualquier chip para inspeccionar su bloque en el blockexplorer.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "Código de invitación", + larpRulesInviteEntryText: "Los miembros de las casas pueden generar códigos de invitación que dan acceso directo a la casa.", + larpRulesOneHouseText: "Solo puedes pertenecer a una casa a la vez. Si no perteneces a ninguna, estás en ACADEMIA. Cada página de casa (no ACADEMIA) muestra un botón \"Salir de la Casa\" a sus miembros que restablece la pertenencia a ACADEMIA. Salir NO anula el enfriamiento del test.", + larpRulesOneHouseTitle: "Una casa cada vez", + larpRulesTestEntry: "Test WILL", + larpRulesTestEntryText: "Cada opción pondera una o varias casas; al enviar, el sistema suma los puntos y te admite automáticamente en la casa con mayor puntuación.", + larpWillTestHint: "Una vez completado, se te asignará la casa que más se ajuste a tus respuestas. Tus respuestas individuales se procesan localmente y nunca se almacenan — solo se publica en tu feed la asignación de casa resultante.", + larpWillTestTitle: "Test WILL", + settingsLanDesc: "Anuncia periódicamente este peer a otras instancias de Oasis en la misma red local. Desactivar para detener las difusiones UDP.", + settingsLanEnable: "Activar difusión LAN", + settingsLanTitle: "Difusión LAN", + settingsReplicationTitle: "Replicación", + settingsReplicationDesc: "Configura el número de saltos que tu nodo sigue desde tu propio feed al replicar contenido.", + settingsReplicationHopsLabel: "Saltos", + aiApproveTagsLabel: "Etiquetas (separadas por comas)", + aiApproveTagsPlaceholder: "p.ej. oasis, gobernanza, ecología", + aiApproveRatingLabel: "Valoración", + aiExchangeLang: "Idioma", + aiExchangeTags: "Etiquetas", + aiExchangeRating: "Valoración", + aiExchangeHelpful: "Útil", + aiExchangeMarkHelpful: "+1 útil", + larpCyclesUntilRuling: "Próximo ciclo de gobierno", + larpVisitTribe: "Visitar tribu", + larpGoverning: "Gobernando:", + larpMyHouse: "Mi Casa:", + larpMembersCount: "Miembros", + larpMembersTitle: "Miembros", + larpNoMembers: "Aún no hay miembros.", + larpRolesLabel: "Roles", + larpFunctionLabel: "Función", + larpMonthLabel: "Ciclo de gobierno", + larpLeaveToAcademia: "Volver a ACADEMIA", + larpAcademiaJoinTitle: "Unirse a una casa", + larpAcademiaJoinHint: "Realiza el test psicológico de perfil para que se te asigne la casa que mejor encaja contigo, o canjea un código de invitación compartido por algún miembro de una casa.", + larpTakeTestButton: "Hacer el test de perfil", + larpInviteRedeem: "Unirse a la Casa", + larpInviteCreate: "Generar código de invitación", + larpInvitePlaceholder: "Código de invitación", + larpInviteBannerTitle: "Nuevo código de invitación", + larpInviteBannerHint: "Comparte este código con alguien que esté en ACADEMIA. Expira en 30 ciclos o tras un uso.", + larpTestAssignedHouse: "Casa asignada", + larpTestRankingTitle: "Puntuación por casa", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "Código de invitación de casa", + invitesHouseJoinButton: "Unirse a la Casa", + ecoTaxLabel: "ECO Tax", + ecoinTaxLabel: "ECOin Tax", + bankTaxes: "Impuestos", + bankOverviewYourTaxes: "Tus impuestos", + bankTaxesNetworkTitle: "Impuestos de la red", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesUserTitle: "Tus impuestos", + bankTaxesUserAmount: "Tu ECO tax (precio a devolver)", + bankTaxesArchTaxUserAmount: "Tu ARCH tax (precio a devolver)", + bankTaxesArchTaxFirstBlock: "Edad de tu primer bloque", + bankTaxesArchTaxRate: "Tasa", + bankTaxesTotalBlocks: "Bloques totales", + bankTaxesTotalBytes: "Bytes totales", + bankTaxesTotalCarbon: "Carbono total", + bankTaxesTotalEcoin: "ECO tax total (vida)", + bankTaxesEcoTaxLifetime: "ECO Tax (vida)", + bankTaxesAnnualEcoin: "ECO Tax (anual)", + bankTaxesMonthlyEcoin: "ECO Tax (mensual)", + bankTaxesArchTaxLifetime: "ARCH Tax (vida)", + bankTaxesArchTaxAnnual: "ARCH Tax (anual)", + bankTaxesArchTaxMonthly: "ARCH Tax (mensual)", + bankTaxesTotalLifetime: "Total (vida)", + bankTaxesTotalAnnual: "Total (anual)", + bankTaxesTotalMonthly: "Total (mensual)", + bankTaxesRate: "Tasa", + bankTaxesSpan: "Periodo de muestreo", + bankTaxesLookupTitle: "Inspeccionar un bloque", + bankTaxesLookupPlaceholder: "ID del bloque (p. ej. %abc...=.sha256)", + bankTaxesLookupButton: "Inspeccionar", + bankTaxesLookupNotFound: "No se ha encontrado en tu feed local ningún bloque con ese ID.", + bankTaxesLookupAuthor: "Autor", + bankTaxesLookupType: "Tipo", + bankTaxesLookupSize: "Tamaño", + bankTaxesLookupCarbon: "Huella de carbono", + bankTaxesLookupEcoin: "ECO Tax", + bankExchangeEcoTaxAnnual: "ECOin Taxes (anual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (mensual)", + bankTaxesTypesLabel: "Selecciona qué impuestos quieres pagar", + bankTaxesTypesApply: "Establecer mis impuestos", + bankRulesPoolTitle: "Bolsa mensual", + bankRulesShareTitle: "Reparto por usuario", + bankRulesKarmaTitle: "Karma (puntuación de implicación)", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesChipTitle: "Banda de color del chip ECO Tax", + bankRulesEpochKind: "Granularidad de epoch", + bankRulesAlpha: "Alpha (cuota máx. del balance del pub por epoch)", + bankRulesReserveMin: "Reserva mínima (se mantiene en el balance del pub)", + bankRulesCapPerEpoch: "Tope por epoch (absoluto)", + bankRulesGraceDays: "Periodo de gracia del claim", + bankRulesWMin: "Peso mínimo (w_min)", + bankRulesWMax: "Peso máximo (w_max)", + bankRulesFloor: "Suelo por usuario (bruto)", + bankRulesCapUser: "Tope por usuario por epoch", + bankRulesEcoTaxRate: "Tasa", + bankRulesArchTaxRate: "Tasa", + bankRulesCarbonFactor: "Factor de carbono", + statsEcoTaxTitle: "ECO Tax", + statsTombstoneEmpty: "Aún no hay tombstones en la red.", + blockchainBlockNotAvailable: "Este bloque no está disponible en tu feed local. Puede pertenecer a un peer del que aún no replicas.", + blockchainBackToBlockexplorer: "Volver al blockexplorer", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeTitle: "Transcode BCS", + audioTranscodeDetailTitle: "Transcodificar", + audioTranscodeDetailDescription: "Decodifica la carga útil incrustada y el mapa de composición original de la blockchain.", + audioTranscodeStegoTitle: "Incrustado en la forma de onda", + audioTranscodeStegoOasisId: "By", + audioTranscodeStegoTimestamp: "Generado el", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoEmpty: "(ninguno)", + audioTranscodeStegoUnknown: "—", + audioTranscodeStegoNotFound: "No se ha podido decodificar ninguna carga esteganográfica en este audio.", + audioTranscodeCompositionEmpty: "Este audio no incluye una composición de blockchain guardada.", + audioBackToAudio: "Volver al audio", + audioBackToBcs: "Volver a BCS", + audioAuthorLabel: "Autor", + melodyCompositionTitle: "Mapa de composición de la blockchain", + melodyFilterMine: "MINE", + melodyByLabel: "Por", + melodyAllEmpty: "Todavía no hay composiciones BCS de otros habitantes.", + melodyNoteUnavailable: "el bloque no está en tu feed local", + melodyUploadBcsTitle: "Publicar", + melodyUploadBcsButton: "Publicar", + melodyUploadStegoLabel: "Mensaje oculto (esteganografía)", + melodyUploadStegoMaxLabel: "máx. 280 caracteres", + melodyUploadStegoPlaceholder: "Opcional.", + melodyUploadBcsNameNote: "El audio se publicará con el nombre auto-generado", + larpLastAttemptTitle: "Tu último intento", + larpLastAttemptHouse: "Casa", + larpLastAttemptResult: "Resultado", + larpLastAttemptWhen: "Cuándo", + larpLastAttemptCooldown: "Próximo intento en", + larpTestTitle: "Test de entrada", + larpTestIntro: "Responde las 10 preguntas. Necesitas al menos 7 respuestas correctas para ser admitido. Puedes intentar un test cada 30 ciclos sin importar el resultado.", + larpTestSubmit: "Unirse a la casa", + larpTestCooldownActive: "Próximo test disponible en", + larpTestCooldownDays: "ciclos", + larpTestResultTitle: "Resultado del test", + larpTestPassed: "Test superado — ¡bienvenida!", + larpTestFailed: "Test no superado", + larpTestScore: "Puntuación", + larpTestNextAttempt: "Puedes intentar otro test en 30 ciclos.", + larpGoToHouse: "Ir a tu casa", + larpBackToAcademia: "Volver a ACADEMIA", + larpBackToHouses: "◀ Casas", + larpBadgeYou: "Tú", + larpBadgeRuling: "Rige", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Módulo para la capa de juego de rol en vivo de Oasis (las 9 Casas).", + melodyUploadTitlePlaceholder: "Título de la composición", + melodyUploadDescPlaceholder: "Descripción opcional", + bankTaxesUserNote: "Tu impuesto ECO se deduce del excedente que la red genera sobre tu UBI; el valor base del UBI es un mínimo inamovible. El impuesto nunca se deduce del importe fijo que corresponde a cada habitante como UBI. Los fondos deducidos alimentan el algoritmo de redistribución de la riqueza y se canalizan a otros habitantes a través de sus reclamaciones de UBI. Aquellos otros habitantes que reciban más UBI para sus proyectos probablemente tengan alguna tarea dedicada a ayudarte a reducir tu impuesto ECO. O pueden dedicarse a reducir el impuesto ECO directamente, y su contribución continua puede ser requerida por consenso de la red.", + bankTaxesArchTaxNote: "El impuesto ARCH refleja el coste de hacer crecer y mantener el archivo de la red. Se calcula a partir del tiempo transcurrido entre tu primer bloque publicado y el bloque más nuevo de la red — cuanto más viva tu historia en el archivo, mayor será tu parte del coste de mantenimiento. Al igual que el impuesto ECO, el impuesto ARCH se deduce del excedente que la red añade sobre tu UBI; el UBI base nunca se reduce por él.", + bankTaxesExample: "Ejemplo", + bankTaxesSummaryNote: "Total agregado de todos los tipos de impuestos que aplica la red. Actualmente solo el impuesto ECO (huella de carbono) está activado; futuros tipos de impuestos aparecerán como filas adicionales y contribuirán al total.", + audioTranscodeDescription: "Interpretación del audio: la composición original de la blockchain que lo produjo, además del OasisID, marca temporal y mensaje oculto incrustado en la forma de onda mediante esteganografía.", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + gameScoreLabel: "PUNTUACIONES", + larpProfileQ1: "Cuando te enfrentas a un problema complejo, ¿qué haces primero?", + larpProfileQ1O1: "Hablar con otras personas para buscar consenso", + larpProfileQ1O2: "Construir un prototipo para probarlo", + larpProfileQ1O3: "Investigar la literatura", + larpProfileQ1O4: "Sabotear el sistema que lo provoca", + larpProfileQ2: "¿Qué da sentido a tu trabajo diario?", + larpProfileQ2O1: "Defender a las personas que quiero", + larpProfileQ2O2: "Crear algo tangible", + larpProfileQ2O3: "Sanar o cuidar la vida", + larpProfileQ2O4: "Trabajar con palabras e ideas", + larpProfileQ2O5: "Hacer reír a los demás", + larpProfileQ3: "Cuando surge un conflicto en tu grupo, tú…", + larpProfileQ3O1: "Lideras el diálogo", + larpProfileQ3O2: "Tomas partido y te mantienes firme", + larpProfileQ3O3: "Sueltas una broma para rebajar la tensión", + larpProfileQ3O4: "Cuestionas si el conflicto es real", + larpProfileQ3O5: "Buscas las causas ecológicas de raíz", + larpProfileQ4: "Tu proyecto favorito a largo plazo sería…", + larpProfileQ4O1: "Construir una ciudad", + larpProfileQ4O2: "Restaurar un bosque", + larpProfileQ4O3: "Redactar una constitución", + larpProfileQ4O4: "Organizar un festival", + larpProfileQ4O5: "Montar una red de defensa", + larpProfileQ4O6: "Diseñar una máquina nueva", + larpProfileQ4O7: "Curar un archivo", + larpProfileQ4O8: "Desmontar un orden injusto", + larpProfileQ5: "¿Qué hace a una buena líder?", + larpProfileQ5O1: "Alguien que escucha y media", + larpProfileQ5O2: "Alguien capaz de luchar y proteger", + larpProfileQ5O3: "Alguien que conoce la historia", + larpProfileQ5O4: "Alguien que te hace sonreír", + larpProfileQ6: "Tu relación con las reglas:", + larpProfileQ6O1: "Las reglas surgen del diálogo y la ley", + larpProfileQ6O2: "Las reglas se deben cumplir estrictamente", + larpProfileQ6O3: "Las reglas hay que romperlas a menudo", + larpProfileQ6O4: "Las reglas deben servir a la vida", + larpProfileQ6O5: "Las reglas levantan infraestructuras sólidas", + larpProfileQ7: "¿Cómo manejas la información?", + larpProfileQ7O1: "La curo y la preservo", + larpProfileQ7O2: "La comparto a través de historias", + larpProfileQ7O3: "Cuestiono sus orígenes", + larpProfileQ7O4: "Extraigo lo útil", + larpProfileQ7O5: "La uso para sanar", + larpProfileQ8: "Tu idea de éxito es…", + larpProfileQ8O1: "Una máquina que funciona", + larpProfileQ8O2: "Una comunidad en paz", + larpProfileQ8O3: "Un festival animado", + larpProfileQ8O4: "Una familia segura", + larpProfileQ8O5: "Un archivo intacto", + larpProfileQ8O6: "Un dogma resquebrajado", + larpProfileQ8O7: "Una cosecha próspera", + larpProfileQ8O8: "Un edificio terminado", + larpProfileQ9: "Al levantarte, lo que quieres es…", + larpProfileQ9O1: "Entrenar el cuerpo", + larpProfileQ9O2: "Leer o escribir", + larpProfileQ9O3: "Cultivar o cocinar", + larpProfileQ9O4: "Trastear con algo", + larpProfileQ9O5: "Cuestionar a la autoridad", + larpProfileQ9O6: "Planificar un proyecto", + larpProfileQ9O7: "Hablar con tus amigas", + larpProfileQ9O8: "Hacer arte", + larpProfileQ10: "Tu debilidad podría ser:", + larpProfileQ10O1: "Hablar demasiado", + larpProfileQ10O2: "Ser demasiado pragmática", + larpProfileQ10O3: "Ser demasiado idealista", + larpProfileQ10O4: "Ser demasiado disruptiva", + larpProfileQ10O5: "Ser demasiado rígida", + larpProfileQ10O6: "Ser demasiado despreocupada", + larpProfileQ10O7: "Ser demasiado cautelosa", + larpProfileQ10O8: "Ser demasiado ambiciosa", + uxModeTitle: "UX", + uxModeDescription: "Selecciona qué modo de navegación UX quieres para tu interfaz.", + uxModeMenus: "Bloques", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_eu.js b/src/client/assets/translations/oasis_eu.js new file mode 100644 index 0000000..648f9aa --- /dev/null +++ b/src/client/assets/translations/oasis_eu.js @@ -0,0 +1,3873 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + eu: { + languageName: "Basque", + shopOrderStatusPaid: "Ordainketa jasota", + shopOrderStatusReceived: "Jasota", + shopOrderMarkPaid: "Markatu ordainketa jasota", + shopOrderConfirmReceived: "Berretsi jasota", + shopMyOrdersTitle: "Nire eskaerak", + shopPurchasesButton: "Erosketak", + shopMyOrdersDescription: "Zure erosketa-eskaerak.", + shopMyOrdersEmpty: "Oraindik ez duzu erosketarik.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Saltzailea", + shopOrderPaymentConcept: "Ordainketa", + shopOrderInvoice: "Faktura", + shopOrderInvoiceConcept: "Faktura", + shopOrderStatusPending: "Zain", + shopOrderStatusAccepted: "Onartua", + shopOrderStatusRejected: "Baztertua", + shopOrderStatusShipped: "Bidalia", + shopOrderStatusDelivered: "Entregatua", + shopOrderAccept: "Onartu", + shopOrderReject: "Baztertu", + shopOrderMarkShipped: "Markatu bidalia", + shopOrderMarkDelivered: "Markatu entregatua", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Kontratua", + shopOrderContractConcept: "Dendako eskaera", + shopOrdersPending: "Eskaera zain", + all: "Guztiak", + mine: "Nireak", + recent: "Azkenak", + anonymous: "Anonimoa", + unnamed: "Anonimoa", + authorLabel: "EGILEA", + delete: "Ezabatu", + save: "Gorde", + vote: "Bozkatu", + url: "URL", + price: "Prezioa", + priority: "Lehentasuna", + severity: "Larritasuna", + organizer: "Antolatzailea", + edited: "editatua", + isPublic: "Publikoa", + searchIsPublicLabel: "Publikoa", + privacyPrivate: "PRIBATUA", + privacyPublic: "PUBLIKOA", + notFound: "Ez da aurkitu", + no_results: "Daturik ez", + searchButton: "Bilatu", + mapZoomLabel: "Zooma", + modulesTitle: "Moduluak", + viewJson: "Ikusi JSON", + matchScore: "Bat-egite puntuazioa", + preferencesLabel: "Hobespenak", + inhabitantProfileTitle: "Biztanlearen profila", + contentWarningLabel: "Eduki abisua", + invalidPost: "Eduki baliogabea", + invalidPostHint: "Mezu honek testu baliogabea/hutsa du.", + spreadBy: "Honek", + spreadChron: "Hedapena", + spreaded: "Hedatua", + spreadMore: "gehiago", + spreadContentUnavailable: "Edukia oraindik ez dago eskuragarri (erreplikazioa zain)", + filteredByTag: "Etiketaz iragazia", + filteredByTagTitle: "Etiketaz iragazia", + feedPublishedSuccess: "Jarioa ondo argitaratu da!", + tribeFeedSent: "Mezua ondo bidali da!", + tribeContentEncrypted: "Eduki zifratua", + tribeTorrentsEmpty: "Oraindik ez dago torrentik.", + torrentDescription: "Deskribapena", + torrentDownload: "Deskargatu", + chatInviteMode: "Gonbidatu", + padInviteMode: "Gonbidatu", + noInviteMode: "Gonbidapen modurik ez", + noDeadline: "Mugaegunik ez", + noStatus: "Egoerarik ez", + noSeverity: "Larritasunik ez", + calendarDeleteDate: "Ezabatu data", + calendarIntervalUntil: "Arte", + bookmarkCategory: "Kategoria", + bookmarkLastVisit: "Azken bisita", + profileClearnetBookmarksLabel: "Laster-markak", + forumFilterHot: "Beroa", + invitesPort: "Ataka", + currentlyUnrecheable: "ERROREA!", + peerHostValidation: "IPv4 baliozkoa (adib. 192.168.1.100) edo ostalari-izena (adib. pub.example.com)", + peerPortValidation: "Ataka 1-65535", + peerKeyValidation: "SSB ed25519 gako publikoa (@<44 karaktere base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Zure lan-eskaintzatik baja", + inboxProjectFollowedTitle: "Zure proiektuaren jarraitzaile berria", + inboxProjectUnfollowedTitle: "Zure proiektua jarraitzeari utzi dio", + pmHasFollowedYourProject: "zure proiektua jarraitu du", + pmHasUnfollowedYourProject: "zure proiektua jarraitzeari utzi dio", + pmHasUnsubscribedFromYourJobOffer: "zure lan-eskaintzatik baja eman du", + marketDeleteButton: "Ezabatu", + marketNoBids: "Oraindik eskaintzarik ez", + marketAuctionBidAmount: "Eskaintzaren zenbatekoa", + marketAuctionBidTime: "Eskaintzaren ordua", + marketAuctionUser: "Eskaintzailea", + parliamentActorInPower: "TRIBU AGINTEAN", + parliamentWinningCandidature: "Hautagaitza irabazlea", + projectBounty: "Saria", + projectFollowing: "JARRAITZEN", + projectBackerAuthor: "Babeslea", + projectBackerDate: "Data", + projectConfirmTransferButton: "Berretsi transferentzia", + projectPendingTransfersTitle: "Transferentzia zain", + transfersUpdateSectionTitle: "Eguneratu transferentzia", + taskUnassignedFrom: "Esleipena kendua", + taskDescriptionPlaceholder: "Zereginaren deskribapena", + exportPasswordLabel: "Esportazio pasahitza", + importPasswordPlaceholder: "Sartu pasahitza", + searchCasesPlaceholder: "Bilatu kasuak…", + shopBuyDeliveryAddressPlaceholder: "Bidalketa-helbidea", + shopBuyNotesPlaceholder: "Oharrak", + bankTaxesUserNoteBold: "ECO zerga zuzenean murriztea", + bankTaxesUserNoteIntro: "Zure ECO zerga sareak zure OBOren gainetik sortzen duen soberakinetik kentzen da; OBOren oinarrizko balioa gutxieneko higiezin gisa ezartzen da. Zerga ez da inoiz kentzen biztanle bakoitzari OBO gisa dagokion kopuru finkotik. Kendutako funtsek aberastasuna birbanatzeko algoritmoa elikatzen dute eta beste biztanleei bideratzen zaizkie haien OBO eskarien bidez. Beren proiektuetarako OBO gehiago jasotzen duten beste biztanle horiek ziurrenik zure ECO zerga murrizten laguntzeko zereginen bat izango dute. Edo honi ekin diezaiokete: ", + bankTaxesUserNoteOutro: ", eta sarearen adostasunak haien etengabeko ekarpena eska dezake.", + courtsViewDetails: "Ikusi", + courtsViewDetailsShort: "Xehetasunak", + courtsThDetails: "Xehetasunak", + courtsDetailsAnswersTitle: "Erantzunak", + courtsDetailsEvidenceTitle: "Frogak", + courtsDetailsSettlementsTitle: "Akordioak", + courtsDetailsVerdictTitle: "Epaia", + courtsDetailsNotPublic: "Ez publikoa", + courtsEvidenceSideAccuser: "Salatzailearen frogak", + courtsEvidenceSideRespondent: "Defentsaren frogak", + courtsEvidenceSideUnknown: "Ezezaguna", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Lagundu kasua", + courtsSettlementAcceptBtn: "Onartu akordioa", + courtsSupportCount: "Babesak", + courtsVerdictVoteTitle: "Bozkatu epaia", + courtsVerdictVoteLabel: "Epaiaren botoa", + courtsVerdictVoteAccept: "Onartu", + courtsVerdictVoteReject: "Baztertu", + courtsVerdictVoteSubmit: "Bidali botoa", + reportsSetStatus: "Ezarri egoera", + reportsStatusClosed: "ITXITA", + statsAvgPerInhabitant: "Batez bestekoa biztanleko", + statsCarbonMaxAnnual: "Urteko gehienezko estimazioa", + statsCarbonNetwork: "Sarearen guztira", + statsCarbonOfEstMax: "estimatutako gehienezko ahalmenaren", + statsCarbonOfNetwork: "sarearen guztizkoaren", + statsCarbonUser: "Zure aztarna", + statsContentCountColumn: "Kopurua", + statsContentTypeColumn: "Mota", + statsMsgsPerDay: "Mezuak/eguneko (historikoa)", + statsMyShare: "Zure zatia sarean", + statsNetworkSpan: "Sarearen iraupena", + statsTombstoneRatioLabel: "Ezabaketa-ratioa", + statsTopTagsTitle: "Etiketa nagusiak", + statsTopTypesTitle: "Eduki mota nagusiak", + statsTotalMsgs: "Mezuak guztira", + feedViewDetails: "Ikusi xehetasunak", + eventFileLabel: "Erantsi irudia", + taskFileLabel: "Erantsi irudia", + courtsRespondentRequired: "Demandatuaren IDa beharrezkoa da", + extended: "Multibertsoa", + extendedDescription: [ + "Norbait laguntzen duzunean, laguntzen dituzun erabiltzaileen bidalketak deskargatu ditzakezu. Mezu horiek hemen azalduko dira, dataren arabera antolatuta.", + ], + popular: "Nabarmenak", + popularDescription: [ + "Zure sarean dauden bizilagunen bidalketak.", + strong("zabalpenen arabera antolatuta"), + ". Hautatu denbora tartea zerrenda bat jasotzeko.", + ], + week: "ASTEA", + month: "HILABETEA", + year: "URTEA", + latest: "Azkena", + latestDescription: [ + "Zure ", strong("bidalketak"), + " eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta.", + ], + topics: "Gaiak", + topicsDescription: [ + "Zure ", strong("Gaiak"), + " eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.", + ], + summaries: "Laburpenak", + summariesDescription: [ + "Zure ", strong("gai eta iruzkin batzuk"), + " eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.", + ], + threads: "Hariak", + threadsDescription: [ + "Zure ", strong("iruzkindutako bidalketak"), + " eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.", + ], + profile: "Abatarra", + inhabitants: "Bizilagunak", + peersReplicatedFeeds: "Errepikatutako jarioak", + graphos: "Graphos", + graphosDescription: "Sarearen mapa interaktiboa zure inguruan.", + graphosViewingGraphOf: "Ikusten", + graphosBackToMine: "← Nire sarea", + graphosShowing: "Erakusten", + graphosYou: "Zu", + graphosTotalNodes: "Nodo guztiak", + manualMode: "Eskuzko modua", + mentions: "Aipamenak", + mentionsDescription: [ + strong("@Aipatzen zaituzten bidalketak."), + ", gaurkotasunaren arabera antolatuta.", + ], + nextPage: "Hurrengoa", + previousPage: "Aurrekoa", + noMentions: "Ez duzu aipamenik jaso, oraindik.", + privateReminders: "OROIGARRIAK", + inboxFiltersLabel: "Iragazkiak:", + inboxToggleToMutuals: "Mutuora aldatu", + inboxToggleToWhole: "Guztiora aldatu", + privateFrom: "Nork", + privateTo: "Nori", + privateDate: "Data", + peers: "Parekoak", + searchDescription: "Deskribapena", + imageSearch: "Irudiak bilatu", + searchFromLabel: "Hasiera", + searchToLabel: "Bukaera", + searchMinOpinionsLabel: "Gutxi. iritziak", + searchInhabitantLabel: "Biztanle (Oasis ID)", + searchQueryLabel: "Bilaketa", + searchPerPageLabel: "Emaitzak orriko", + settings: "Ezarpenak", + continueReading: "Irakurtzen jarraitu", + moreComments: "iruzkin gehiago", + readThread: "irakurri hari osoa", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Marraztu pixelak saretan eta ekin elkARTlanari zure sarean.', + melodyTitle: 'Melody', + melodyDescription: 'Erreproduzitu zure blockchain-aren melodia — bloke bakoitza nota bat bihurtzen da.', + melodyEmpty: 'Oraindik notarik ez — zure blockchain-ak ez du oraindik blokerik sortu.', + melodyCompositionTitle: 'Konposizioa', + melodyCompositionHelp: 'Zure blockchain-eko bloke bakoitza musika-nota bat bihurtzen da, motaren eta tamainaren arabera.', + melodyStatsTitle: 'Motaren araberako banaketa', + melodyInhabitantLabel: 'Biztanlea', + melodyTotalBlocks: 'Notak', + melodyType: 'Mota', + melodyCount: 'Blokeak', + melodyFilterMine: 'NIREA', + melodyFilterAll: 'GUZTIAK', + melodyFilterShare: 'Igo melodia', + melodyShareTitle: 'Partekatu zure melodia', + melodyShareHelp: 'Argitaratu zure uneko melodiaren irudia, besteek entzun eta birmoldatu ahal izateko.', + melodyShareTitleLabel: 'Izenburua (aukerakoa)', + melodyShareTitlePlaceholder: 'Blockchain mamu bat', + melodyShareSubmit: 'Argitaratu melodia', + melodyNoShared: 'Oraindik ez dago blockchain melodiarik.', + melodyRegenerate: 'Birsortu', + melodyDownload: "Deskargatu BCS", + profileActivityTitle: 'Jarduera', + profileActivityMore: 'Ikusi jarduera guztia', + profileContentSectionTitle: 'Avatarraren Edukia', + profileContentHelp: 'Aukeratu zein modulu agertuko diren zure abatarrean.', + profileSensorsHelp: 'Zure abatarrean agertzen diren aukerako metrikak.', + profileClearnetHelp: 'Oasis kanpotik atzitu daitezkeen moduluak.', + profileHubAll: 'Guztiak', + profileHubTitle: 'Eduki Publikoa', + footerPulseTitle: 'Pultsua', + footerPulsePeers: 'Kideak', + footerPulseInbox: 'Sarrera-ontzia', + footerPulseSync: 'Azken sinkr.', + footerPulseEcoValue: 'ECO/h', + footerPulseLastActivity: 'Azken jarduera', + footerLicenseLabel: 'Lizentzia', + profileSwitchToOasis: 'Itzuli Oasisera', + profileSwitchToClearnet: 'Murgildu Clearneten', + profileVisibilityFediverse: "Fediverse", + profileSwitchToFediverse: "Murgildu fediversoan", + coordLabel: 'Koordenatuak (Adb. A3)', + coordPlaceholder: 'Sartu koordenatuak', + contributorsTitle: "Ekarpenak", + colorLabel: 'Hautatu kolorea', + paintButton: 'Koloreztatu!', + invalidCoordinate: 'Koordenatu okerrak', + goToMuralButton: "Ikusi Murala", + totalPixels: 'Pixelak guztira', + pixeliaBy: "egilea", + modules: "Moduluak", + modulesViewTitle: "Moduluak", + modulesViewDescription: "Konfiguratu zure ingurunea moduluak gaitu edo desgaituz.", + inbox: "Sarrera-ontzia", + multiverse: "Multibertsoa", + fediverse: "Fediverse", + fediverseDescription: "Kudeatu zure beste fediverso kontuak, edukia bidaltzea eta jasotzea barne.", + fediverseStatus: "Egoera", + fediverseDisconnected: "Fediversoa deskonektatuta. Egiaztatu %LINK% edo konexioaren egoera.", + fediverseSettingsTitle: "Fediverse", + fediverseInstanceLabel: "Helbidea", + fediverseTokenLabel: "Sarbide token-a", + fediverseTokenHelp: "Ezarri zure sarbide token-a. Zure Mastodon kontua Oasis barrutik kudeatzeko erabiliko da.", + fediverseConnect: "Konektatu", + fediverseConnectedAs: "Honela konektatuta", + fediverseDisconnect: "Deskonektatu", + fediverseEmpty: "Zure %LINK% beste fediverso kontu batzuk konektatzen dituzunean, hemendik kudeatu ahal izango dituzu.", + fediverseEmptyLink: "ezarpenetatik", + fediverseComposePlaceholder: "Zer duzu buruan?", + fediverseReplyPlaceholder: "Idatzi zure erantzuna…", + fediverseAttach: "Erantsi multimedia", + fediversePublish: "Argitaratu", + fediverseReply: "Erantzun", + fediverseBoosted: "bultzatu du", + fediverseNoPosts: "Zure denbora-lerroa hutsik dago.", + fediverseThread: "Haria", + fediverseTimeline: "Denbora-lerroak", + fediverseManageTimeline: "Kudeatu denbora-lerroa", + fediverseFollowers: "Jarraitzaileak", + fediverseFollowing: "Jarraitzen", + fediversePosts: "Argitalpenak", + fediverseJoined: "Elkartua", + fediverseOverview: "Ikuspegi orokorra", + fediverseRefresh: "Freskatu", + fediverseWrite: "Idatzi", + fediversePreview: "Aurrebista", + fediverseEdit: "Editatu", + fediverseOpenFeed: "Ikusi jarioa", + fediverseManage: "Kudeatu", + fediverseStatusNotConnected: "Konektatu gabe", + fediverseError: "Ezin izan da Mastodon-ekin konektatu.", + fediverseErrInstance: "Instantzia URL baliogabea.", + fediverseErrAuth: "Token baliogabea edo iraungia.", + fediverseErrConnect: "Ezin izan da instantziarekin konektatu.", + fediverseErrToken: "Token-a beharrezkoa da.", + fediverseErrPublic: "Ez dago erabilgarri modu publikoan.", + fediverseErrFetch: "Ezin izan da denbora-lerroa kargatu.", + fediverseErrPost: "Ezin izan da argitaratu.", + fediverseErrMedia: "Ezin izan da fitxategia igo.", + fediverseErrEmpty: "Idatzi zerbait edo erantsi fitxategi bat.", + popularLabel: "Nabarmenak", + topicsLabel: "Gaiak", + latestLabel: "Azkena", + summariesLabel: "Laburpenak", + threadsLabel: "Hariak", + multiverseLabel: "Multibertsoa", + inboxLabel: "Sarrera-ontzia", + invitesLabel: "Gonbidapenak", + walletLabel: "Zorroa", + legacyLabel: "Gakoak", + cipherLabel: "Zifratzailea", + bookmarksLabel: "Markagailuak", + videosLabel: "Bideoak", + torrentsLabel: "Torrentak", + docsLabel: "Dokumentuak", + audiosLabel: "Audioak", + tagsLabel: "Etiketak", + imagesLabel: "Irudiak", + inhabitantsLabel: "Bizilagunak", + trendingLabel: "Pil-pilean", + eventsLabel: "Gertakariak", + tasksLabel: "Atazak", + apply: "Aplikatu", + menuPersonal: "Pertsonala", + menuContent: "Edukia", + menuBlogs: "Blogak", + menuGovernance: "Gobernantza", + menuOffice: "Bulegoa", + menuMultiverse: "Multibertsoa", + menuNetwork: "Sarea", + menuCreative: "Sortzailea", + menuEconomy: "Ekonomia", + menuMedia: "Multimedia", + menuTools: "Tresnak", + comment: "Iruzkindu", + subtopic: "Azpi-gaia", + json: "JSON", + createdBy: "Sortzailea", + unfollow: "Laguntza eten", + follow: "Lagundu", + block: "Blokeatu", + unblock: "Desblokeatu", + newerPosts: "Bidalketa berrienak", + olderPosts: "Bidalketa zaharrenak", + feedRangeEmpty: "Hautatuako tartea hutsik dago jario honentzat. Saiatu ", + seeFullFeed: "jario osoa ikusiz", + feedEmpty: "Oasis sareak ez du kontu honen bidalketarik ikusi inoiz.", + beginningOfFeed: "Jarioaren hasiera da hau", + noNewerPosts: "Ez da bidalketa berriagorik jaso oraindik.", + relationshipNotFollowing: "Ez zaitu laguntzen", + relationshipTheyFollow: "Laguntzen zaitu", + relationshipMutuals: "Elkar laguntzen", + relationshipFollowing: "Laguntzen ari zara", + relationshipYou: "Zu", + relationshipBlocking: "Blokeatzen duzu", + relationshipBlockedBy: "Blokeatzen zaitu", + relationshipMutualBlock: "Elkar blokeatzen", + relationshipNone: "Ez duzu laguntzen", + relationshipConflict: "Gatazka", + relationshipBlockingPost: "Blokeatutako bidalketa", + viewLikes: "Ikusi zabalpenak", + spreadedDescription: "Bizilagunak zabaldutako bidalketen zerrenda.", + totalspreads: "Hedapenak guztira", + attachFiles: "Erantsi fitxategiak", + preview: "Aurrebista", + publish: "Idatzi", + contentWarningPlaceholder: "Gehitu gaia bidalketari (hautazkoa)", + privateWarningPlaceholder: "Gehitu bizilagunak mezu pribatua bidaltzeko (hautazkoa)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "GOGORATU: Blockchain teknologia dela eta, bidalketa argitaratu ezkero, ezin izango da aldatu ezta ezabatu.", + ], + commentWarning: [ + "GOGORATU: Blockchain teknologia dela eta, bidalketa argitaratu ezkero, ezin izango da aldatu ezta ezabatu.", + ], + commentPublic: "publikoa", + commentPrivate: "pribatua", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "GOGORATU: Blockchain teknologia dela eta, bidalketa argitaratu ezkero, ezin izango da aldatu ezta ezabatu.", + ], + replyLabel: ({ markdownUrl }) => [ + "GOGORATU: Blockchain teknologia dela eta, bidalketa argitaratu ezkero, ezin izango da aldatu ezta ezabatu.", + ], + publishCustomInfo: ({ href }) => [ + "Esperientzia badaukazu, ", + a({ href }, "bidalketa aurreratua idatzi "), + " dezakezu.", + ], + publishBasicInfo: ({ href }) => [ + "Esperientziarik ez badaukazu, ", + a({ href }, "bidalketa bat idatzi "), + "beharko zenuke.", + ], + publishCustom: "Idatzi bidalketa aurreratua", + subtopicLabel: "Bidalketa honen azpi-gaia sortu", + messagePreview: "Aurreikusi bidalketa", + mentionsMatching: "Bat datozen aipamenak", + mentionsName: "Izena", + mentionsRelationship: "Erlazioa", + updateit: "LORTU EGUNERAKETAK!", + updateBannerText: "Oasis-en bertsio berri bat eskuragarri dago.", + updateBannerAction: "Eguneratu orain →", + info: "Infoa", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "dela", + sendTime: "honi buruz ", + theme: "Gaia", + legacy: "Gakoak", + legacyTitle: "Gakoak", + legacyDescription: "Kudeatu zure sekretua (gako pribatua) bizkor eta segurtasunez.", + legacyExportButton: "Esportatu", + legacyImportButton: "Inportatu", + ssbLogStream: "Blockchain", + ssbLogStreamDescription: "Konfiguratu blockchain fluxuetarako mezu-muga.", + saveSettings: "Ezarpenak gorde", + exportTitle: "Esportatu datuak", + exportDescription: "Ezarri pasahitza (gutxienez 32 karaktere) zure gakoa zifratzeko", + exportDataTitle: "Babeskopia", + exportDataDescription: "Deskargatu zure datuak (gako sekretua izan ezik!)", + exportDataButton: "Deskargatu datu-basea", + pubWallet: "PUB Wallet", + pubWalletDescription: "Ezarri PUB wallet-aren URLa. Hau PUB transakzioetarako erabiliko da (RBU barne).", + pubWalletConfiguration: "Ezarpenak gorde", + importTitle: "Inportatu datuak", + importDescription: "Inportatu zifratutako sekretua (gako pribatua) zure abatarra gaitzeko", + importAttach: "Gehitu zifratutako fitxategia (.enc)", + passwordLengthInfo: "Pasahitzak 32 karaketereko luzera izan behar du.", + passwordImport: "Idatzi pasahitza zure karpeta nagusian gordeko diren datuak deszifratzeko (izena: secret)", + exportPasswordPlaceholder: "Erabili minuskulak, maiuskulak, zenbakiak eta sinboloak.", + fileInfo: "Zifratutako gako sekretua zure etxeko karpetan gordeko da. (izena: oasis.enc)", + themeIntro: + "Hautatu gaia.", + setTheme: "Ezarri gaia", + language: "Hizkuntza", + languageDescription: + "Beste hizkuntza bat erabili nahi baduzu, hautatu hemen.", + setLanguage: "Ezarri hizkuntza", + peerConnections: "Parekoak", + peerConnectionsIntro: "Kudeatu beste parekoekin dituzun konexio guztiak.", + peerConnectionsTitle: "Konexioak", + online: "Linean", + offline: "Lineaz kanpo", + discovered: 'Aurkituak', + unknown: 'Ezezagunak', + lanBroadcastLabel: "LAN igorpena", + lanBroadcastActive: "Aktibo (UDP multicast LANean)", + lanBroadcastDisabled: "Desgaituta (pub modua)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Berriena", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Lagunguta", + recommended: "Gomendatuta", + blocked: "Blokeatuta", + noConnections: "Konektatutako parekorik ez.", + noDiscovered: "Ez duzu nodorik aurkitu.", + noUnknownPeers: "Ez dago kide ezezagunik adiskidetza-grafoan.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Berritu", + peerPruneIdle: "Inaktiboak kendu", + peerExport: "Esportatu", + peerImport: "Inportatu", + peerImportTitle: "Peer-zerrenda inportatu", + peerImportPlaceholder: "Itsatsi multiserver-helbide bat lerro bakoitzeko, edo igo .txt fitxategi bat…", + noSupportedConnections: "Lagundutako parekorik ez.", + noBlockedConnections: "Blokeatutako parekorik ez.", + noRecommendedConnections: "Gomendatutako parekorik ez.", + connectionActionIntro: + "", + startNetworking: "Abiarazi sarea", + stopNetworking: "Gelditu sarea", + restartNetworking: "Berrabiarazi sarea", + sync: "Sinkronizatu sarea", + indexes: "Indizeak", + indexesDescription: + "Indizeak birsortzea segurua da eta hutsegite mota batzuk konpon ditzake.", + homePageTitle: "Hasierako orria", + homePageDescription: "Hautatu nahi duzun moduluaren orria hasiera gisa.", + saveHomePage: "Gorde hasierako orria", + invites: "Gonbidapenak", + invitesTitle: "Gonbidapenak", + invitesInvites: "Gonbidapenak", + invitesDescription: "Kudeatu eta erabili gonbidapen kodeak zure sarean.", + invitesTribesTitle: "Tribuak", + invitesTribeInviteCodePlaceholder: "Sartu tribuaren gonbidapen kodea", + invitesTribeJoinButton: "Sartu Tribuan", + invitesChatsTitle: "Txatak", + invitesChatInviteCodePlaceholder: "Sartu txataren gonbidapen kodea", + invitesChatJoinButton: "Sartu txatean", + invitesPadsTitle: "Padak", + invitesPadInviteCodePlaceholder: "Sartu padaren gonbidapen kodea", + invitesPadJoinButton: "Sartu padean", + invitesCalendarsTitle: "Egutegia", + invitesCalendarInviteCodePlaceholder: "Sartu egutegiaren gonbidapen kodea", + invitesCalendarJoinButton: "Sartu egutegian", + invitesEventsTitle: "Ekitaldiak", + invitesEventInviteCodePlaceholder: "Sartu ekitaldiaren gonbidapen kodea", + invitesEventJoinButton: "Sartu ekitaldian", + invitesForumsTitle: "Foroak", + invitesForumInviteCodePlaceholder: "Sartu foroaren gonbidapen kodea", + invitesForumJoinButton: "Sartu foroan", + invitesMapsTitle: "Mapak", + invitesMapInviteCodePlaceholder: "Sartu maparen gonbidapen kodea", + invitesMapJoinButton: "Sartu mapan", + invitesShopsTitle: "Dendak", + invitesShopInviteCodePlaceholder: "Sartu dendaren gonbidapen-kodea", + invitesShopJoinButton: "Bat egin dendarekin", + invitesPubsTitle: "PUBak", + invitesPubInviteCodePlaceholder: "Sartu PUB-aren gonbidapen kodea", + invitesAcceptInvite: "Sartu PUBean", + invitesAlreadyFederated: "Dagoeneko pub honekin federatuta zaude.", + invitesFederationsTitle: "Federazioak", + invitesAcceptedInvites: "Federatuak", + invitesNoInvites: "Ez da gonbidapenik onartu, oraindik.", + invitesUnfollow: "Utzi jarraitzea", + invitesFollow: "Jarraitu", + invitesUnfollowedInvites: "Ez federatuak", + invitesNoUnfollowed: "Ez dago pub ez-federaturik.", + invitesNoFederatedPubs: "Ez dago pub federaturik.", + invitesUnreachablePubs: "Iristezinak", + invitesNoUnreachablePubs: "Ez dago pub iristezinik.", + invitesPubsRefresh: "Berritu", + invitesPubsClearUnreachable: "Kendu iristezinak", + invitesPubsExport: "Esportatu", + invitesPubsImport: "Inportatu", + invitesPubsImportTitle: "Inportatu pubak", + invitesPubsImportPlaceholder: "Itsatsi multiserver-helbide bat edo gonbidapen-kode bat lerro bakoitzeko, edo igo .txt fitxategi bat…", + currentlyUnreachable: "ERROR!", + errorDetails: "Errore Xehetasunak", + genericError: "Errore bat gertatu da.", + panicMode: "Izu Modua!", + encryptData: "Idatzi pasahitza (gutxienez 32 karaketere) zure blockchain-a zifratzeko.", + decryptData: "Sartu pasahitza zure blockchain-a deszifratzeko.", + panicModeDescription: "Zifratu/deszifratu edo EZABATU zure blockchain-a", + removeDataDescription: "KONTUZ: Prozesu hau ezin da desegin.", + encryptPanicButton: "Zifratu blockchain-a", + decryptPanicButton: "Deszifratu blockchain-a", + removePanicButton: "EZABATU ZURE DATU GUZTIAK!", + searchTitle: "Bilatu", + searchDescriptionLabel: "Bilatu edukia zure sarean.", + searchLanguagesLabel: "Hizkuntzak", + searchSkillsLabel: "Trebetasunak", + searchPlaceholder:"Bilatu edukia...", + searchDateLabel:"Data", + searchLocationLabel:"Kokapena", + searchPriceLabel:"Prezioa", + searchUrlLabel:"URL", + searchCategoryLabel:"Kategoria", + searchStartLabel:"Hasiera ordua", + searchEndLabel:"Amaiera ordua", + searchPriorityLabel:"Lehentasuna", + searchStatusLabel:"Egoera", + statusLabel:"Egoera", + totalVotesLabel:"Bozkak Guztira", + noResultsFound:"Emaitzik ez.", + votesOption:"Bozkatzeko Aukerak", + voteYesLabel:"Bai", + voteNoLabel:"Ez", + allTypesLabel: "MUGAGABE", + ABSTENTIONLabel:"ABSTENTZIOA", + YESLabel:"BAI", + NOLabel:"EZ", + FOLLOW_MAJORITYLabel: "JARRAITU GEHIENGOA", + CONFUSEDLabel: "NAHASTUTA", + NOT_INTERESTEDLabel: "INTERESIK EZ", + StatusLabel:"Egoera", + votesOptionYesLabel:"Bai", + votesOptionNoLabel:"Ez", + votesOptionAbstentionLabel:"Abstentzioa", + votesQuestionLabel:"Galdera", + votesCreatedByLabel:"Noiz", + voteStatusOpen:"IREKITA", + voteStatusClosed:"ITXITA", + imageSearchLabel: "Hitz hauek dituzten irudiak bilatu.", + commentDescription: ({ parentUrl }) => [ + " noiz komentatuta ", + a({ href: parentUrl }, " haria"), + ], + commentTitle: ({ authorName }) => [`Komentatu @${authorName}-(r)en bidalketa`], + subtopicDescription: ({ parentUrl }) => [ + " azpigaia hemendik sortuta ", + a({ href: parentUrl }, " bidalketa bat"), + ], + subtopicTitle: ({ authorName }) => [`Subtopic on @${authorName}'s post`], + mysteryDescription: " bidalketa misteriotsua argitaratu du", + oasisDescription: "OASIS Proiektuko Sarea", + searchSubmit: "Bilatu...", + postLabel: "BIDALKETAK", + aboutLabel: "BIZILAGUNAK", + feedLabel: "JARIOAK", + votesLabel: "GOBERNUA", + reportLabel: "TXOSTENAK", + imageLabel: "IRUDIAK", + videoLabel: "BIDEOAK", + audioLabel: "AUDIOAK", + documentLabel: "DOKUMENTUAK", + torrentLabel: "TORRENTAK", + pdfFallbackLabel: "PDF Dokumentua", + eventLabel: "EKITALDIAK", + taskLabel: "ATAZAK", + transferLabel: "TRANSFERENTZIAK", + curriculumLabel: "KURRIKULUMA", + bookmarkLabel: "MARKAGAILUAK", + tribeLabel: "TRIBUAK", + marketLabel: "MERKATUA", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "ZORROTAK", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Bidali", + subjectLabel: "Gaia", + editProfile: "Editatu Abatarra", + profileQrAlt: "Profilaren QR kodea", + profileQrHint: "Eskaneatu Oasis ID hau kopiatzeko.", + oasisIdLabel: "OasisID", + spreadLabel: "Hedatu", + spreadHint: "Hedatu zure babesleei (zure feed bidez errepikatzen da).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Ongi etorri Oasis-era — sare sozial libre, parekideen arteko, federatu eta zifratua, gonbidapen bidezko arkitektura banatu batekin.\n\nHasteko leku interesgarri batzuk:\n\n- /profile — Editatu zure avatarra, izena, deskribapena eta zein modulu agertuko diren zure alde publikoan.\n- /invites — Gehitu pub bat sarearen gainerakoarekin federatzeko.\n- /peers — Ikusi nor dagoen konektatuta, eskaneatu LAN-a berriz, esportatu edo inportatu peer-zerrendak.\n- /inhabitants — Aurkitu eta bisitatu beste pertsonen avatarrak.\n- /tribes — Sortu edo elkartu muturretik muturrera zifratutako talde pribatuetan.\n- /inbox — Irakurri eta bidali mezu pribatuak.\n- /activity — Ikusi azken jarduera, zurea eta zure sarearena.\n- /publish — Idatzi post bat edo partekatu irudia, audioa, bideoa edo dokumentua.\n\nKonektatzeko leku interesgarri batzuk:\n\n- /fediverse — Kudeatu zure beste fediverso kontuak, edukia bidaltzea eta jasotzea barne.\n\nMezu hau zugandik zuregana modu pribatuan bidali da; horregatik ez zen konexiorik behar jasotzeko.", + profileVisibilityTitle: "Profil publikoaren ikusgaitasuna", + profileVisibilityHint: "Aukeratu zein eremu ikus ditzaketen beste biztanleek zure profilean. Lehenetsia: Karma soilik.", + profileSensorsSectionTitle: "Sentsoreak", + profileVisibilityActivity: "Jarduera-maila", + profileVisibilityDevice: "Gailua", + profileDeviceLockedHint: "Sentsore hau beti dago aktibo: besteei zein gailutatik konektatzen zaren erakusten die eta ezin da desgaitu.", + profileVisibilityKarma: "KARMA puntuazioa", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "ECOIN zorroa", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "GPG Gakoa", + profileVisibilityClearnet: "Nire profila argitaratu", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Dendak", + profileClearnetJobsLabel: "Lanak", + profileClearnetEventsLabel: "Ekitaldiak", + profileClearnetProjectsLabel: "Proiektuak", + profileClearnetPostsLabel: "Blogak", + profileClearnetAudiosLabel: "Audioak", + profileClearnetVideosLabel: "Bideoak", + profileClearnetImagesLabel: "Irudiak", + profileClearnetDocumentsLabel: "Dokumentuak", + profileClearnetTorrentsLabel: "Torrentak", + editProfileDescription: + "", + profileName: "Izena", + profileImage: "Avatar Irudia", + profileGpgKey: "GPG Gako Publikoa (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Deskargatu", + profileGpgRemove: "Kendu", + profileDescription: "Deskribapena", + hashtagDescription: + "Zure sareko bizilagunen bidalketak non #traola hau aipatzen den, gaurkotasunaren arabera antilatuta.", + rebuildName: "Berreraiki datubasea", + wallet: "Zorroa", + walletAddress: "Helbidea", + walletAmount: "Zenbatekoa", + walletAddressLine: ({ address }) => `Helbidea: ${address}`, + walletAmountLine: ({ amount }) => `Zenbatekoa: ${amount} ECO`, + walletBack: "Atzera", + walletBalanceTitle: "Guztira", + walletWalletSendTitle: "Bidali", + walletReceiveTitle: "Jaso", + walletHistoryTitle: "Historia", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Berr.", + walletConfirm: "Berretsi", + walletDescription: "Kudeatu zure ondasun digitalak. Hala nola, ECOin-ak bidali eta jaso, zure balantzea ikusi eta zure transakzio historia kontsultatu.", + walletDate: "Data", + walletFee: "Tasa (Zenbat eta tasa altuagoa, orduan eta bizkorrago prozesatuko da trantsakzioa.)", + walletFeeLine: ({ fee }) => `Tasa: ECO ${fee}`, + walletHistory: "Historia", + walletReceive: "Jaso", + walletReset: "Berrezarri", + walletSend: "Bidali", + walletStatus: "Egoera", + walletDisconnected: [ + "ECOin ", + strong("zorroa deskonektatuta"), + ". Check ", + a({ href: '/settings#wallet' }, "zure ezarpenak"), + " edo konexioaren egoera.", + ], + walletSentToLine: ({ destination, amount }) => `${amount} ECO bidali dira ${destination} helbidera`, + walletSettingsTitle: "Zorroa", + walletSettingsDescription: "Integratu Oasis zure ECOin zorroarekin.", + walletSettingsDocLink: "ECOin instalazio gida", + walletStatusMessages: { + invalid_amount: "Zenbatekoa oker", + invalid_dest: "Hartzailearen helbidea oker", + invalid_fee: "Tasa oker", + validation_errors: "Balidazio erroreak", + send_tx_success: "Trantsakzio arrakastatsua", + }, + walletTitle: "Zorroa", + walletTotalCostLine: ({ totalCost }) => `Kostua guztira: ECO ${totalCost}`, + walletTransactionId: "Transakzioaren ID-a", + walletTxId: "Tx ID", + walletType: "Mota", + walletUser: "Erabiltzaile izena", + walletPass: "Pasahitza", + walletConfiguration: "Ezarri zorroa", + cipher: "Zifratzailea", + cipherTitle: "Zifratzailea", + cipherDescription: "Zifratu eta deszifratu testua simetrikoki (Pasahitza erabiliz).", + randomPassword: "Ausazko pasahitza", + cipherEncryptTitle: "Zifratu testua", + cipherEncryptDescription: "Ezarri pasahitza (gutxienez 32 karaktereko luzera) zure testua zifratzeko", + cipherTextLabel: "Zifratuko den testua", + cipherTextPlaceholder: "Idatzi zifratuko den testua...", + cipherPasswordLabel: "Pasahitza", + cipherPasswordDecryptLabel: "Pasahitza ezarri (gutxienez 32 karaktere) testua deszifratzeko", + cipherPasswordPlaceholder: "Idatzi pasahitza...", + cipherEncryptButton: "Zifratu", + cipherDecryptTitle: "Deszifratu testua", + cipherDecryptDescription: "Sartu zifratutako testua eta pasahitza, deszifratzeko.", + cipherEncryptedMessageLabel: "Zifratutako testua", + cipherDecryptedMessageLabel: "Deszifratutako Testua", + cipherPasswordUsedLabel: "Zifratzeko pasahitza (gorde!)", + cipherEncryptedTextPlaceholder: "Sartu zifratutako testua...", + cipherIvLabel: "HB", + cipherIvPlaceholder: "Sartu hasieratzeko bektorea...", + cipherDecryptButton: "Deszifratu", + password: "Pasahitza", + text: "Testua", + encryptedText: "Zifratutako testua", + iv: "Hasieratze Bektorea (HB)", + encryptTitle: "Zifratu zure testua", + encryptDescription: "Sartu zifratu nahi duzun testua eta eman pasahitz bat.", + encryptButton: "Zifratu", + decryptTitle: "Deszifratu zure testua", + decryptDescription: "Sartu zifratutako testua eta eman enkripatzeko erabili duzun pasahitz berbera.", + decryptButton: "Deszifratu", + passwordLengthError: "Pasahitzak gutxienez 32 karaketere izan behar ditu.", + missingFieldsError: "Ez da testurik, pasahitzik edo HB-rik sartu.", + encryptionError: "Errorea testua zifratzean.", + decryptionError: "Errorea testua deszifratzean.", + bookmarkTitle: "Laster-markak", + bookmarkDescription: "Zure sareko laster-markak aurkitu eta kudeatu.", + bookmarkAllSectionTitle: "Laster-markak", + bookmarkMineSectionTitle: "Zure laster-markak", + bookmarkRecentSectionTitle: "Azken laster-markak", + bookmarkTopSectionTitle: "Laster-marka onenak", + bookmarkFavoritesSectionTitle: "Gogokoak", + bookmarkCreateSectionTitle: "Laster-marka sortu", + bookmarkUpdateSectionTitle: "Laster-marka eguneratu", + bookmarkFilterAll: "GUZTIAK", + bookmarkFilterMine: "NIREAK", + bookmarkFilterTop: "TOP", + bookmarkFilterFavorites: "GOGOKOAK", + bookmarkFilterRecent: "AZKENAK", + bookmarkCreateButton: "Laster-marka sortu", + bookmarkUpdateButton: "Eguneratu", + bookmarkDeleteButton: "Ezabatu", + bookmarkAddFavoriteButton: "Gogokoetara gehitu", + bookmarkRemoveFavoriteButton: "Gogokoetatik kendu", + bookmarkUrlLabel: "Esteka", + bookmarkUrlPlaceholder: "https://adibidea.com", + bookmarkDescriptionLabel: "Deskribapena", + bookmarkDescriptionPlaceholder: "Aukerakoa", + bookmarkTagsLabel: "Tagak", + bookmarkTagsPlaceholder: "Sartu tagak komaz banatuta", + bookmarkCategoryLabel: "Kategoria", + bookmarkCategoryPlaceholder: "Aukerakoa", + bookmarkLastVisitLabel: "Azken bisita", + bookmarkSearchPlaceholder: "Bilatu URLa, tagak, kategoria, egilea...", + bookmarkSortRecent: "Berrienak", + bookmarkSortOldest: "Zaharrenak", + bookmarkSortTop: "Bozkatuenak", + bookmarkSearchButton: "Bilatu", + bookmarkUpdatedAt: "Eguneratua", + bookmarkNoMatch: "Ez dago zure bilaketarekin bat datorren laster-markarik.", + noBookmarks: "Ez dago laster-markarik.", + noUrl: "Estekarik ez", + noCategory: "Kategoriarik ez", + noLastVisit: "Azken bisitarik ez", + videoTitle: "Bideoak", + videoDescription: "Arakatu eta kudeatu zure sareko bideo-edukia.", + videoPluginTitle: "Izenburua", + videoPluginDescription: "Deskribapena", + videoMineSectionTitle: "Zure bideoak", + videoCreateSectionTitle: "Bideoa igo", + videoUpdateSectionTitle: "Bideoa eguneratu", + videoAllSectionTitle: "Bideoak", + videoRecentSectionTitle: "Azken bideoak", + videoTopSectionTitle: "Bozkatuenak", + videoFavoritesSectionTitle: "Gogokoak", + videoFilterAll: "GUZTIAK", + videoFilterMine: "NIREAK", + videoFilterRecent: "AZKENAK", + videoFilterTop: "TOP", + videoFilterFavorites: "GOGOKOAK", + videoCreateButton: "Bideoa igo", + videoUpdateButton: "Eguneratu", + videoDeleteButton: "Ezabatu", + videoAddFavoriteButton: "Gehitu gogokoetara", + videoRemoveFavoriteButton: "Kendu gogokoetatik", + videoFileLabel: "Aukeratu bideo-fitxategi bat (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Etiketak", + videoTagsPlaceholder: "Idatzi etiketak komaz bereizita", + videoTitleLabel: "Izenburua", + videoTitlePlaceholder: "Aukerakoa", + videoDescriptionLabel: "Deskribapena", + videoDescriptionPlaceholder: "Aukerakoa", + videoNoFile: "Ez da bideo-fitxategirik eman", + noVideos: "Ez dago bideorik erabilgarri.", + videoSearchPlaceholder: "Bilatu izenburua, etiketak, egilea...", + videoSortRecent: "Berrienak", + videoSortOldest: "Zaharrenak", + videoSortTop: "Bozkatuenak", + videoSearchButton: "Bilatu", + videoMessageAuthorButton: "MP", + videoUpdatedAt: "Eguneratua", + videoNoMatch: "Ez dago zure bilaketarekin bat datorren bideorik.", + documentTitle: "Dokumentuak", + documentDescription: "Aurkitu eta kudeatu dokumentuak zure sarean.", + documentAllSectionTitle: "Dokumentuak", + documentMineSectionTitle: "Zure dokumentuak", + documentRecentSectionTitle: "Azken dokumentuak", + documentTopSectionTitle: "Dokumentu top", + documentFavoritesSectionTitle: "Gogokoak", + documentCreateSectionTitle: "Dokumentua igo", + documentUpdateSectionTitle: "Dokumentua editatu", + documentFilterAll: "GUZTIAK", + documentFilterMine: "NIREAK", + documentFilterRecent: "AZKENAK", + documentFilterTop: "TOP", + documentFilterFavorites: "GOGOKOAK", + documentCreateButton: "Dokumentua igo", + documentUpdateButton: "Eguneratu", + documentDeleteButton: "Ezabatu", + documentAddFavoriteButton: "Gehitu gogokoetara", + documentRemoveFavoriteButton: "Gogokoetatik kendu", + documentMessageAuthorButton: "MP", + documentFileLabel: "Dokumentua igo (.pdf)", + documentTagsLabel: "Etiketak", + documentTagsPlaceholder: "Sartu komaz bereizitako etiketak", + documentTitleLabel: "Izenburua", + documentTitlePlaceholder: "Aukerakoa", + documentDescriptionLabel: "Deskribapena", + documentDescriptionPlaceholder: "Aukerakoa", + documentNoFile: "Fitxategirik ez.", + noDocuments: "Ez dago dokumenturik.", + documentSearchPlaceholder: "Bilatu izenburua, etiketak, deskribapena, egilea...", + documentSortRecent: "Berrienak", + documentSortOldest: "Zaharrenak", + documentSortTop: "Bozkatuenak", + documentSearchButton: "Bilatu", + documentNoMatch: "Ez dago bilaketarekin bat datorren dokumenturik.", + documentUpdatedAt: "Eguneratua", + audioTitle: "Audioak", + audioDescription: "Arakatu eta kudeatu zure sareko audio-edukia.", + audioPluginTitle: "Izenburua", + audioPluginDescription: "Deskribapena", + audioMineSectionTitle: "Zure audioak", + audioCreateSectionTitle: "Audioa igo", + audioUpdateSectionTitle: "Audioa eguneratu", + audioAllSectionTitle: "Audioak", + audioRecentSectionTitle: "Azken audioak", + audioTopSectionTitle: "Bozkatuenak", + audioFilterAll: "GUZTIAK", + audioFilterMine: "NIREAK", + audioFilterRecent: "AZKENAK", + audioFilterTop: "TOP", + audioFilterBlockchain: "BLOCKCHAIN", + audioCreateButton: "Audioa igo", + audioUpdateButton: "Eguneratu", + audioDeleteButton: "Ezabatu", + audioAddFavoriteButton: "Gehitu gogokoetara", + audioRemoveFavoriteButton: "Kendu gogokoetatik", + audioFileLabel: "Aukeratu audio-fitxategi bat (.mp3, .wav, .ogg)", + audioTagsLabel: "Etiketak", + audioTagsPlaceholder: "Idatzi etiketak komaz bereizita", + audioTitleLabel: "Izenburua", + audioTitlePlaceholder: "Aukerakoa", + audioDescriptionLabel: "Deskribapena", + audioDescriptionPlaceholder: "Aukerakoa", + audioNoFile: "Ez da audio-fitxategirik eman", + noAudios: "Ez dago audiorik erabilgarri.", + audioSearchPlaceholder: "Bilatu izenburua, etiketak, egilea...", + audioSortRecent: "Berrienak", + audioSortOldest: "Zaharrenak", + audioSortTop: "Bozkatuenak", + audioSearchButton: "Bilatu", + audioMessageAuthorButton: "MP", + audioUpdatedAt: "Eguneratua", + audioNoMatch: "Ez dago zure bilaketarekin bat datorren audiorik.", + audioFavoritesSectionTitle: "Gogokoak", + audioFilterFavorites: "GOGOKOAK", + favoritesTitle: "Gogokoak", + favoritesDescription: "Zure gogoko guztiak leku berean.", + favoritesFilterAll: "GUZTIAK", + favoritesFilterRecent: "AZKENAK", + favoritesFilterAudios: "AUDIOAK", + favoritesFilterBookmarks: "MARKATZAILEAK", + favoritesFilterDocuments: "DOKUMENTUAK", + favoritesFilterImages: "IRUDIAK", + favoritesFilterMaps: "MAPAK", + favoritesFilterPads: "PADS", + favoritesFilterChats: "TXATAK", + favoritesFilterCalendars: "EGUTEGIAK", + favoritesFilterVideos: "BIDEOAK", + favoritesRemoveButton: "Gogokoetatik kendu", + favoritesNoItems: "Oraindik ez dago gogokorik.", + yourContacts: "Zure Kontaktuak", + allInhabitants: "Bizilagunak", + allCVs: "CV guztiak", + discoverPeople: "Aurkitu bizilagunak zure sarean.", + allInhabitantsButton: "GUZTIAK", + contactsButton: "LAGUNTZEN", + CVsButton: "CV-ak", + matchSkills: "Aurkitu trebetasunak", + matchSkillsButton: "AURKITU TREBETASUNAK", + suggestedButton: "GOMENDATUTAKOAK", + searchInhabitantsPlaceholder: "IRAGAZI bizilagunak IZENAREN ARABERA …", + filterLocation: "IRAGAZI bizilagunak KOKAPENAREN ARABERA …", + filterLanguage: "IRAGAZI bizilagunak BY HIZKUNTZAREN ARABERA …", + filterSkills: "IRAGAZI bizilagunak BY TREBETASUNEN ARABERA …", + applyFilters: "Ezarri Iragazkiak", + locationLabel: "Kokapena", + languagesLabel: "Hizkuntzak", + skillsLabel: "Trebetasunak", + commonSkills: "Trebetasunak amankomunean", + mutualFollowers: "Jarraitzaile amankomunean", + suggestedFollowsYou: "Zu jarraitzen zaitu", + latestInteractions: "Interakzioak Azkenak", + viewAvatar: "Ikusi Abatarra", + viewCV: "Ikusi CV-a", + suggestedSectionTitle: "Gomendatuta", + topkarmaSectionTitle: "Karma Onena", + topecoSectionTitle: "Eko Onena", + topactivitySectionTitle: "Top Jarduera", + blockedSectionTitle: "Blokeatuta", + gallerySectionTitle: "GALERIA", + blockedButton: "BLOKETATUTA", + blockedLabel: "Blokeatutako Erabiltzailea", + inhabitantviewDetails: "Ikusi Xehetasunak", + keepReading: "Irakurtzen jarraitu...", + oasisId: "ID-a", + noInhabitantsFound: "Bizilagunik ez, oraindik.", + inhabitantActivityLevel: "Jarduera Maila", + lifespanLabel: "Bizi-iraupena", + deviceLabel: "Gailua", + parliamentTitle: "Legebiltzarra", + parliamentDescription: "Gobernu formak eta kudeaketa kolektiboko politikak arakatu.", + parliamentFilterGovernment: "GOBERNUA", + parliamentFilterCandidatures: "HAUTAGAITZAK", + parliamentFilterProposals: "PROPOSAMENAK", + parliamentFilterLaws: "LEGEAK", + parliamentFilterHistorical: "HISTORIKOA", + parliamentFilterLeaders: "LIDERAK", + parliamentFilterRules: "ARAUAK", + parliamentGovernmentCard: "Uneko gobernua", + parliamentActorInPowerInhabitant: 'BIZTANLEA AGINTEAN', + parliamentActorInPowerTribe: 'TRIBUA AGINTEAN', + parliamentHistoricalGovernmentsTitle: 'GOBERNUAK', + parliamentHistoricalElectionsTitle: 'HAUTESKUNDE ZIKLOAK', + parliamentHistoricalLawsTitle: 'HISTORIKOA', + parliamentHistoricalLeadersTitle: 'BURUZAGIAK', + parliamentThCycles: 'ZIKLOAK', + parliamentThTimesInPower: 'AGINTEAN', + parliamentThTotalCandidatures: 'KANDIDATURAK', + parliamentThProposed: 'PROPOSATUTAKO LEGEAK', + parliamentThApproved: 'ONARTUTAKO LEGEAK', + parliamentThDeclined: 'UKATUTAKO LEGEAK', + parliamentThDiscarded: 'BAZTERTUTAKO LEGEAK', + parliamentMembers: "KIDEAK", + parliamentLegSince: "ZIKLOAREN HASIERA", + parliamentLegEnd: "ZIKLOAREN AMAIERA", + parliamentPoliciesProposal: "POLITIKA PROPOSAMENAK", + parliamentPoliciesApproved: "ONARTUTAKO LEGEAK", + parliamentPoliciesDeclined: "BAZTERTUTAKO LEGEAK", + parliamentPoliciesDiscarded: "POLITIKA BAZTERTUAK", + parliamentEfficiency: "% ERAGINKORTASUNA", + parliamentFilterRevocations: 'INDARGABETZEAK', + parliamentRevocationFormTitle: 'INDARGABETU', + parliamentRevocationLaw: 'Legea', + parliamentRevocationTitle: 'Izenburua', + parliamentRevocationReasons: 'Arrazoiak', + parliamentCurrentRevocationsTitle: 'Uneko Indargabetzeak', + parliamentFutureRevocationsTitle: 'Etorkizuneko Indargabetzeak', + parliamentPoliciesRevocated: 'HISTORIKOA', + parliamentPoliciesTitle: 'HISTORICAL', + parliamentLawsTitle: 'ONARTUTAKO LEGEAK', + parliamentRulesRevocations: 'Onartutako edozein lege indargabetu daiteke indarrean dagoen gobernu metodoa erabiliz.', + parliamentNoStableGov: "Oraindik ez da gobernurik hautatu.", + parliamentNoGovernments: "Oraindik ez dago gobernurik.", + parliamentCandidatureFormTitle: "Kandidatura Proposatu", + parliamentCandidatureIdPh: "Oasis ID (@...) edo tribuaren izena", + parliamentCandidatureSlogan: "Leloa (geh. 140 karaktere)", + parliamentCandidatureSloganPh: "Lelo labur bat", + parliamentCandidatureMethod: "Metodoa", + parliamentCandidatureProposeBtn: "Hautagaitza Proposatu", + parliamentThDate: "Proposamen data", + parliamentThSlogan: "Leloa", + parliamentThSince: "Profila noiztik", + parliamentThVotes: "Jasotako botoak", + parliamentThVoteAction: "Bozkatu", + parliamentTypeUser: "Biztanlea", + parliamentTypeTribe: "Tribu", + parliamentVoteBtn: "Bozkatu", + parliamentProposalFormTitle: "Legea Proposatu", + parliamentProposalDescription: "Deskribapena (≤1000)", + parliamentProposalPublish: "Proposamena Argitaratu", + parliamentFinalize: "Amaitu", + parliamentDeadline: "Epea", + parliamentNoProposals: "Oraindik ez dago lege-proposamenik.", + parliamentNoLaws: "Oraindik ez dago onartutako legerik.", + parliamentMethodDEMOCRACY: "Demokrazia", + parliamentMethodMAJORITY: "Gehiengoa (80%)", + parliamentMethodMINORITY: "Gutxiengoa (20%)", + parliamentMethodDICTATORSHIP: "Diktadura", + parliamentMethodKARMATOCRACY: "Karmatokrazia", + parliamentThId: "ID", + parliamentThProposalDate: "Proposamenaren data", + parliamentThMethod: "Metodoa", + parliamentThKarma: "Karma", + parliamentThSupports: "Babesak", + parliamentThVote: "Bozkatu", + parliamentCurrentProposalsTitle: "Uneko proposamenak", + parliamentVotesSlashTotal: "Botoak/Guztira", + parliamentVotesNeeded: "Beharrezko Botoak", + parliamentFutureLawsTitle: "Etorkizuneko legeak", + parliamentNoFutureLaws: "Oraindik ez dago etorkizuneko legerik", + parliamentVoteAction: "Bozkatu", + parliamentLeadersTitle: "Liderak", + parliamentThLeader: "AVATAR", + parliamentPopulation: "Biztanleria", + parliamentThType: "Mota", + parliamentThInPower: "Agintean", + parliamentThPresented: "HAUTAGAITZAK", + parliamentNoLeaders: "Oraindik ez dago liderrik.", + parliamentCandidaturesListTitle: "Kandidaturen Zerrenda", + parliamentTimeRemaining: "Geratzen den denbora", + parliamentNoLeader: "Oraindik ez dago irabazten ari den kandidaturarik.", + parliamentElectionsStatusTitle: "Hurrengo Gobernua", + parliamentProposalDeadlineLabel: "Epemuga", + parliamentProposalTimeLeft: "Geratzen den denbora", + parliamentProposalOnTrack: "Onartzeko bidean", + parliamentProposalOffTrack: "Euskarri nahikorik ez", + parliamentRulesTitle: "Legebiltzarrak nola funtzionatzen duen", + parliamentRulesIntro: "Hauteskundeak 2 hilean behin ebazten dira; hautagaitzak etengabeak dira eta gobernua aukeratzean berrabiarazten dira.", + parliamentRulesCandidates: "Edozein biztanlek bere burua, beste biztanle bat edo edozein tribu proposa dezake. Biztanle bakoitzak gehienez 3 hautagaitza proposa ditzake ziklo bakoitzean; ziklo bereko bikoiztuak baztertzen dira.", + parliamentRulesElection: "Irabazlea ebazpen unean boto gehien dituen hautagaitza da.", + parliamentRulesTies: "Berdinketa haustea: biztanlearen karma handiena; berdin jarraituz gero, profilaren antzinatasun handiena; oraindik berdin, proposamen goiztiarrena; ondoren, IDaren hurrenkera lexikografikoa.", + parliamentRulesFallback: "Inork bozkatzen ez badu, azken proposatutako hautagaitzak irabazten du. Hautagaitzarik ez badago, ausazko tribu bat hautatzen da.", + parliamentRulesTerm: "Gobernua fitxak uneko gobernua eta estatistikak erakusten ditu.", + parliamentRulesMethods: "Ereduak: Anarkia (gehiengo sinplea), Demokrazia (%50+1), Gehiengoa (%80), Gutxiengoa (%20), Karmatokrazia (karma handiena duten proposamenak), Diktadura (berehalako onarpena).", + parliamentRulesAnarchy: "Anarkia modu lehenetsia da: ebazpenean ez bada kandidaturarik hautatzen, Anarkia ezartzen da. Anarkian, edozein biztanlek legeak proposa ditzake.", + parliamentRulesProposals: "Agintean dagoen biztanlea bazara edo aginteko tribuko kidea bazara, lege-proposamenak argitaratu ditzakezu. Diktaduraz besteko metodoek boz publiko bat sortzen dute.", + parliamentRulesLimit: "Biztanle bakoitzak gehienez 3 proposamen argitaratu ditzake ziklo bakoitzean.", + parliamentRulesLaws: "Proposamenak bere atalasea gainditzen duenean, Lege bihurtzen da eta Legeak fitxan agertzen da indarrean sartzeko datarekin.", + parliamentRulesHistorical: "Historia fitxan egondako gobernu ziklo bakoitza eta haren kudeaketari buruzko datuak ikus daitezke.", + parliamentRulesLeaders: "Liderak fitxan, gobernatu duten (edo aurkeztu diren) biztanle/tribuen sailkapena ikus daiteke, eraginkortasunaren arabera ordenatuta.", + parliamentProposalVoteStatusLabel: "Bozketa egoera", + parliamentProposalOnTrackYes: "Atalasea lortuta", + parliamentProposalOnTrackNo: "Atalasearen azpitik", + courtsTitle: "Auzitegiak", + courtsDescription: "Aztertu gatazkak konpontzeko eta justizia kolektiboa kudeatzeko modu desberdinak.", + courtsFilterCases: "KASUAK", + courtsFilterMyCases: "NIREAK", + courtsFilterJudges: "EPAILEAK", + courtsFilterHistory: "HISTORIA", + courtsFilterRules: "ARAUAK", + courtsFilterOpenCase: "KASU BERRIA", + courtsCaseFormTitle: "Kasu ireki", + courtsCaseTitle: "Izenburua", + courtsCaseRespondent: "Akusatua / Erantzulea", + courtsCaseRespondentPh: "Oasis ID (@...) edo Tribu izena", + courtsCaseMediatorsAccuser: "Bitartekariak (akusazioa)", + courtsCaseMediatorsPh: "Oasis IDak, komaz bereizita", + courtsCaseMethod: "Ebazpen metodoa", + courtsCaseDescription: "Azalpena (gehienez 1000 karaktere)", + courtsCaseEvidenceTitle: "Kasuko frogak", + courtsCaseEvidenceHelp: "Erantsi zure kasua babesten duten irudiak, audioak, dokumentuak (PDF) edo bideoak.", + courtsCaseSubmit: "Kasu bidali", + courtsNominateJudge: "Epailea proposatu", + courtsJudgeId: "Epailea", + courtsJudgeIdPh: "Oasis ID (@...) edo Biztanle izena", + courtsNominateBtn: "Proposatu", + courtsJudge: "Epailea", + courtsAddEvidence: "Frogak gehitu", + courtsEvidenceText: "Testua", + courtsEvidenceLink: "Esteka", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "Erantsi", + courtsAnswerTitle: "Erreklamazioari erantzun", + courtsAnswerText: "Erantzun laburra", + courtsAnswerSubmit: "Erantzuna bidali", + courtsStanceDENY: "Ukatu", + courtsStanceADMIT: "Onartu", + courtsStancePARTIAL: "Partziala", + courtsStanceCOUNTERCLAIM: "Kontraerreklamazioa", + courtsStanceNEUTRAL: "Neutrala", + courtsVerdictTitle: "Ebazpena eman", + courtsVerdictResult: "Emaitza", + courtsVerdictOrders: "Aginduak", + courtsVerdictOrdersPh: "Ekintzak, epeak, neurri zuzentzaileak...", + courtsIssueVerdict: "Ebazpena eman", + courtsMediationPropose: "Akordioa proposatu", + courtsSettlementText: "Baldintzak", + courtsSettlementAccepted: "Onartua", + courtsSettlementPending: "Zain", + courtsSettlementProposeBtn: "Proposatu", + courtsNominationsTitle: "Epailetzako izendapenak", + courtsThJudge: "Epailea", + courtsThSupports: "Babesak", + courtsThDate: "Data", + courtsThVote: "Botoa", + courtsNoNominations: "Oraindik ez dago izendapenik.", + courtsAccuser: "Akusatzailea", + courtsRespondent: "Erantzulea", + courtsRespondentInvalid: "SSB ID baliozkoa izan behar da (@...ed25519)", + courtsThStatus: "Egoera", + courtsThAnswerBy: "Erantzuteko epea", + courtsThEvidenceBy: "Frogak aurkezteko epea", + courtsThDecisionBy: "Ebazteko epea", + courtsThCase: "Kasua", + courtsThCreatedAt: "Hasiera-data", + courtsThActions: "Ekintzak", + courtsCaseMediatorsRespondentTitle: "Defentsako bitartekariak gehitu", + courtsCaseMediatorsRespondent: "Bitartekariak (defentsa)", + courtsMediatorsAccuserLabel: "Bitartekariak (akusazioa)", + courtsMediatorsRespondentLabel: "Bitartekariak (defentsa)", + courtsMediatorsSubmit: "Bitartekariak gorde", + courtsVotesNeeded: "Beharrezko botoak", + courtsVotesSlashTotal: "BAI / GUZTIRA", + courtsOpenVote: "Bozketa ireki", + courtsPublicPrefLabel: "Ebazpenaren ondorengo ikusgarritasuna", + courtsPublicPrefYes: "Ados nago kasu hau guztiz publikoa izatearekin", + courtsPublicPrefNo: "Hobe dut xehetasunak pribatuan mantentzea", + courtsPublicPrefSubmit: "Ikusgarritasun lehentasuna gorde", + courtsNoCases: "Kasurik ez.", + courtsNoMyCases: "Oraindik ez duzu gatazkarik.", + courtsNoHistory: "Oraindik ez dago auzirik erregistratuta.", + courtsMethodJUDGE: "Epailea", + courtsMethodDICTATOR: "Diktadorea", + courtsMethodPOPULAR: "Herritarren bozketa", + courtsMethodMEDIATION: "Bitartekaritza", + courtsMethodKARMATOCRACY: "Karmatokrazia", + courtsMethodJUDGES: "Epaile-mahaia", + courtsMethodSINGLE_JUDGE: "Epaile bakarra", + courtsMethodJURY: "Epaimahaia", + courtsMethodCOUNCIL: "Kontseilua", + courtsMethodCOMMUNITY: "Komunitatea", + courtsMethodARBITRATION: "Arbitrajea", + courtsMethodVOTE: "Komunitate-bozketa", + courtsMethod: "Metodoa", + courtsRulesTitle: "Nola funtzionatzen duten Auzitegiek", + courtsRulesIntro: "Auzitegiak komunitateak kudeatutako prozesuak dira, gatazkak konpontzeko eta justizia zuzentzailea sustatzeko. Elkarrizketa, froga argiak eta neurri proportzionalak lehenesten dira.", + courtsRulesLifecycle: "Prozesua: 1) Kasua ireki 2) Metodoa aukeratu 3) Frogak aurkeztu 4) Entzunaldia eta eztabaida 5) Epai eta neurriak 6) Betetze eta itxiera 7) Helegitea (badagokio).", + courtsRulesRoles: "Akusatzailea: kasua irekitzen du. Defentsa: akusatutako pertsona edo tribua. Metodoa: komunitateak aukeratutako mekanismoa, froga baloratu eta ebazpena emateko. Lekukoa: testigantza edo frogak ematen dituen pertsona. Bitartekariak: akusatzaileak eta/edo defentsak gonbidatutako pertsona neutralak, xehetasun guztietara sarbidea dutenak, gatazka deseskalatzen eta akordioak lankidetzan eraikitzen laguntzen dutenak.", + courtsRulesEvidence: "Gehienez 1000 karaktereko azalpena. Erantsi irudi, audio, bideo eta PDF dokumentu garrantzitsu eta legezkoak. Ez partekatu datu pribatu sentikorrak baimenik gabe.", + courtsRulesDeliberation: "Entzunaldiak publikoak edo pribatuak izan daitezke. Epaileek errespetua bermatzen dute, argipenak eskatzen dituzte eta material ez-legala edo esanguragabea baztertu dezakete.", + courtsRulesVerdict: "Lehentasuna da konponketa: barkamenak, bitartekaritza-akordioak, edukiaren moderazioa, behin-behineko murrizketak edo beste neurri proportzional batzuk. Arrazoibidea erregistratuta geratu behar da.", + courtsRulesAppeals: "Helegitea: onartzen da frogak berriak direnean edo akats prozesal argia dagoenean. Gehienez 7 eguneko epean aurkeztu behar da, bestela adierazi ezean.", + courtsRulesPrivacy: "Errespetatu pribatutasuna eta segurtasuna. Doxing-a, gorrotoa edo mehatxuak ezabatzen dira. Epaileek erregistroaren zatiak editatu edo ezkutatu ditzakete arriskuan dauden pertsonak babesteko.", + courtsRulesMisconduct: "Jazarpenak, manipulazioak edo faltsututako frogek berehalako ebazpen negatiboa ekar dezakete.", + courtsRulesGlossary: "Kasua: gatazkaren erregistroa. Froga: aldarrikapenak babesten dituzten materialak. Epai/ebazpena: neurri edo konponketarekin datorren erabakia. Helegitea: ebazpena berrikusteko eskaera.", + courtsFilterActions: "EKINTZAK", + courtsNoActions: "Ez duzu zain dauden ekintzarik zure rolarentzat.", + courtsCaseTitlePlaceholder: "Gatazkaren deskribapen laburra", + courtsCaseSeverity: "Larritasuna", + courtsCaseSeverityNone: "Larritasun etiketarik ez", + courtsCaseSeverityLOW: "Baxua", + courtsCaseSeverityMEDIUM: "Ertaina", + courtsCaseSeverityHIGH: "Handia", + courtsCaseSeverityCRITICAL: "Larria", + courtsCaseSubject: "Gaia", + courtsCaseSubjectNone: "Gairik gabeko etiketa", + courtsCaseSubjectBEHAVIOUR: "Jokabidea", + courtsCaseSubjectCONTENT: "Edukia", + courtsCaseSubjectGOVERNANCE: "Gobernantza / arauak", + courtsCaseSubjectFINANCIAL: "Finantzak / baliabideak", + courtsCaseSubjectOTHER: "Bestelakoa", + courtsHiddenRespondent: "Ezkutatua (rol inplikatuek bakarrik ikus dezakete).", + courtsThRole: "Rola", + courtsRoleAccuser: "Akusatzailea", + courtsRoleDefence: "Defentsa", + courtsRoleMediator: "Bitartekaria", + courtsRoleJudge: "Epailea", + courtsRoleDictator: "Diktadorea", + courtsAssignJudgeTitle: "Epailea aukeratu", + courtsAssignJudgeBtn: "Epailea aukeratu", + trendingTitle: "Pil-pilean", + exploreTrending: "Aurkitu pil-pileko edukia zure sarean.", + bookmarkButton: "MARKAGAILUAK", + transferButton: "TRANSFERENTZIAK", + eventButton: "EKITALDIAK", + taskButton: "ATAZAK", + votesButton: "GOBERNUA", + reportButton: "TXOSTENAK", + feedButton: "JARIOA", + marketButton: "MERKATUA", + imageButton: "IRUDIAK", + audioButton: "AUDIOAK", + videoButton: "BIDEOAK", + documentButton: "DOKUMENTUAK", + torrentButton: "TORRENTAK", + noTrendingFound: "Pil-pileko edukirik ez.", + noContentMessage: "Pil-pileko edukirik ez, oraindik.", + trendingDescription: "Deskribapena", + trendingDate: "Data", + trendingLocation: "Kokapena", + trendingPrice: "Prezioa", + trendingUrl: "URL", + trendingCategory: "Kategoria", + trendingStart: "Hasi", + trendingEnd: "Amaitu", + trendingPriority: "Lehentasuna", + trendingStatus: "Egoera", + trendingFrom: "Nork", + trendingTo: "Nori", + trendingConcept: "Kontzeptua", + trendingAmount: "Kopurua", + trendingDeadline: "Epemuga", + trendingItemStatus: "Elementuaren Egoera", + trendingTotalVotes: "Bozkak Guztira", + trendingTotalOpinions: "Iritzial Guztira", + trendingNoContentMessage: "Edukirik ez.", + trendingAuthor: "Nork", + trendingCreatedAtLabel: "Noiz", + trendingTotalCount: "Kopurua Guztira", + tasksTitle: "Atazak", + tasksDescription: "Aurkitu eta kudeatu atazak", + taskTitleLabel: "Izenburua", + taskDescriptionLabel: "Deskribapena", + taskStartTimeLabel: "Hasiera Data", + taskEndTimeLabel: "Amaiera Data", + taskPriorityLabel: "Lehentasuna", + taskPrioritySelect: "Aukeratu lehentasuna", + taskPriorityUrgent: "Larria", + taskPriorityHigh: "Altua", + taskPriorityMedium: "Ertaina", + taskPriorityLow: "Baxua", + taskLocationLabel: "Kokapena", + taskTagsLabel: "Etiketak", + taskVisibilityLabel: "Ikusgaitasuna", + taskPublic: "publikoa", + taskPrivate: "pribatua", + taskCreatedAt: "Noiz", + taskBy: "Nork", + taskStatus: "Egoera", + taskStatusOpen: "Irekita", + taskStatusInProgress: "Ekinean", + taskStatusClosed: "Itxita", + taskAssignedTo: "Honi esleituta", + taskAssignees: "Hauei esleituta", + taskAssignButton: "Esleitu neuri", + taskUnassignButton: "Kendu esleipena", + taskCreateButton: "Sortu Ataza", + taskUpdateButton: "Eguneratu", + taskDeleteButton: "Ezabatu", + taskFilterAll: "GUZTIAK", + taskFilterMine: "NEUREAK", + taskFilterOpen: "IREKITA", + taskFilterInProgress: "EKINEAN", + taskFilterClosed: "ITXITA", + taskFilterAssigned: "ESLEITUTA", + taskFilterArchived: "ARTXIBATUTA", + taskFilterUrgent: "LARRIA", + taskFilterHigh: "ALTUA", + taskFilterMedium: "ERTAINA", + taskFilterLow: "BAXUA", + taskAllSectionTitle: "Atazak", + taskMineSectionTitle: "Zeure atazak", + taskCreateSectionTitle: "Sortu ataza", + taskUpdateSectionTitle: "Eguneratu", + taskOpenTitle: "Ireki Atazak", + taskInProgressTitle: "Atazak Ekinean", + taskClosedTitle: "Atazak itxita", + taskAssignedTitle: "Atazak esleituta", + taskArchivedTitle: "Atazak artxibatuta", + taskPublicTitle: "Ataza publikoak", + taskPrivateTitle: "Ataza pribatuak", + notasks: "Atazarik ez.", + noLocation: "Ez da kokapenik zehaztu", + taskSetStatus: "Egoera ezarri", + eventTitle: "Ekitaldiak", + eventDateLabel: "Data", + eventsTitle: "Ekitaldiak", + eventsDescription: "Aurkitu eta kudeatu ekitaldiak zure sarean.", + eventDescription: "Deskribapena", + eventPrice: "Prezioa", + eventStatus: "Egoera", + eventOrganizer: "Antolatzailea", + eventAllSectionTitle: "Ekitaldiak", + eventMineSectionTitle: "Zeure Ekitaldiak", + eventArchivedTitle: "Artxibatutako Ekitaldiak", + eventCreateSectionTitle: "Sortu Ekitaldia", + eventUpdateSectionTitle: "Eguneratu Ekitaldia", + eventDeleteButton: "Ezabatu", + eventCreateButton: "Sortu Ekitaldia", + eventAddToCalendar: "Egutegira gehitu", + eventVisitCalendar: "Egutegia ikusi", + viewTask: "Ikusi ataza", + viewReport: "Ikusi txostena", + viewTransfer: "Ikusi transferentzia", + viewItem: "Ikusi elementua", + viewShop: "Ikusi denda", + viewProject: "Ikusi proiektua", + viewVotation: "Ikusi bozketa", + voteCastTitle: "Eman botoa", + voteResults: "Emaitzak", + eventTitleLabel: "Izenburua", + eventDescriptionLabel: "Deskribapena", + eventDescriptionPlaceholder: "Sartu ekitaldiaren deskribapena...", + eventUpdateButton: "Eguneratu", + eventAttendeesLabel: "Partaideak", + eventAttendeesPlaceholder: "Sartu partaideak, erabili komak bereizteko.", + eventTagsLabel: "Etiketak", + eventTagsPlaceholder: "Sartu ekitaldiaren etiketak, erabili komak bereizteko.", + eventTags: "Etiketak", + eventPriceLabel: "Prezioa", + eventUrlLabel: "URL", + eventAttendees: "Partaideak", + noAttendees: "Partaiderik ez oraindik", + eventCreatedAt: "Noiz sortua", + eventLocation: "Kokapena", + eventLocationLabel: "Kokapena", + eventNoLocation: "Ez da zehaztu kokapenik", + eventNoURL: "Ez da zehaztu URL-rik", + eventBy: "Nork", + noevents: "Ekitaldirik ez.", + eventDate: "Data", + eventDateFormat: "YYYY/MM/DD HH:mm", + eventAttendButton: "Joan Ekitaldira", + eventUnattendButton: "Joateari utzi", + eventCreatedBy: "Nork", + eventAttendeesCount: "Partaide kopurua", + eventCreatedByYou: "Zeuk sortu duzu ekitaldia", + eventFilterAll: "GUZTIAK", + eventFilterMine: "NEUREAK", + eventFilterToday: "GAUR", + eventFilterWeek: "ASTEON", + eventFilterMonth: "HILEON", + eventFilterYear: "AURTEN", + eventFilterArchived: "ARTXIBATUTA", + eventTodayTitle: "Ekitaldiak gaur", + eventThisWeekTitle: "Ekitaldiak asteon", + eventThisMonthTitle: "Ekitaldiak hileon", + eventThisYearTitle: "Ekitaldiak aurten", + eventPrivacyLabel: "Ikusgaitasuna", + eventPublic: "Publikoa", + eventPrivate: "Pribatua", + eventPublicTitle: "Ekitaldi Publikoak", + eventNoPrice: "Ekitaldia doan", + eventNoImage: "Ez da irudirik igo", + eventAttendConfirmation: "Ekitaldi honetara zoaz", + eventUnattendConfirmation: "Ez zara ekitaldi honetara joango", + eventAttended: "Bertaratzen naiz", + eventUnattended: "Ez naiz bertaratzen", + eventStatusOpen: "Irekia", + eventStatusClosed: "Itxita", + tagsTitle: "Etiketak", + tagsDescription: "Aurkitu eta kudeatu taxonomia ereduak zure sarean.", + tagsAllSectionTitle: "Etiketak", + tagsTopSectionTitle: "Etiketa Gorenak", + tagsCloudSectionTitle: "Etiketen hodeia", + tagsFilterAll: "GUZTIAK", + tagsFilterTop: "GORENAK", + tagsFilterCloud: "HODEIA", + tagsNoItems: "Etiketarik ez.", + tagsTableHeaderTag: "ETIKETA/LOTURA:", + tagsTableHeaderCount: "KONTAGAILUA:", + transfersTitle: "Transferentziak", + transfersDescription: "Aurkitu eta kudeatu transferentziak zure sarean.", + transfersFrom: "Nork", + transfersTo: "Nori", + transfersFilterAll: "GUZTIAK", + transfersFilterMine: "NEUREAK", + transfersFilterUBI: "RBU", + transfersFilterMarket: "MERKATUA", + transfersFilterTop: "GORENAK", + transfersFilterPending: "ZAIN", + transfersFilterUnconfirmed: "BERRETSI GABE", + transfersFilterClosed: "ITXITA", + transfersFilterDiscarded: "BAZTERTUTA", + transfersFilterEconomic: "EKONOMIKOA", + transfersFilterTime: "DENBORA", + transfersFilterTrust: "KONFIANTZA", + transfersCategory: "Kategoria", + transfersCategoryEconomic: "Ekonomikoa", + transfersCategoryTime: "Denbora", + transfersCategoryTrust: "Konfiantza", + transfersCategoryApply: "Aplikatu", + transfersUnitEco: "ECO", + transfersUnitHours: "h", + transfersUnitTrust: "konfiantza", + transfersEconomicSectionTitle: "Transferentzia ekonomikoak", + transfersTimeSectionTitle: "Denbora transferentziak", + transfersTrustSectionTitle: "Konfiantza transferentziak", + transfersCreateButton: "Sortu Transferentzia", + transfersUpdateButton: "Eguneratu", + transfersDeleteButton: "Ezabatu", + transfersToUser: "Oasis ID-a", + transfersToUserValidation: "Baliozko Oasis ID-a, adb. @…=.ed25519", + transfersConcept: "Kontzeptua", + transfersAmount: "Kopurua", + transfersDeadline: "Epemuga", + transfersTags: "Etiketak", + transfersStatus: "Egoera", + transfersStatusUnconfirmed: "BERRETSI GABE", + transfersStatusClosed: "ITXITA", + transfersStatusDiscarded: "BAZTERTUTA", + transfersCreatedAt: "Noiz", + transfersConfirmations: "BERRESPENAK", + transfersContainingBlock: "Bloke edukitzailea", + transfersExportContract: "Sortu kontratua", + transfersConfirmButton: "Berretsi Transferentzia", + transfersNoItems: "Transferentziarik ez.", + transfersMineSectionTitle: "Zeure Transferentziak", + transfersUBISectionTitle: "RBU Transferentziak", + transfersMarketSectionTitle: "Merkatuko Transferentziak", + transfersTopSectionTitle: "Transferentzia Gorenak", + transfersPendingSectionTitle: "Transferentziak Zain", + transfersUnconfirmedSectionTitle: "Transferentziak Berretsi Gabe", + transfersClosedSectionTitle: "Transferentziak Itxita", + transfersDiscardedSectionTitle: "Transferentziak Baztertuta", + transfersCreateSectionTitle: "Sortu transferentzia", + transfersAllSectionTitle: "Transferentziak", + transfersFilterFavs: "Gustukoak", + transfersFavsSectionTitle: "Gustuko transferentziak", + transfersSearchLabel: "Bilatu", + transfersSearchPlaceholder: "Bilatu kontzeptua, etiketak, erabiltzaileak...", + transfersMinAmountLabel: "Gutx. zenbatekoa", + transfersMaxAmountLabel: "Geh. zenbatekoa", + transfersSortLabel: "Ordenatu", + transfersSortRecent: "Berrienak", + transfersSortAmount: "Zenbateko handiena", + transfersSortDeadline: "Epe hurbilena", + transfersSearchButton: "Bilatu", + transfersFavoriteButton: "Gustukoa", + transfersUnfavoriteButton: "Kendu gustukoa", + transfersMessageUserButton: "Mezua", + transfersExpiringSoonBadge: "LASTER", + transfersExpiredBadge: "IRAUNGITA", + transfersUpdatedAt: "Eguneratua", + transfersNoMatch: "Ez dago bilaketarekin bat datorren transferentziarik.", + votationsTitle: "Bozkatzeak", + votationsDescription: "Aurkitu eta kudeatu bozketak zure sarean.", + voteMineSectionTitle: "Zeure Bozketak", + voteCreateSectionTitle: "Sortu Bozketa", + voteUpdateSectionTitle: "Eguneratu", + voteOpenTitle: "Bozketak Irekita", + voteClosedTitle: "Bozketak Itxita", + voteAllSectionTitle: "Bozketak", + voteCreateButton: "Sortu Bozketa", + voteUpdateButton: "Eguneratu", + voteDeleteButton: "Ezabatu", + voteOptionYes: "BAI", + voteOptionNo: "EZ", + voteOptionAbstention: "ABSTENTZIOA", + voteConfused: "ZALANTZAN", + voteFollowMajority: "GEHIENGOAREN HAUTUA", + voteNotInterested: "INTERESIK EZ", + voteFilterAll: "GUZTIAK", + voteFilterMine: "NEUREAK", + voteFilterOpen: "IREKITA", + voteFilterClosed: "ITXITA", + voteQuestionLabel: "Galdera", + voteDeadlineLabel: "Epemuga", + voteOptionsLabel: "Zure bozka", + voteBreakdown: "Banaketa", + voteFinalResult: "EMAITZA", + voteNoQuorum: "QUORUMIK EZ", + voteTagsLabel: "Etiketa", + voteDeadline: "Epemuga", + voteStatus: "Egoera", + voteTags: "Etiketak", + voteOpinions: "Iritziak", + novotes: "Bozkatzeko proposamenik ez.", + voteBy: "Nork", + voteCreatedAt: "Noiz", + voteNoQuestion: "Galderarik ez", + voteUnknownCreator: "Sortzaile Ezezaguna", + voteUnknownDate: "Data Ezezaguna", + errorVoteNotFound: "Bozka ez da aurkitu", + errorAlreadyVoted: "Jadanik bozkatu duzu", + errorVoteClosedCannotEdit: "Ezin duzu itxitako bozketa bat editatu", + errorVoteDeadlinePassed: "Bozketa honen epemuga igaro da", + errorRetrievingVote: "Errorea bozka eskuratzerakoan", + errorCreatingVote: "Errorea bozka sortzerakoan", + errorVoteAlreadyVoted: "Ezin duzu bozka editatu jadanik bozkatu baduzu", + errorDeletingOldVote: "Errorea bozka zaharra ezabatzerakoan", + errorCreatingUpdatedVote: "Errorea eguneratutako bozka sortzean", + errorCreatingTombstone: "Errorea hilarria sortzerakoan", + voteDetailSectionTitle: 'Bozketa xehetasunak', + voteCommentsLabel: 'Iruzkinak', + voteCommentsForumButton: 'Eztabaida ireki', + voteCommentsSectionTitle: 'Eztabaida irekia', + voteNoCommentsYet: 'Oraindik ez dago iruzkinik. Izan zaitez lehen erantzuten.', + voteNewCommentPlaceholder: 'Idatzi hemen zure iruzkina…', + voteNewCommentButton: 'Bidali iruzkina', + voteNewCommentLabel: 'Gehitu iruzkina', + cvTitle: "CV", + cvLabel: "Curriculum Vitae (CV)", + cvEditSectionTitle: "Editatu CV-a", + cvCreateSectionTitle: "Sortu CV-a", + cvDescription: "Kudeatu eta partekatu zure trebetasun profesionalak eta informazio gehiago.", + cvNameLabel: "Izen Osoa", + cvDescriptionLabel: "Laburpena", + cvPhotoLabel: "Argazkia", + cvPersonalExperiencesLabel: "Esperientzia Pertsonalak", + cvPersonalSkillsLabel: "Trebetasun Pertsonalak (komaz bereizita)", + cvOasisExperiencesLabel: "Esperientzia Oasis-i Ekarpenak egiten", + cvOasisSkillsLabel: "Oasis-en Parte Hartzeko Trebetasunak (komaz bereizita)", + cvEducationExperiencesLabel: "Hezkuntza-esperientzia", + cvEducationalSkillsLabel: "Educational Skills (komaz bereizita)", + cvProfessionalExperiencesLabel: "Lan-esperientzia", + cvProfessionalSkillsLabel: "Trebetasun Profesionalak (komaz bereizita)", + cvLanguagesLabel: "Hizkuntzak", + cvLocationLabel: "Kokapena", + cvStatusLabel: "Egoera", + cvPreferencesLabel: "Lehentasunak", + cvOasisContributorLabel: "Oasis-eko Parte-Hartzailea", + cvPersonal: "Pertsonala", + cvOasis: "Oasis-eko Parte-Hartzailea (optional)", + cvOasisContributorView: "Oasis-i Ekarpena", + cvEducational: "Hezkuntza (hautazkoa)", + cvEducationalView: "Hezkuntza", + cvProfessional: "Profesionala (hautazkoa)", + cvProfessionalView: "Profesionala", + cvAvailability: "Eskuragarritasuna (hautazkoa)", + cvAvailabilityView: "Eskuragarritasuna", + cvUpdateButton: "Eguneratu", + cvCreateButton: "Sortu CV-a", + cvContactLabel: "Kontaktua", + cvCreatedAt: "Noiz sortua", + cvUpdatedAt: "Noiz eguneratua", + cvEditButton: "Eguneratu", + cvDeleteButton: "Ezabatu", + cvNoCV: "CV-rik ez.", + blogSubject: "Gaia", + blogMessage: "Edukia 8096 karakteretara mugatuta.", + blogImage: "Multimedia igo (max: 50MB)", + blogPublish: "Aurrebista", + noPopularMessages: "Pil-pileko mezurike ez, oraindik", + forumTitle: "Foroak", + forumCategoryLabel: "Kategoria", + forumTitleLabel: "Izenburua", + forumTitlePlaceholder: "Foroaren izenburua...", + forumCreateButton: "Sortu foroa", + forumCreateSectionTitle: "Sortu foroa", + forumDescription: "Hitz egin sareko beste erabiltzaileekin libreki.", + forumFilterAll: "GUZTIAK", + forumFilterMine: "NIREAK", + forumFilterRecent: "BERRIENAK", + forumFilterTop: "GORENAK", + forumMineSectionTitle: "Zure Foroak", + forumRecentSectionTitle: "Azken Foroak", + forumAllSectionTitle: "Foroak", + forumDeleteButton: "Ezabatu", + forumParticipants: "parte-hartzaileak", + forumMessages: "mezuak", + forumLastMessage: "Azken mezua", + forumMessageLabel: "Mezua", + forumMessagePlaceholder: "Idatzi zure mezua...", + forumSendButton: "Bidali", + forumVisitForum: "Bisitatu Foroaren", + noForums: "Ez da fororik aurkitu.", + forumVisitButton: "Foroa bisitatu", + forumCatGENERAL: "Orokorra", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "Politika", + forumCatTECH: "Teknologia", + forumCatSCIENCE: "Zientzia", + forumCatMUSIC: "Musika", + forumCatART: "Artea", + forumCatGAMING: "Bideojokoak", + forumCatBOOKS: "Liburuak", + forumCatFILMS: "Filmak", + forumCatPHILOSOPHY: "Filosofia", + forumCatSOCIETY: "Gizartea", + forumCatPRIVACY: "Pribatutasuna", + forumCatCYBERWARFARE: "Zibergerra", + forumCatSURVIVALISM: "Biziraupena", + imageTitle: "Irudiak", + imageDescription: "Arakatu eta kudeatu zure sareko irudi-edukia.", + imagePluginTitle: "Izenburua", + imagePluginDescription: "Deskribapena", + imageMineSectionTitle: "Zure irudiak", + imageCreateSectionTitle: "Irudia igo", + imageUpdateSectionTitle: "Irudia eguneratu", + imageAllSectionTitle: "Irudiak", + imageRecentSectionTitle: "Azken irudiak", + imageTopSectionTitle: "Bozkatuenak", + imageFavoritesSectionTitle: "Gogokoak", + imageGallerySectionTitle: "Galeria", + imageMemeSectionTitle: "Memak", + imageFilterAll: "GUZTIAK", + imageFilterMine: "NIREAK", + imageFilterRecent: "AZKENAK", + imageFilterTop: "TOP", + imageFilterFavorites: "GOGOKOAK", + imageFilterGallery: "GALERIA", + imageFilterMeme: "MEMAK", + imageCreateButton: "Irudia igo", + imageUpdateButton: "Eguneratu", + imageDeleteButton: "Ezabatu", + imageAddFavoriteButton: "Gehitu gogokoetara", + imageRemoveFavoriteButton: "Kendu gogokoetatik", + imageFileLabel: "Aukeratu irudi-fitxategi bat (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Etiketak", + imageTagsPlaceholder: "Idatzi etiketak komaz bereizita", + imageTitleLabel: "Izenburua", + imageTitlePlaceholder: "Aukerakoa", + imageDescriptionLabel: "Deskribapena", + imageDescriptionPlaceholder: "Aukerakoa", + imageMemeLabel: "¿MEME?", + imageNoFile: "Ez da irudi-fitxategirik eman", + noImages: "Ez dago irudirik erabilgarri.", + imageSearchPlaceholder: "Bilatu izenburua, etiketak, deskribapena, egilea...", + imageSortRecent: "Berrienak", + imageSortOldest: "Zaharrenak", + imageSortTop: "Bozkatuenak", + imageSearchButton: "Bilatu", + imageMessageAuthorButton: "Mezua", + imageUpdatedAt: "Eguneratua", + imageNoMatch: "Ez dago zure bilaketarekin bat datorren irudirik.", + feedTitle: "Jarioa", + feedDetailTitle: "Feed", + feedOpenDiscussion: "Eztabaida ireki", + feedPostComment: "Iruzkina argitaratu", + noComments: "Oraindik iruzkinik ez", + createFeedTitle: "Sortu Jarioa", + createFeedButton: "Bidali Jarioa!", + feedPlaceholder: "Zer berri? (max 280 characters)", + TODAYButton: "GAUR", + CREATEButton: "Sortu Jarioa", + totalOpinions: "Iritziak Guztira", + moreVoted: "Gehien bozkatu", + noFeedsFound: "Ez da jariorik aurkitu.", + createdAtLabel: "Noiz", + FeedshareYourOpinions: "Aurkitu eta partekatu testu laburrak zure sarean.", + refeedButton: "Realimentatu", + alreadyRefeeded: "Berjaiotu duzu jada.", + activityTitle: "Jarduera", + yourActivity: "Zure jarduera", + globalActivity: "Jarduera globala", + activityList: "Jarduera", + activityDesc: "Ikusi zure sareko azken jarduera.", + allButton: "GUZTIA", + mineButton: "NIREAK", + noActions: "Ez dago jarduerarik eskuragarri.", + from: "Nork", + to: "Nori", + amount: "Kopurua", + concept: "Kontzeptua", + description: "Deskribapena", + meme: "Meme", + activityContact: "Kontaktua", + activityBy: "Izena", + activityPixelia: "Pixel berria gehituta", + viewImage: "Ikusi irudia", + playAudio: "Audioa erreproduzitu", + playVideo: "Bideoa erreproduzitu", + typeRecent: "AZKENA", + errorActivity: "Errorea jarduera eskuratzean", + typePost: "BLOGAK", + recentButton: "AZKENAK", + typeTribe: "TRIBUAK", + typeLarp: "L.A.R.P.", + typeInhabitants: "BIZTANLEAK", + activityProfileUpdated: "bere profila eguneratu du", + typeAbout: "BIZTANLEAK", + typeCurriculum: "CVAK", + typeImage: "IRUDIAK", + typeBookmark: "LASTER-MARKAK", + typeDocument: "DOKUMENTUAK", + typeVotes: "BOZKETAK", + typeAudio: "AUDIOAK", + typeMarket: "MERKATUA", + typeJob: "LANAK", + typeProject: "PROIEKTUAK", + typeVideo: "BIDEOAK", + typeVote: "ZABALKUNDEA", + typeEvent: "EKITALDIAK", + typeTransfer: "TRANSFERENTZIAK", + typeTask: "ATASKAK", + typePixelia: "PIXELIA", + typeForum: "FOROAK", + typeReport: "TXOSTENAK", + typeFeed: "FEEDAK", + typeContact: "KONTAKTUA", + typePub: "PUB", + typeTombstone: "TOMBSTONE", + typeBanking: "BANKUA", + typeBankWallet: "BANKUA/ZORROA", + typeBankClaim: "BANKUA/UBI", + typeKarmaScore: "KARMA", + typeParliament: "PARLAMENTUA", + typeSpread: "ERREPLIKAK", + typeParliamentCandidature: "Parlamentua · Hautagaitza", + typeParliamentTerm: "Parlamentua · Agintaldia", + typeParliamentProposal:"Parlamentua · Proposamena", + typeParliamentRevocation:"Parlamentua · Ezespena", + typeParliamentLaw: "Parlamentua · Lege berria", + typeCourts: "EPAITEGIAK", + typeCourtsCase: "Epaitegiak · Kausa", + typeCourtsEvidence: "Epaitegiak · Froga", + typeCourtsAnswer: "Epaitegiak · Erantzuna", + typeCourtsVerdict: "Epaitegiak · Epai", + typeCourtsSettlement: "Epaitegiak · Akordioa", + typeCourtsSettlementProposal: "Epaitegiak · Akordio proposamena", + typeCourtsSettlementAccepted: "Epaitegiak · Akordio onartua", + typeCourtsNomination: "Epaitegiak · Izendapena", + typeCourtsNominationVote: "Epaitegiak · Izendapen bozketak", + activitySupport: "Aliantza berria sortua", + activityJoin: "PUB berria batu da", + question: "Galdera", + deadline: "Epemuga", + status: "Egoera", + votes: "Botoak", + totalVotes: "Guztira botoak", + voteTotalVotes: "Guztira botoak", + name: "Izena", + skills: "Gaitasunak", + tags: "Etiketak", + title: "Izenburua", + date: "Data", + category: "Kategoria", + attendees: "Parte-hartzaileak", + activitySpread: "->", + visitLink: "Esteka bisitatu", + viewDocument: "Dokumentua ikusi", + location: "Kokalekua", + contentWarning: "Gaia", + personName: "Biztanlearen izena", + bankWalletConnected: "ECOin zorroa", + bankUbiReceived: "Jasotako UBI", + bankTx: "Tx", + bankEpochShort: "Epoka", + viewDetails: "Xehetasunak ikusi", + link: "Esteka", + activityProjectFollow: "%OASIS% orain %ACTION% proiektu hau %PROJECT%", + activityProjectUnfollow: "%OASIS% orain %ACTION% proiektu hau %PROJECT%", + activityProjectPledged: "%OASIS%-(e)k %ACTION% %AMOUNT% egin du %PROJECT% proiektuan", + following: "JARRAITZEN", + unfollowing: "EZ JARRAITZEN", + pledged: "KONPROMISOA", + parliamentCandidatureId: "Kandidatura", + parliamentGovMethod: "Metodoa", + parliamentVotesReceived: "Jasotako botoak", + parliamentMethodANARCHY: "Anarkia", + parliamentMethodVOTE: "Komunitate-bozketa", + parliamentMethodRANKED: "Hobespen-bozketa", + parliamentMethodPLURALITY: "Pluralitatea", + parliamentMethodCOUNCIL: "Kontseilua", + parliamentMethodJURY: "Epaimahaia", + parliamentAnarchy: "ANARKIA", + parliamentElectionsStart: "Hauteskundeen hasiera", + parliamentElectionsEnd: "Hauteskundeen amaiera", + parliamentCurrentLeader: "Irabazleko kandidatura", + parliamentProposalTitle: "Izenburua", + parliamentOpenVote: "Bozketa irekia", + parliamentStatus: "Egoera", + parliamentLawQuestion: "Galdera", + parliamentLawMethod: "Metodoa", + parliamentLawProposer: "Proposatzailea", + parliamentLawEnacted: "Onartutako eguna", + parliamentLawVotes: "Botoak", + createdAt: "Sortze-data", + reportsTitle: "Txostenak", + reportsDescription: "Kudeatu eta jarraitu arazo, akats, gehiegikeri eta eduki-abisuei buruzko txostena zure sarean.", + reportsFilterAll: "GUZTIAK", + reportsFilterMine: "NEUREAK", + reportsFilterFeatures: "GAITASUNAK", + reportsFilterBugs: "BUGAK", + reportsFilterAbuse: "GEHIEGIKERIAK", + reportsFilterContent: "EUDUKIAK", + reportsFilterConfirmed: "BERRETSITA", + reportsFilterResolved: "KONPONDUTA", + reportsFilterOpen: "IREKITA", + reportsFilterUnderReview: "BERRIKUSTEN", + reportsFilterInvalid: "BALIOGABEKOA", + reportsCreateButton: "Sortu Txostena", + reportsTitleLabel: "Izenburua", + reportsDescriptionLabel: "Deskribapena", + reportsDescriptionPlaceholder: "Idatzi deskribapen zehatza, mesedez.", + reportsCategory: "Kategoria", + reportsCategoryLabel: "Kategoria", + reportsCategoryFeatures: "Gaitasunak", + reportsCategoryBugs: "Bugak", + reportsCategoryAbuse: "Gehiegikeria", + reportsCategoryContent: "Arazoak Edukiarekin", + reportsUpdateButton: "Eguneratu", + reportsDeleteButton: "Ezabatu", + reportsDateLabel: "Data", + reportsUploadFile: "Multimedia igo (max: 50MB)", + reportsMineSectionTitle: "Zeure Txostenak", + reportsFeaturesSectionTitle: "Gaitasuna Eskatu", + reportsBugsSectionTitle: "Bugak", + reportsAbuseSectionTitle: "Gehiegikeria Txostenak", + reportsContentSectionTitle: "Arazoak Edukiarekin", + reportsAllSectionTitle: "Txostenak", + reportsNoItems: "Txostenik ez.", + reportsValidationTitle: "Sartu izenburu zuzena, mesedez.", + reportsValidationDescription: "Deskribapena ezin da hutsi egon.", + reportsValidationCategory: "Aukeratu kategoria, mesedez.", + reportsCreatedAt: "Noiz", + reportsCreatedBy: "Nork", + reportsSeverity: "Larritasuna", + reportsSeverityLow: "Baxua", + reportsSeverityMedium: "Ertaina", + reportsSeverityHigh: "Altua", + reportsSeverityCritical: "Kritikoa", + reportsStatus: "Egoera", + reportsStatusOpen: "Irekita", + reportsStatusUnderReview: "Berrikusten", + reportsStatusResolved: "Zuzenduta", + reportsStatusInvalid: "Balio gabekoa", + reportsUpdateStatusButton: "Eguneratu Egoera", + reportsAnonymityOption: "Bidali Anonimoki", + reportsAnonymousAuthor: "Anonimoa", + reportsConfirmButton: "BERRETSI TXOSTENA!", + reportsConfirmations: "Berrespenak", + reportsConfirmedSectionTitle: "Berretsitako Txostenak", + reportsCreateTaskButton: "SORTU ATAZA", + reportsOpenSectionTitle: "Ireki Txostenak", + reportsUnderReviewSectionTitle: "Txostenak Berrikuspenean", + reportsResolvedSectionTitle: "Zuzendutako Txostenak", + reportsInvalidSectionTitle: "Txosten Okerrak", + reportsTemplateSectionTitle: 'Txosten-eredua', + reportsBugTemplateTitle: 'Erreprodukzio xehetasunak (Bug-ak)', + reportsFeatureTemplateTitle: 'Xehetasunak (Ezaugarriak)', + reportsAbuseTemplateTitle: 'Xehetasunak (Abusua)', + reportsContentTemplateTitle: 'Xehetasunak (Arazoak Edukiarekin)', + reportsStepsToReproduceLabel: 'Erreproduzitzeko urratsak', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'Espero den emaitza', + reportsExpectedBehaviorPlaceholder: 'Zer gertatu beharko litzateke?', + reportsActualBehaviorLabel: 'Benetako emaitza', + reportsActualBehaviorPlaceholder: 'Zer gertatzen da benetan?', + reportsEnvironmentLabel: 'Ingurunea', + reportsEnvironmentPlaceholder: 'Bertsioa, gailua, OS, nabigatzailea, ezarpenak, log-ak, etab.', + reportsReproduceRateLabel: 'Maiztasuna', + reportsReproduceRateAlways: 'Beti', + reportsReproduceRateOften: 'Askotan', + reportsReproduceRateSometimes: 'Batzuetan', + reportsReproduceRateRarely: 'Gutxitan', + reportsReproduceRateUnable: 'Ezin da erreproduzitu', + reportsReproduceRateUnknown: 'Zehaztu gabe', + reportsProblemStatementLabel: 'Arazoa / beharra', + reportsProblemStatementPlaceholder: 'Zein arazo konpontzen du ezaugarri honek?', + reportsUserStoryLabel: 'User story', + reportsUserStoryPlaceholder: ' naizenez, nahi dut, lortzeko.', + reportsAcceptanceCriteriaLabel: 'Onarpen-irizpideak', + reportsAcceptanceCriteriaPlaceholder: '- Emanda...\n- Noiz...\n- Orduan...', + reportsWhatHappenedLabel: 'Zer gertatu da?', + reportsWhatHappenedPlaceholder: 'Deskribatu gertakaria testuinguruarekin eta gutxi gorabeherako datekin.', + reportsReportedUserLabel: 'Salatutako erabiltzailea / entitatea', + reportsReportedUserPlaceholder: '@erabiltzailea, feed, ID, esteka, etab.', + reportsEvidenceLinksLabel: 'Frogak / estekak', + reportsEvidenceLinksPlaceholder: 'Itsatsi estekak, mezu ID-ak, pantaila-argazkiak (bada), etab.', + reportsContentLocationLabel: 'Non dago edukia', + reportsContentLocationPlaceholder: 'Esteka, ID, kanala, haria, egilea, etab.', + reportsWhyInappropriatePlaceholder: 'Azaldu arrazoia eta eragina.', + reportsRequestedActionLabel: 'Eskatutako ekintza', + reportsRequestedActionPlaceholder: 'Kendu, ezkutatu, etiketatu, ohartarazi, etab.', + tribesTitle: "Tribuak", + tribeAllSectionTitle: "Tribuak", + tribeMineSectionTitle: "Zeure Tribuak", + tribeCreateSectionTitle: "Sortu Tribua", + tribeUpdateSectionTitle: "Eguneratu Tribua", + tribeGallerySectionTitle: "Tribuen Galeria", + tribeRecentSectionTitle: "Tribu Berriak", + tribeTopSectionTitle: "Tribu Gorenak", + tribeviewTribeButton: "Tribua Bisitatu", + tribeviewSubTribeButton: "Azpi-Tribua Bisitatu", + tribeRootLabel: "ERROA", + tribeDescription: "Aurkitu edo sortu tribuak zure sarean.", + tribeFilterAll: "GUZTIAK", + tribeFilterMine: "NEUREAK", + tribeFilterMembership: "BAZKIDETZA", + tribeFilterRecent: "BERRIAK", + tribeFilterTop: "TOP", + tribeFilterSubtribes: "AZPI-TRIBUAK", + tribeFilterGallery: "GALERIA", + tribeMainTribeLabel: "TRIBU NAGUSIA", + tribeCreateButton: "Sortu Tribua", + tribeUpdateButton: "Egutneratu", + tribeDeleteButton: "Ezabatu", + tribeImageLabel: "Multimedia igo (max: 50MB)", + tribeTitleLabel: "Izenburua", + searchTribesPlaceholder: "IRAGAZI tribuak IZENAREN ARABERA …", + tribeTitlePlaceholder: "Tribuaren izena", + tribeDescriptionLabel: "Deskribapena", + tribeDescriptionPlaceholder: "Deskribatu tribua", + tribeLocationLabel: "Kokapena", + tribeLocationPlaceholder: "Non kokatzen da tribua?", + tribeTagsLabel: "Etiketak", + tribeTagsPlaceholder: "Sartu etiketak, erabili komak bereizteko.", + tribeInviteMode: "Gonbidapen modua", + tribeModeLabel: "Gonbidapen MODUA", + tribeIsAnonymousLabel: "Publikoa?", + tribeMembersCount: "Partaideak", + tribeInviteCodePlaceholder: "Sartu gonbidapen kodea", + tribeJoinByCodeButton: "Sartu kodearekin", + tribeJoinButton: "SARTU", + tribeEnterInvite: "SARTU GONBIDAPENA", + tribeLeaveButton: "ATERA", + tribeYes: "BAI", + tribeNo: "EZ", + tribePublic: "PULIKOA", + tribePrivate: "PRIBATUA", + tribeGenerateInvite: "GONBIDAPEN BAKARRA", + tribeOpenInvitation: "GONBIDAPEN IREKIA", + tribeJoinOpen: "TRIBURA BATU", + tribeRemoveInvitation: "KENDU GONBIDAPENA", + tribeCreatedAt: "Noiz", + tribeAuthor: "Nork", + tribeAuthorLabel: "EGILEA", + tribeStrict: "Zorrotza", + tribeOpen: "Irekita", + tribeFeedFilterRECENT: "BERRIAK", + tribeFeedFilterMINE: "NEUREAK", + tribeFeedFilterALL: "GUZTIAK", + tribeFeedFilterTOP: "GORENAK", + tribeFeedRefeeds: "BERJARIOAK", + tribeFeedRefeed: "Berjariotu", + tribeFeedMessagePlaceholder: "Idatzi jarioa…", + tribeFeedSend: "Bidali", + tribeFeedEmpty: "Jariorik ez, oraindik", + noTribes: "Triburik ez, oraindik.", + tribeNotFound: "Tribua ez da aurkitu!", + createTribeTitle: "Sortu Tribua", + updateTribeTitle: "Eguneratu Tribua", + tribeSectionOverview: "Ikuspegi orokorra", + tribeSectionInhabitants: "Biztanleak", + tribeSectionVotations: "BOZKATZEAK", + tribeSectionEvents: "EKITALDIAK", + tribeSectionReports: "Txostenak", + tribeSectionTasks: "ATAZAK", + tribeSectionFeed: "JARIOA", + tribeSectionForum: "FOROA", + tribeSectionMarket: "Merkatua", + tribeSectionJobs: "Lanak", + tribeSectionProjects: "Proiektuak", + tribeSectionMedia: "Media", + tribeSectionImages: "IRUDIAK", + tribeSectionAudios: "AUDIOAK", + tribeSectionVideos: "BIDEOAK", + tribeSectionDocuments: "DOKUMENTUAK", + tribeSectionBookmarks: "LASTER-MARKAK", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "Tribu honetan biztanlerik ez, oraindik.", + tribeEventCreate: "Sortu Ekitaldia", + tribeEventsEmpty: "Ekitaldirik ez, oraindik.", + tribeEventTitle: "Izenburua", + tribeEventDescription: "Deskribapena", + tribeEventDate: "Data", + tribeEventLocation: "Kokalekua", + tribeEventAttend: "JOAN", + tribeEventUnattend: "UTZI", + tribeEventAttendees: "Parte-hartzaileak", + tribeTaskCreate: "Sortu Ataza", + tribeTasksEmpty: "Atazorik ez, oraindik.", + tribeTaskTitle: "Izenburua", + tribeTaskDescription: "Deskribapena", + tribeTaskPriority: "Lehentasuna", + tribeTaskDeadline: "Epemuga", + tribeTaskAssignees: "Esleituak", + tribeTaskStatusInProgress: "ABIAN", + tribeTaskStatusClosed: "ITXI", + tribeTaskAssign: "ESLEITU", + tribeTaskUnassign: "KENDU", + tribeReportCreate: "Sortu Txostena", + tribeReportsEmpty: "Txostenik ez, oraindik.", + tribeReportTitle: "Izenburua", + tribeReportDescription: "Deskribapena", + tribeReportCategory: "Kategoria", + tribeVotationCreate: "Sortu Bozketa", + tribeVotationsEmpty: "Bozketarik ez, oraindik.", + tribeVotationTitle: "Izenburua", + tribeVotationDescription: "Deskribapena", + tribeVotationOptions: "Aukerak", + tribeVotationDeadline: "Epemuga", + tribeVotationVote: "BOZKATU", + tribeVotationResults: "Botoak", + tribeVotationClose: "ITXI BOZKETA", + tribeVotationOptionPlaceholder: "Aukera", + tribeForumCreate: "Forum Sortu", + tribeForumEmpty: "Harik ez, oraindik.", + tribeForumTitle: "Izenburua", + tribeForumText: "Mezua", + tribeForumCategory: "Kategoria", + tribeForumReply: "Erantzun", + tribeForumReplies: "Erantzunak", + tribeMarketCreate: "Sortu Iragarkia", + tribeMarketEmpty: "Iragarkirik ez, oraindik.", + tribeMarketTitle: "Izenburua", + tribeMarketDescription: "Deskribapena", + tribeMarketPrice: "Prezioa", + tribeMarketImage: "Irudia", + tribeMarketCategory: "Kategoria", + tribeJobCreate: "Sortu Lana", + tribeJobsEmpty: "Lanik ez, oraindik.", + tribeJobTitle: "Izenburua", + tribeJobDescription: "Deskribapena", + tribeJobLocation: "Kokalekua", + tribeJobSalary: "Soldata", + tribeJobDeadline: "Epemuga", + tribeProjectCreate: "Sortu Proiektua", + tribeProjectsEmpty: "Proiekturik ez, oraindik.", + tribeProjectTitle: "Izenburua", + tribeProjectDescription: "Deskribapena", + tribeProjectGoal: "Helburua", + tribeProjectFunded: "Finantzatua", + tribeProjectDeadline: "Epemuga", + tribeMediaUpload: "Igo Media", + readDocument: "Dokumentua Irakurri", + tribeCreateImage: "Irudia Sortu", + tribeCreateAudio: "Audioa Sortu", + tribeCreateVideo: "Bideoa Sortu", + tribeCreateDocument: "Dokumentua Sortu", + tribeCreateBookmark: "Laster-marka Sortu", + tribeMediaEmpty: "Mediarik ez, oraindik.", + tribeMediaTitle: "Izenburua", + tribeMediaDescription: "Deskribapena", + tribeMediaType: "Mota", + tribeMediaTypeImage: "Irudia", + tribeMediaTypeVideo: "Bideoa", + tribeMediaTypeAudio: "Audioa", + tribeMediaTypeDocument: "Dokumentua", + tribeMediaTypeBookmark: "Laster-marka", + tribeContentDelete: "EZABATU", + tribeInviteCodeText: "Gonbidapen kodea: ", + oasisVersionLabel: "Oasis Bertsioa", + tribeInviteCodeHint: "Partekatu kode hau gonbidatu nahi duzun pertsonarekin. /invites → Tribes bidez sar daiteke.", + tribeGroupTribe: "Tribua", + tribeGroupOffice: "Bulegoa", + tribeGroupNetwork: "Sarea", + tribeGroupEconomy: "Ekonomia", + tribeGroupMedia: "Media", + tribeStatusOpen: "IREKITA", + tribeStatusClosed: "ITXITA", + tribeStatusInProgress: "ABIAN", + tribePriorityLow: "BAXUA", + tribePriorityMedium: "ERTAINA", + tribePriorityHigh: "ALTUA", + tribePriorityCritical: "KRITIKOA", + tribeTaskFilterAll: "GUZTIAK", + tribeMediaFilterAll: "GUZTIAK", + tribeReportCatBug: "AKATSA", + tribeReportCatAbuse: "ABUSUA", + tribeReportCatContent: "EDUKIA", + tribeReportCatOther: "BESTEA", + tribeForumCatGeneral: "OROKORRA", + tribeForumCatProposal: "PROPOSAMENA", + tribeForumCatQuestion: "GALDERA", + tribeForumCatAnnouncement: "IRAGARPENA", + tribeMarketCatGoods: "ONDASUNAK", + tribeMarketCatServices: "ZERBITZUAK", + tribeMarketCatFood: "ELIKADURA", + tribeMarketCatOther: "BESTEA", + tribeStatusLabel: "Egoera", + tribeSubTribes: "AZPI-TRIBUAK", + tribeSubTribesCreate: "Azpi-Tribua Sortu", + tribeSubTribesStrictDenied: "Tribuaren modu zorrotzak ez dizu azpi-tribu berriak sortzen uzten. Jarri harremanetan administratzailearekin.", + tribeSubTribesEmpty: "Ez da azpi-triburik sortu, oraindik.", + tribeActivityJoined: "BATUTA", + tribeActivityLeft: "IRTEN", + tribeActivityFeed: "FEED", + tribeActivityRefeed: "REFEED", + tribeGroupAnalytics: "Analitikak", + tribeGroupCreative: "Sormenez", + tribeSectionActivity: "JARDUERA", + tribeSectionTrending: "JOERAK", + tribeSectionOpinions: "IRITZIAK", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "ETIKETAK", + tribeSectionSearch: "BILATU", + tribeActivityEmpty: "Ez dago jarduerarik oraindik.", + tribeActivityCreated: "sortu du", + tribeActivityPosted: "argitaratu du", + tribeActivityReplied: "erantzun du", + tribeTrendingEmpty: "Ez dago joera-edukirik oraindik.", + tribeTrendingPeriodDay: "Gaur", + tribeTrendingPeriodWeek: "Aste Honetan", + tribeTrendingPeriodAll: "Dena", + tribeTrendingEngagement: "parte-hartzea", + tribeOpinionsEmpty: "Ez dago iritzirik oraindik.", + tribeOpinionsCast: "Bozkatu", + tribeOpinionsRankings: "Sailkapenak", + tribeOpinionsAlreadyVoted: "Dagoeneko bozkatu duzu", + tribeTopCategory: "Gehien bozkatua", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Pixel art oihal kolaboratiboa.", + tribePixeliaPaint: "Pintatu", + tribePixeliaContributors: "laguntzaileak", + tribePixeliaTotalPixels: "pixel margotuta", + tribeTagsEmpty: "Ez da etiketarik aurkitu.", + tribeTagsCloud: "Etiketa Hodeia", + tribeTagsContentWith: "Etiketadun edukia", + tribeSearchPlaceholder: "Bilatu tribuaren edukian...", + tribeSearchEmpty: "Emaitzarik ez.", + tribeSearchResults: "Emaitzak", + tribeSearchMinChars: "Idatzi gutxienez 2 karaktere bilatzeko.", + agendaTitle: "Agenda", + agendaDescription: "Eskeituta dauzkazun elementu guztiak aurki ditzakezu hemen.", + agendaFilterAll: "GUZTIAK", + agendaFilterToday: "GAUR", + agendaFilterUpcoming: "DATOZENAK", + agendaFilterOverdue: "ATZERATUTA", + agendaFilterOpen: "IREKITA", + agendaFilterClosed: "ITXITA", + agendaFilterTasks: "ATAZAK", + agendaFilterMarket: "MERKATUA", + agendaFilterTribes: "TRIBUAK", + agendaFilterEvents: "EKITALDIAK", + agendaFilterReports: "TXOSTENAK", + agendaFilterTransfers: "TRANSFERENTZIAK", + agendaFilterJobs: "LANPOSTUAK", + agendaFilterProjects: "PROIEKTUAK", + agendaFilterCalendars: "EGUTEGIAK", + agendaInviteModeLabel: "Egoera", + agendaNoItems: "Esleipenik ez.", + agendaDiscardButton: "Baztertu", + agendaRestoreButton: "Berrezarri", + agendaAuthor: "Nork", + agendaCreatedAt: "Noiz", + agendaTitleLabel: "Izenburua", + agendaMembersCount: "Partaideak", + agendaDescriptionLabel: "Deskribapena", + agendaStatus: "Egoera", + agendaVisibility: "Ikusgaitasuna", + agendaEventDate: "Ekitaldiaren Data", + agendaEventLocation: "Kokapena", + agendaEventPrice: "Prezioa", + agendaEventUrl: "URL-a", + agendaTaskStart: "Hasiera Data", + agendaLocationLabel: "Kokapena", + agendaYes: "BAI", + agendaNo: "EZ", + agendaAnonymousLabel: "Modu Anonimoa", + agendaMembersLabel: "Partaideak", + agendareportCategory: "Kategoria", + agendareportSeverity: "Larritasuna", + agendareportStatus: "Egoera", + agendareportDescription: "Deskribapena", + agendaTaskEnd: "Amaiera Data", + agendaTaskPriority: "Lehentasuna", + agendaTransferFrom: "Nork", + agendaTransferTo: "Nori", + agendaTransferConcept: "Kontzeptua", + agendaTransferAmount: "Kopurua", + agendaTransferDeadline: "Epemuga", + opinionsTitle: "Iritziak", + shareYourOpinions: "Deskubritu eta bozkatu zure sareko iritziak.", + author: "Egilea", + voteNow: "Bozkatu orain", + alreadyVoted: "Jada zure iritzia eman duzu.", + noOpinionsFound: "Ez da iritzirik aurkitu.", + RECENTButton: "AZKENAK", + TOPButton: "GORDEAK", + interestingButton: "INTERESGARRIA", + necessaryButton: "BEHARREZKOA", + funnyButton: "BARREGARRIA", + disgustingButton: "LOREZTUGARRIA", + sensibleButton: "SENTIKORRA", + propagandaButton: "PROPAGANDA", + adultOnlyButton: "ADULTO BAKARRIK", + boringButton: "NEKAGARRIA", + confusingButton: "ZAILA", + usefulButton: "ERABILI", + informativeButton: "INFORMATIBOA", + wellResearchedButton: "ONDO IKERTUA", + accurateButton: "EGOKITUA", + needsSourcesButton: "ITURRIK BEHAR DITU", + wrongButton: "OKERREKOA", + lowQualityButton: "BEHERA KALITATEA", + creativeButton: "KREABO", + insightfulButton: "ARRETAZKOA", + actionableButton: "ERABILI DAITEKE", + inspiringButton: "INSPIRATZAILEA", + loveButton: "MAITASUN", + clearButton: "ARGI", + upliftingButton: "EGOITZAILEA", + unnecessaryButton: "EZINBESTEKOA", + rejectedButton: "UKATUA", + misleadingButton: "ENGAINEZKOA", + offTopicButton: "GAITIK AT", + duplicateButton: "DUPLIKATU", + clickbaitButton: "KLIK-APURKA", + spamButton: "SPAM", + trollButton: "TROLL", + nsfwButton: "NSFW", + violentButton: "BORTITZA", + toxicButton: "TOXIKOA", + harassmentButton: "BAZTERKETA", + hateButton: "ARRETA", + scamButton: "ENGAINU", + triggeringButton: "HAUSKORRA", + opinionsCreatedAt: "Sortu zen", + opinionsTotalCount: "Iritzi guztiak", + voteInteresting: "Interesgarria", + voteNecessary: "Beharrezkoa", + voteUseful: "Erabilgarria", + voteInformative: "Informatiboa", + voteWellResearched: "Ondo ikertua", + voteNeedsSources: "Iturririk behar du", + voteWrong: "Okerrak", + voteLowQuality: "Behera kalitatea", + voteLove: "Maitasuna", + voteClear: "Argi", + voteMisleading: "Engainagarria", + voteOffTopic: "Gaiaz kanpo", + voteDuplicate: "Bikoiztu", + voteClickbait: "Klik-apurkoa", + votePropaganda: "Propaganda", + voteFunny: "Barregarria", + voteInspiring: "Inspiratzailea", + voteUplifting: "Igokuntzailea", + voteUnnecessary: "Ezinezkoa", + voteRejected: "Ukatu", + voteConfusing: "Konfusio", + voteTroll: "Troll", + voteNsfw: "NSFW", + voteViolent: "Bortitza", + voteToxic: "Toxikoa", + voteHarassment: "Jazarpena", + voteHate: "Arraza", + voteScam: "Estafa", + voteTriggering: "Hauskorra", + voteInsightful: "Arretazkoa", + voteAccurate: "Egokia", + voteActionable: "Erabili daiteke", + voteCreative: "Sortzailea", + voteSpam: "Spam", + voteAdultOnly: "Ados bakarrik", + publishBlog: "Bloga argitaratu", + privateMessage: "MP", + pmSendTitle: "Mezu Pribatuak", + pmSend: "Bidali!", + pmDescription: "Erabili formulario hau beste biztanleei mezu zifratua bidaltzeko.", + pmRecipients: "Hartzaileak", + pmRecipientsHint: "Idatzi Oasis IDak, komaz bereizita", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Gaia", + pmSubjectHint: "Idatzi mezuaren gaia", + pmText: "Mezua", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Deszifratu", + pmCrypterBadKey: "Partekatutako gakoa ez da zuzena!", + pmCrypterTooLong: "Mezua luzeegia da Crypter-arekin zifratzeko. Laburtu eta saiatu berriro.", + pmCrypterCipherLabel: "Berriz zifratutako mezua", + pmCrypterCharsLabel: "karaktere", + pmInvalidRecipients: "Oasis ID baliogabea (@…=.ed25519 itxurakoa izan behar du).", + pmEncryptionLabel: "Zifratzea", + pmFile: "Eranskina", + private: "Pribatuak", + privateDescription: "Zure mezu zifratuak.", + privateInbox: "SARRERA-ONTZIA", + privateSent: "BIDALITAKOAK", + privateDelete: "Ezabatu", + pmCreateButton: "MP idatzi", + noPrivateMessages: "Ez dago mezu pribaturik.", + pmReply: "Erantzun", + pmReplies: "erantzunak", + pmNew: "berriak", + pmMarkRead: "Irakurritako gisa markatu", + inReplyTo: "HONI ERANTZUNEZ", + pmPreview: "Aurrebista", + pmPreviewTitle: "Mezuaren aurrebista", + performed: "→", + pmFromLabel: "Nork:", + pmToLabel: "Nori:", + pmInvalidMessage: "Mezu baliogabea", + pmNoSubject: "(gaia gabe)", + pmSubjectLabel: "Gaia:", + pmBodyLabel: "Gorputza", + pmBotJobs: "42-JobsBOT", + pmBotProjects: "42-ProjectsBOT", + pmBotMarket: "42-MarketBOT", + inboxJobSubscribedTitle: "Lan-eskaintzara harpidetza berria", + pmInhabitantWithId: "OASIS ID duen biztanlea:", + pmHasSubscribedToYourJobOffer: "zure lan-eskaintzara harpidetu da", + inboxProjectCreatedTitle: "Proiektu berria sortu da", + pmHasCreatedAProject: "proiektu bat sortu du", + inboxMarketItemSoldTitle: "Artikulua salduta", + pmYourItem: "Zure artikulua", + pmHasBeenSoldTo: "honi saldu zaio", + pmFor: "truke", + inboxProjectPledgedTitle: "Ekarpen berria zure proiektuan", + pmHasPledged: "ekarpena egin du", + pmToYourProject: "zure proiektura", + blockchainTitle: 'BlockExplorer', + blockchainDescription: 'Blokeak aztertu eta ikusgaitu blockchain-ean.', + blockchainNoBlocks: 'Ez da blokeik aurkitu blockchain-ean.', + blockchainStatsTitle: 'Estatistikak', + blockchainStatsTypeBreakdown: 'Banaketa motaren arabera', + blockchainStatsTopAuthors: 'Egile nagusiak', + blockchainStatsCount: 'Blokeak', + blockchainBlockID: 'Bloke ID-a', + blockchainBlockAuthor: 'Egilea', + blockchainBlockCarbon: 'Karbono-aztarna', + blockchainBlockType: 'Mota', + blockchainBlockTimestamp: 'Tzeitza', + blockchainBlockContent: 'Bloke', + blockchainBlockURL: 'URL:', + blockchainContent: 'Bloke', + blockchainContentPreview: 'Bloke edukia aurrebista', + blockchainLatestDatagram: 'Azken Datagrama', + blockchainDatagram: 'Datagrama', + blockchainDetails: 'Ikusi blokearen xehetasunak', + blockchainViewBlockexplorer: "Ikusi blockexplorer-ean", + blockchainBlockInfo: 'Blokearen informazioa', + blockchainBlockDetails: 'Hautatutako blokearen xehetasunak', + blockchainBack: 'Itzuli blokearen azterkira', + blockchainContentDeleted: "Edukia ezabatu egin da", + banking: 'Banking', + bankingTitle: 'Banking', + bankingDescription: 'Aztertu ECOin-en egungo balioa eta dagokion RBU esleipena, astero banatzen dena parte-hartzearen eta konfiantzaren arabera.', + bankOverview: 'Laburpena', + bankEpochs: 'Epeak', + bankRules: 'Arauak', + pending: 'Zain', + closed: 'Itxita', + bankBack: 'Itzuli Banking-era', + bankViewTx: 'Tx ikusi', + bankClaimNow: 'Esleitu', + bankClaimUBI: 'RBU eskatu!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'Ez dago RBU esleipen zain garai honetan.', + bankEpoch: 'Epea', + bankPool: 'Funtsa (epe honetan)', + bankWeightsSum: 'Pisuen batura', + bankAllocations: 'Esleipenak', + bankNoAllocations: 'Ez da esleipenik aurkitu.', + bankNoEpochs: 'Ez da eperik aurkitu.', + bankEpochAllocations: 'Epearen esleipenak', + bankAllocId: 'Esleipen IDa', + bankAllocDate: 'Data', + bankAllocConcept: 'Kontzeptua', + bankAllocFrom: 'Nork', + bankAllocTo: 'Nori', + bankAllocAmount: 'Zenbatekoa', + bankAllocStatus: 'Egoera', + bankEpochId: 'Epearen IDa', + bankRuleHash: 'Arauen snapshot hash-a', + bankViewEpoch: 'Epea ikusi', + bankUserBalance: 'Zure saldoa', + ecoWalletNotConfigured: 'ECOin poltsa ez dago konfiguratuta', + editWallet: 'Poltsa editatu', + addWallet: 'Poltsa gehitu', + bankAddresses: 'Helbideak', + bankNoAddresses: 'Ez da helbiderik aurkitu.', + bankUser: 'Oasis ID', + bankAddress: 'Helbidea', + bankAddAddressTitle: 'ECOIN helbidea gehitu', + bankAddAddressUser: 'Oasis ID', + bankAddAddressAddress: 'ECOIN helbidea', + bankAddAddressSave: 'Gorde', + bankAddressAdded: 'Helbidea gehituta', + bankAddressUpdated: 'Helbidea eguneratuta', + bankAddressExists: 'Helbidea lehendik badago', + bankAddressInvalid: 'Helbide baliogabea', + bankAddressDeleted: 'Helbidea ezabatuta', + bankAddressNotFound: 'Helbiderik ez da aurkitu', + bankAddressForbidden: 'Zure ordainketa-helbidea soilik konfigura dezakezu', + bankAddressTotal: 'Guztira', + bankAddressSearch: 'Erabiltzailea edo helbidea bilatu', + bankAddressActions: 'Ekintzak', + bankAddressDelete: 'Ezabatu', + bankAddressSource: 'Iturria', + bankAddressDeleteConfirm: 'Helbide hau ezabatu?', + search: 'Bilatu!', + bankLocal: 'Lokala', + bankFromOasis: 'Oasis', + bankMyAddress: 'Zure helbidea', + bankRemoveMyAddress: 'Nire helbidea kendu', + bankNotRemovableOasis: 'Oasis helbideak ezin dira lokalki kendu', + bankingUserEngagementScore: "KARMA puntuazioa", + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "FUNTSIK EZ!", + bankUbiAvailableOk: "ESKURAGARRI!", + bankUbiAvailability: "UBI eskuragarritasuna", + bankAlreadyClaimedThisMonth: "Hilabete honetan jada aldarrikatu da", + bankUbiThisMonth: "UBI (hilabete honetan)", + bankUbiLastClaimed: "UBI (azken aldarrikapena)", + bankUbiNeverClaimed: "Inoiz aldarrikatu ez", + bankUbiTotalClaimed: "UBI (guztira aldarrikatua)", + bankUbiPub: "PUB", + bankUbiInhabitant: "BIZTANLEA", + bankUbiClaimedAmount: "ALDARRIKATUA (ECO)", + typeBankUbiResult: "BANKUA - UBI", + bankNoPubConfigured: "PUBik ez dago konfiguratuta. Ezarri zure PUB IDa Ezarpenetan.", + shopsTitle: "Dendak", + shopDescription: "Sareko dendak aurkitu eta kudeatu.", + shopTitle: "Denda", + shopFilterAll: "GUZTIAK", + shopFilterMine: "NIREAK", + shopFilterRecent: "BERRIAK", + shopFilterTop: "TOP", + shopFilterProducts: "PRODUKTUAK", + shopFilterPrices: "PREZIOAK", + shopFilterFavorites: "GOGOKOAK", + shopUpload: "Denda sortu", + shopAllSectionTitle: "Denda guztiak", + shopMineSectionTitle: "Nire Dendak", + shopRecentSectionTitle: "Denda Berriak", + shopTopSectionTitle: "Top Dendak", + shopProductsSectionTitle: "Top Produktuak", + shopPricesSectionTitle: "Prezio Arabera Produktuak", + shopFavoritesSectionTitle: "Gogokoak", + shopCreateSectionTitle: "Denda sortu", + shopUpdateSectionTitle: "Denda eguneratu", + shopCreate: "Sortu", + shopUpdate: "Eguneratu", + shopDelete: "Ezabatu", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Clearnet-en argitaratu", + shopClearnetUnpublish: "Clearnet-etik kendu", + shopClearnetUrlLabel: "Clearnet URLa", + shopClearnetWarning: "Clearnet-en argitaratzeak denda hau kanpoko bilatzaileek eta crawlerrek indexagarri egiten du.", + shopAddFavorite: "Gogoko gehitu", + shopRemoveFavorite: "Gogoko kendu", + shopOpen: "IREKIA", + shopClosed: "ITXIA", + shopOpenShop: "Denda ireki", + shopCloseShop: "Denda itxi", + shopMakePrivate: "PRIBATU BIHURTU (E2E)", + shopMakePublic: "PUBLIKO BIHURTU", + shopProducts: "Produktuak", + shopProductAdd: "Produktua gehitu", + shopProductTitle: "Produktua", + shopProductUpdate: "Produktua eguneratu", + shopProductPrice: "Prezioa (ECO)", + shopProductStock: "Stock", + shopProductUntitled: "Izengabeko produktua", + shopUntitled: "Izengabeko denda", + shopNoItems: "Ez da dendarik aurkitu.", + shopNoProducts: "Oraindik produkturik ez.", + shopOutOfStock: "Agortuta", + shopBuy: "Erosi", + shopBuyEncryptedNote: "Zure entrega datuak zifratuta bidaltzen dira, dendaren jabeak soilik ikus ditzake.", + shopBuyDeliveryAddress: "Entrega helbidea", + shopBuyContact: "Kontaktua", + shopBuyContactPlaceholder: "e-posta, telefonoa, etab.", + shopBuyNotes: "Oharrak", + shopBuyDeliveryRequired: "Entrega helbidea derrigorrezkoa da.", + shopOrdersTitle: "Eskaerak", + shopOrdersDescription: "Denda honek jasotako eskaerak.", + shopOrdersEmpty: "Oraindik ez dago eskaerarik.", + shopOrderProduct: "Produktua", + shopOrderPrice: "Prezioa", + shopOrderBuyer: "Erosle", + shopBackToShop: "Dendara itzuli", + shopShareUrl: "Partekatzeko URLa", + shopVisitShop: "DENDA BISITATU", + marketVisitItem: "ITEMA BISITATU", + shopStatus: "EGOERA", + shopCreatedAt: "SORTUA", + shopSearchPlaceholder: "Dendak bilatu...", + shopUrl: "URL", + shopLocation: "Kokapena", + shopTags: "Etiketak", + shopVisibility: "Ikusgarritasuna", + shopImage: "Irudia", + shopShortDescription: "Deskribapen Laburra", + shopShortDescriptionPlaceholder: "Deskribapen laburra txarteletarako (gehienez 160 karaktere)", + shopTitlePlaceholder: "Zure dendaren izena", + shopDescriptionPlaceholder: "Zure dendaren deskribapen zehatza", + shopUrlPlaceholder: "https://zure-denda-url.com", + shopLocationPlaceholder: "Hiria, Herrialdea", + shopTagsPlaceholder: "tag1, tag2, tag3", + mapTitlePlaceholder: "Maparen titulua", + shopProductFeatured: "Produktu Nabarmendua", + shopSendToMarket: "Send to Market", + typeShop: "DENDAK", + typeShopProduct: "DENDA PRODUKTUA", + bankExchange: 'Trukea', + bankExchangeCurrentValue: 'ECOin Balioa (1h)', + bankTotalSupply: 'ECOin Hornidura Guztizkoa', + bankHoursOfWork: 'lan orduak', + bankUnitMs: "ms", + bankUnitSeconds: "segundo", + bankUnitMinutes: "minutu", + bankUnitDays: "egun", + bankExchangeNoData: 'Ez dago daturik eskuragarri', + bankExchangeIndex: 'ECOin Balioa (1h)', + bankInflation: 'ECOin Inflazioa (anual)', + bankInflationMonthly: 'ECOin Inflazioa (mensual)', + bankExchangeChartTitle: 'ECOin balioaren bilakaera denboran zehar', + bankExchangeChartValue: 'Balioa (ECO/h)', + bankExchangeChartSupply: 'Eskaintza', + bankExchangeChartInflation: 'Inflazioa %', + bankExchangeChartEmpty: 'Ez dago lagin nahikorik oraindik — itzuli geroago', + bankCurrentSupply: 'ECOin Oraingo Hornidura', + bankingSyncStatus: 'ECOin Egoera', + bankingSyncStatusSynced: 'Sinkronizatuta', + bankingSyncStatusOutdated: 'Desegonetik', + statsTitle: 'Estatistikak', + statistics: "Estatistikak", + statsInhabitant: "Bizilagunaren estatistikak", + statsDescription: "Ezagutu zure sareari buruzko estatistikak.", + ALLButton: "GUZTIAK", + MINEButton: "NIREAK", + TOMBSTONEButton: "EZABAKETAK", + statsYou: "Zu", + statsUserId: "Oasis ID", + statsCreatedAt: "Sortze data", + statsYourContent: "Edukia", + statsYourOpinions: "Iritziak", + statsYourTombstone: "Ezabaketak", + statsNetwork: "Sarea", + statsTotalInhabitants: "Bizilagunak", + statsDiscoveredTribes: "Tribuak (Publikoak)", + statsPrivateDiscoveredTribes: "Tribuak (Pribatuak)", + statsNetworkContent: "Edukia", + statsYourMarket: "Merkatua", + statsYourJob: "Lanak", + statsYourProject: "Proiektuak", + statsYourTransfer: "Transferentziak", + statsYourForum: "Foroak", + statsNetworkOpinions: "Iritziak", + statsDiscoveredMarket: "Merkatua", + statsDiscoveredJob: "Lanak", + statsDiscoveredProject: "Proiektuak", + statsBankingTitle: "Bankua", + statsEcoWalletLabel: "ECOIN zorroa", + statsEcoWalletNotConfigured: "Konfiguratu gabe!", + statsTotalEcoAddresses: "Helbide kopurua", + statsDiscoveredTransfer: "Transferentziak", + statsDiscoveredForum: "Foroak", + statsNetworkTombstone: "Ezabaketak", + statsBookmark: "Laster-markak", + statsEvent: "Ekitaldiak", + statsTask: "Zereginak", + statsVotes: "Botoak", + statsMarket: "Merkatua", + statsForum: "Foroak", + statsJob: "Lanak", + statsReport: "Txostenak", + statsFeed: "Jarioak", + statsTribe: "Tribuak", + statsImage: "Irudiak", + statsAudio: "Audioak", + statsVideo: "Bideoak", + statsDocument: "Dokumentuak", + statsMap: "Mapak", + statsShop: "Dendak", + statsShopProduct: "Denda produktuak", + statsTransfer: "Transferentziak", + statsAiExchange: "IA", + statsPUBs: 'PUBack', + statsPost: "Mezuak", + statsOasisID: "Oasis ID", + statsSize: "Guztira (tamaina)", + statsBlockchainSize: "Blockchain (tamaina)", + statsBlobsSize: "Blobak (tamaina)", + statsActivity7d: "Jarduera (azken 7 egun)", + statsActivity7dTotal: "7 eguneko guztira", + statsActivity30dTotal: "30 eguneko guztira", + statsKarmaScore: "KARMA puntuazioa", + statsPublic: "Publikoa", + statsPrivate: "Pribatua", + day: "Eguna", + messages: "Mezuak", + statsProject: "Proiektuak", + statsProjectsTitle: "Proiektuak", + statsProjectsTotal: "Proiektu kopurua", + statsProjectsActive: "Aktibo", + statsProjectsCompleted: "Amaituak", + statsProjectsPaused: "Pausatuta", + statsProjectsCancelled: "Bertan behera", + statsProjectsGoalTotal: "Helburu osoa", + statsProjectsPledgedTotal: "Konprometitutako guztira", + statsProjectsSuccessRate: "Arrakasta tasa", + statsProjectsAvgProgress: "Batez besteko aurrerapena", + statsProjectsMedianProgress: "Aurrerapen mediana", + statsProjectsActiveFundingAvg: "Finantzaketa aktiboaren batez bestekoa", + statsJobsTitle: "Lanak", + statsJobsTotal: "Lan kopurua", + statsJobsOpen: "Irekita", + statsJobsClosed: "Itxita", + statsJobsOpenVacants: "Plaza irekiak", + statsJobsSubscribersTotal: "Harpidedun kopurua", + statsJobsAvgSalary: "Soldata ertaina", + statsJobsMedianSalary: "Soldata mediana", + statsMarketTitle: "Merkatua", + statsMarketTotal: "Gai kopurua", + statsMarketForSale: "Salgai", + statsMarketReserved: "Erreserbatuta", + statsMarketClosed: "Itxita", + statsMarketSold: "Salduta", + statsMarketRevenue: "Diru-sarrerak", + statsMarketAvgSoldPrice: "Salmenta prezio ertaina", + statsUsersTitle: "Bizilagunak", + user: "Bizilaguna", + statsTombstoneTitle: "Ezabaketak", + statsNetworkTombstones: "Sareko ezabaketak", + statsTombstoneRatio: "Ezabaketa ratioa (%)", + statsParliamentCandidature: "Parlamenturako hautagaitzak", + statsParliamentTerm: "Parlamentuko agintaldiak", + statsParliamentProposal: "Parlamentuko proposamenak", + statsParliamentRevocation: "Parlamentuko ezeztapenak", + statsParliamentLaw: "Parlamentuko legeak", + statsCourtsCase: "Epaitegietako kasuak", + statsCourtsEvidence: "Frogak", + statsCourtsAnswer: "Erantzunak", + statsCourtsVerdict: "Epaia", + statsCourtsSettlement: "Akordioak", + statsCourtsSettlementProposal: "Akordio-proposamenak", + statsCourtsSettlementAccepted: "Onartutako akordioak", + statsCourtsNomination: "Epaileen izendapenak", + statsCourtsNominationVote: "Izendapenen botoak", + ai: "IA", + aiTitle: "IA", + aiDescription: "Zure saretik ikasten duen '42' izeneko Adimen Artifizial Kolektibo (AIA) bat.", + aiUserQuestion: "Galdera", + aiResponseTitle: "Erantzuna", + aiSubmitButton: "Bidali!", + aiSettingsDescription: "Konfiguratu zure IA ereduaren prompt-a (gehienez 128 karaktere).", + aiPrompt: "Eman erantzun informatibo eta zehatza.", + aiConfiguration: "Konfiguratu prompt-a", + aiPromptUsed: "Prompt-a", + aiClearHistory: "Txataren historia garbitu", + aiSharePrompt: "Erantzun hau entrenamendu kolektibora gehitu?", + aiShareYes: "Bai", + aiShareNo: "Ez", + aiSharedLabel: "Entrenamendura gehituta", + aiRejectedLabel: "Ez da gehitu entrenamendura", + aiServerError: "Ezin izan da IAren erantzuna lortu. Saiatu berriro.", + aiInputPlaceholder: "What is Oasis?", + typeAiExchange: "IA", + aiApproveTrain: "Prestakuntza kolektibora gehitu", + aiRejectTrain: "Ez entrenatu", + aiTrainPending: "Onartzearen zain", + aiTrainApproved: "Entrenamendurako onartua", + aiTrainRejected: "Entrenamendurako baztertua", + aiSnippetsUsed: "Erabilitako zatiak", + aiSnippetsLearned: "Ikasitako fragementuak", + statsAITraining: "IA prestakuntza", + aiApproveCustomTrain: "Erantzun pertsonalizatu hau erabiliz trebatu", + aiCustomAnswerPlaceholder: "Idatzi zure erantzun pertsonalizatua…", + statsAIExchanges: "Ereduen trukea", + marketMineSectionTitle: "Zure artikuluak", + marketCreateSectionTitle: "Artikulua sortu", + marketUpdateSectionTitle: "Eguneratu", + marketAllSectionTitle: "Merkatua", + marketRecentSectionTitle: "Azken merkatua", + marketTitle: "Merkatua", + marketDescription: "Zure sarean ondasunak edo zerbitzuak trukatzeko merkatua.", + marketFilterAll: "GUZTIAK", + marketFilterMine: "NIREAK", + marketFilterAuctions: "ENKANTEAK", + marketFilterItems: "TRUKEA", + marketFilterNew: "BERRIA", + marketFilterUsed: "ERABILIA", + marketFilterBroken: "HAUTSIA", + marketFilterForSale: "SALGAI", + marketFilterSold: "SALDUTA", + marketFilterDiscarded: "BAZTERTUTA", + marketFilterRecent: "AZKENA", + marketFilterMyBids: "PUJAK", + marketCreateButton: "Artikulua sortu", + marketItemType: "Mota", + marketItemTitle: "Izenburua", + marketItemAvailable: "Epemuga", + marketItemDescription: "Deskribapena", + marketItemDescriptionPlaceholder: "Deskribatu saltzen ari zaren artikulua", + marketItemStatus: "Egoera", + marketShopLabel: "Denda", + marketItemCondition: "Egoera (kalitatea)", + marketItemPrice: "Prezioa", + marketItemTags: "Etiketak", + marketItemTagsPlaceholder: "Sartu etiketak komaz bereizita", + marketItemDeadline: "Epemuga", + marketItemIncludesShipping: "Bidalketa barne?", + marketItemHighestBid: "Puja altuena", + marketItemHighestBidder: "Pujatzaile onena", + marketItemStock: "Stocka", + marketOutOfStock: "Stockik gabe", + marketItemBidTime: "Pujaren data", + marketActionsUpdate: "Eguneratu", + marketUpdateButton: "Eguneratu", + marketActionsDelete: "Ezabatu", + marketActionsSold: "Salduta gisa markatu", + marketActionsChangeStatus: "EGOERA ALDATU", + marketActionsBuy: "EROSI!", + marketAuctionBids: "Oraingo pujаk", + marketPlaceBidButton: "Puja egin", + marketItemSeller: "Saltzailea", + marketNoItems: "Oraindik ez dago artikulurik eskuragarri.", + marketYourBid: "Zure puja", + marketCreateFormImageLabel: "Multimedia igo (max: 50MB)", + marketSearchLabel: "Bilatu", + marketSearchPlaceholder: "Izenburuan edo etiketetan bilatu", + marketMinPriceLabel: "Gutxieneko prezioa", + marketMaxPriceLabel: "Gehieneko prezioa", + marketSortLabel: "Ordenatu", + marketSortRecent: "Berrienak", + marketSortPrice: "Prezioa", + marketSortDeadline: "Epemuga", + marketSearchButton: "Bilatu", + marketAuctionEndsIn: "Amaitzen da", + marketAuctionEnded: "Amaituta", + marketMyBidBadge: "Puja egin duzu", + marketNoItemsMatch: "Ez dago zure bilaketarekin bat datorren artikulurik.", + jobsTitle: "Lanak", + jobsCandidatesTitle: "Hautagai iradokitakoak", + jobsCandidatesDescription: "Lan honetarako trebetasunak dituzten biztanleak. Bidali mezu pribatua gonbidatzeko.", + jobsCandidatesPmBody: "Kaixo, uste dut zure profila nire lan eskaintzarekin bat datorrela", + jobsDescription: "Aurki eta kudeatu zure sarean lan eskaintzak.", + jobsFilterRecent: "BERANDUENAK", + jobsFilterMine: "NIRE LANEK", + jobsFilterAll: "GUZTIAK", + jobsFilterRemote: "ERREMOTAK", + jobsFilterOpen: "IREKIAK", + jobsFilterClosed: "ITXITA", + jobsFilterTop: "GORENAK", + jobsTopTitle: "Soldata Onena duten Lanak", + jobTypeFreelance: "Autonomoa", + jobTypeSalary: "Langilea", + jobTypeExchange: "Ordu trukea", + jobTypeEXCHANGE: "ORDU TRUKEA", + jobsFilterExchange: "TRUKEA", + jobsExchangeTitle: "Ordu-truke lanak", + jobsHoursOffered: "Eskainitako orduak", + jobsHoursRequested: "Trukean eskatutako orduak", + jobsExchangeSkill: "Trukean eskatutako gaitasuna", + jobsHoursOfferedPlaceholder: "ad. 4", + jobsHoursRequestedPlaceholder: "ad. 4", + jobsExchangeSkillPlaceholder: "ad. zurgintza, diseinua", + jobExchangeHint: "Ordu-truke lanetarako, bete beheko eremuak soldataren ordez.", + jobsCV: "CV-ak", + jobsCreateJob: "Argitaratu Lana", + jobsRecentTitle: "Lana Azkenak", + jobsMineTitle: "Zure Lankideak", + jobsAllTitle: "Lanen Guztiak", + jobsRemoteTitle: "Erremotako Lanak", + jobsOpenTitle: "Irekitako Lanak", + jobsClosedTitle: "Itxitako Lanak", + jobsCVTitle: "CV-ak", + jobsFilterPresencial: "PRESENTZIALA", + jobsFilterFreelancer: "FREELANCE", + jobsFilterEmployee: "LANGILE", + jobsPresencialTitle: "Presentzial Lanak", + jobsFreelancerTitle: "Freelance Lanak", + jobsEmployeeTitle: "Langile Lanak", + jobTitle: "Izenburua", + jobLocation: "Kokalekua", + jobSalary: "Soldata (ECO/1h)", + jobVacants: "Postu Libreak", + jobDescription: "Deskribapena", + jobRequirements: "Eskakizunak", + jobLanguages: "Hizkuntzak", + jobStatus: "Egoera", + jobStatusOPEN: "IREKITA", + jobStatusCLOSED: "ITXITA", + jobSetClosed: "ITXI gisa markatu", + jobSubscribeButton: "Batu eskaintza honetara!", + jobUnsubscribeButton: "Utzi eskaintza hau!", + jobTitlePlaceholder: "Idatzi lanaren titulua", + jobDescriptionPlaceholder: "Deskribatu lana", + jobRequirementsPlaceholder: "Idatzi eskakizunak", + jobLanguagesPlaceholder: "Ingelesa, Frantsesa, Gaztelania, Euskara...", + jobTasksPlaceholder: "Zeregin zerrenda", + jobLocationPresencial: "Aurrez aurrekoa", + jobLocationRemote: "Urrunekoa", + jobVacantsPlaceholder: "Postu kopurua", + jobSalaryPlaceholder: "Ordubeteko soldata ECO", + jobImage: "Multimedia igo (max: 50MB)", + jobTasks: "Eginkizunak", + jobType: "Lanen Motak", + jobTime: "Lanen Denbora", + jobSubscribers: "Harpidetzaileak", + noSubscribers: "Ez da harpidetzailerik", + createJobButton: "Argitaratu Lana", + viewDetailsButton: "Ikusi Xehetasunak", + noJobsFound: "Ez daude lan eskaintzak.", + jobAuthor: "Egilea", + jobTimePartial: "Partziala", + jobTimeComplete: "Osotua", + jobsDeleteButton: "EZABATU", + jobsUpdateButton: "EGUNERATU", + jobsFilterApplied: "ESKATUTA", + jobsAppliedTitle: "Nire eskaerak", + jobsAppliedBadge: "Izena emanda", + jobsFilterFavs: "Gustukoak", + jobsFavsTitle: "Gustukoak", + jobsFilterNeeds: "Laguntza behar du", + jobsNeedsTitle: "Laguntza behar du", + jobsSearchLabel: "Bilatu", + jobsSearchPlaceholder: "Bilatu izenburua, etiketak, deskribapena...", + jobsMinSalaryLabel: "Gutx. soldata", + jobsMaxSalaryLabel: "Geh. soldata", + jobsSortLabel: "Ordenatu", + jobsSortRecent: "Berrienak", + jobsSortSalary: "Soldata altuena", + jobsSortSubscribers: "Hautagai gehien", + jobsSearchButton: "Bilatu", + jobsFavoriteButton: "Gustukoa", + jobsUnfavoriteButton: "Kendu gustukoa", + jobsMessageAuthorButton: "MP", + jobsApplicants: "Hautagaiak", + jobsUpdatedAt: "Eguneratua", + jobSetOpen: "Ireki gisa markatu", + jobNewBadge: "BERRIA", + jobsTagsLabel: "Etiketak", + jobsTagsPlaceholder: "etiketa1, etiketa2, etiketa3", + noJobsMatch: "Ez dago bilaketarekin bat datorren lan-eskaintzarik.", + projectsTitle: "Proiektuak", + projectsDescription: "Sortu, finantzatu eta jarraitu komunitateak gidatutako proiektuak zure sarean.", + projectCreateProject: "Proiektu Bat Sortu", + projectCreateButton: "Proiektu Bat Sortu", + projectUpdateButton: "EGUNERATU", + projectDeleteButton: "EZABATU", + projectNoProjectsFound: "Ez dira proiektuak aurkitu.", + projectFilterAll: "GUZTIAK", + projectFilterMine: "NIRE PROIEKTUAK", + projectFilterActive: "AKTIBOAK", + projectFilterPaused: "PAUSATUTAKOAK", + projectFilterCompleted: "OSATUTA", + projectFilterFollowing: "JARRAITZEN", + projectFilterRecent: "AZKENAK", + projectFilterTop: "GORA", + projectAllTitle: "Proiektuak", + projectMineTitle: "Zure Proiektuak", + projectActiveTitle: "Proiektu Aktiboak", + projectPausedTitle: "Proiektu Pausatuak", + projectCompletedTitle: "Proiektu Osatuak", + projectFollowingTitle: "Jarraitzen dituzun Proiektuak", + projectRecentTitle: "Proiektu Azkenak", + projectTopTitle: "Finantzatuenak", + projectTitlePlaceholder: "Proiektuaren izena", + projectImage: "Multimedia igo (max: 50MB)", + projectDescription: "Deskribapena", + projectDescriptionPlaceholder: "Kontatu istorioa eta helburuak...", + projectGoal: "Helburu (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Epea", + projectProgress: "Progresua hasi (%)", + projectStatus: "Egoera", + projectFunding: "Finantzaketa", + projectPledged: "Aginduak", + projectSetStatus: "Egoera ezarri", + projectSetProgress: "Progresua eguneratu", + projectFollowButton: "JARRAITU", + projectUnfollowButton: "JARRAITU BEHARREZ", + projectStatusACTIVE: "AKTIBOA", + projectStatusPAUSED: "PAUSATUTA", + projectStatusCOMPLETED: "OSATUTA", + projectStatusCANCELLED: "EZEZTATUTA", + projectPledgeTitle: "Proiektua babestu", + projectPledgePlaceholder: "Kantitatea ECOtan", + projectBounties: "Saria", + projectBountiesInputLabel: "Sariak (lerro bakoitzean: Izenburua|Kantitatea [ECO]|Deskribapena)", + projectBountiesPlaceholder: "UI akatsa konpondu|100|Arazoaren esteka\nDokumentuak idatzi|250|Erabilera adibideak", + projectNoBounties: "Ez da sariarik aurkitu.", + projectTitle: "Izenburua", + projectAddBountyTitle: "Saria berri bat gehitu", + projectBountyTitle: "Sariaren izenburua", + projectBountyAmount: "Kantitatea (ECO)", + projectBountyDescription: "Deskribapena", + projectMilestoneSelect: "Hitoa aukeratu", + projectBountyCreateButton: "Saria sortu", + projectBountyStatus: "Sariaren egoera", + projectBountyOpen: "irekia", + projectBountyClaimed: "reklamatua", + projectBountyDone: "osatua", + projectBountyClaimedBy: "reklamatua", + projectBountyClaimButton: "erakutsi", + projectBountyCompleteButton: "Markatu amaitutzat", + projectMilestones: "Hitoak", + projectAddMilestoneTitle: "Hito berria", + projectMilestoneTitle: "Hitoaren izenburua", + projectMilestoneTargetPercent: "Portzentajea (%)", + projectMilestoneDueDate: "Data", + projectMilestoneCreateButton: "Hitoa sortu", + projectMilestoneStatus: "Hitoaren egoera", + projectMilestoneOpen: "irekia", + projectMilestoneDone: "osatua", + projectMilestoneDue: "egonkor", + projectNoMilestones: "Ez da hitorik aurkitu.", + projectMilestoneMarkDone: "Markatu amaitutako bezala", + projectMilestoneTitlePlaceholder: "Hitoaren izenburua sartu", + projectMilestoneDescriptionPlaceholder: "Sartu deskribapena", + projectMilestoneDescription: "Hitoaren deskribapena", + projectBudgetGoal: "Aurrekontua (Helburua)", + projectBudgetAssigned: "Sarietara esleituta", + projectBudgetRemaining: "Geratzen dena", + projectBudgetOver: "⚠ Aurrekontua gaindituta: esleitutako zenbatekoak helburua gainditzen du", + projectFollowers: "Jarraitzaileak", + projectFollowersTitle: "Jarraitzaileak", + projectFollowersNone: "Oraindik ez dago jarraitzailerik.", + projectMore: "gehiago", + projectYouFollowHint: "Proiektu hau jarraitzen duzu", + projectBackers: "Babesleak", + projectBackersTitle: "Babesleak", + projectBackersTotal: "Babesleak guztira", + projectBackersTotalPledged: "Ekarpenen guztizkoa", + projectBackersYourPledge: "Zure ekarpena", + projectBackersNone: "Oraindik ez dago ekarpenik.", + projectNoRemainingBudget: "Ez da aurrekonturik geratzen.", + projectFilterApplied: "ESKATUTA", + projectAppliedTitle: "ESKATUTA", + projectFilterBackers: "BABESLEAK", + projectBackersLeaderboardTitle: "Babesle nagusiak", + projectNoBackersFound: "Ez dago babeslerik.", + projectBackerAmount: "Ekarpen osoa", + projectBackerPledges: "Ekarpenak", + projectBackerProjects: "Proiektuak", + projectPledgeAmount: "Kantitatea", + projectSelectMilestoneOrBounty: "Hegoa edo Saria hautatu", + projectPledgeButton: "Ekimen egin", + footerLicense: "GPLv3", + footerPackage: "Paketea", + footerVersion: "Bertsioa", + modulesModuleName: "Izena", + modulesModuleDescription: "Deskribapena", + modulesModuleStatus: "Egoera", + modulesTotalModulesLabel: "Kargatutako Moduluak", + modulesEnabledModulesLabel: "Gaituta", + modulesDisabledModulesLabel: "Desgaituta", + modulesPopularLabel: "Nabarmenak", + modulesPopularDescription: "Gehien ikusi edo komentatu diren mezu ezagunak jasotzeko modulua.", + modulesTopicsLabel: "Gaiak", + modulesTopicsDescription: "Interes partekatutako kategorien araberako eztabaidak jasotzeko modulua.", + modulesSummariesLabel: "Laburpenak", + modulesSummariesDescription: "Eztabaida edo mezu luzeen laburpenak jasotzeko modulua.", + modulesLatestLabel: "Azkenak", + modulesLatestDescription: "Mezu eta eztabaida berrienak jasotzeko modulua.", + modulesThreadsLabel: "Hariak", + modulesThreadsDescription: "Gai edo galdera baten inguruko elkarrizketak jasotzeko modulua.", + modulesMultiverseLabel: "Multibertsoa", + modulesMultiverseDescription: "Beste federatutako kideetatik edukia jasotzeko modulua.", + modulesInvitesLabel: "Gonbidapenak", + modulesInvitesDescription: "Gonbidapen-kodeak kudeatu eta aplikatzeko modulua.", + modulesWalletLabel: "Zorroa", + modulesWalletDescription: "Zure aktibo digitalak (ECOin) kudeatzeko modulua.", + modulesLegacyLabel: "Gakoak", + modulesLegacyDescription: "Zure sekretua (gako pribatua) modu azkar eta seguruan kudeatzeko modulua.", + modulesCipherLabel: "Zifratzailea", + modulesCipherDescription: "Zure testua simetrikoki zifratu eta deszifratzeko modulua (pasahitz partekatu baten bidez).", + modulesBookmarksLabel: "Markagailuak", + modulesBookmarksDescription: "Markagailuak aurkitu eta kudeatzeko modulua.", + modulesVideosLabel: "Bideoak", + modulesVideosDescription: "Bideoak aurkitu eta kudeatzeko modulua.", + modulesDocsLabel: "Dokumentuak", + modulesDocsDescription: "Dokumentuak aurkitu eta kudeatzeko modulua.", + modulesAudiosLabel: "Audioak", + modulesAudiosDescription: "Audioak aurkitu eta kudeatzeko modulua.", + modulesTagsLabel: "Etiketak", + modulesTagsDescription: "Taxonomia ereduak (etiketak) aurkitu eta aztertzeko modulua.", + modulesImagesLabel: "Irudiak", + modulesImagesDescription: "Irudiak aurkitu eta kudeatzeko modulua.", + modulesTrendingLabel: "Pil-pilean", + modulesTrendingDescription: "Edukirik ezagunenak esploratzeko modulua.", + modulesEventsLabel: "Ekitaldiak", + modulesEventsDescription: "Ekitaldiak aurkitu eta kudeatzeko modulua.", + modulesTasksLabel: "Atazak", + modulesTasksDescription: "Atazak aurkitu eta kudeatzeko modulua.", + modulesMarketLabel: "Merkatua", + modulesMarketDescription: "Ondasun edo zerbitzuak trukatzeko modulua.", + modulesShopsLabel: "Dendak", + modulesShopsDescription: "Dendak kudeatzeko eta aurkitzeko modulua.", + modulesTribesLabel: "Tribuak", + modulesTribesDescription: "Tribuak (taldeak) esploratu edo sortzeko modulua.", + modulesVotationsLabel: "Bozkatzeak", + modulesVotationsDescription: "Bozketak aurkitu eta kudeatzeko modulua.", + modulesParliamentLabel: "Legebiltzarra", + modulesParliamentDescription: "Gobernuak hautatu eta legeak bozkatzeko modulua.", + modulesCourtsLabel: "Epaitegiak", + modulesCourtsDescription: "Gatazkak konpontzeko eta epaia emateko modulua.", + modulesReportsLabel: "Txostenak", + modulesReportsDescription: "Arazo, akats, abusu eta eduki-abisuetan erlazionatutako txostenak kudeatzeko modulua.", + modulesOpinionsLabel: "Iritziak", + modulesOpinionsDescription: "Iritziak aurkitu eta bozkatzeko modulua.", + modulesTransfersLabel: "Transferentziak", + modulesTransfersDescription: "Kontratu adimendunak (transferentziak) aurkitu eta kudeatzeko modulua.", + modulesFediverseLabel: "Fediverse", + modulesFediverseDescription: "Kudeatu zure beste fediverso kontuak, edukia bidaltzea eta jasotzea barne.", + modulesFeedLabel: "Jarioa", + modulesFeedDescription: "Testu laburrak (jarioak) aurkitu eta partekatzeko modulua.", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Lauki kolaboratibo batean marrazteko modulua.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Zure blockchain-aren \"soinua\" sortzeko eta partekatzeko modulua.", + modulesAgendaLabel: "Agenda", + modulesAgendaDescription: "Esleitu zaizkizun elementu guztiak kudeatzeko modulua.", + modulesAILabel: "AI", + modulesAIDescription: "'42' izeneko LLM batekin hitz egiteko modulua.", + modulesForumLabel: "Foroak", + modulesForumDescription: "Foroak deskubritu eta kudeatzeko modulua.", + modulesJobsLabel: "Lanpostuak", + modulesJobsDescription: "Lan eskaintzak aurkitu eta kudeatzeko modulu.", + modulesProjectsLabel: "Proiektuak", + modulesProjectsDescription: "Proiektuak esploratzeko, finantzatzeko eta kudeatzeko modulu.", + modulesBankingLabel: "Bankua", + modulesBankingDescription: "ECOINen benetako balioa zehazteko eta UBI bat altxortegi komuna erabiliz banatzeko modulua.", + modulesFavoritesLabel: "Gogokoak", + modulesFavoritesDescription: "Zure gogoko edukia kudeatzeko modulua.", + fileTooLargeTitle: "Fitxategia handiegia", + fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (50 MB). Mesedez, hautatu fitxategi txikiago bat.", + goBack: "Itzuli", + errorPageTitle: "Zerbait gaizki joan da", + visibilityLabel: "Ikusgaitasuna", + visibilityPublic: "Publikoa", + visibilityHidden: "Ezkutua", + visibilityMakePublic: "Publiko egin", + visibilityMakeHidden: "Ezkutatu", + invitesInhabitantsTitle: "Biztanleak", + invitesInhabitantsFollow: "Jarraitu", + aiNavPlaceholder: "Nora joan nahi duzu?", + aiNavDisabled: "AI nabigazioa desgaituta dago.", + aiNavResultsTitle: "AI nabigazio iradokizunak", + aiNavQueryLabel: "Galdera", + aiNavResultsDescription: "Aukeratu zure bilaketarekin bat datorren ibilbidea.", + aiNavResultPath: "Ibilbidea", + aiNavResultDescription: "Zer egin dezakezu", + aiNavResultMatch: "Bat-egitea", + aiNavResultsEmpty: "Ez dago bat datorren ibilbiderik. Saiatu /search.", + aiNavSearchInstead: "Bilatu beste modu batez", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Sareko edukiari buruzko hizkuntza naturalezko galderak egiteko modulua.", + directConnect: "Zuzeneko Konexioa", + directConnectDescription: "Konektatu zuzenean parekide batera bere IP helbidea, portua eta gako publikoa sartuz. Parekidea jarraipen-konexio gisa gehituko da.", + peerHost: "IP", + peerPort: "Portua (lehenetsia: 8008)", + peerPublicKey: "Gako Publikoa (@...ed25519)", + connectAndFollow: "Konektatu", + deviceSourceLabel: "Gailua", + modulesPresetTitle: "Konfigurazio Arruntak", + modulesPreset_minimal: "Gutxienekoa", + modulesPreset_basic: "Oinarrizkoa", + modulesPreset_social: "Soziala", + modulesPreset_economy: "Ekonomia", + modulesPreset_full: "Osoa", + statsCarbonFootprintTitle: "Karbono Aztarna", + statsCarbonFootprintNetwork: "Sarearen karbono aztarna", + statsCarbonFootprintYours: "Zure karbono aztarna", + statsCarbonTombstone: "Tombstoning-aren aztarna", + feedSuccessMsg: "Feeda arrakastaz argitaratu da!", + dominantOpinionLabel: "Iritzi nagusia", + uploadMedia: "Multimedia igo (max: 50MB)", + + reportsWhyInappropriateLabel: "Zergatik da desegokia?", + visitContent: "Edukia bisitatu", + bankEcoinHours: "ECOin Orduak", + mapsLabel: "Mapak", + mapTitle: "Mapak", + mapDescription: "Esploratu eta kudeatu lineaz kanpoko mapak zure sarean.", + mapMineSectionTitle: "Zure Mapak", + mapCreateSectionTitle: "Mapa Sortu", + mapUpdateSectionTitle: "Mapa Eguneratu", + mapAllSectionTitle: "Mapak", + mapRecentSectionTitle: "Azken Mapak", + mapFavoritesSectionTitle: "Gogokoak", + mapFilterAll: "DENAK", + mapFilterMine: "NIREAK", + mapFilterRecent: "AZKENAK", + mapFilterFavorites: "GOGOKOAK", + mapUploadButton: "Mapa Sortu", + mapCreateButton: "Mapa Sortu", + mapUpdateButton: "Eguneratu", + mapDeleteButton: "Ezabatu", + mapAddFavoriteButton: "Gogokoetara gehitu", + mapRemoveFavoriteButton: "Gogokoetatik kendu", + mapLatLabel: "Latitudea", + mapLatPlaceholder: "adib. 43.2630", + mapLngLabel: "Longitudea", + mapLngPlaceholder: "adib. -2.9350", + mapDescriptionLabel: "Deskribapena", + mapDescriptionPlaceholder: "Deskribatu mapa edo kokapena...", + mapTypeLabel: "Mapa mota", + mapTypeSingle: "BAKARRA (hasierako kokapena soilik)", + mapTypeOpen: "IREKIA (edonork gehitu ditzake markatzaileak)", + mapTypeClosed: "ITXIA (sortzaileak soilik gehitzen ditu markatzaileak)", + mapTagsLabel: "Etiketak", + mapTagsPlaceholder: "Sartu etiketak komaz bereizita", + mapUrlLabel: "Maparen URLa", + mapPickCoordLabel: "Edo hautatu kokapen bat sarean:", + mapMarkersLabel: "markatzaileak", + mapMarkersTitle: "Markatzaileak", + mapMarkerDefault: "Markatzailea", + mapMarkerLatLabel: "Markatzailearen latitudea", + mapMarkerLngLabel: "Markatzailearen longitudea", + mapMarkerLabelField: "Markatzailearen etiketa", + mapMarkerLabelPlaceholder: "Deskribatu markatzaile hau...", + markerImageLabel: "Markatzailearen Irudia", + mapAddMarkerTitle: "Markatzailea Gehitu", + mapAddMarkerButton: "Markatzailea Gehitu", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Zoom aplikatu", + mapSearchPlaceholder: "Bilatu deskribapena, etiketak, egilea...", + mapSearchButton: "Bilatu", + mapUpdatedAt: "Eguneratua", + mapNoMatch: "Ez da maparik aurkitu zure bilaketarekin.", + noMaps: "Ez dago maparik eskuragarri.", + mapLocationTitle: "Kokapena", + mapVisitLabel: "Mapa bisitatu", + typeMap: "MAPAK", + typeMapMarker: "MAPA MARKATZAILEA", + modulesMapLabel: "Mapak", + modulesMapDescription: "Lineaz kanpoko mapak kudeatzeko eta partekatzeko modulua.", + padsTitle: "Padak", + padTitle: "Pad", + modulesPadsLabel: "Padak", + modulesPadsDescription: "Testu editoreak lankidetzan kudeatzeko modulua.", + padFilterAll: "GUZTIAK", + padFilterMine: "NIREA", + padFilterRecent: "BERRIA", + padFilterOpen: "IREKIA", + padFilterClosed: "ITXIA", + padCreate: "Pad Sortu", + padUpdate: "Pad Eguneratu", + padDelete: "Pad Ezabatu", + padTitleLabel: "Izenburua", + padTitlePlaceholder: "Idatzi pad-aren izenburua...", + padStatusLabel: "Egoera", + padStatusOpen: "IREKIA", + padStatusInviteOnly: "GONBIDATUAK SOILIK", + padStatusClosed: "ITXIA", + padDeadlineLabel: "Epemuga", + padTagsLabel: "Etiketak", + padTagsPlaceholder: "etiketa1, etiketa2, ...", + padMembersLabel: "Kideak", + padVisitPad: "Pad Bisitatu", + padShareUrl: "URL Partekatu", + padCreated: "Sortua", + padAuthor: "Egilea", + padGenerateCode: "Kodea Sortu", + padInviteCodeLabel: "Gonbidapen Kodea", + padInviteCodePlaceholder: "Sartu gonbidapen kodea...", + padValidateInvite: "Balioztatu", + padStartEditing: "EDITZEN HASI!", + padEditorPlaceholder: "Idazten hasi...", + padSubmitEntry: "Bidali", + padNoEntries: "Oraindik sarrerarik ez.", + padAllSectionTitle: "Pad Guztiak", + padMineSectionTitle: "Nire Padak", + padsDescription: "Kudeatu zure sareko enkriptatutako testu-editore kolaboratiboak.", + padRecentSectionTitle: "Azken Padak", + padOpenSectionTitle: "Pad Irekiak", + padClosedSectionTitle: "Pad Itxiak", + padCreateSectionTitle: "Pad Berria Sortu", + padUpdateSectionTitle: "Pad Eguneratu", + padInviteGenerated: "Gonbidapen Kodea Sortua", + typePad: "PADAK", + padNew: "BERRIA", + padAddFavorite: "Gogokoen Gehitu", + padRemoveFavorite: "Gogokoetatik Kendu", + padClose: "Itxi Pad", + padBackToEditor: "Editorera itzuli", + padSearchPlaceholder: "Bilatu padak...", + + modulesChatsLabel: "Txatak", + modulesChatsDescription: "Txat zifratu publikoak aurkitu eta kudeatzeko modulua.", + typeChat: "TXATAK", + typeChatMessage: "TXAT MEZUA", + chatLabel: "TXATAK", + chatMessageLabel: "TXAT MEZUAK", + chatsTitle: "Txatak", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "Gogokoak", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "Deskribapena", + chatCategory: "Kategoria", + chatStatus: "EGOERA", + chatFilterAll: "GUZTIAK", + chatFilterMine: "NIREA", + chatFilterRecent: "BERRIA", + chatFilterFavorites: "GOGOKOAK", + chatFilterOpen: "IREKIA", + chatFilterClosed: "ITXIA", + chatCreate: "Txata Sortu", + chatUpdate: "Txata Eguneratu", + chatDelete: "Txata Ezabatu", + chatClose: "Txata Itxi", + chatVisitChat: "TXATA IKUSI", + chatUntitled: "Izenburugabeko Txata", + chatNoItems: "Ez da txatarik aurkitu.", + chatParticipants: "Parte-hartzaileak", + chatStartChatting: "TXATEATZEN HASI!", + chatGenerateCode: "Kodea Sortu", + chatShareUrl: "URLa Partekatu", + chatCreatedAt: "SORTUA", + chatSearchPlaceholder: "Txatak bilatu...", + chatStatusOpen: "IREKIA", + chatStatusInviteOnly: "GONBIDAPENEZ BAKARRIK", + chatStatusClosed: "ITXIA", + chatSendMessage: "Bidali", + chatMessagePlaceholder: "Idatzi zure mezua...", + chatNoMessages: "Oraindik mezurik ez.", + chatLeave: "Txata Utzi", + chatInviteCodeLabel: "Gonbidapen kodea sartu", + chatJoinByInvite: "Sartu", + chatTitlePlaceholder: "Txataren izenburua", + chatDescriptionPlaceholder: "Txataren deskribapena", + chatTagsPlaceholder: "etiketa1, etiketa2", + chatImageLabel: "Aukeratu irudi-fitxategi bat (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Gonbidapen Kodea", + chatAuthor: "Egilea", + chatCreated: "Sortua", + chatAddFavorite: "Gogokoen artean gehitu", + chatRemoveFavorite: "Gogokoenetatik kendu", + chatPM: "MP", + chatStatusLabel: "Egoera", + chatCategoryLabel: "Kategoria", + chatParticipantsLabel: "Parte-hartzaileak", + gamesTitle: "Jokoak", + gamesDescription: "Aurkitu eta jokatu joko batzuk.", + gamesFilterAll: "GUZTIAK", + gamesPlayButton: "JOLASTU!", + gamesBackToGames: "Jokoetara itzuli", + modulesGamesLabel: "Jokoak", + modulesGamesDescription: "Jokoak aurkitzeko eta jolasteko modulua.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Sarea nodoen mapa interaktibo gisa esploratzeko modulua.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Lotu PUBak biztanleekin baliozkotzaileen, denden eta metatzaileen bidez. Iraun CBDC mehatxuari!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Sartu saretik, bildu datu konfidentzialak, saihestu segurtasun dronak eta ihes egin. Zenbat maila gainditu ditzakezu?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "Gelditu inbasio extraterrestrea! Inbasore olatuak bota.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Hautsi adreilu guztiak zure pala eta pilotarekin. Arcade klasiko bat.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Ping-pong klasikoa IA baten aurka. Lehenak 5 puntu lortzen duena irabazten du.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "Denboraren aurka lasterketa! Saihestu trafikoa eta iritsi helmugara garaiz.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Gidatu zure ontzia asteroide eremuaren bidez. Tiro egin haiek jo aurretik.", + gamesRockPaperScissorsTitle: "Harria Paper Artaziak", + gamesRockPaperScissorsDesc: "Harria, papera edo artaziak IA baten aurka. Hiru txandako onena irabazten du.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Tic-Tac-Toe klasikoa IA aurka. Hiru jarraian lortu irabazteko.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Bota txanpon bat eta apostatu aurpegian edo buztanean. Zenbat zorte duzu?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "Egutegia", + calendarTitle: "Egutegia", + modulesCalendarsLabel: "Egutegiak", + modulesCalendarsDescription: "Egutegiak aurkitu eta kudeatzeko modulua.", + typeCalendar: "EGUTEGIAK", + calendarFilterAll: "GUZTIAK", + calendarFilterMine: "NIREAK", + calendarFilterOpen: "IREKIA", + calendarFilterClosed: "ITXIA", + calendarFilterRecent: "BERRIAK", + calendarFilterFavorites: "GOGOKOAK", + calendarCreate: "Egutegia sortu", + calendarUpdate: "Eguneratu", + calendarDelete: "Ezabatu", + calendarTitleLabel: "Izenburua", + calendarTitlePlaceholder: "Egutegiko izenburua...", + calendarStatusLabel: "Egoera", + calendarStatusOpen: "IREKIA", + calendarStatusClosed: "ITXIA", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Etiketak", + calendarTagsPlaceholder: "etiketa1, etiketa2...", + calendarParticipantsLabel: "Parte-hartzaileak", + calendarParticipantsCount: "Parte-hartzaileak", + calendarVisitCalendar: "Egutegia bisitatu", + calendarCreated: "Sortua", + calendarAuthor: "Egilea", + calendarJoin: "Batu", + calendarGenerateInvite: "Sortu gonbidapena", + calendarInviteCodePlaceholder: "Sartu gonbidapen-kodea...", + calendarValidateInvite: "Egiaztatu kodea", + calendarJoined: "Batuta", + calendarAddDate: "Data gehitu", + calendarAddNote: "Oharra gehitu", + calendarDateLabel: "Data", + calendarDatePlaceholder: "Data hau deskribatu...", + calendarNoteLabel: "Oharra", + calendarNotePlaceholder: "Oharra gehitu...", + calendarFirstDateLabel: "Data", + calendarFirstNoteLabel: "Oharrak", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Deskribapena", + calendarNoDates: "Ez dago datarik.", + calendarNoNotes: "Ez dago oharrik.", + calendarsNoItems: "Ez da egutegiak aurkitu.", + calendarsDescription: "Aurkitu eta kudeatu zure sareko egutegiak.", + calendarMonthPrev: "← Aurrekoa", + calendarMonthNext: "Hurrengoa →", + calendarMonthLabel: "Datak", + calendarsShareUrl: "Partekatzeko URLa", + calendarAllSectionTitle: "Egutegiak", + calendarRecentSectionTitle: "Egutegi Berriak", + calendarFavoritesSectionTitle: "Gogokoak", + calendarMineSectionTitle: "Zure Egutegiak", + calendarOpenSectionTitle: "Egutegi irekiak", + calendarClosedSectionTitle: "Egutegi itxiak", + calendarCreateSectionTitle: "Egutegi berria sortu", + calendarUpdateSectionTitle: "Egutegia eguneratu", + calendarAddFavorite: "Gogokoen gehitu", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Gogokoetatik kendu", + calendarSearchPlaceholder: "Egutegiak bilatu...", + calendarAddEntry: "Gehitu Sarrera", + calendarLeave: "Irten Egutegitik", + statsCalendar: "Egutegiak", + statsCalendarDate: "Egutegi datak", + statsCalendarNote: "Egutegi oharrak", + chatAccessDenied: "Ez duzu sarbiderik txat honetara. Eskatu gonbidapen bat edukira sartzeko.", + padAccessDenied: "Ez duzu sarbiderik pad honetara. Eskatu gonbidapen bat edukira sartzeko.", + contentAccessDenied: "Ez duzu sarbiderik eduki honetara.", + blockAccessRestricted: "Sarbidea mugatuta", + tribeContentAccessDenied: "Sarbidea Ukatua", + tribeContentAccessDeniedMsg: "Eduki hau tribu batena da. Kide izan behar zara sartzeko.", + tribeViewTribes: "Tribuak Ikusi", + torrentsTitle: "Torrentak", + torrentsDescription: "Esploratu eta kudeatu torrentak zure sarean.", + torrentAllSectionTitle: "Torrentak", + torrentMineSectionTitle: "Zure Torrentak", + torrentRecentSectionTitle: "Azken Torrentak", + torrentTopSectionTitle: "Torrent Onenak", + torrentFavoritesSectionTitle: "Gogokoak", + torrentFilterAll: "DENAK", + torrentFilterMine: "NIREAK", + torrentFilterRecent: "AZKENAK", + torrentFilterTop: "ONENAK", + torrentFilterFavorites: "GOGOKOAK", + torrentCreateSectionTitle: "Torrenta Igo", + torrentUpdateSectionTitle: "Torrenta Eguneratu", + torrentCreateButton: "Torrenta Igo", + torrentUpdateButton: "Eguneratu", + torrentDeleteButton: "Ezabatu", + torrentAddFavoriteButton: "Gogokoetara Gehitu", + torrentRemoveFavoriteButton: "Gogokoetatik Kendu", + torrentFileLabel: "Torrent fitxategia aukeratu (.torrent)", + torrentTitleLabel: "Izenburua", + torrentTitlePlaceholder: "Izenburua", + torrentDescriptionLabel: "Deskribapena", + torrentDescriptionPlaceholder: "Deskribapena", + torrentTagsLabel: "Etiketak", + torrentTagsPlaceholder: "Sartu etiketak komaz bereizita", + torrentSizeLabel: "Tamaina", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "Torrent fitxategirik ez", + noTorrents: "Ez da torrentik aurkitu.", + torrentSearchPlaceholder: "Bilatu izenburua, etiketak, egilea...", + torrentSearchButton: "Bilatu", + torrentSortRecent: "Berrienak", + torrentSortOldest: "Zaharrenak", + torrentSortTop: "Bozkatu enak", + torrentNoMatch: "Ez dago torrent bat datorrenik.", + torrentMessageAuthorButton: "Mezua Bidali Egileari", + torrentUpdatedAt: "Eguneratua", + statsTorrent: "Torrentak", + typeTorrent: "TORRENTAK", + modulesTorrentsLabel: "Torrentak", + modulesTorrentsDescription: "Torrentak aurkitu eta kudeatzeko modulua.", + favoritesFilterTorrents: "TORRENTAK", + favoritesFilterMarket: "MERKATUA", + favoritesFilterShopProducts: "DENDAKO ARTIKULUAK", + tribeSectionTorrents: "TORRENTAK", + tribeCreateTorrent: "Torrenta Igo", + tribeMediaTypeTorrent: "Torrenta", + settingsWishTitle: "Nahia", + settingsWishDesc: "Konfiguratu zure Avatarraren nahia.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Mezu Pribatuak", + settingsPmVisibilityDesc: "Konfiguratu sarean izan nahi duzun mezu pribatuen esposizio maila.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "Bidalketa UX babes bat da. Jasotzea arreta-iragazkia.", + pmBlockedNonMutual: "Elkarrekiko laguntza duten biztanleei bakarrik bidal diezaiekezu.", + inhabitantsPendingFollowsTitle: "Onespen zain dauden eskaerak", + inhabitantsPendingAccept: "Onartu", + inhabitantsPendingReject: "Ukatu", + bxEncrypted: "ZIFRATUA", + bxEncryptedHexLabel: "Testu zifratua (aurrebista)", + tribeSectionGovernance: "GOBERNANTZA", + tribeSubStatusPublic: "PUBLIKOA", + tribeSubStatusPrivate: "PRIBATUA", + tribeSubInheritedPrivate: "PRIBATUA (tribu nagusitik heredatua)", + tribePadInviteRequired: "Ez duzu pada atzipena. Eskatu gonbidapena.", + tribeChatInviteRequired: "Ez duzu txata atzipena. Eskatu gonbidapena.", + tribeGovernanceDesc: "Tribu honen barne gobernantza.", + tribeGovernanceNoGov: "Ez dago gobernu aktiborik", + tribeGovernanceNoGovDesc: "Tribu honek ez du oraindik gobernurik aukeratu.", + tribeGovCardTitle: "Egungo Gobernua", + tribeGovCycleSince: "ZIKLOAREN HASIERA", + tribeGovCycleEnd: "ZIKLOAREN AMAIERA", + tribeGovTimeRemaining: "GERATZEN DEN DENBORA", + tribeGovPopulation: "BIZTANLERIA", + tribeGovMethod: "METODOA", + tribeGovVotesReceived: "JASOTAKO BOTOAK", + tribeGovLeader: "BURUZAGIA", + tribeGovFilterGovernment: "GOBERNUA", + tribeGovFilterCandidatures: "HAUTAGAITZAK", + tribeGovFilterLaws: "LEGEAK", + tribeGovCandidatureId: "Hautagaitza", + tribeGovCandidatureMethod: "Metodoa", + tribeGovCandidatureProposeBtn: "Hautagaitza argitaratu", + tribeGovRuleTitle: "Arauaren izenburua", + tribeGovRuleBody: "Arauaren edukia", + tribeGovProposals: "PROPOSAMENAK", + tribeGovRevocations: "EZEZTAPENAK", + tribeGovHistorical: "HISTORIKOA", + tribeGovRules: "ARAUAK", + tribeGovernanceAlreadyPublished: "Tribu honek jada hautagai ireki bat du.", + tribeGovernanceProposeInternal: "Proposatu barne hautagaia", + tribeGovernanceInternalCandidatures: "Barne hautagaiak", + tribeGovernanceNoCandidatures: "Ez dago hautagai irekirik.", + tribeGovernanceAddRule: "Gehitu araua", + tribeGovernanceNoRules: "Arauerik ez oraindik.", + tribeGovernanceNoLeaders: "Liderrik ez oraindik aukeratu.", + tribeGovernanceComingSoon: "Laster gobernantza moduluan.", + tribeGovNoProposals: "Oraindik ez dago proposamenik", + tribeGovNoLaws: "Oraindik ez dago legerik", + tribeGovNoRevocations: "Oraindik ez dago errebokatzerik", + tribeGovNoHistorical: "Oraindik ez dago erregistrorik", + logsTitle: "Egunkaria", + logsDescription: "Erregistratu zure esperientzia sarean.", + logsReadMore: "Gehiago irakurri", + logsViewTitle: "Egunkaria", + logsColumnType: "Mota", + logsView: "Ikusi", + logsManualPrompt: "Idatzi zure egunkaria", + logsUpdateButton: "Eguneratu", + logsEditTitle: "Sarrera editatu", + logsCreateDescription: "Erregistratu zure esperientzia...", + logsCreateTitle: "Sarrera sortu", + logsWriteButton: "Idatzi", + logsTextPlaceholder: "Deskribatu zure esperientziak...", + logsTextField: "Testua", + logsLabelPlaceholder: "Etiketa...", + logsLabelField: "Idatzi zure egunkaria (etiketa)", + logsAiDisabledWarn: "Gaitu AA modulua /modules-en AA-idatzitako sarrerak erabiltzeko.", + logsAiContextValue: "Blockchain-eguna", + logsAiContext: "Testuingurua", + logsAiModStatus: "AImod", + logsModeApply: "Aplikatu!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Eskuz", + logsModeAI: "AA", + logsColumnDelete: "Ezabatu", + logsColumnEdit: "Editatu", + logsColumnLog: "Egunkaria", + logsColumnDate: "Data", + logsDelete: "Ezabatu", + logsEdit: "Editatu", + logsFilterAlways: "BETI", + logsFilterYear: "AZKEN URTEA", + logsFilterMonth: "AZKEN HILABETEA", + logsFilterWeek: "AZKEN ASTEA", + logsFilterToday: "GAUR", + logsFilterRecent: "BERRIAK", + logsFilterAll: "DENAK", + logsCreate: "Sarrera sortu", + logsExport: "Egunkaria esportatu", + logsExportOne: "Esportatu", + logsViewDetails: "Ikusi xehetasunak", + logsGenerateButton: "Sortu testua", + logsSearchText: "Bilatu egunkarietan...", + logsSearchAnyType: "Edozein mota", + logsSearchButton: "Bilatu", + logsEmpty: "Oraindik sarrerarik ez.", + modulesLogsLabel: "Egunkaria", + modulesLogsDescription: "Modulua zure esperientziak (AA laguntzailearen bidez) erregistratzeko.", + statsLogsTitle: "Egunkaria", + statsLogsEntries: "Sarrerak", + typeLog: "EGUNKARIA", + blockchainCycle: "Zikloa", + + larpTitle: "L.A.R.P.", + larpDescription: "Esperimentazio kolaboratiborako zuzeneko rol-joko geruza bat.", + larpAllHouses: "Etxeak:", + larpFilterRuling: "AGINTZEN", + larpFilterHouses: "ETXEAK", + larpFilterRules: "FAQ", + larpRulesTitle: "L.A.R.P. FAQ", + larpRulesEntryTitle: "Hasierako etxea", + larpRulesEntryText: "Bizilagun bakoitza ACADEMIA-n hasten da lehenespenez. ACADEMIA-tik, aukeratu etxe bat \"Etxe batera sartu\" panelean: horrek sarrera-proba irekitzen du. Erantzun 10 galderak eta bidali. Atalasea (7/10) gainditzen baduzu, automatikoki onartzen zaitu etxe horretan; bestela, baztertuta gelditzen zara eta ACADEMIA-n geratzen zara. Bietan, sistemak zure OasisID eta saiakera-zikloa erregistratzen ditu eta beste saiakera bat eragozten du 30 zikloko itxaronaldia igaro arte.", + larpRulesLeaveText: "Edozein etxetako kideek ACADEMIA-ra itzul daitezke noiznahi beren etxe-orritik; horrek kidetza berrabiarazten du, baina ez du saiakera-itxaronaldia indargabetzen.", + larpRulesGovernanceTitle: "Gobernu-horma", + larpRulesGovernanceText: "Bere zikloan, etxe agintariak \"Agintzen\" intsignia darama eta bere horma da soilik AGINTZEN fitxapean publikoki erakusten dena.", + larpRulesSignTitle: "L.A.R.P. Ikurra", + larpRulesSignText: "Bizilagunek aktiba dezakete (/profile/edit > Sentsoreak) beren uneko etxearen irudia zigilu gisa erakusteko profilean, egile- eta bizilagun-txartelean. Zigiluak etxearen orrira eramaten du.", + larpPostsTitle: "Horma", + larpPostsEmpty: "Oraindik ez dago argitalpenik.", + larpPostLabel: "Argitalpen berria", + larpPostPlaceholder: "Zer du etxe honek esateko?", + larpPostPublic: "Publikoa (edonork ikusgai, bestela kideek soilik)", + larpPostSubmit: "Argitaratu", + larpPostMembersOnly: "Kideek soilik", + larpCycleLabel: "Zikloa:", + audioBackToBcs: "Back to BCS", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeCompositionEmpty: "This audio does not include a stored blockchain composition.", + audioTranscodeDetailDescription: "Decode the embedded payload and the original blockchain composition map.", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeStegoEmpty: "(none)", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoNotFound: "No steganographic payload could be decoded from this audio.", + audioTranscodeStegoOasisId: "By", + audioTranscodeStegoTimestamp: "Generated at", + audioTranscodeStegoUnknown: "—", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (monthly)", + bankOverviewYourTaxes: "Your taxes", + bankRulesAlpha: "Alpha (max share of pub balance per epoch)", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesArchTaxRate: "Rate", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesCapPerEpoch: "Cap per epoch (absolute)", + bankRulesCapUser: "Cap per user per epoch", + bankRulesCarbonFactor: "Carbon factor", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesChipTitle: "ECO Tax chip color band", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesEcoTaxRate: "Rate", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesEpochKind: "Epoch granularity", + bankRulesFloor: "Floor per user (gross)", + bankRulesGraceDays: "Claim grace period", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesKarmaTitle: "Karma (user engagement score)", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesPoolTitle: "Monthly pool", + bankRulesReserveMin: "Reserve minimum (kept in pub balance)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesShareTitle: "Per-user share", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankRulesWMax: "Maximum weight (w_max)", + bankRulesWMin: "Minimum weight (w_min)", + bankTaxes: "Taxes", + bankTaxesAnnualEcoin: "ECO Tax (annual)", + bankTaxesArchTaxAnnual: "ARCH Tax (annual)", + bankTaxesArchTaxFirstBlock: "Your first block age", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxLifetime: "ARCH Tax (lifetime)", + bankTaxesArchTaxMonthly: "ARCH Tax (monthly)", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxRate: "Rate", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesArchTaxUserAmount: "Your ARCH tax (price to return)", + bankTaxesEcoTaxLifetime: "ECO Tax (lifetime)", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesLookupAuthor: "Author", + bankTaxesLookupButton: "Inspect", + bankTaxesLookupCarbon: "Carbon footprint", + bankTaxesLookupEcoin: "ECO Tax", + bankTaxesLookupNotFound: "No block found in your local feed with that ID.", + bankTaxesLookupPlaceholder: "Block ID (e.g. %abc...=.sha256)", + bankTaxesLookupSize: "Size", + bankTaxesLookupTitle: "Inspect a block", + bankTaxesLookupType: "Type", + bankTaxesMonthlyEcoin: "ECO Tax (monthly)", + bankTaxesRate: "Rate", + bankTaxesSpan: "Sampling span", + bankTaxesTotalAnnual: "Total (annual)", + bankTaxesTotalBlocks: "Total blocks", + bankTaxesTotalBytes: "Total bytes", + bankTaxesTotalCarbon: "Total carbon", + bankTaxesTotalEcoin: "Total ECO tax (lifetime)", + bankTaxesTotalLifetime: "Total (lifetime)", + bankTaxesTotalMonthly: "Total (monthly)", + bankTaxesTypesApply: "Set my taxes", + bankTaxesTypesLabel: "Select which taxes you want to pay", + bankTaxesUserAmount: "Your ECO tax (price to return)", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + blockchain: "BlockExplorer", + blockchainBackToBlockexplorer: "Back to blockexplorer", + blockchainBlockNotAvailable: "This block is not available in your local feed. It may belong to a peer you are not yet replicating from.", + courtsMediatorsLabel: "Mediators", + ecoTaxLabel: "ECO Tax", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + melodyAllEmpty: "No BCS compositions from other inhabitants yet.", + melodyByLabel: "By", + melodyNoteUnavailable: "block not in your local feed", + melodyUploadBcsButton: "Publish", + melodyUploadBcsNameNote: "The audio will be published with the auto-generated name", + melodyUploadBcsTitle: "Publish", + melodyUploadStegoLabel: "Hidden message (steganography)", + melodyUploadStegoMaxLabel: "max 280 characters", + melodyUploadStegoPlaceholder: "Optional.", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + parliamentRevocationPublish: "Publish Revocation", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + statsTombstoneEmpty: "No tombstones in the network yet.", + typeGameScore: "GAME SCORES", + bankTaxesLookupNote: "Sartu bloke-ID bat zure jario lokalean bilatzeko.", + bankTaxesUserNoteChipsClick: "Egin klik edozein chiptan bere blokea blockexplorer-en ikuskatzeko.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "Gonbidapen kodea", + larpRulesInviteEntryText: "Etxeko kideek gonbidapen kodeak sor ditzakete, etxerako sarrera zuzena ematen dutenak.", + larpRulesOneHouseText: "Une bakoitzean etxe bakar batekoa izan zaitezke gehienez. Inori ez baduzu, ACADEMIA-n zaude. ACADEMIA ez den etxeko orri bakoitzak \"Etxetik atera\" botoia erakusten die kideei, kidetza ACADEMIA-ra berrezartzen duena. Ateratzea EZ du proba-itxaronaldia indargabetzen.", + larpRulesOneHouseTitle: "Etxe bana aldi bakoitzean", + larpRulesTestEntry: "WILL proba", + larpRulesTestEntryText: "Aukera bakoitzak etxe bati edo gehiagori pisua ematen die; bidaltzean, sistemak puntuak batzen ditu eta puntuazio handiena duen etxean automatikoki onartzen zaitu.", + larpWillTestHint: "Behin osatutakoan, zure erantzunekin ondoen bat datorren etxera esleituko zaitu. Zure erantzun indibidualak lokalki prozesatzen dira eta inoiz ez dira gordetzen — soilik etxe-esleipenaren emaitza argitaratzen da zure jarioan.", + larpWillTestTitle: "WILL proba", + settingsLanDesc: "Aldian behin iragarri pareko hau sare lokal bereko beste Oasis instantziei. Desgaitu UDP igorpenak gelditzeko.", + settingsLanEnable: "Gaitu LAN igorpena", + settingsLanTitle: "LAN igorpena", + settingsReplicationTitle: "Erreplikazioa", + settingsReplicationDesc: "Konfiguratu zenbat jauzi jarraitu behar dituen zure kideak zure jariotik abiatuz edukia erreplikatzean.", + settingsReplicationHopsLabel: "Jauziak", + aiApproveTagsLabel: "Etiketak (komaz bereizita)", + aiApproveTagsPlaceholder: "adib. oasis, gobernantza, ekologia", + aiApproveRatingLabel: "Balorazioa", + aiExchangeLang: "Hizkuntza", + aiExchangeTags: "Etiketak", + aiExchangeRating: "Balorazioa", + aiExchangeHelpful: "Lagungarria", + aiExchangeMarkHelpful: "+1 lagungarri", + larpCyclesUntilRuling: "Hurrengo gobernu-zikloa", + larpVisitTribe: "Tribua bisitatu", + larpGoverning: "Agintzen:", + larpMyHouse: "Nire Etxea:", + larpMembersCount: "Kideak", + larpMembersTitle: "Kideak", + larpNoMembers: "Oraindik ez dago kiderik.", + larpRolesLabel: "Rolak", + larpFunctionLabel: "Funtzioa", + larpMonthLabel: "Gobernu-zikloa", + larpLeaveToAcademia: "Itzuli ACADEMIA-ra", + larpAcademiaJoinTitle: "Etxe batera sartu", + larpAcademiaJoinHint: "Egin profil psikologikoaren proba zuri ondoen egokitzen zaizun etxera esleitua izateko, edo erabili etxeko kide batek partekatutako gonbidapen-kodea.", + larpTakeTestButton: "Profil-proba egin", + larpInviteRedeem: "Etxera sartu", + larpInviteCreate: "Gonbidapen-kodea sortu", + larpInvitePlaceholder: "Gonbidapen-kodea", + larpInviteBannerTitle: "Gonbidapen-kode berria", + larpInviteBannerHint: "Partekatu kode hau ACADEMIA-n dagoen norbaitekin. 30 zikloko edo erabilera bakar baten ostean iraungitzen da.", + larpTestAssignedHouse: "Esleitutako etxea", + larpTestRankingTitle: "Etxe bakoitzeko puntuazioa", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "Etxearen gonbidapen-kodea", + invitesHouseJoinButton: "Etxera sartu", + larpLastAttemptTitle: "Zure azken saiakera", + larpLastAttemptHouse: "Etxea", + larpLastAttemptResult: "Emaitza", + larpLastAttemptWhen: "Noiz", + larpLastAttemptCooldown: "Hurrengo saiakera", + larpTestTitle: "Sarrera-proba", + larpTestIntro: "Erantzun 10 galderei. Gutxienez 7 erantzun zuzen behar dituzu onartua izateko. Emaitza edozein delarik ere, 30 zikloko behin egin dezakezu proba.", + larpTestSubmit: "Etxera batu", + larpTestCooldownActive: "Hurrengo proba erabilgarri", + larpTestCooldownDays: "ziklo", + larpTestResultTitle: "Probaren emaitza", + larpTestPassed: "Proba gainditua — ongi etorri!", + larpTestFailed: "Proba ez da gainditu", + larpTestScore: "Puntuazioa", + larpTestNextAttempt: "Beste proba bat egin dezakezu 30 ziklo barru.", + larpGoToHouse: "Joan zure etxera", + larpBackToAcademia: "Itzuli ACADEMIA-ra", + larpBackToHouses: "◀ Etxeak", + larpBadgeYou: "Zu", + larpBadgeRuling: "Agintzen", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Oasis-en zuzeneko rol-joko geruzaren modulua (9 Etxeak).", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + ecoinTaxLabel: "ECOin Tax", + bankTaxesNetworkTitle: "Network Taxes", + bankTaxesUserTitle: "Your taxes", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + statsEcoTaxTitle: "ECO Tax", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + audioTranscodeStegoTitle: "Embedded in the waveform", + audioBackToAudio: "Back to audio", + audioAuthorLabel: "Author", + courtsEvidenceFileLabel: "Evidence file (image, audio, video or PDF)", + courtsCaseMediators: "Mediators", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "Arazo konplexu baten aurrean, lehenik zer egiten duzu?", + larpProfileQ1O1: "Besteekin hitz egin adostasuna lortzeko", + larpProfileQ1O2: "Prototipo bat egin probatzeko", + larpProfileQ1O3: "Literatura ikertu", + larpProfileQ1O4: "Sortzen duen sistema sabotatu", + larpProfileQ2: "Zer ematen dio zentzua zure eguneroko lanari?", + larpProfileQ2O1: "Maite ditudan pertsonak defendatu", + larpProfileQ2O2: "Zerbait ukigarria sortu", + larpProfileQ2O3: "Bizitza sendatu edo zaindu", + larpProfileQ2O4: "Hitzak eta ideiak landu", + larpProfileQ2O5: "Besteak barreari eragin", + larpProfileQ3: "Taldean gatazka sortzen denean, zu…", + larpProfileQ3O1: "Elkarrizketa zuzentzen duzu", + larpProfileQ3O2: "Alde batera jartzen zara eta sendo eusten duzu", + larpProfileQ3O3: "Txantxa bat egiten duzu lasaitzeko", + larpProfileQ3O4: "Gatazka benetakoa den galdetzen duzu", + larpProfileQ3O5: "Kausa ekologikoak bilatzen dituzu", + larpProfileQ4: "Zure epe luzeko proiektu kuttuna izango litzateke…", + larpProfileQ4O1: "Hiri bat eraiki", + larpProfileQ4O2: "Baso bat berreskuratu", + larpProfileQ4O3: "Konstituzio bat idatzi", + larpProfileQ4O4: "Jaialdi bat antolatu", + larpProfileQ4O5: "Defentsa sare bat sortu", + larpProfileQ4O6: "Makina berri bat diseinatu", + larpProfileQ4O7: "Artxibo bat zaindu", + larpProfileQ4O8: "Bidegabeko ordena bat hautsi", + larpProfileQ5: "Zer egiten du lider ona?", + larpProfileQ5O1: "Entzun eta bitartekaritzan aritzen dena", + larpProfileQ5O2: "Borrokatu eta babesten dakiena", + larpProfileQ5O3: "Historia ezagutzen duena", + larpProfileQ5O4: "Irribarrea ateratzen dizuna", + larpProfileQ6: "Arauekiko zure harremana:", + larpProfileQ6O1: "Arauak elkarrizketatik eta legetik sortzen dira", + larpProfileQ6O2: "Arauak zorrotz bete behar dira", + larpProfileQ6O3: "Arauak sarritan hautsi behar dira", + larpProfileQ6O4: "Arauek bizitzari zerbitzatu behar diote", + larpProfileQ6O5: "Arauek azpiegitura sendoak eraikitzen dituzte", + larpProfileQ7: "Nola kudeatzen duzu informazioa?", + larpProfileQ7O1: "Zaindu eta gorde egiten dut", + larpProfileQ7O2: "Istorioen bidez partekatzen dut", + larpProfileQ7O3: "Bere jatorria zalantzan jartzen dut", + larpProfileQ7O4: "Erabilgarria dena ateratzen dut", + larpProfileQ7O5: "Sendatzeko erabiltzen dut", + larpProfileQ8: "Arrakastaren zure ideia da…", + larpProfileQ8O1: "Makina bat funtzionatzen", + larpProfileQ8O2: "Komunitate baketsua", + larpProfileQ8O3: "Jaialdi bizia", + larpProfileQ8O4: "Familia segurua", + larpProfileQ8O5: "Artxibo hautsi gabea", + larpProfileQ8O6: "Dogma pitzatua", + larpProfileQ8O7: "Uzta oparoa", + larpProfileQ8O8: "Eraikin amaitua", + larpProfileQ9: "Esnatzean, nahi duzuna da…", + larpProfileQ9O1: "Gorputza entrenatu", + larpProfileQ9O2: "Irakurri edo idatzi", + larpProfileQ9O3: "Lorategia landu edo sukaldatu", + larpProfileQ9O4: "Zerbaitekin jolastu", + larpProfileQ9O5: "Aginteari aurre egin", + larpProfileQ9O6: "Proiektu bat planifikatu", + larpProfileQ9O7: "Lagunekin hitz egin", + larpProfileQ9O8: "Artea egin", + larpProfileQ10: "Zure ahultasuna izan daiteke:", + larpProfileQ10O1: "Gehiegi hitz egin", + larpProfileQ10O2: "Pragmatikoegia izan", + larpProfileQ10O3: "Idealistegia izan", + larpProfileQ10O4: "Aldakorregia izan", + larpProfileQ10O5: "Zurrunegia izan", + larpProfileQ10O6: "Arinegia izan", + larpProfileQ10O7: "Zuhurregia izan", + larpProfileQ10O8: "Anbiziotsuegia izan", + uxModeTitle: "UX", + uxModeDescription: "Aukeratu nahi duzun UX nabigazio modua zure interfazerako.", + uxModeMenus: "Blokeak", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_fr.js b/src/client/assets/translations/oasis_fr.js new file mode 100644 index 0000000..d18f647 --- /dev/null +++ b/src/client/assets/translations/oasis_fr.js @@ -0,0 +1,3872 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + fr: { + languageName: "Français", + shopOrderStatusPaid: "Paiement reçu", + shopOrderStatusReceived: "Reçu", + shopOrderMarkPaid: "Marquer paiement reçu", + shopOrderConfirmReceived: "Confirmer la réception", + shopMyOrdersTitle: "Mes commandes", + shopPurchasesButton: "Achats", + shopMyOrdersDescription: "Vos commandes d’achat.", + shopMyOrdersEmpty: "Vous n’avez pas encore d’achats.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Vendeur", + shopOrderPaymentConcept: "Paiement", + shopOrderInvoice: "Facture", + shopOrderInvoiceConcept: "Facture", + shopOrderStatusPending: "En attente", + shopOrderStatusAccepted: "Accepté", + shopOrderStatusRejected: "Rejeté", + shopOrderStatusShipped: "Expédié", + shopOrderStatusDelivered: "Livré", + shopOrderAccept: "Accepter", + shopOrderReject: "Rejeter", + shopOrderMarkShipped: "Marquer expédié", + shopOrderMarkDelivered: "Marquer livré", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Contrat", + shopOrderContractConcept: "Commande boutique", + shopOrdersPending: "Commandes en attente", + all: "Tous", + mine: "Les miens", + recent: "Récents", + anonymous: "Anonyme", + unnamed: "Anonyme", + authorLabel: "AUTEUR", + delete: "Supprimer", + save: "Enregistrer", + vote: "Voter", + url: "URL", + price: "Prix", + priority: "Priorité", + severity: "Gravité", + organizer: "Organisateur", + edited: "modifié", + isPublic: "Public", + searchIsPublicLabel: "Public", + privacyPrivate: "PRIVÉ", + privacyPublic: "PUBLIC", + notFound: "Introuvable", + no_results: "Aucune donnée", + searchButton: "Rechercher", + mapZoomLabel: "Zoom", + modulesTitle: "Modules", + viewJson: "Voir le JSON", + matchScore: "Score de correspondance", + preferencesLabel: "Préférences", + inhabitantProfileTitle: "Profil de l’habitant", + contentWarningLabel: "Avertissement de contenu", + invalidPost: "Contenu invalide", + invalidPostHint: "Ce message a un texte vide/invalide.", + spreadBy: "Par", + spreadChron: "Diffusion", + spreaded: "Diffusé", + spreadMore: "plus", + spreadContentUnavailable: "Contenu pas encore disponible (réplication en attente)", + filteredByTag: "Filtré par étiquette", + filteredByTagTitle: "Filtré par étiquette", + feedPublishedSuccess: "Flux publié avec succès !", + tribeFeedSent: "Message envoyé avec succès !", + tribeContentEncrypted: "Contenu chiffré", + tribeTorrentsEmpty: "Pas encore de torrents.", + torrentDescription: "Description", + torrentDownload: "Télécharger", + chatInviteMode: "Inviter", + padInviteMode: "Inviter", + noInviteMode: "Aucun mode d’invitation", + noDeadline: "Aucune échéance", + noStatus: "Aucun statut", + noSeverity: "Aucune gravité", + calendarDeleteDate: "Supprimer la date", + calendarIntervalUntil: "Jusqu’au", + bookmarkCategory: "Catégorie", + bookmarkLastVisit: "Dernière visite", + profileClearnetBookmarksLabel: "Favoris", + forumFilterHot: "Populaires", + invitesPort: "Port", + currentlyUnrecheable: "ERREUR !", + peerHostValidation: "IPv4 valide (ex. 192.168.1.100) ou nom d’hôte (ex. pub.example.com)", + peerPortValidation: "Port 1-65535", + peerKeyValidation: "Clé publique SSB ed25519 (@<44 caractères base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Désinscription de votre offre d’emploi", + inboxProjectFollowedTitle: "Nouvel abonné à votre projet", + inboxProjectUnfollowedTitle: "A cessé de suivre votre projet", + pmHasFollowedYourProject: "a suivi votre projet", + pmHasUnfollowedYourProject: "a cessé de suivre votre projet", + pmHasUnsubscribedFromYourJobOffer: "s’est désinscrit de votre offre d’emploi", + marketDeleteButton: "Supprimer", + marketNoBids: "Aucune enchère pour l’instant", + marketAuctionBidAmount: "Montant de l’enchère", + marketAuctionBidTime: "Heure de l’enchère", + marketAuctionUser: "Enchérisseur", + parliamentActorInPower: "TRIBU AU POUVOIR", + parliamentWinningCandidature: "Candidature gagnante", + projectBounty: "Récompense", + projectFollowing: "ABONNÉ", + projectBackerAuthor: "Contributeur", + projectBackerDate: "Date", + projectConfirmTransferButton: "Confirmer le transfert", + projectPendingTransfersTitle: "Transferts en attente", + transfersUpdateSectionTitle: "Mettre à jour le transfert", + taskUnassignedFrom: "Désassigné de", + taskDescriptionPlaceholder: "Description de la tâche", + exportPasswordLabel: "Mot de passe d’export", + importPasswordPlaceholder: "Saisissez le mot de passe", + searchCasesPlaceholder: "Rechercher des affaires…", + shopBuyDeliveryAddressPlaceholder: "Adresse de livraison", + shopBuyNotesPlaceholder: "Notes", + bankTaxesUserNoteBold: "réduire directement la taxe ECO", + bankTaxesUserNoteIntro: "Votre taxe ECO est déduite de l’excédent que le réseau génère au-dessus de votre RBU ; la valeur de base de la RBU est fixée comme minimum inamovible. La taxe n’est jamais déduite du montant fixe qui correspond à chaque habitant en tant que RBU. Les fonds déduits alimentent l’algorithme de redistribution des richesses et sont redirigés vers d’autres habitants via leurs demandes de RBU. Ces autres habitants qui reçoivent plus de RBU pour leurs projets auront probablement une tâche dédiée à vous aider à réduire votre taxe ECO. Ou ils peuvent se consacrer à ", + bankTaxesUserNoteOutro: ", et le consensus du réseau peut exiger leur contribution continue.", + courtsViewDetails: "Voir", + courtsViewDetailsShort: "Détails", + courtsThDetails: "Détails", + courtsDetailsAnswersTitle: "Réponses", + courtsDetailsEvidenceTitle: "Preuves", + courtsDetailsSettlementsTitle: "Règlements", + courtsDetailsVerdictTitle: "Verdict", + courtsDetailsNotPublic: "Non public", + courtsEvidenceSideAccuser: "Preuves de l’accusateur", + courtsEvidenceSideRespondent: "Preuves de la défense", + courtsEvidenceSideUnknown: "Inconnu", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Soutenir l’affaire", + courtsSettlementAcceptBtn: "Accepter l’accord", + courtsSupportCount: "Soutiens", + courtsVerdictVoteTitle: "Voter sur le verdict", + courtsVerdictVoteLabel: "Vote du verdict", + courtsVerdictVoteAccept: "Accepter", + courtsVerdictVoteReject: "Rejeter", + courtsVerdictVoteSubmit: "Soumettre le vote", + reportsSetStatus: "Définir le statut", + reportsStatusClosed: "CLÔTURÉ", + statsAvgPerInhabitant: "Moy. par habitant", + statsCarbonMaxAnnual: "Estimation max annuelle", + statsCarbonNetwork: "Total du réseau", + statsCarbonOfEstMax: "de la capacité max estimée", + statsCarbonOfNetwork: "du total du réseau", + statsCarbonUser: "Votre empreinte", + statsContentCountColumn: "Nombre", + statsContentTypeColumn: "Type", + statsMsgsPerDay: "Messages/jour (cumulé)", + statsMyShare: "Votre part du réseau", + statsNetworkSpan: "Étendue du réseau", + statsTombstoneRatioLabel: "Taux de suppressions", + statsTopTagsTitle: "Principales étiquettes", + statsTopTypesTitle: "Principaux types de contenu", + statsTotalMsgs: "Total des messages", + feedViewDetails: "Voir les détails", + eventFileLabel: "Joindre une image", + taskFileLabel: "Joindre une image", + courtsRespondentRequired: "L'identifiant du défendeur est requis", + extended: "Multivers", + extendedDescription: [ + "Lorsque vous soutenez quelqu’un, vous pouvez télécharger les publications des habitants qu’il soutient, et ces publications apparaîtront ici, triées de la plus récente.", + ], + popular: "À la une", + popularDescription: [ + "Publications d’habitants de votre réseau, ", + strong("triées par soutiens"), + ". Sélectionnez la période pour obtenir une liste.", + ], + week: "Semaine", + month: "Mois", + year: "Année", + latest: "Derniers", + latestDescription: [ + strong("Publications"), + " à vous et aux habitants que vous soutenez, triées des plus récentes.", + ], + topics: "Sujets", + topicsDescription: [ + strong("Sujets"), + " à vous et aux habitants que vous soutenez, triés des plus récents.", + ], + summaries: "Résumés", + summariesDescription: [ + strong("Sujets et quelques commentaires"), + " à vous et aux habitants que vous soutenez, triés des plus récents.", + ], + threads: "Fils", + threadsDescription: [ + strong("Publications qui ont des commentaires"), + " des habitants que vous soutenez et de votre multivers, triées des plus récentes.", + ], + profile: "Avatar", + inhabitants: "Habitants", + peersReplicatedFeeds: "Flux répliqués", + graphos: "Graphos", + graphosDescription: "Carte interactive du réseau autour de toi.", + graphosViewingGraphOf: "Affichage", + graphosBackToMine: "← Mon réseau", + graphosShowing: "Affichage", + graphosYou: "Toi", + graphosTotalNodes: "Nœuds totaux", + manualMode: "Mode Manuel", + mentions: "Mentions", + mentionsDescription: [ + strong("Publications qui vous @mentionnent"), + ", triées des plus récentes.", + ], + nextPage: "Suivant", + previousPage: "Précédent", + noMentions: "Vous n’avez pas encore reçu de @mentions.", + privateReminders: "RAPPELS", + inboxFiltersLabel: "Filtres :", + inboxToggleToMutuals: "Passer à mutuels", + inboxToggleToWhole: "Passer à tous", + privateFrom: "De", + privateTo: "Pour", + privateDate: "Date", + peers: "Nœuds", + searchDescription: "Description", + imageSearch: "Rechercher des images", + searchFromLabel: "De", + searchToLabel: "À", + searchMinOpinionsLabel: "Min opinions", + searchInhabitantLabel: "Habitant (Oasis ID)", + searchQueryLabel: "Recherche", + searchPerPageLabel: "Résultats par page", + settings: "Configuration", + continueReading: "Continuer la lecture", + moreComments: "plus de commentaires", + readThread: "lire le reste du fil", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Dessinez des pixels sur une grille et collabor-ART avec d’autres dans votre réseau.', + melodyTitle: 'Melody', + melodyDescription: 'Joue la mélodie de ta blockchain — chaque bloc devient une note.', + melodyEmpty: 'Pas encore de notes — ta blockchain n’a pas encore produit de bloc.', + melodyCompositionHelp: 'Chaque bloc de ta blockchain devient une note musicale selon son type et sa taille.', + melodyStatsTitle: 'Répartition par type', + melodyInhabitantLabel: 'Habitant', + melodyTotalBlocks: 'Notes', + melodyType: 'Type', + melodyCount: 'Blocs', + melodyFilterAll: 'TOUTES', + melodyFilterShare: 'Téléverser une mélodie', + melodyShareTitle: 'Partage ta mélodie', + melodyShareHelp: 'Publie un instantané de ta mélodie actuelle pour que d’autres puissent l’écouter et la remixer.', + melodyShareTitleLabel: 'Titre (optionnel)', + melodyShareTitlePlaceholder: 'Un fantôme de blockchain', + melodyShareSubmit: 'Publier la mélodie', + melodyNoShared: 'Aucune mélodie blockchain pour l’instant.', + melodyRegenerate: 'Régénérer', + melodyDownload: "Télécharger BCS", + profileActivityTitle: 'Activité', + profileActivityMore: 'Voir toute l’activité', + profileContentSectionTitle: 'Contenu de l’avatar', + profileContentHelp: 'Choisis les modules à afficher sur ton avatar.', + profileSensorsHelp: 'Métriques optionnelles affichées sur ton avatar.', + profileClearnetHelp: 'Modules accessibles depuis l’extérieur d’Oasis.', + profileHubAll: 'Tout', + profileHubTitle: 'Contenu Public', + footerPulseTitle: 'Pouls', + footerPulsePeers: 'Pairs', + footerPulseInbox: 'Boîte', + footerPulseSync: 'Dernière sync', + footerPulseEcoValue: 'ECO/h', + footerPulseLastActivity: 'Dernière activité', + footerLicenseLabel: 'Licence', + profileSwitchToOasis: 'Retour à Oasis', + profileSwitchToClearnet: 'Plonger dans le Clearnet', + profileVisibilityFediverse: "Fediverse", + profileSwitchToFediverse: "Plonger dans le fédiverse", + coordLabel: 'Coordonnées (ex., A3)', + coordPlaceholder: 'Saisissez une coordonnée', + contributorsTitle: "Contributions", + pixeliaBy: "par", + colorLabel: 'Choisir une couleur', + paintButton: 'Dessiner !', + invalidCoordinate: 'Coordonnée incorrecte', + goToMuralButton: "Voir la fresque", + totalPixels: 'Total de pixels', + modules: "Modules", + modulesViewTitle: "Modules", + modulesViewDescription: "Définissez votre environnement idéal en activant et désactivant des modules.", + inbox: "Boîte", + multiverse: "Multivers", + fediverse: "Fediverse", + fediverseDescription: "Gérez vos autres comptes du fédiverse, y compris l'envoi et la réception de contenu.", + fediverseStatus: "Statut", + fediverseDisconnected: "Fédiverse déconnecté. Vérifiez %LINK% ou l'état de la connexion.", + fediverseSettingsTitle: "Fediverse", + fediverseInstanceLabel: "Adresse", + fediverseTokenLabel: "Jeton d'accès", + fediverseTokenHelp: "Définissez votre jeton d'accès. Il servira à gérer votre compte Mastodon depuis Oasis.", + fediverseConnect: "Connecter", + fediverseConnectedAs: "Connecté en tant que", + fediverseDisconnect: "Déconnecter", + fediverseEmpty: "Lorsque vous connectez d'autres comptes du fédiverse depuis %LINK%, vous pouvez les gérer ici.", + fediverseEmptyLink: "vos réglages", + fediverseComposePlaceholder: "À quoi pensez-vous ?", + fediverseReplyPlaceholder: "Écrivez votre réponse…", + fediverseAttach: "Joindre un média", + fediversePublish: "Publier", + fediverseReply: "Répondre", + fediverseBoosted: "a partagé", + fediverseNoPosts: "Votre fil est vide.", + fediverseThread: "Fil", + fediverseTimeline: "Fils", + fediverseManageTimeline: "Gérer le fil", + fediverseFollowers: "Abonnés", + fediverseFollowing: "Abonnements", + fediversePosts: "Publications", + fediverseJoined: "Inscrit", + fediverseOverview: "Vue d'ensemble", + fediverseRefresh: "Actualiser", + fediverseWrite: "Écrire", + fediversePreview: "Aperçu", + fediverseEdit: "Modifier", + fediverseOpenFeed: "Voir le flux", + fediverseManage: "Gérer", + fediverseStatusNotConnected: "Non connecté", + fediverseError: "Impossible de contacter Mastodon.", + fediverseErrInstance: "URL d'instance invalide.", + fediverseErrAuth: "Jeton invalide ou expiré.", + fediverseErrConnect: "Impossible de se connecter à l'instance.", + fediverseErrToken: "Jeton requis.", + fediverseErrPublic: "Indisponible en mode public.", + fediverseErrFetch: "Impossible de charger le fil.", + fediverseErrPost: "Impossible de publier.", + fediverseErrMedia: "Impossible d'envoyer le fichier.", + fediverseErrEmpty: "Écrivez quelque chose ou joignez un fichier.", + popularLabel: "À la une", + topicsLabel: "Sujets", + latestLabel: "Derniers", + summariesLabel: "Résumés", + threadsLabel: "Fils", + multiverseLabel: "Multivers", + inboxLabel: "Boîte", + invitesLabel: "Invitations", + walletLabel: "Portefeuille", + legacyLabel: "Clés", + cipherLabel: "Chiffreur", + bookmarksLabel: "Marque-pages", + videosLabel: "Vidéos", + torrentsLabel: "Torrents", + docsLabel: "Documents", + audiosLabel: "Audios", + tagsLabel: "Étiquettes", + imagesLabel: "Images", + inhabitantsLabel: "Habitants", + trendingLabel: "Tendances", + eventsLabel: "Événements", + tasksLabel: "Tâches", + apply: "Appliquer", + menuPersonal: "Personnel", + menuContent: "Contenu", + menuBlogs: "Blogs", + menuGovernance: "Gouvernance", + menuOffice: "Bureau", + menuMultiverse: "Multivers", + menuNetwork: "Réseau", + menuCreative: "Créatif", + menuEconomy: "Économie", + menuMedia: "Médias", + menuTools: "Outils", + comment: "Commenter", + subtopic: "Sous-sujet", + json: "JSON", + createdBy: "par", + unfollow: "Ne plus soutenir", + follow: "Soutenir", + block: "Bloquer", + unblock: "Débloquer", + newerPosts: "Nouveaux posts", + olderPosts: "Anciens posts", + feedRangeEmpty: "La plage appliquée est vide pour ce fil. Essayez de voir le ", + seeFullFeed: "fil complet", + feedEmpty: "Le réseau Oasis n’a jamais vu de posts depuis ce compte.", + beginningOfFeed: "Ceci est le début du fil", + noNewerPosts: "Aucun nouveau post reçu pour l’instant.", + relationshipNotFollowing: "Ne vous soutient pas", + relationshipTheyFollow: "Vous soutient", + relationshipMutuals: "Soutien mutuel", + relationshipFollowing: "Vous soutenez", + relationshipYou: "Vous", + relationshipBlocking: "Vous bloquez", + relationshipBlockedBy: "Vous bloque", + relationshipMutualBlock: "Blocage mutuel", + relationshipNone: "Vous ne soutenez pas", + relationshipConflict: "Conflit", + relationshipBlockingPost: "Post bloqué", + viewLikes: "Voir les soutiens", + spreadedDescription: "Liste des posts soutenus par habitant.", + totalspreads: "Soutiens totales", + attachFiles: "Joindre des fichiers", + preview: "Prévisualiser", + publish: "Publier", + contentWarningPlaceholder: "Ajoutez un titre à votre post (optionnel)", + privateWarningPlaceholder: "Ajoutez des habitants pour envoyer un post privé (optionnel)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "RAPPELEZ-VOUS : En raison de la technologie blockchain, une fois que vous avez publié quelque chose, cela ne peut pas être modifié ni supprimé. Réfléchissez bien !", + ], + commentWarning: [ + "RAPPELEZ-VOUS : En raison de la technologie blockchain, une fois que vous avez publié quelque chose, cela ne peut pas être modifié ni supprimé. Réfléchissez bien !", + ], + commentPublic: "public", + commentPrivate: "privé", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "RAPPELEZ-VOUS : En raison de la technologie blockchain, une fois que vous avez publié quelque chose, cela ne peut pas être modifié ni supprimé. Réfléchissez bien !", + ], + replyLabel: ({ markdownUrl }) => [ + "RAPPELEZ-VOUS : En raison de la technologie blockchain, une fois que vous avez publié quelque chose, cela ne peut pas être modifié ni supprimé. Réfléchissez bien !", + ], + publishCustomInfo: ({ href }) => [ + "Si vous avez de l’expérience, vous pouvez aussi ", + a({ href }, "écrire un post avancé"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "Si vous n’avez pas d’expérience, vous devriez ", + a({ href }, "écrire un post normal"), + ".", + ], + publishCustom: "Écrire un post avancé", + subtopicLabel: "Créez un sous-sujet pour ce post", + messagePreview: "Prévisualisation", + mentionsMatching: "Mentions", + mentionsName: "Nom", + mentionsRelationship: "Relation", + updateit: "OBTENIR DES MISES À JOUR !", + updateBannerText: "Une nouvelle version d'Oasis est disponible.", + updateBannerAction: "Mettre à jour →", + info: "Info", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "il y a", + sendTime: "écrit ", + theme: "Thème", + legacy: "Clés", + legacyTitle: "Clés", + legacyDescription: "Gérez votre secret (clé privée) rapidement et en toute sécurité.", + legacyExportButton: "Exporter", + legacyImportButton: "Importer", + ssbLogStream: "Blockchain", + ssbLogStreamDescription: "Configurez la limite de messages pour les flux de la blockchain.", + saveSettings: "Enregistrer la configuration", + exportTitle: "Exporter des données", + exportDescription: "Définissez un mot de passe (min 32 caractères) pour chiffrer votre secret", + exportDataTitle: "Sauvegarde", + exportDataDescription: "Télécharger votre blockchain (clé privée exclue !)", + exportDataButton: "Télécharger la blockchain", + pubWallet: "Portefeuille du PUB", + pubWalletDescription: "Définissez l’URL du portefeuille du PUB. Elle sera utilisée pour les transactions du PUB (RBU incluse).", + pubWalletConfiguration: "Enregistrer la configuration", + importTitle: "Importer des données", + importDescription: "Importez votre secret chiffré (clé privée) pour activer votre avatar", + importAttach: "Joindre le fichier chiffré (.enc)", + passwordLengthInfo: "Le mot de passe doit contenir au moins 32 caractères.", + passwordImport: "Saisissez votre mot de passe pour déchiffrer les données qui seront enregistrées sur votre système (nom : secret)", + exportPasswordPlaceholder: "Utilisez des minuscules, majuscules, chiffres et symboles", + fileInfo: "Votre secret chiffré sera enregistré sur votre système (nom : oasis.enc)", + themeIntro: + "Choisissez un thème.", + setTheme: "Définir le thème", + language: "Langue", + languageDescription: + "Si vous souhaitez utiliser une autre langue, sélectionnez-la ici.", + setLanguage: "Définir la langue", + peerConnections: "Nœuds", + peerConnectionsIntro: "Gérez toutes vos connexions avec d’autres nœuds.", + peerConnectionsTitle: "Connexions", + online: "En ligne", + offline: "Hors ligne", + discovered: 'Découverts', + unknown: 'Inconnus', + lanBroadcastLabel: "Diffusion LAN", + lanBroadcastActive: "Active (multicast UDP sur le LAN)", + lanBroadcastDisabled: "Désactivée (mode pub)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Récent", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Pris en charge", + recommended: "Recommandé", + blocked: "Bloqué", + noConnections: "Aucun nœud connecté.", + noDiscovered: "Vous n’avez découvert aucun nœud.", + noUnknownPeers: "Aucun pair inconnu dans le graphe d’amis.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Rafraîchir", + peerPruneIdle: "Retirer les inactifs", + peerExport: "Exporter", + peerImport: "Importer", + peerImportTitle: "Importer la liste de pairs", + peerImportPlaceholder: "Colle une adresse multiserver par ligne, ou téléverse un fichier .txt…", + noSupportedConnections: "Aucun nœud pris en charge.", + noBlockedConnections: "Aucun nœud bloqué.", + noRecommendedConnections: "Aucun nœud recommandé.", + connectionActionIntro: + "", + startNetworking: "Démarrer le réseau", + stopNetworking: "Arrêter le réseau", + restartNetworking: "Redémarrer le réseau", + sync: "Synchroniser le réseau", + indexes: "Index", + indexesDescription: + "Reconstruire les index est sans risque et peut corriger certains problèmes dans votre blockchain.", + homePageTitle: "Page d'accueil", + homePageDescription: "Sélectionnez le module que vous souhaitez comme page d'accueil.", + saveHomePage: "Enregistrer la page d'accueil", + invites: "Invitations", + invitesTitle: "Invitations", + invitesInvites: "Invitations", + invitesDescription: "Gérer et appliquer des codes d’invitation dans votre réseau.", + invitesTribesTitle: "Tribus", + invitesTribeInviteCodePlaceholder: "Saisissez le code de la tribu", + invitesTribeJoinButton: "Entrer dans la tribu", + invitesChatsTitle: "Chats", + invitesChatInviteCodePlaceholder: "Saisissez le code du chat", + invitesChatJoinButton: "Rejoindre le chat", + invitesPadsTitle: "Pads", + invitesPadInviteCodePlaceholder: "Saisissez le code du pad", + invitesPadJoinButton: "Rejoindre le pad", + invitesCalendarsTitle: "Calendriers", + invitesCalendarInviteCodePlaceholder: "Saisissez le code du calendrier", + invitesCalendarJoinButton: "Rejoindre le calendrier", + invitesEventsTitle: "Événements", + invitesEventInviteCodePlaceholder: "Saisissez le code de l'événement", + invitesEventJoinButton: "Rejoindre l'événement", + invitesForumsTitle: "Forums", + invitesForumInviteCodePlaceholder: "Saisissez le code du forum", + invitesForumJoinButton: "Rejoindre le forum", + invitesMapsTitle: "Cartes", + invitesMapInviteCodePlaceholder: "Saisissez le code de la carte", + invitesMapJoinButton: "Rejoindre la carte", + invitesShopsTitle: "Boutiques", + invitesShopInviteCodePlaceholder: "Saisir le code d'invitation de la boutique", + invitesShopJoinButton: "Rejoindre la boutique", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "Saisissez le code d’invitation du PUB", + invitesAcceptInvite: "Entrer dans le PUB", + invitesAlreadyFederated: "Vous êtes déjà fédéré avec ce pub.", + invitesFederationsTitle: "Fédérations", + invitesAcceptedInvites: "Fédérés", + invitesNoInvites: "Aucune invitation acceptée pour le moment.", + invitesNoFederatedPubs: "Aucun pub fédéré.", + invitesUnreachablePubs: "Inaccessibles", + invitesNoUnreachablePubs: "Aucun pub inaccessible.", + invitesPubsRefresh: "Rafraîchir", + invitesPubsClearUnreachable: "Retirer les injoignables", + invitesPubsExport: "Exporter", + invitesPubsImport: "Importer", + invitesPubsImportTitle: "Importer des pubs", + invitesPubsImportPlaceholder: "Colle une adresse multiserver ou un code d'invitation par ligne, ou téléverse un fichier .txt…", + currentlyUnreachable: "ERREUR !", + errorDetails: "Détails de l’erreur", + genericError: "Une erreur s’est produite.", + panicMode: "Mode Panique !", + encryptData: "Définissez un mot de passe (min 32 caractères) pour chiffrer votre blockchain", + decryptData: "Saisissez le mot de passe pour déchiffrer votre blockchain", + panicModeDescription: "Chiffrer/Déchiffrer ou SUPPRIMER votre blockchain", + removeDataDescription: "ATTENTION : Ce processus ne peut pas être annulé.", + encryptPanicButton: "Chiffrer la blockchain", + decryptPanicButton: "Déchiffrer la blockchain", + removePanicButton: "SUPPRIMER TOUTES VOS DONNÉES !", + searchTitle: "Rechercher", + searchDescriptionLabel: "Rechercher du contenu dans votre réseau.", + searchLanguagesLabel: "Langues", + searchSkillsLabel: "Compétences", + searchPlaceholder:"Rechercher du contenu...", + searchDateLabel:"Date", + searchLocationLabel:"Localisation", + searchPriceLabel:"Prix", + searchUrlLabel:"URL", + searchCategoryLabel:"Catégorie", + searchStartLabel:"Date de début", + searchEndLabel:"Date de fin", + searchPriorityLabel:"Priorité", + searchStatusLabel:"État", + statusLabel:"État", + totalVotesLabel:"Total des votes", + noResultsFound:"Aucun résultat trouvé.", + votesOption:"Opinions votées", + voteYesLabel:"Oui", + voteNoLabel:"Non", + allTypesLabel: "SANS LIMITES", + ABSTENTIONLabel:"ABSTENTION", + YESLabel:"OUI", + NOLabel:"NON", + FOLLOW_MAJORITYLabel: "SUIVRE LA MAJORITÉ", + CONFUSEDLabel: "CONFUS", + NOT_INTERESTEDLabel: "SANS INTÉRÊT", + StatusLabel:"État", + votesOptionYesLabel:"Oui", + votesOptionNoLabel:"Non", + votesOptionAbstentionLabel:"Abstention", + votesQuestionLabel:"Question", + votesCreatedByLabel:"Créé le", + voteStatusOpen:"OUVERT", + voteStatusClosed:"FERMÉ", + imageSearchLabel: "Saisissez des mots pour rechercher les images étiquetées avec ces mots.", + commentDescription: ({ parentUrl }) => [ + " a commenté dans ", + a({ href: parentUrl }, " fil"), + ], + commentTitle: ({ authorName }) => [`A commenté sur le post de @${authorName}`], + subtopicDescription: ({ parentUrl }) => [ + " a créé un sous-sujet depuis ", + a({ href: parentUrl }, " un post"), + ], + subtopicTitle: ({ authorName }) => [`A créé un sous-sujet dans le post de @${authorName}`], + mysteryDescription: "a publié un post mystérieux", + oasisDescription: "OASIS Réseau de Projets", + searchSubmit: "Rechercher...", + postLabel: "PUBLICATIONS", + aboutLabel: "HABITANTS", + feedLabel: "FLUX", + votesLabel: "GOUVERNANCE", + reportLabel: "RAPPORTS", + imageLabel: "IMAGES", + videoLabel: "VIDÉOS", + audioLabel: "AUDIOS", + documentLabel: "DOCUMENTS", + torrentLabel: "TORRENTS", + pdfFallbackLabel: "Document PDF", + eventLabel: "ÉVÉNEMENTS", + taskLabel: "TÂCHES", + transferLabel: "TRANSFERTS", + curriculumLabel: "CURRICULUM VITAE", + bookmarkLabel: "LIENS", + tribeLabel: "TRIBUS", + marketLabel: "MARCHÉ", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "PORTEFEUILLES", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Appliquer", + subjectLabel: "Sujet", + editProfile: "Éditer l’Avatar", + profileQrAlt: "Code QR du profil", + profileQrHint: "Scannez pour copier cet identifiant Oasis.", + oasisIdLabel: "OasisID", + spreadLabel: "Diffuser", + spreadHint: "Diffusez ceci à ceux qui vous soutiennent (réplique via votre feed).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Bienvenue sur Oasis — un réseau social libre, pair-à-pair, fédéré et chiffré, avec une architecture distribuée sur invitation uniquement.\n\nQuelques endroits intéressants par où commencer :\n\n- /profile — Édite ton avatar, ton nom, ta description et les modules visibles sur ta partie publique.\n- /invites — Ajoute un pub pour fédérer avec le reste du réseau.\n- /peers — Vois qui est connecté, rescanne ton LAN, exporte ou importe des listes de pairs.\n- /inhabitants — Découvre et visite les avatars d'autres personnes.\n- /tribes — Crée ou rejoins des groupes privés chiffrés de bout en bout.\n- /inbox — Lis et envoie des messages privés.\n- /activity — Vois l'activité récente, la tienne et celle de ton réseau.\n- /publish — Écris un post ou partage une image, un audio, une vidéo ou un document.\n\nQuelques endroits intéressants pour se connecter :\n\n- /fediverse — Gérez vos autres comptes du fédiverse, y compris l'envoi et la réception de contenu.\n\nCe message a été envoyé en privé de toi à toi-même : aucune connexion n'était nécessaire pour le recevoir.", + profileVisibilityTitle: "Visibilité du profil public", + profileVisibilityHint: "Choisissez les champs visibles par les autres habitants sur votre profil. Par défaut, seul Karma est affiché.", + profileSensorsSectionTitle: "Capteurs", + profileVisibilityActivity: "Niveau d’activité", + profileVisibilityDevice: "Appareil", + profileDeviceLockedHint: "Ce capteur est toujours actif : il permet aux autres de voir depuis quel appareil vous vous connectez et ne peut pas être désactivé.", + profileVisibilityKarma: "Score KARMA", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "Portefeuille ECOIN", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "Clé GPG", + profileVisibilityClearnet: "Publier mon profil", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Boutiques", + profileClearnetJobsLabel: "Offres d’emploi", + profileClearnetEventsLabel: "Événements", + profileClearnetProjectsLabel: "Projets", + profileClearnetPostsLabel: "Blogs", + profileClearnetAudiosLabel: "Audios", + profileClearnetVideosLabel: "Vidéos", + profileClearnetImagesLabel: "Images", + profileClearnetDocumentsLabel: "Documents", + profileClearnetTorrentsLabel: "Torrents", + editProfileDescription: + "", + profileName: "Nom", + profileImage: "Image Avatar", + profileGpgKey: "Clé Publique GPG (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Télécharger", + profileGpgRemove: "Supprimer", + profileDescription: "Description", + hashtagDescription: + "Publications des habitants de votre réseau qui référencent ce #hashtag, triées par la plus récente.", + rebuildName: "Reconstruire la blockchain", + wallet: "Portefeuille", + walletAddress: "Adresse", + walletAmount: "Montant", + walletAddressLine: ({ address }) => `Adresse : ${address}`, + walletAmountLine: ({ amount }) => `Montant : ${amount} ECO`, + walletBack: "Retour", + walletBalanceTitle: "Solde", + walletWalletSendTitle: "Envoyer", + walletReceiveTitle: "Recevoir", + walletHistoryTitle: "Historique", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Cnfrs", + walletConfirm: "Confirmer", + walletDescription: "Gérez vos actifs numériques, y compris l’envoi et la réception d’ECOin, l’affichage du solde et l’accès à l’historique des transactions.", + walletDate: "Date", + walletFee: "Frais (Plus les frais sont élevés, plus votre transaction sera traitée rapidement)", + walletFeeLine: ({ fee }) => `Frais : ECO ${fee}`, + walletHistory: "Historique", + walletReceive: "Recevoir", + walletReset: "Réinitialiser", + walletSend: "Envoyer", + walletStatus: "État", + walletDisconnected: [ + "Vous avez le ", + strong("portefeuille ECOin déconnecté"), + ". Vérifiez ", + a({ href: '/settings#wallet' }, "votre configuration"), + " ou l’état de votre connexion.", + ], + walletSentToLine: ({ destination, amount }) => `Envoyer ECO ${amount} à ${destination}`, + walletSettingsTitle: "Portefeuille", + walletSettingsDescription: "Intégrer Oasis avec votre portefeuille ECOin.", + walletSettingsDocLink: "Guide d’installation d’ECOin", + walletStatusMessages: { + invalid_amount: "Montant invalide", + invalid_dest: "Adresse de destination invalide", + invalid_fee: "Frais invalides", + validation_errors: "Erreurs de validation", + send_tx_success: "Transaction effectuée", + }, + walletTitle: "Portefeuille", + walletTotalCostLine: ({ totalCost }) => `Coût total : ECO ${totalCost}`, + walletTransactionId: "ID de transaction", + walletTxId: "Tx ID", + walletType: "Type", + walletUser: "Utilisateur", + walletPass: "Mot de passe", + walletConfiguration: "Configurer le portefeuille", + cipher: "Chiffreur", + cipherTitle: "Chiffreur", + cipherDescription: "Chiffrez et déchiffrez votre texte de manière symétrique (à l’aide d’un mot de passe partagé).", + randomPassword: "Mot de passe aléatoire", + cipherEncryptTitle: "Chiffrer le texte", + cipherEncryptDescription: "Saisissez le texte à chiffrer", + cipherTextLabel: "Texte à chiffrer", + cipherTextPlaceholder: "Saisissez le texte à chiffrer...", + cipherPasswordLabel: "Définissez un mot de passe (minimum 32 caractères) pour chiffrer votre texte", + cipherPasswordDecryptLabel: "Définissez un mot de passe (32 caractères minimum) pour déchiffrer votre texte", + cipherPasswordPlaceholder: "Saisissez un mot de passe...", + cipherEncryptButton: "Chiffrer", + cipherDecryptTitle: "Déchiffrer le texte", + cipherDecryptDescription: "Saisissez le texte à déchiffrer", + cipherEncryptedMessageLabel: "Texte chiffré", + cipherDecryptedMessageLabel: "Texte déchiffré", + cipherPasswordUsedLabel: "Mot de passe utilisé pour chiffrer (gardez-le !)", + cipherEncryptedTextPlaceholder: "Saisissez le texte chiffré...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "Saisissez le vecteur d’initialisation...", + cipherDecryptButton: "Déchiffrer", + password: "Mot de passe", + text: "Texte", + encryptedText: "Texte chiffré", + iv: "Vecteur d’initialisation (IV)", + encryptTitle: "Chiffrez votre texte", + encryptDescription: "Saisissez le texte que vous souhaitez chiffrer et fournissez un mot de passe.", + encryptButton: "Chiffrer", + decryptTitle: "Déchiffrez votre texte", + decryptDescription: "Saisissez le texte chiffré et fournissez le même mot de passe que celui utilisé pour le chiffrement.", + decryptButton: "Déchiffrer", + passwordLengthError: "Le mot de passe doit comporter au moins 32 caractères.", + missingFieldsError: "Texte, mot de passe ou IV non fournis.", + encryptionError: "Erreur lors du chiffrement du texte.", + decryptionError: "Erreur lors du déchiffrement du texte.", + bookmarkTitle: "Marque-pages", + bookmarkDescription: "Découvrez et gérez des marque-pages dans votre réseau.", + bookmarkAllSectionTitle: "Marque-pages", + bookmarkMineSectionTitle: "Vos marque-pages", + bookmarkRecentSectionTitle: "Marque-pages récents", + bookmarkTopSectionTitle: "Meilleurs marque-pages", + bookmarkFavoritesSectionTitle: "Favoris", + bookmarkCreateSectionTitle: "Créer un marque-page", + bookmarkUpdateSectionTitle: "Mettre à jour le marque-page", + bookmarkFilterAll: "TOUS", + bookmarkFilterMine: "MIENS", + bookmarkFilterTop: "TOP", + bookmarkFilterFavorites: "FAVORIS", + bookmarkFilterRecent: "RÉCENTS", + bookmarkCreateButton: "Créer un marque-page", + bookmarkUpdateButton: "Mettre à jour", + bookmarkDeleteButton: "Supprimer", + bookmarkAddFavoriteButton: "Ajouter aux favoris", + bookmarkRemoveFavoriteButton: "Retirer des favoris", + bookmarkUrlLabel: "Lien", + bookmarkUrlPlaceholder: "https://exemple.com", + bookmarkDescriptionLabel: "Description", + bookmarkDescriptionPlaceholder: "Optionnel", + bookmarkTagsLabel: "Étiquettes", + bookmarkTagsPlaceholder: "Saisissez des tags séparés par des virgules", + bookmarkCategoryLabel: "Catégorie", + bookmarkCategoryPlaceholder: "Optionnel", + bookmarkLastVisitLabel: "Dernière visite", + bookmarkSearchPlaceholder: "Rechercher URL, tags, catégorie, auteur...", + bookmarkSortRecent: "Plus récents", + bookmarkSortOldest: "Plus anciens", + bookmarkSortTop: "Les plus votés", + bookmarkSearchButton: "Rechercher", + bookmarkUpdatedAt: "Mis à jour", + bookmarkNoMatch: "Aucun marque-page ne correspond à votre recherche.", + noBookmarks: "Aucun marque-page disponible.", + noUrl: "Aucun lien", + noCategory: "Aucune catégorie", + noLastVisit: "Aucune dernière visite", + videoTitle: "Vidéos", + videoDescription: "Explorez et gérez du contenu vidéo dans votre réseau.", + videoPluginTitle: "Titre", + videoPluginDescription: "Description", + videoMineSectionTitle: "Vos vidéos", + videoCreateSectionTitle: "Téléverser une vidéo", + videoUpdateSectionTitle: "Mettre à jour la vidéo", + videoAllSectionTitle: "Vidéos", + videoRecentSectionTitle: "Vidéos récentes", + videoTopSectionTitle: "Vidéos les plus votées", + videoFavoritesSectionTitle: "Favoris", + videoFilterAll: "TOUS", + videoFilterMine: "À MOI", + videoFilterRecent: "RÉCENTES", + videoFilterTop: "TOP", + videoFilterFavorites: "FAVORIS", + videoCreateButton: "Téléverser une vidéo", + videoUpdateButton: "Mettre à jour", + videoDeleteButton: "Supprimer", + videoAddFavoriteButton: "Ajouter aux favoris", + videoRemoveFavoriteButton: "Retirer des favoris", + videoFileLabel: "Sélectionnez un fichier vidéo (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Étiquettes", + videoTagsPlaceholder: "Saisissez des tags séparés par des virgules", + videoTitleLabel: "Titre", + videoTitlePlaceholder: "Facultatif", + videoDescriptionLabel: "Description", + videoDescriptionPlaceholder: "Facultatif", + videoNoFile: "Aucun fichier vidéo fourni", + noVideos: "Aucune vidéo disponible.", + videoSearchPlaceholder: "Rechercher par titre, tags, auteur…", + videoSortRecent: "Les plus récentes", + videoSortOldest: "Les plus anciennes", + videoSortTop: "Les plus votées", + videoSearchButton: "Rechercher", + videoMessageAuthorButton: "MP", + videoUpdatedAt: "Mis à jour", + videoNoMatch: "Aucune vidéo ne correspond à votre recherche.", + documentTitle: "Documents", + documentDescription: "Découvrez et gérez des documents dans votre réseau.", + documentAllSectionTitle: "Documents", + documentMineSectionTitle: "Vos documents", + documentRecentSectionTitle: "Documents récents", + documentTopSectionTitle: "Documents top", + documentFavoritesSectionTitle: "Favoris", + documentCreateSectionTitle: "Téléverser un document", + documentUpdateSectionTitle: "Modifier le document", + documentFilterAll: "TOUS", + documentFilterMine: "MIENS", + documentFilterRecent: "RÉCENTS", + documentFilterTop: "TOP", + documentFilterFavorites: "FAVORIS", + documentCreateButton: "Téléverser", + documentUpdateButton: "Mettre à jour", + documentDeleteButton: "Supprimer", + documentAddFavoriteButton: "Ajouter aux favoris", + documentRemoveFavoriteButton: "Retirer des favoris", + documentMessageAuthorButton: "PM", + documentFileLabel: "Téléverser un document (.pdf)", + documentTagsLabel: "Étiquettes", + documentTagsPlaceholder: "Saisissez des tags séparés par des virgules", + documentTitleLabel: "Titre", + documentTitlePlaceholder: "Optionnel", + documentDescriptionLabel: "Description", + documentDescriptionPlaceholder: "Optionnel", + documentNoFile: "Aucun fichier.", + noDocuments: "Aucun document disponible.", + documentSearchPlaceholder: "Rechercher titre, tags, description, auteur...", + documentSortRecent: "Les plus récents", + documentSortOldest: "Les plus anciens", + documentSortTop: "Les plus votés", + documentSearchButton: "Rechercher", + documentNoMatch: "Aucun document ne correspond à votre recherche.", + documentUpdatedAt: "Mis à jour", + audioTitle: "Audios", + audioDescription: "Explorez et gérez du contenu audio dans votre réseau.", + audioPluginTitle: "Titre", + audioPluginDescription: "Description", + audioMineSectionTitle: "Vos audios", + audioCreateSectionTitle: "Téléverser un audio", + audioUpdateSectionTitle: "Mettre à jour l’audio", + audioAllSectionTitle: "Audios", + audioRecentSectionTitle: "Audios récents", + audioTopSectionTitle: "Audios les mieux notés", + audioFilterAll: "TOUS", + audioFilterMine: "À MOI", + audioFilterRecent: "RÉCENTS", + audioFilterTop: "TOP", + audioFilterBlockchain: "BLOCKCHAIN", + audioCreateButton: "Téléverser un audio", + audioUpdateButton: "Mettre à jour", + audioDeleteButton: "Supprimer", + audioAddFavoriteButton: "Ajouter aux favoris", + audioRemoveFavoriteButton: "Retirer des favoris", + audioFileLabel: "Sélectionnez un fichier audio (.mp3, .wav, .ogg)", + audioTagsLabel: "Étiquettes", + audioTagsPlaceholder: "Saisissez des tags séparés par des virgules", + audioTitleLabel: "Titre", + audioTitlePlaceholder: "Facultatif", + audioDescriptionLabel: "Description", + audioDescriptionPlaceholder: "Facultatif", + audioNoFile: "Aucun fichier audio fourni", + noAudios: "Aucun audio disponible.", + audioSearchPlaceholder: "Rechercher par titre, tags, auteur…", + audioSortRecent: "Les plus récents", + audioSortOldest: "Les plus anciens", + audioSortTop: "Les plus votés", + audioSearchButton: "Rechercher", + audioMessageAuthorButton: "PM", + audioUpdatedAt: "Mis à jour", + audioNoMatch: "Aucun audio ne correspond à votre recherche.", + audioFavoritesSectionTitle: "Favoris", + audioFilterFavorites: "FAVORIS", + favoritesTitle: "Favoris", + favoritesDescription: "Tous vos favoris au même endroit.", + favoritesFilterAll: "TOUS", + favoritesFilterRecent: "RÉCENTS", + favoritesFilterAudios: "AUDIOS", + favoritesFilterBookmarks: "MARQUE-PAGES", + favoritesFilterDocuments: "DOCUMENTS", + favoritesFilterImages: "IMAGES", + favoritesFilterMaps: "CARTES", + favoritesFilterPads: "PADS", + favoritesFilterChats: "CHATS", + favoritesFilterCalendars: "CALENDRIERS", + favoritesFilterVideos: "VIDÉOS", + favoritesRemoveButton: "Retirer des favoris", + favoritesNoItems: "Aucun favori pour le moment.", + yourContacts: "Vos contacts", + allInhabitants: "Habitants", + allCVs: "Tous les CV", + discoverPeople: "Découvrez des habitants dans votre réseau.", + allInhabitantsButton: "TOUS", + contactsButton: "SOUTIENS", + CVsButton: "CVs", + matchSkills: "Faire correspondre des compétences", + matchSkillsButton: "FAIRE CORRESPONDRE LES COMPÉTENCES", + suggestedButton: "SUGGÉRÉS", + searchInhabitantsPlaceholder: "FILTRER les habitants PAR NOM …", + filterLocation: "FILTRER les habitants PAR LOCALISATION …", + filterLanguage: "FILTRER les habitants PAR LANGUE …", + filterSkills: "FILTRER les habitants PAR COMPÉTENCES …", + applyFilters: "Appliquer les filtres", + locationLabel: "Localisation", + languagesLabel: "Langues", + skillsLabel: "Compétences", + commonSkills: "Compétences communes", + mutualFollowers: "Soutiens mutuels", + suggestedFollowsYou: "Te suit", + latestInteractions: "Dernières interactions", + viewAvatar: "Voir l’Avatar", + viewCV: "Voir le CV", + suggestedSectionTitle: "Suggérés", + topkarmaSectionTitle: "Meilleur Karma", + topecoSectionTitle: "Meilleur Éco", + topactivitySectionTitle: "Top activité", + blockedSectionTitle: "Bloqués", + gallerySectionTitle: "GALERIE", + blockedButton: "BLOQUÉS", + blockedLabel: "Utilisateur bloqué", + inhabitantviewDetails: "Voir les détails", + keepReading: "Lire la suite...", + oasisId: "ID", + noInhabitantsFound: "Aucun habitant trouvé pour l’instant.", + inhabitantActivityLevel: "Niveau Activité", + lifespanLabel: "Durée de vie", + deviceLabel: "Appareil", + parliamentTitle: "Parlement", + parliamentDescription: "Explorez les formes de gouvernement et les politiques de gestion collective.", + parliamentFilterGovernment: "GOUVERNEMENT", + parliamentFilterCandidatures: "CANDIDATURES", + parliamentFilterProposals: "PROPOSITIONS", + parliamentFilterLaws: "LOIS", + parliamentFilterHistorical: "HISTORIQUE", + parliamentFilterLeaders: "DIRIGEANTS", + parliamentFilterRules: "RÈGLES", + parliamentGovernmentCard: "Gouvernement actuel", + parliamentActorInPowerInhabitant: 'HABITANT AU POUVOIR', + parliamentActorInPowerTribe: 'TRIBU AU POUVOIR', + parliamentHistoricalGovernmentsTitle: 'GOUVERNEMENTS', + parliamentHistoricalElectionsTitle: 'CYCLES ÉLECTORAUX', + parliamentHistoricalLawsTitle: 'HISTORIQUE', + parliamentHistoricalLeadersTitle: 'DIRIGEANTS', + parliamentThCycles: 'CYCLES', + parliamentThTimesInPower: 'AU POUVOIR', + parliamentThTotalCandidatures: 'CANDIDATURES', + parliamentThProposed: 'LOIS PROPOSÉES', + parliamentThApproved: 'LOIS APPROUVÉES', + parliamentThDeclined: 'LOIS REJETÉES', + parliamentThDiscarded: 'LOIS ÉCARTÉES', + parliamentMembers: "MEMBRES", + parliamentLegSince: "DÉBUT DU CYCLE", + parliamentLegEnd: "FIN DU CYCLE", + parliamentPoliciesProposal: "PROPOSITIONS DE LOIS", + parliamentPoliciesApproved: "LOIS APPROUVÉES", + parliamentPoliciesDeclined: "LOIS REJETÉES", + parliamentPoliciesDiscarded: "LOIS REJETÉES", + parliamentEfficiency: "% EFFICACITÉ", + parliamentFilterRevocations: 'RÉVOCATIONS', + parliamentRevocationFormTitle: 'Révoquer la Loi', + parliamentRevocationLaw: 'Loi', + parliamentRevocationTitle: 'Titre', + parliamentRevocationReasons: 'Motifs', + parliamentCurrentRevocationsTitle: 'Révocations en Cours', + parliamentFutureRevocationsTitle: 'Révocations à Venir', + parliamentPoliciesRevocated: 'HISTORIQUE', + parliamentPoliciesTitle: 'HISTORIQUE', + parliamentLawsTitle: 'LOIS APPROUVÉES', + parliamentRulesRevocations: 'Toute loi approuvée peut être révoquée en utilisant la méthode de gouvernement en vigueur.', + parliamentNoStableGov: "Aucun gouvernement choisi pour l’instant.", + parliamentNoGovernments: "Il n’y a pas encore de gouvernements.", + parliamentCandidatureFormTitle: "Proposer une Candidature", + parliamentCandidatureIdPh: "ID Oasis (@...) ou nom de la tribu", + parliamentCandidatureSlogan: "Slogan (max 140 car.)", + parliamentCandidatureSloganPh: "Un court slogan", + parliamentCandidatureMethod: "Méthode", + parliamentCandidatureProposeBtn: "Proposer une Candidature", + parliamentThDate: "Date de proposition", + parliamentThSlogan: "Slogan", + parliamentThSince: "Profil depuis", + parliamentThVotes: "Votes reçus", + parliamentThVoteAction: "Voter", + parliamentTypeUser: "Habitant", + parliamentTypeTribe: "Tribu", + parliamentVoteBtn: "Voter", + parliamentProposalFormTitle: "Proposer une Loi", + parliamentProposalDescription: "Description (≤1000)", + parliamentProposalPublish: "Publier la Proposition", + parliamentFinalize: "Finaliser", + parliamentDeadline: "Date limite", + parliamentNoProposals: "Il n’y a pas encore de propositions de loi.", + parliamentNoLaws: "Il n’y a pas encore de lois approuvées.", + parliamentMethodDEMOCRACY: "Démocratie", + parliamentMethodMAJORITY: "Majorité (80%)", + parliamentMethodMINORITY: "Minorité (20%)", + parliamentMethodDICTATORSHIP: "Dictature", + parliamentMethodKARMATOCRACY: "Karmatocratie", + parliamentThId: "ID", + parliamentThProposalDate: "Date de proposition", + parliamentThMethod: "Méthode", + parliamentThKarma: "Karma", + parliamentThSupports: "Soutiens", + parliamentThVote: "Voter", + parliamentCurrentProposalsTitle: "Propositions en cours", + parliamentVotesSlashTotal: "Votes/Total", + parliamentVotesNeeded: "Votes Requis", + parliamentFutureLawsTitle: "Lois futures", + parliamentNoFutureLaws: "Pas encore de lois futures", + parliamentVoteAction: "Voter", + parliamentLeadersTitle: "Dirigeants", + parliamentThLeader: "AVATAR", + parliamentPopulation: "Population", + parliamentThType: "Type", + parliamentThInPower: "Au pouvoir", + parliamentThPresented: "CANDIDATURES", + parliamentNoLeaders: "Aucun dirigeant", + parliamentCandidaturesListTitle: "Liste des Candidatures", + parliamentTimeRemaining: "Temps restant", + parliamentNoLeader: "Pas encore de dirigeants.", + parliamentElectionsStatusTitle: "Prochain Gouvernement", + parliamentProposalDeadlineLabel: "Date limite", + parliamentProposalTimeLeft: "Temps restant", + parliamentProposalOnTrack: "En bonne voie pour être adoptée", + parliamentProposalOffTrack: "Soutien insuffisant", + parliamentRulesTitle: "Fonctionnement du Parlement", + parliamentRulesIntro: "Les élections se résolvent tous les 2 mois ; les candidatures sont continues et sont réinitialisées lorsqu’un gouvernement est choisi.", + parliamentRulesCandidates: "Tout habitant peut se proposer, proposer un autre habitant ou n’importe quelle tribu. Chaque habitant peut proposer jusqu’à 3 candidatures par cycle ; les doublons dans le même cycle sont rejetés.", + parliamentRulesElection: "Gagne la candidature ayant le plus de votes au moment de la résolution.", + parliamentRulesTies: "En cas d’égalité : karma d’habitant le plus élevé ; sinon, profil le plus ancien ; sinon, proposition la plus précoce ; puis ordre lexicographique par ID.", + parliamentRulesFallback: "Si personne ne vote, la dernière candidature proposée gagne. S’il n’y a aucune candidature, une tribu aléatoire est sélectionnée.", + parliamentRulesTerm: "L’onglet Gouvernement affiche le gouvernement actuel et ses statistiques.", + parliamentRulesMethods: "Formes : Anarchie (majorité simple), Démocratie (50 % + 1), Majorité (80 %), Minorité (20 %), Karmatocratie (propositions au karma le plus élevé), Dictature (approbation instantanée).", + parliamentRulesAnarchy: "L’Anarchie est le mode par défaut : si aucune candidature n’est élue à la résolution, l’Anarchie est proclamée. En Anarchie, tout habitant peut proposer des lois.", + parliamentRulesProposals: "Si vous êtes l’habitant au pouvoir ou membre de la tribu au pouvoir, vous pouvez publier des propositions de loi. Les méthodes non dictatoriales ouvrent un vote public.", + parliamentRulesLimit: "Chaque habitant peut publier au maximum 3 loi propositions par cycle.", + parliamentRulesLaws: "Lorsqu’une proposition atteint son seuil, elle devient une Loi et apparaît dans l’onglet Lois avec sa date d’entrée en vigueur.", + parliamentRulesHistorical: "Dans l’onglet Historique, vous pouvez voir chaque cycle de gouvernement qui a eu lieu ainsi que des données sur sa gestion.", + parliamentRulesLeaders: "Dans l’onglet Dirigeants, vous pouvez voir un classement des habitants/tribus qui ont gouverné (ou se sont présentés), classés par efficacité.", + parliamentProposalVoteStatusLabel: "Statut du vote", + parliamentProposalOnTrackYes: "Seuil atteint", + parliamentProposalOnTrackNo: "En dessous du seuil", + courtsTitle: "Tribunaux", + courtsDescription: "Explorez des formes de résolution des conflits et de gestion collective de la justice.", + courtsFilterCases: "AFFAIRES", + courtsFilterMyCases: "MIENNES", + courtsFilterJudges: "JUGES", + courtsFilterHistory: "HISTORIQUE", + courtsFilterRules: "RÈGLES", + courtsCaseFormTitle: "Ouvrir une affaire", + courtsCaseRespondent: "Accusé / Défendeur", + courtsCaseRespondentPh: "ID Oasis (@...) ou nom de Tribu", + courtsCaseMediatorsAccuser: "Médiateurs (accusation)", + courtsCaseMethod: "Méthode de résolution", + courtsCaseDescription: "Description (1000 caractères max)", + courtsCaseEvidenceTitle: "Preuves de l'affaire", + courtsCaseEvidenceHelp: "Joignez des images, audios, documents (PDF) ou vidéos qui appuient votre affaire.", + courtsCaseSubmit: "Déposer l'affaire", + courtsNominateJudge: "Nommer un juge", + courtsJudgeId: "Juge", + courtsJudgeIdPh: "ID Oasis (@...) ou nom d'Habitant", + courtsNominateBtn: "Nommer", + courtsAddEvidence: "Ajouter des preuves", + courtsEvidenceText: "Texte", + courtsEvidenceLink: "Lien", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "Joindre", + courtsAnswerText: "Résumé de la réponse", + courtsAnswerSubmit: "Envoyer la réponse", + courtsVerdictTitle: "Rendre un verdict", + courtsVerdictOrdersPh: "Actions, délais, mesures réparatrices...", + courtsIssueVerdict: "Rendre le verdict", + courtsMediationPropose: "Proposer un accord", + courtsSettlementProposeBtn: "Proposer", + courtsNominationsTitle: "Nominations à la magistrature", + courtsThJudge: "Juge", + courtsThDate: "Date", + courtsThVote: "Vote", + courtsNoNominations: "Aucune nomination pour l’instant.", + courtsRespondentInvalid: "Doit être un identifiant SSB valide (@...ed25519)", + courtsCaseMediatorsRespondentTitle: "Ajouter des médiateurs de la défense", + courtsCaseMediatorsRespondent: "Médiateurs (défense)", + courtsMediatorsAccuserLabel: "Médiateurs (accusation)", + courtsMediatorsRespondentLabel: "Médiateurs (défense)", + courtsMediatorsSubmit: "Enregistrer les médiateurs", + courtsNoMyCases: "Vous n’avez pas encore de conflits.", + courtsNoHistory: "Aucun procès enregistré pour l’instant.", + courtsMethodDICTATOR: "Dictateur", + courtsMethodPOPULAR: "Populaire", + courtsMethodKARMATOCRACY: "Karmatocratie", + courtsRulesTitle: "Fonctionnement des Tribunaux", + courtsRulesIntro: "Les Tribunaux sont un processus géré par la communauté pour résoudre les conflits et promouvoir la justice réparatrice. Le dialogue, des preuves claires et des remèdes proportionnels sont privilégiés.", + courtsRulesLifecycle: "Processus : 1) Ouvrir une affaire 2) Choisir une méthode 3) Soumettre des preuves 4) Audience et délibération 5) Verdict et remède 6) Exécution et clôture 7) Appel (le cas échéant).", + courtsRulesRoles: "Accusation : ouvre l’affaire. Défense : personne ou tribu mise en cause. Méthode : mécanisme choisi par la communauté pour faciliter, évaluer les preuves et rendre un verdict. Témoin : fournit des témoignages ou des preuves. Médiateurs : personnes neutres invitées par l’accusation et/ou la défense, avec accès à tous les détails, qui aident à désamorcer le conflit et à co-créer des accords.", + courtsRulesEvidence: "Description jusqu’à 1000 caractères. Joignez des images, audios, vidéos et documents PDF pertinents et légaux. Ne partagez pas de données privées sensibles sans consentement.", + courtsRulesDeliberation: "Les audiences peuvent être publiques ou privées. Les juges garantissent le respect, demandent des clarifications et peuvent écarter le matériel non pertinent ou illégal.", + courtsRulesVerdict: "La restauration est prioritaire : excuses, accords de médiation, modération de contenu, restrictions temporaires ou autres mesures proportionnées. Le raisonnement doit être consigné.", + courtsRulesAppeals: "Appel : autorisé en présence de nouvelles preuves ou d’une erreur procédurale manifeste. Doit être déposé dans les 7 jours sauf indication contraire.", + courtsRulesPrivacy: "Respectez la vie privée et la sécurité. Le doxing, la haine ou les menaces sont supprimés. Les juges peuvent modifier ou sceller certaines parties du dossier pour protéger les personnes à risque.", + courtsRulesMisconduct: "Le harcèlement, la manipulation ou les preuves fabriquées peuvent entraîner une résolution négative immédiate.", + courtsRulesGlossary: "Affaire : enregistrement d’un conflit. Preuves : éléments à l’appui des affirmations. Verdict : décision avec remède. Appel : demande de réexamen du verdict.", + courtsFilterActions: "ACTIONS", + courtsNoActions: "Aucune action en attente pour votre rôle.", + courtsCaseTitlePlaceholder: "Brève description du conflit", + courtsCaseSeverity: "Gravité", + courtsCaseSeverityNone: "Sans étiquette de gravité", + courtsCaseSeverityLOW: "Faible", + courtsCaseSeverityMEDIUM: "Moyenne", + courtsCaseSeverityHIGH: "Élevée", + courtsCaseSeverityCRITICAL: "Critique", + courtsCaseSubject: "Sujet", + courtsCaseSubjectNone: "Sans étiquette de sujet", + courtsCaseSubjectBEHAVIOUR: "Comportement", + courtsCaseSubjectCONTENT: "Contenu", + courtsCaseSubjectGOVERNANCE: "Gouvernance / règles", + courtsCaseSubjectFINANCIAL: "Financier / ressources", + courtsCaseSubjectOTHER: "Autre", + courtsHiddenRespondent: "Caché (visible uniquement pour les rôles impliqués).", + courtsThRole: "Rôle", + courtsRoleAccuser: "Accusation", + courtsRoleDefence: "Défense", + courtsRoleMediator: "Médiateur", + courtsRoleJudge: "Juge", + courtsRoleDictator: "Dictateur", + courtsAssignJudgeTitle: "Choisir un juge", + courtsAssignJudgeBtn: "Choisir un juge", + trendingTitle: "Tendances", + exploreTrending: "Explorez le contenu le plus populaire dans votre réseau.", + bookmarkButton: "MARQUE-PAGES", + transferButton: "TRANSFERTS", + eventButton: "ÉVÉNEMENTS", + taskButton: "TÂCHES", + votesButton: "GOUVERNANCE", + reportButton: "RAPPORTS", + feedButton: "FLUX", + marketButton: "MARCHÉ", + imageButton: "IMAGES", + audioButton: "AUDIOS", + videoButton: "VIDÉOS", + documentButton: "DOCUMENTS", + torrentButton: "TORRENTS", + noTrendingFound: "Aucun contenu populaire trouvé.", + noContentMessage: "Aucun contenu populaire disponible pour le moment.", + trendingDescription: "Description", + trendingDate: "Date", + trendingLocation: "Localisation", + trendingPrice: "Prix", + trendingUrl: "URL", + trendingCategory: "Catégorie", + trendingStart: "Début", + trendingEnd: "Fin", + trendingPriority: "Priorité", + trendingStatus: "État", + trendingFrom: "De", + trendingTo: "À", + trendingConcept: "Concept", + trendingAmount: "Montant", + trendingDeadline: "Échéance", + trendingItemStatus: "Statut de l’article", + trendingTotalVotes: "Total des votes", + trendingTotalOpinions: "Total des avis", + trendingNoContentMessage: "Aucune tendance disponible pour le moment.", + trendingAuthor: "Par", + trendingCreatedAtLabel: "Créé le", + trendingTotalCount: "Total des compteurs", + tasksTitle: "Tâches", + tasksDescription: "Découvrez et gérez des tâches dans votre réseau.", + taskTitleLabel: "Titre", + taskDescriptionLabel: "Description", + taskStartTimeLabel: "Date de début", + taskEndTimeLabel: "Date de fin", + taskPriorityLabel: "Priorité", + taskPrioritySelect: "Sélectionner la priorité", + taskPriorityUrgent: "Urgente", + taskPriorityHigh: "Élevée", + taskPriorityMedium: "Moyenne", + taskPriorityLow: "Faible", + taskLocationLabel: "Localisation", + taskTagsLabel: "Étiquettes", + taskVisibilityLabel: "Visibilité", + taskPublic: "public", + taskPrivate: "privé", + taskCreatedAt: "Créé le", + taskBy: "Par", + taskStatus: "État", + taskStatusOpen: "Ouverte", + taskStatusInProgress: "En cours", + taskStatusClosed: "Fermée", + taskAssignedTo: "Assignée à", + taskAssignees: "Assignés", + taskAssignButton: "M’assigner", + taskUnassignButton: "Désassigner", + taskCreateButton: "Créer une tâche", + taskUpdateButton: "Mettre à jour", + taskDeleteButton: "Supprimer", + taskFilterAll: "TOUS", + taskFilterMine: "MIENS", + taskFilterOpen: "OUVERTES", + taskFilterInProgress: "EN COURS", + taskFilterClosed: "FERMÉES", + taskFilterAssigned: "ASSIGNÉES", + taskFilterArchived: "ARCHIVÉES", + taskFilterUrgent: "URGENTES", + taskFilterHigh: "HAUTES", + taskFilterMedium: "MOYENNES", + taskFilterLow: "FAIBLES", + taskAllSectionTitle: "Tâches", + taskMineSectionTitle: "Vos tâches", + taskCreateSectionTitle: "Créer une tâche", + taskUpdateSectionTitle: "Mettre à jour", + taskOpenTitle: "Tâches ouvertes", + taskInProgressTitle: "Tâches en cours", + taskClosedTitle: "Tâches fermées", + taskAssignedTitle: "Tâches assignées", + taskArchivedTitle: "Tâches archivées", + taskPublicTitle: "Tâches publiques", + taskPrivateTitle: "Tâches privées", + notasks: "Aucune tâche disponible.", + noLocation: "Aucune localisation spécifiée", + taskSetStatus: "Définir l'état", + eventTitle: "Événements", + eventDateLabel: "Date", + eventsTitle: "Événements", + eventsDescription: "Découvrez et gérez des événements dans votre réseau.", + eventDescription: "Description", + eventPrice: "Prix", + eventStatus: "État", + eventOrganizer: "Organisateur", + eventAllSectionTitle: "Événements", + eventMineSectionTitle: "Vos événements", + eventArchivedTitle: "Événements archivés", + eventCreateSectionTitle: "Créer un événement", + eventUpdateSectionTitle: "Mettre à jour l'événement", + eventDeleteButton: "Supprimer", + eventCreateButton: "Créer un événement", + eventAddToCalendar: "Ajouter au calendrier", + eventVisitCalendar: "Voir le calendrier", + viewTask: "Voir la tâche", + viewReport: "Voir le rapport", + viewTransfer: "Voir le transfert", + viewItem: "Voir l'élément", + viewShop: "Voir la boutique", + viewProject: "Voir le projet", + viewVotation: "Voir le vote", + voteCastTitle: "Voter", + voteResults: "Résultats", + eventTitleLabel: "Titre", + eventDescriptionLabel: "Description", + eventDescriptionPlaceholder: "Saisissez la description de l'événement...", + eventUpdateButton: "Mettre à jour", + eventAttendeesLabel: "Participants", + eventAttendeesPlaceholder: "Saisissez les participants, séparés par des virgules...", + eventTagsLabel: "Étiquettes", + eventTagsPlaceholder: "Saisissez les étiquettes de l'événement, séparées par des virgules...", + eventTags: "Étiquettes", + eventPriceLabel: "Prix", + eventUrlLabel: "URL", + eventAttendees: "Participants", + noAttendees: "Aucun participant pour l'instant", + eventCreatedAt: "Créé le", + eventLocation: "Localisation", + eventLocationLabel: "Localisation", + eventNoLocation: "Aucune localisation spécifiée", + eventNoURL: "Aucune URL spécifiée", + eventBy: "Par", + noevents: "Aucun événement disponible.", + eventDate: "Date", + eventDateFormat: "DD:MM:YYYY HH:mm", + eventAttendButton: "Assister à l'événement", + eventUnattendButton: "Ne plus assister à l'événement", + eventCreatedBy: "Créé par", + eventAttendeesCount: "Nombre de participants", + eventCreatedByYou: "Vous avez créé cet événement", + eventFilterAll: "TOUS", + eventFilterMine: "MIENS", + eventFilterToday: "AUJOURD'HUI", + eventFilterWeek: "CETTE SEMAINE", + eventFilterMonth: "CE MOIS", + eventFilterYear: "CETTE ANNÉE", + eventFilterArchived: "ARCHIVÉS", + eventTodayTitle: "Événements d'aujourd'hui", + eventThisWeekTitle: "Événements cette semaine", + eventThisMonthTitle: "Événements ce mois", + eventThisYearTitle: "Événements cette année", + eventPrivacyLabel: "Visibilité", + eventPublic: "Public", + eventPrivate: "Privé", + eventPublicTitle: "Événements publics", + eventNoPrice: "Événement gratuit", + eventNoImage: "Aucune image téléchargée", + eventAttendConfirmation: "Vous assistez maintenant à cet événement", + eventUnattendConfirmation: "Vous ne participez plus à cet événement", + eventAttended: "Présent", + eventUnattended: "Absent", + eventStatusOpen: "Ouvert", + eventStatusClosed: "Fermé", + tagsTitle: "Étiquettes", + tagsDescription: "Découvrez et explorez les modèles de taxonomie dans votre réseau.", + tagsAllSectionTitle: "Étiquettes", + tagsTopSectionTitle: "Étiquettes principales", + tagsCloudSectionTitle: "Nuage d'étiquettes", + tagsFilterAll: "TOUS", + tagsFilterTop: "TOP", + tagsFilterCloud: "NUAGE", + tagsNoItems: "Aucune étiquette disponible.", + tagsTableHeaderTag: "ÉTIQUETTE/LINK :", + tagsTableHeaderCount: "COMPTEUR :", + transfersTitle: "Transferts", + transfersDescription: "Découvrez et gérez les transferts dans votre réseau.", + transfersFrom: "De", + transfersTo: "À", + transfersFilterAll: "TOUS", + transfersFilterMine: "MIENS", + transfersFilterUBI: "RBU", + transfersFilterMarket: "MARCHÉ", + transfersFilterTop: "TOP", + transfersFilterPending: "EN ATTENTE", + transfersFilterUnconfirmed: "NON CONFIRMÉES", + transfersFilterClosed: "FERMÉES", + transfersFilterDiscarded: "REJETÉES", + transfersFilterEconomic: "ÉCONOMIQUE", + transfersFilterTime: "TEMPS", + transfersFilterTrust: "CONFIANCE", + transfersCategory: "Catégorie", + transfersCategoryEconomic: "Économique", + transfersCategoryTime: "Temps", + transfersCategoryTrust: "Confiance", + transfersCategoryApply: "Appliquer", + transfersUnitEco: "ECO", + transfersUnitHours: "h", + transfersUnitTrust: "confiance", + transfersEconomicSectionTitle: "Transferts économiques", + transfersTimeSectionTitle: "Transferts de temps", + transfersTrustSectionTitle: "Transferts de confiance", + transfersCreateButton: "Créer un transfert", + transfersUpdateButton: "Mettre à jour", + transfersDeleteButton: "Supprimer", + transfersToUser: "ID d'Oasis", + transfersToUserValidation: "ID d'Oasis valide, ex. @…=.ed25519", + transfersConcept: "Concept", + transfersAmount: "Montant", + transfersDeadline: "Date limite", + transfersTags: "Étiquettes", + transfersStatus: "État", + transfersStatusUnconfirmed: "NON CONFIRMÉE", + transfersStatusClosed: "FERMÉE", + transfersStatusDiscarded: "REJETÉE", + transfersCreatedAt: "Créé le", + transfersConfirmations: "CONFIRMATIONS", + transfersContainingBlock: "Bloc contenant", + transfersExportContract: "Créer le contrat", + transfersConfirmButton: "Confirmer le transfert", + transfersNoItems: "Aucun transfert trouvé.", + transfersMineSectionTitle: "Vos transferts", + transfersUBISectionTitle: "Transferts RBU", + transfersMarketSectionTitle: "Transferts du marché", + transfersTopSectionTitle: "Transferts principaux", + transfersPendingSectionTitle: "Transferts en attente", + transfersUnconfirmedSectionTitle: "Transferts non confirmés", + transfersClosedSectionTitle: "Transferts fermés", + transfersDiscardedSectionTitle: "Transferts rejetés", + transfersCreateSectionTitle: "Créer un transfert", + transfersAllSectionTitle: "Transferts", + transfersFilterFavs: "Favoris", + transfersFavsSectionTitle: "Transferts favoris", + transfersSearchLabel: "Recherche", + transfersSearchPlaceholder: "Rechercher concept, tags, utilisateurs...", + transfersMinAmountLabel: "Montant min.", + transfersMaxAmountLabel: "Montant max.", + transfersSortLabel: "Trier par", + transfersSortRecent: "Plus récents", + transfersSortAmount: "Montant le plus élevé", + transfersSortDeadline: "Échéance la plus proche", + transfersSearchButton: "Rechercher", + transfersFavoriteButton: "Favori", + transfersUnfavoriteButton: "Retirer favori", + transfersMessageUserButton: "Message", + transfersExpiringSoonBadge: "BIENTÔT", + transfersExpiredBadge: "EXPIRÉ", + transfersUpdatedAt: "Mis à jour", + transfersNoMatch: "Aucun transfert ne correspond à votre recherche.", + votationsTitle: "Votations", + votationsDescription: "Découvrez et gérez les votes dans votre réseau.", + voteMineSectionTitle: "Vos votes", + voteCreateSectionTitle: "Créer un vote", + voteUpdateSectionTitle: "Mettre à jour", + voteOpenTitle: "Votes ouverts", + voteClosedTitle: "Votes fermés", + voteAllSectionTitle: "Gouvernance", + voteCreateButton: "Créer un vote", + voteUpdateButton: "Mettre à jour", + voteDeleteButton: "Supprimer", + voteOptionYes: "OUI", + voteOptionNo: "NON", + voteOptionAbstention: "ABSTENTION", + voteConfused: "CONFUS", + voteFollowMajority: "SUIVRE LA MAJORITÉ", + voteNotInterested: "SANS INTÉRÊT", + voteFilterAll: "TOUS", + voteFilterMine: "MIENS", + voteFilterOpen: "OUVERTS", + voteFilterClosed: "FERMÉS", + voteQuestionLabel: "Question", + voteDeadlineLabel: "Date limite", + voteOptionsLabel: "Votre vote", + voteBreakdown: "Répartition", + voteFinalResult: "RÉSULTAT", + voteNoQuorum: "PAS DE QUORUM", + voteTagsLabel: "Étiquettes", + voteDeadline: "Date limite", + voteStatus: "État", + voteTags: "Étiquettes", + voteOpinions: "Opinions", + novotes: "Aucune proposition de vote disponible.", + voteBy: "Par", + voteCreatedAt: "Créé le", + voteNoQuestion: "Aucune question fournie", + voteUnknownCreator: "Créateur inconnu", + voteUnknownDate: "Date inconnue", + errorVoteNotFound: "Vote introuvable", + errorAlreadyVoted: "Vous avez déjà voté.", + errorVoteClosedCannotEdit: "Impossible de modifier un vote fermé", + errorVoteDeadlinePassed: "Le délai de ce vote est passé", + errorRetrievingVote: "Erreur lors de la récupération du vote", + errorCreatingVote: "Erreur lors de la création du vote", + errorVoteAlreadyVoted: "Impossible de modifier après avoir voté", + errorDeletingOldVote: "Erreur lors de la suppression du vote précédent", + errorCreatingUpdatedVote: "Erreur lors de la création du vote mis à jour", + errorCreatingTombstone: "Erreur lors de la création de la pierre tombale", + voteDetailSectionTitle: 'Détails du vote', + voteCommentsLabel: 'Commentaires', + voteCommentsForumButton: 'Ouvrir la discussion', + voteCommentsSectionTitle: 'Discussion ouverte', + voteNoCommentsYet: 'Il n’y a pas encore de commentaires. Soyez le premier à répondre.', + voteNewCommentPlaceholder: 'Écrivez votre commentaire ici…', + voteNewCommentButton: 'Publier le commentaire', + voteNewCommentLabel: 'Ajouter un commentaire', + cvTitle: "CV", + cvLabel: "Curriculum Vitae (CV)", + cvEditSectionTitle: "Modifier le CV", + cvCreateSectionTitle: "Créer un CV", + cvDescription: "Gérez et partagez vos compétences professionnelles et informations.", + cvNameLabel: "Nom complet", + cvDescriptionLabel: "Résumé", + cvPhotoLabel: "Photo", + cvPersonalExperiencesLabel: "Expériences personnelles", + cvPersonalSkillsLabel: "Compétences personnelles (séparées par des virgules)", + cvOasisExperiencesLabel: "Expériences de contribution dans Oasis", + cvOasisSkillsLabel: "Compétences de contribution dans Oasis (séparées par des virgules)", + cvEducationExperiencesLabel: "Expériences éducatives", + cvEducationalSkillsLabel: "Compétences éducatives (séparées par des virgules)", + cvProfessionalExperiencesLabel: "Expériences professionnelles", + cvProfessionalSkillsLabel: "Compétences professionnelles (séparées par des virgules)", + cvLanguagesLabel: "Langues", + cvLocationLabel: "Localisation", + cvStatusLabel: "État", + cvPreferencesLabel: "Préférences", + cvOasisContributorLabel: "Contributeur d’Oasis", + cvPersonal: "Personnel", + cvOasis: "Contributeur d’Oasis (optionnel)", + cvOasisContributorView: "Contribution dans Oasis", + cvEducational: "Éducatif (optionnel)", + cvEducationalView: "Éducatif", + cvProfessional: "Professionnel (optionnel)", + cvProfessionalView: "Professionnel", + cvAvailability: "Disponibilité (optionnel)", + cvAvailabilityView: "Disponibilité", + cvUpdateButton: "Mettre à jour", + cvCreateButton: "Créer un CV", + cvContactLabel: "Contact", + cvCreatedAt: "Créé le", + cvUpdatedAt: "Mis à jour le", + cvEditButton: "Mettre à jour", + cvDeleteButton: "Supprimer", + cvNoCV: "Aucun CV trouvé.", + blogSubject: "Sujet", + blogMessage: "Corps limité à 8096 caractères.", + blogImage: "Télécharger un média (max: 50 Mo)", + blogPublish: "Aperçu", + noPopularMessages: "Aucun message populaire publié pour l’instant", + forumTitle: "Forums", + forumCategoryLabel: "Catégorie", + forumTitleLabel: "Titre", + forumTitlePlaceholder: "Titre du forum...", + forumCreateButton: "Créer un forum", + forumCreateSectionTitle: "Créer un forum", + forumDescription: "Discutez ouvertement avec d’autres habitants de votre réseau.", + forumFilterAll: "TOUS", + forumFilterMine: "MIENS", + forumFilterRecent: "RÉCENTS", + forumFilterTop: "TOP", + forumMineSectionTitle: "Vos forums", + forumRecentSectionTitle: "Forums récents", + forumAllSectionTitle: "Forums", + forumDeleteButton: "Supprimer", + forumParticipants: "participants", + forumMessages: "messages", + forumLastMessage: "Dernier message", + forumMessageLabel: "Message", + forumMessagePlaceholder: "Écrivez votre message...", + forumSendButton: "Envoyer", + forumVisitForum: "Visiter le forum", + noForums: "Aucun forum disponible pour l’instant.", + forumVisitButton: "Visiter le forum", + forumCatGENERAL: "Général", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "Politique", + forumCatTECH: "Technologie", + forumCatSCIENCE: "Science", + forumCatMUSIC: "Musique", + forumCatART: "Art", + forumCatGAMING: "Jeux", + forumCatBOOKS: "Livres", + forumCatFILMS: "Films", + forumCatPHILOSOPHY: "Philosophie", + forumCatSOCIETY: "Société", + forumCatPRIVACY: "Vie privée", + forumCatCYBERWARFARE: "Cyberguerre", + forumCatSURVIVALISM: "Survivalisme", + imageTitle: "Images", + imageDescription: "Explorez et gérez du contenu image dans votre réseau.", + imagePluginTitle: "Titre", + imagePluginDescription: "Description", + imageMineSectionTitle: "Vos images", + imageCreateSectionTitle: "Téléverser une image", + imageUpdateSectionTitle: "Mettre à jour l'image", + imageAllSectionTitle: "Images", + imageRecentSectionTitle: "Images récentes", + imageTopSectionTitle: "Top images", + imageFavoritesSectionTitle: "Favoris", + imageGallerySectionTitle: "Galerie", + imageMemeSectionTitle: "Mèmes", + imageFilterAll: "TOUS", + imageFilterMine: "À MOI", + imageFilterRecent: "RÉCENTES", + imageFilterTop: "TOP", + imageFilterFavorites: "FAVORIS", + imageFilterGallery: "GALERIE", + imageFilterMeme: "MÈMES", + imageCreateButton: "Téléverser une image", + imageUpdateButton: "Mettre à jour", + imageDeleteButton: "Supprimer", + imageAddFavoriteButton: "Ajouter aux favoris", + imageRemoveFavoriteButton: "Retirer des favoris", + imageFileLabel: "Sélectionnez un fichier image (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Étiquettes", + imageTagsPlaceholder: "Saisissez des tags séparés par des virgules", + imageTitleLabel: "Titre", + imageTitlePlaceholder: "Facultatif", + imageDescriptionLabel: "Description", + imageDescriptionPlaceholder: "Facultatif", + imageMemeLabel: "MÈME ?", + imageNoFile: "Aucun fichier image fourni", + noImages: "Aucune image disponible.", + imageSearchPlaceholder: "Rechercher par titre, tags, description, auteur…", + imageSortRecent: "Les plus récentes", + imageSortOldest: "Les plus anciennes", + imageSortTop: "Les plus votées", + imageSearchButton: "Rechercher", + imageMessageAuthorButton: "Message", + imageUpdatedAt: "Mis à jour", + imageNoMatch: "Aucune image ne correspond à votre recherche.", + feedTitle: "Fil", + feedDetailTitle: "Fil", + feedOpenDiscussion: "Ouvrir la discussion", + feedPostComment: "Publier un commentaire", + noComments: "Pas encore de commentaires", + createFeedTitle: "Créer un fil", + createFeedButton: "Envoyer le fil !", + feedPlaceholder: "Que se passe-t-il ? (maximum 280 caractères)", + TODAYButton: "AUJOURD’HUI", + CREATEButton: "Créer un fil", + totalOpinions: "Total des opinions", + moreVoted: "Le plus voté", + noFeedsFound: "Aucun fil trouvé.", + createdAtLabel: "Créé le", + FeedshareYourOpinions: "Découvrez et partagez de courts textes dans votre réseau.", + refeedButton: "Repartager", + alreadyRefeeded: "Vous avez déjà repartagé ceci.", + activityTitle: "Activité", + yourActivity: "Votre activité", + globalActivity: "Activité globale", + activityList: "Activité", + activityDesc: "Découvrez l’activité récente de votre réseau.", + allButton: "TOUT", + mineButton: "MIENNES", + noActions: "Aucune activité disponible.", + from: "De", + to: "À", + amount: "Montant", + concept: "Concept", + description: "Description", + meme: "Mème", + activityContact: "Contact", + activityBy: "Nom", + activityPixelia: "Nouveau pixel ajouté", + viewImage: "Voir l’image", + playAudio: "Lire l’audio", + playVideo: "Lire la vidéo", + typeRecent: "RÉCENT", + errorActivity: "Erreur lors de la récupération de l’activité", + typePost: "BLOGS", + recentButton: "RÉCENT", + typeTribe: "TRIBUS", + typeLarp: "L.A.R.P.", + typeInhabitants: "HABITANTS", + activityProfileUpdated: "a mis à jour son profil", + typeAbout: "HABITANTS", + typeCurriculum: "CVS", + typeImage: "IMAGES", + typeBookmark: "SIGNETS", + typeDocument: "DOCUMENTS", + typeVotes: "VOTES", + typeAudio: "AUDIOS", + typeMarket: "MARCHÉ", + typeJob: "EMPLOIS", + typeProject: "PROJETS", + typeVideo: "VIDÉOS", + typeVote: "PARTAGE", + typeEvent: "ÉVÉNEMENTS", + typeTransfer: "TRANSFERTS", + typeTask: "TÂCHES", + typePixelia: "PIXELIA", + typeForum: "FORUMS", + typeReport: "RAPPORTS", + typeFeed: "FLUX", + typeContact: "CONTACT", + typePub: "PUB", + typeTombstone: "SUPPRIMÉ", + typeBanking: "BANQUE", + typeBankWallet: "BANQUE/PORTEFEUILLE", + typeBankClaim: "BANQUE/UBI", + typeKarmaScore: "KARMA", + typeParliament: "PARLEMENT", + typeSpread: "SOUTIENS", + typeParliamentCandidature: "Parlement · Candidature", + typeParliamentTerm: "Parlement · Mandat", + typeParliamentProposal:"Parlement · Proposition", + typeParliamentRevocation:"Parlement · Révocation", + typeParliamentLaw: "Parlement · Nouvelle loi", + typeCourts: "TRIBUNAUX", + typeCourtsCase: "Tribunaux · Affaire", + typeCourtsEvidence: "Tribunaux · Preuve", + typeCourtsAnswer: "Tribunaux · Réponse", + typeCourtsVerdict: "Tribunaux · Verdict", + typeCourtsSettlement: "Tribunaux · Règlement", + typeCourtsSettlementProposal: "Tribunaux · Proposition de règlement", + typeCourtsSettlementAccepted: "Tribunaux · Règlement accepté", + typeCourtsNomination: "Tribunaux · Nomination", + typeCourtsNominationVote: "Tribunaux · Vote de nomination", + activitySupport: "Nouvelle alliance forgée", + activityJoin: "Nouveau PUB rejoint", + question: "Question", + deadline: "Échéance", + status: "Statut", + votes: "Votes", + totalVotes: "Votes totaux", + voteTotalVotes: "Votes totaux", + name: "Nom", + skills: "Compétences", + tags: "Étiquettes", + title: "Titre", + date: "Date", + category: "Catégorie", + attendees: "Participants", + activitySpread: "->", + visitLink: "Visiter le lien", + viewDocument: "Voir le document", + location: "Lieu", + contentWarning: "Sujet", + personName: "Nom de l’habitant", + bankWalletConnected: "Portefeuille ECOin", + bankUbiReceived: "UBI reçu", + bankTx: "Tx", + bankEpochShort: "Époque", + viewDetails: "Voir les détails", + link: "Lien", + activityProjectFollow: "%OASIS% est maintenant %ACTION% ce projet %PROJECT%", + activityProjectUnfollow: "%OASIS% est maintenant %ACTION% ce projet %PROJECT%", + activityProjectPledged: "%OASIS% a %ACTION% %AMOUNT% au projet %PROJECT%", + following: "SUIVRE", + unfollowing: "NE PLUS SUIVRE", + pledged: "ENGAGÉ", + parliamentCandidatureId: "Candidature", + parliamentGovMethod: "Méthode", + parliamentVotesReceived: "Votes reçus", + parliamentMethodANARCHY: "Anarchie", + parliamentMethodVOTE: "Vote communautaire", + parliamentMethodRANKED: "Vote préférentiel", + parliamentMethodPLURALITY: "Pluralité", + parliamentMethodCOUNCIL: "Conseil", + parliamentMethodJURY: "Jury", + parliamentAnarchy: "ANARCHIE", + parliamentElectionsStart: "Début des élections", + parliamentElectionsEnd: "Fin des élections", + parliamentCurrentLeader: "Candidature gagnante", + parliamentProposalTitle: "Titre", + parliamentOpenVote: "Vote ouvert", + parliamentStatus: "Statut", + parliamentLawQuestion: "Question", + parliamentLawMethod: "Méthode", + parliamentLawProposer: "Proposant", + parliamentLawEnacted: "Promulgué le", + parliamentLawVotes: "Votes", + createdAt: "Créé le", + courtsCaseTitle: "Affaire", + courtsMethod: "Méthode", + courtsMethodJUDGE: "Juge", + courtsMethodJUDGES: "Collège de juges", + courtsMethodSINGLE_JUDGE: "Juge unique", + courtsMethodJURY: "Jury", + courtsMethodCOUNCIL: "Conseil", + courtsMethodCOMMUNITY: "Communauté", + courtsMethodARBITRATION: "Arbitrage", + courtsMethodVOTE: "Vote communautaire", + courtsAccuser: "Accusation", + courtsRespondent: "Défense", + courtsThStatus: "Statut", + courtsThAnswerBy: "Réponse avant", + courtsThEvidenceBy: "Preuves avant", + courtsThDecisionBy: "Décision avant", + courtsVotesNeeded: "Votes nécessaires", + courtsVotesSlashTotal: "OUI/TOTAL", + courtsOpenVote: "Vote ouvert", + courtsAnswerTitle: "Réponse", + courtsStanceADMIT: "Admettre", + courtsStanceDENY: "Refuser", + courtsStancePARTIAL: "Partiel", + courtsStanceCOUNTERCLAIM: "Demande reconventionnelle", + courtsStanceNEUTRAL: "Neutre", + courtsVerdictResult: "Résultat", + courtsVerdictOrders: "Ordonnances", + courtsSettlementText: "Accord", + courtsSettlementAccepted: "Accepté", + courtsSettlementPending: "En attente", + courtsJudge: "Juge", + courtsThSupports: "Soutiens", + courtsFilterOpenCase: 'Ouvrir un dossier', + courtsEvidenceFileLabel: 'Fichier de preuve (image, audio, vidéo ou PDF)', + courtsCaseMediators: 'Médiateurs', + courtsCaseMediatorsPh: 'IDs Oasis des médiateurs, séparés par des virgules', + courtsMediatorsLabel: 'Médiateurs', + courtsThCase: 'Dossier', + courtsThCreatedAt: 'Date de début', + courtsThActions: 'Actions', + courtsPublicPrefLabel: 'Visibilité après la résolution', + courtsPublicPrefYes: "J'accepte que ce dossier soit entièrement public", + courtsPublicPrefNo: 'Je préfère garder les détails privés', + courtsPublicPrefSubmit: 'Enregistrer la préférence de visibilité', + courtsMethodMEDIATION: 'Médiation', + courtsNoCases: 'Aucun dossier.', + reportsTitle: "Rapports", + reportsDescription: "Gérez et suivez les rapports liés aux problèmes, aux erreurs, aux abus et aux avertissements de contenu dans votre réseau.", + reportsFilterAll: "TOUS", + reportsFilterMine: "MIENS", + reportsFilterFeatures: "FONCTIONS", + reportsFilterBugs: "ERREURS", + reportsFilterAbuse: "ABUS", + reportsFilterContent: "CONTENU", + reportsFilterConfirmed: "CONFIRMÉS", + reportsFilterResolved: "RÉSOLUS", + reportsFilterOpen: "OUVERTS", + reportsFilterUnderReview: "EN RÉVISION", + reportsFilterInvalid: "INVALIDES", + reportsCreateButton: "Créer un rapport", + reportsTitleLabel: "Titre", + reportsDescriptionLabel: "Description", + reportsDescriptionPlaceholder: "Veuillez fournir une description détaillée.", + reportsCategory: "Catégorie", + reportsCategoryLabel: "Catégorie", + reportsCategoryFeatures: "Fonctions", + reportsCategoryBugs: "Erreurs", + reportsCategoryAbuse: "Abus", + reportsCategoryContent: "Problèmes de Contenu", + reportsUpdateButton: "Mettre à jour", + reportsDeleteButton: "Supprimer", + reportsDateLabel: "Date", + reportsUploadFile: "Télécharger un média (max: 50 Mo)", + reportsMineSectionTitle: "Vos rapports", + reportsFeaturesSectionTitle: "Demandes de fonctions", + reportsBugsSectionTitle: "Erreurs", + reportsAbuseSectionTitle: "Rapports d’abus", + reportsContentSectionTitle: "Problèmes de contenu", + reportsAllSectionTitle: "Rapports", + reportsNoItems: "Aucun rapport disponible.", + reportsValidationTitle: "Veuillez saisir un titre valide.", + reportsValidationDescription: "La description ne peut pas être vide.", + reportsValidationCategory: "Veuillez sélectionner une catégorie.", + reportsCreatedAt: "Créé le", + reportsCreatedBy: "Par", + reportsSeverity: "Gravité", + reportsSeverityLow: "Faible", + reportsSeverityMedium: "Moyenne", + reportsSeverityHigh: "Élevée", + reportsSeverityCritical: "Critique", + reportsStatus: "État", + reportsStatusOpen: "Ouvert", + reportsStatusUnderReview: "En révision", + reportsStatusResolved: "Résolu", + reportsStatusInvalid: "Invalide", + reportsUpdateStatusButton: "Mettre à jour l’état", + reportsAnonymityOption: "Envoyer de façon anonyme", + reportsAnonymousAuthor: "Anonyme", + reportsConfirmButton: "CONFIRMER LE RAPPORT !", + reportsConfirmations: "Confirmations", + reportsConfirmedSectionTitle: "Rapports confirmés", + reportsCreateTaskButton: "CRÉER UNE TÂCHE", + reportsOpenSectionTitle: "Rapports ouverts", + reportsUnderReviewSectionTitle: "Rapports en révision", + reportsResolvedSectionTitle: "Rapports résolus", + reportsInvalidSectionTitle: "Rapports invalides", + reportsTemplateSectionTitle: 'Modèle de signalement', + reportsBugTemplateTitle: 'Détails de reproduction (Bugs)', + reportsFeatureTemplateTitle: 'Détails (Fonctionnalités)', + reportsAbuseTemplateTitle: 'Détails (Abus)', + reportsContentTemplateTitle: 'Détails (Problèmes de Contenu)', + reportsStepsToReproduceLabel: 'Étapes pour reproduire', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'Résultat attendu', + reportsExpectedBehaviorPlaceholder: 'Que devrait-il se passer ?', + reportsActualBehaviorLabel: 'Résultat obtenu', + reportsEnvironmentLabel: 'Environnement', + reportsEnvironmentPlaceholder: 'Version, appareil, OS, navigateur, paramètres, logs, etc.', + reportsReproduceRateLabel: 'Fréquence', + reportsReproduceRateAlways: 'Toujours', + reportsReproduceRateOften: 'Souvent', + reportsReproduceRateSometimes: 'Parfois', + reportsReproduceRateRarely: 'Rarement', + reportsReproduceRateUnable: 'Impossible à reproduire', + reportsReproduceRateUnknown: 'Non précisé', + reportsProblemStatementLabel: 'Problème / besoin', + reportsProblemStatementPlaceholder: 'Quel problème cette fonctionnalité résout-elle ?', + reportsAcceptanceCriteriaPlaceholder: '- Étant donné...\n- Quand...\n- Alors...', + reportsReportedUserLabel: 'Utilisateur / entité signalé(e)', + reportsReportedUserPlaceholder: '@utilisateur, feed, ID, lien, etc.', + reportsEvidenceLinksLabel: 'Preuves / liens', + reportsContentLocationLabel: 'Où se trouve le contenu', + reportsContentLocationPlaceholder: 'Lien, ID, canal, fil, auteur, etc.', + reportsWhyInappropriateLabel: "Pourquoi est-ce inapproprié ?", + reportsWhyInappropriatePlaceholder: 'Expliquez la raison et l’impact.', + reportsRequestedActionLabel: 'Action demandée', + reportsRequestedActionPlaceholder: 'Supprimer, masquer, étiqueter, avertir, etc.', + tribesTitle: "Tribus", + tribeAllSectionTitle: "Tribus", + tribeMineSectionTitle: "Vos tribus", + tribeCreateSectionTitle: "Créer une tribu", + tribeUpdateSectionTitle: "Mettre à jour la tribu", + tribeGallerySectionTitle: "Galerie de tribus", + tribeRecentSectionTitle: "Tribus récentes", + tribeTopSectionTitle: "Tribus populaires", + tribeviewTribeButton: "Visiter la tribu", + tribeviewSubTribeButton: "Visiter la sous-tribu", + tribeRootLabel: "RACINE", + tribeDescription: "Explorez ou créez des tribus dans votre réseau.", + tribeFilterAll: "TOUS", + tribeFilterMine: "MIENS", + tribeFilterMembership: "MEMBRES", + tribeFilterRecent: "RÉCENTS", + tribeFilterTop: "TOP", + tribeFilterSubtribes: "SOUS-TRIBUS", + tribeFilterGallery: "GALERIE", + tribeMainTribeLabel: "TRIBU PRINCIPALE", + tribeCreateButton: "Créer une tribu", + tribeUpdateButton: "Mettre à jour", + tribeDeleteButton: "Supprimer", + tribeImageLabel: "Télécharger un média (max: 50 Mo)", + tribeTitleLabel: "Titre", + searchTribesPlaceholder: "FILTRER les tribus PAR NOM …", + tribeTitlePlaceholder: "Nom de la tribu", + tribeDescriptionLabel: "Description", + tribeDescriptionPlaceholder: "Décrivez cette tribu", + tribeLocationLabel: "Localisation", + tribeLocationPlaceholder: "Où se situe cette tribu ?", + tribeTagsLabel: "Étiquettes", + tribeTagsPlaceholder: "Saisissez des étiquettes séparées par des virgules", + tribeInviteMode: "Mode d’invitation", + tribeModeLabel: "MODE", + tribeIsAnonymousLabel: "STATUT", + tribeMembersCount: "Membres", + tribeInviteCodePlaceholder: "Saisissez le code d’invitation", + tribeJoinByCodeButton: "Rejoindre avec un code", + tribeJoinButton: "REJOINDRE", + tribeEnterInvite: "SAISIR LE CODE", + tribeLeaveButton: "QUITTER", + tribeYes: "OUI", + tribeNo: "NON", + tribePublic: "PUBLIQUE", + tribePrivate: "PRIVÉE", + tribeGenerateInvite: "INVITATION UNIQUE", + tribeOpenInvitation: "INVITATION OUVERTE", + tribeJoinOpen: "REJOINDRE LA TRIBU", + tribeRemoveInvitation: "SUPPRIMER L’INVITATION", + tribeCreatedAt: "Créé le", + tribeAuthor: "Par", + tribeAuthorLabel: "AUTEUR", + tribeStrict: "Strict", + tribeOpen: "Ouverte", + tribeFeedFilterRECENT: "RÉCENTS", + tribeFeedFilterMINE: "MIENS", + tribeFeedFilterALL: "TOUS", + tribeFeedFilterTOP: "TOP", + tribeFeedRefeeds: "Repartages", + tribeFeedRefeed: "Repartager", + tribeFeedMessagePlaceholder: "Écrivez un feed…", + tribeFeedSend: "Envoyer", + tribeFeedEmpty: "Aucun message de feed disponible pour l’instant.", + noTribes: "Aucune tribu trouvée pour l'instant.", + tribeNotFound: "Tribu introuvable!", + createTribeTitle: "Créer une tribu", + updateTribeTitle: "Mettre à jour la tribu", + tribeSectionOverview: "Aperçu", + tribeSectionInhabitants: "Habitants", + tribeSectionVotations: "VOTATIONS", + tribeSectionEvents: "ÉVÉNEMENTS", + tribeSectionReports: "Rapports", + tribeSectionTasks: "TÂCHES", + tribeSectionFeed: "FIL", + tribeSectionForum: "FORUM", + tribeSectionMarket: "Marché", + tribeSectionJobs: "Emplois", + tribeSectionProjects: "Projets", + tribeSectionMedia: "Média", + tribeSectionImages: "IMAGES", + tribeSectionAudios: "AUDIOS", + tribeSectionVideos: "VIDÉOS", + tribeSectionDocuments: "DOCUMENTS", + tribeSectionBookmarks: "SIGNETS", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "Aucun habitant dans cette tribu pour l'instant.", + tribeEventCreate: "Créer un événement", + tribeEventsEmpty: "Aucun événement pour l'instant.", + tribeEventTitle: "Titre", + tribeEventDescription: "Description", + tribeEventDate: "Date", + tribeEventLocation: "Lieu", + tribeEventAttend: "PARTICIPER", + tribeEventUnattend: "QUITTER", + tribeEventAttendees: "Participants", + tribeTaskCreate: "Créer une tâche", + tribeTasksEmpty: "Aucune tâche pour l'instant.", + tribeTaskTitle: "Titre", + tribeTaskDescription: "Description", + tribeTaskPriority: "Priorité", + tribeTaskDeadline: "Date limite", + tribeTaskAssignees: "Assignés", + tribeTaskStatusInProgress: "EN COURS", + tribeTaskStatusClosed: "FERMER", + tribeTaskAssign: "ASSIGNER", + tribeTaskUnassign: "DÉSASSIGNER", + tribeReportCreate: "Créer un rapport", + tribeReportsEmpty: "Aucun rapport pour l'instant.", + tribeReportTitle: "Titre", + tribeReportDescription: "Description", + tribeReportCategory: "Catégorie", + tribeVotationCreate: "Créer une votation", + tribeVotationsEmpty: "Aucune votation pour l'instant.", + tribeVotationTitle: "Titre", + tribeVotationDescription: "Description", + tribeVotationOptions: "Options", + tribeVotationDeadline: "Date limite", + tribeVotationVote: "VOTER", + tribeVotationResults: "Votes", + tribeVotationClose: "FERMER LA VOTATION", + tribeVotationOptionPlaceholder: "Option", + tribeForumCreate: "Créer Forum", + tribeForumEmpty: "Aucun fil pour l'instant.", + tribeForumTitle: "Titre", + tribeForumText: "Message", + tribeForumCategory: "Catégorie", + tribeForumReply: "Répondre", + tribeForumReplies: "Réponses", + tribeMarketCreate: "Créer une annonce", + tribeMarketEmpty: "Aucune annonce pour l'instant.", + tribeMarketTitle: "Titre", + tribeMarketDescription: "Description", + tribeMarketPrice: "Prix", + tribeMarketImage: "Image", + tribeMarketCategory: "Catégorie", + tribeJobCreate: "Créer un emploi", + tribeJobsEmpty: "Aucun emploi pour l'instant.", + tribeJobTitle: "Titre", + tribeJobDescription: "Description", + tribeJobLocation: "Lieu", + tribeJobSalary: "Salaire", + tribeJobDeadline: "Date limite", + tribeProjectCreate: "Créer un projet", + tribeProjectsEmpty: "Aucun projet pour l'instant.", + tribeProjectTitle: "Titre", + tribeProjectDescription: "Description", + tribeProjectGoal: "Objectif", + tribeProjectFunded: "Financé", + tribeProjectDeadline: "Date limite", + tribeMediaUpload: "Télécharger un média", + readDocument: "Lire le Document", + tribeCreateImage: "Créer Image", + tribeCreateAudio: "Créer Audio", + tribeCreateVideo: "Créer Vidéo", + tribeCreateDocument: "Créer Document", + tribeCreateBookmark: "Créer Signet", + tribeMediaEmpty: "Aucun média pour l'instant.", + tribeMediaTitle: "Titre", + tribeMediaDescription: "Description", + tribeMediaType: "Type", + tribeMediaTypeImage: "Image", + tribeMediaTypeVideo: "Vidéo", + tribeMediaTypeAudio: "Audio", + tribeMediaTypeDocument: "Document", + tribeMediaTypeBookmark: "Signet", + tribeContentDelete: "SUPPRIMER", + tribeInviteCodeText: "Code d'invitation : ", + oasisVersionLabel: "Version d’Oasis", + tribeInviteCodeHint: "Partagez ce code avec la personne que vous souhaitez inviter. Elle peut rejoindre via /invites → Tribes.", + tribeGroupTribe: "Tribu", + tribeGroupOffice: "Bureau", + tribeGroupNetwork: "Réseau", + tribeGroupEconomy: "Économie", + tribeGroupMedia: "Média", + tribeStatusOpen: "OUVERT", + tribeStatusClosed: "FERMÉ", + tribeStatusInProgress: "EN COURS", + tribePriorityLow: "BASSE", + tribePriorityMedium: "MOYENNE", + tribePriorityHigh: "HAUTE", + tribePriorityCritical: "CRITIQUE", + tribeTaskFilterAll: "TOUS", + tribeMediaFilterAll: "TOUS", + tribeReportCatBug: "BUG", + tribeReportCatAbuse: "ABUS", + tribeReportCatContent: "CONTENU", + tribeReportCatOther: "AUTRE", + tribeForumCatGeneral: "GÉNÉRAL", + tribeForumCatProposal: "PROPOSITION", + tribeForumCatQuestion: "QUESTION", + tribeForumCatAnnouncement: "ANNONCE", + tribeMarketCatGoods: "BIENS", + tribeMarketCatServices: "SERVICES", + tribeMarketCatFood: "ALIMENTATION", + tribeMarketCatOther: "AUTRE", + tribeStatusLabel: "Statut", + tribeSubTribes: "SOUS-TRIBUS", + tribeSubTribesCreate: "Créer Sous-Tribu", + tribeSubTribesStrictDenied: "Le mode strict de la tribu ne vous permet pas de créer de nouvelles sous-tribus. Veuillez contacter l'administrateur.", + tribeSubTribesEmpty: "Aucune sous-tribu créée, pour l'instant.", + tribeActivityJoined: "REJOINT", + tribeActivityLeft: "QUITTÉ", + tribeActivityFeed: "FIL", + tribeActivityRefeed: "REPARTAGE", + tribeGroupAnalytics: "Analytiques", + tribeGroupCreative: "Créatif", + tribeSectionActivity: "ACTIVITÉ", + tribeSectionTrending: "TENDANCES", + tribeSectionOpinions: "OPINIONS", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "TAGS", + tribeSectionSearch: "RECHERCHE", + tribeActivityEmpty: "Aucune activité pour le moment.", + tribeActivityCreated: "a créé", + tribeActivityPosted: "a publié", + tribeActivityReplied: "a répondu", + tribeTrendingEmpty: "Aucun contenu tendance pour le moment.", + tribeTrendingPeriodDay: "Aujourd'hui", + tribeTrendingPeriodWeek: "Cette Semaine", + tribeTrendingPeriodAll: "Tout", + tribeTrendingEngagement: "engagement", + tribeOpinionsEmpty: "Aucune opinion pour le moment.", + tribeOpinionsCast: "Voter", + tribeOpinionsRankings: "Classements", + tribeOpinionsAlreadyVoted: "Déjà voté", + tribeTopCategory: "Plus voté", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Toile collaborative de pixel art.", + tribePixeliaPaint: "Peindre", + tribePixeliaContributors: "contributeurs", + tribePixeliaTotalPixels: "pixels peints", + tribeTagsEmpty: "Aucun tag trouvé.", + tribeTagsCloud: "Nuage de Tags", + tribeTagsContentWith: "Contenu avec le tag", + tribeSearchPlaceholder: "Rechercher dans la tribu...", + tribeSearchEmpty: "Aucun résultat.", + tribeSearchResults: "Résultats", + tribeSearchMinChars: "Entrez au moins 2 caractères pour rechercher.", + agendaTitle: "Agenda", + agendaDescription: "Ici vous pouvez trouver tous vos éléments assignés.", + agendaFilterAll: "TOUS", + agendaFilterToday: "AUJOURD’HUI", + agendaFilterUpcoming: "À VENIR", + agendaFilterOverdue: "EN RETARD", + agendaFilterOpen: "OUVERTS", + agendaFilterClosed: "FERMÉS", + agendaFilterTasks: "TÂCHES", + agendaFilterMarket: "MARCHÉ", + agendaFilterTribes: "TRIBUS", + agendaFilterEvents: "ÉVÉNEMENTS", + agendaFilterReports: "RAPPORTS", + agendaFilterTransfers: "TRANSFERTS", + agendaFilterJobs: "EMPLOIS", + agendaFilterProjects: "PROJETS", + agendaFilterCalendars: "CALENDRIERS", + agendaNoItems: "Aucune affectation trouvée.", + agendaDiscardButton: "Écarter", + agendaRestoreButton: "Restaurer", + agendaAuthor: "Par", + agendaCreatedAt: "Créé le", + agendaTitleLabel: "Titre", + agendaMembersCount: "Membres", + agendaDescriptionLabel: "Description", + agendaStatus: "État", + agendaVisibility: "Visibilité", + agendaEventDate: "Date de l’événement", + agendaEventLocation: "Localisation", + agendaEventPrice: "Prix", + agendaEventUrl: "URL", + agendaTaskStart: "Heure de début", + agendaLocationLabel: "Localisation", + agendaYes: "OUI", + agendaNo: "NON", + agendaInviteModeLabel: "Mode d’invitation", + agendaAnonymousLabel: "Mode anonyme", + agendaMembersLabel: "Membres", + agendareportCategory: "Catégorie", + agendareportSeverity: "Gravité", + agendareportStatus: "État", + agendareportDescription: "Description", + agendaTaskEnd: "Heure de fin", + agendaTaskPriority: "Priorité", + agendaTransferFrom: "De", + agendaTransferTo: "À", + agendaTransferConcept: "Concept", + agendaTransferAmount: "Montant", + agendaTransferDeadline: "Date limite", + opinionsTitle: "Opinions", + shareYourOpinions: "Découvrez et votez pour les opinions dans votre réseau.", + author: "Par", + voteNow: "Votez maintenant", + alreadyVoted: "Vous avez déjà donné votre avis.", + noOpinionsFound: "Aucune opinion trouvée.", + RECENTButton: "RÉCENT", + TOPButton: "TOP", + interestingButton: "INTÉRESSANT", + necessaryButton: "NÉCESSAIRE", + funnyButton: "AMUSANT", + disgustingButton: "DÉGOUTANT", + sensibleButton: "SENSIBLE", + propagandaButton: "PROPAGANDE", + adultOnlyButton: "ADULTE SEULEMENT", + boringButton: "ENNUYEUX", + confusingButton: "CONFUS", + usefulButton: "UTILE", + informativeButton: "INFORMATIF", + wellResearchedButton: "BIEN RECHERCHÉ", + accurateButton: "PRÉCIS", + needsSourcesButton: "BESOIN DE SOURCES", + wrongButton: "FAUX", + lowQualityButton: "FAIBLE QUALITÉ", + creativeButton: "CRÉATIF", + insightfulButton: "PERSPECTIF", + actionableButton: "RÉALISABLE", + inspiringButton: "INSPIRANT", + loveButton: "AMOUR", + clearButton: "CLAIR", + upliftingButton: "ÉLÉVANT", + unnecessaryButton: "INUTILE", + rejectedButton: "REJETÉ", + misleadingButton: "TROMPEUR", + offTopicButton: "HORS SUJET", + duplicateButton: "DUPE", + clickbaitButton: "APPÂT À CLIC", + spamButton: "SPAM", + trollButton: "TROLL", + nsfwButton: "NSFW", + violentButton: "VIOLENT", + toxicButton: "TOXIQUE", + harassmentButton: "HARCÈLEMENT", + hateButton: "HAINE", + scamButton: "ESCROQUERIE", + triggeringButton: "PROVOCANT", + opinionsCreatedAt: "Créé à", + opinionsTotalCount: "Total des opinions", + voteInteresting: "Intéressant", + voteNecessary: "Nécessaire", + voteUseful: "Utile", + voteInformative: "Informatif", + voteWellResearched: "Bien recherché", + voteNeedsSources: "Besoin de sources", + voteWrong: "Faux", + voteLowQuality: "Faible qualité", + voteLove: "Amour", + voteClear: "Clair", + voteMisleading: "Trompeur", + voteOffTopic: "Hors sujet", + voteDuplicate: "Dupe", + voteClickbait: "Appât à clic", + votePropaganda: "Propagande", + voteFunny: "Amusant", + voteInspiring: "Inspirant", + voteUplifting: "Élevant", + voteUnnecessary: "Inutile", + voteRejected: "Rejeté", + voteConfusing: "Confus", + voteTroll: "Troll", + voteNsfw: "NSFW", + voteViolent: "Violent", + voteToxic: "Toxique", + voteHarassment: "Harcèlement", + voteHate: "Haine", + voteScam: "Escroquerie", + voteTriggering: "Provocant", + voteInsightful: "Perspicace", + voteAccurate: "Précis", + voteActionable: "Réalisable", + voteCreative: "Créatif", + voteSpam: "Spam", + voteAdultOnly: "Adultes uniquement", + publishBlog: "Publier un blog", + privateMessage: "MP", + pmSendTitle: "Messages privés", + pmSend: "Envoyer !", + pmDescription: "Utilisez ce formulaire pour envoyer un message chiffré à d’autres habitants.", + pmRecipients: "Destinataires", + pmRecipientsHint: "Saisissez les IDs Oasis séparés par des virgules", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Sujet", + pmSubjectHint: "Saisissez l’objet du message", + pmText: "Message", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Déchiffrer", + pmCrypterBadKey: "La clé partagée est incorrecte !", + pmCrypterTooLong: "Le message est trop long pour être chiffré avec le Crypter. Raccourcissez-le et réessayez.", + pmCrypterCipherLabel: "Message rechiffré", + pmCrypterCharsLabel: "caractères", + pmInvalidRecipients: "Identifiant Oasis invalide (devrait ressembler à @…=.ed25519).", + pmEncryptionLabel: "Chiffrement", + pmFile: "Fichier joint", + private: "Privés", + privateDescription: "Vos messages chiffrés.", + privateInbox: "BOÎTE DE RÉCEPTION", + privateSent: "ENVOYÉS", + privateDelete: "Supprimer", + pmCreateButton: "Écrire un MP", + noPrivateMessages: "Aucun message privé.", + pmReply: "Répondre", + pmReplies: "réponses", + pmNew: "nouvelles", + pmMarkRead: "Marquer comme lu", + inReplyTo: "EN RÉPONSE À", + pmPreview: "Aperçu", + pmPreviewTitle: "Aperçu", + performed: "→", + pmFromLabel: "De :", + pmToLabel: "À :", + pmInvalidMessage: "Message non valide", + pmNoSubject: "(sans objet)", + pmSubjectLabel: "Objet :", + pmBodyLabel: "Corps", + pmBotJobs: "42-EmploisBOT", + pmBotProjects: "42-ProjetsBOT", + pmBotMarket: "42-MarchéBOT", + inboxJobSubscribedTitle: "Nouvelle inscription à votre offre d’emploi", + pmInhabitantWithId: "Habitat avec ID OASIS :", + pmHasSubscribedToYourJobOffer: "s’est inscrit à votre offre d’emploi", + inboxProjectCreatedTitle: "Nouveau projet créé", + pmHasCreatedAProject: "a créé un projet", + inboxMarketItemSoldTitle: "Article vendu", + pmYourItem: "Votre article", + pmHasBeenSoldTo: "a été vendu à", + pmFor: "pour", + inboxProjectPledgedTitle: "Nouvelle contribution à votre projet", + pmHasPledged: "a contribué", + pmToYourProject: "à votre projet", + blockchain: 'Explorateur de blocs', + blockchainTitle: 'Explorateur de blocs', + blockchainDescription: 'Explorez et visualisez les blocs de la chaîne.', + blockchainNoBlocks: 'Aucun bloc trouvé sur la chaîne.', + blockchainStatsTitle: 'Statistiques', + blockchainStatsTypeBreakdown: 'Répartition par type', + blockchainStatsTopAuthors: 'Auteurs principaux', + blockchainStatsCount: 'Blocs', + blockchainBlockID: 'ID du bloc', + blockchainBlockAuthor: 'Auteur', + blockchainBlockCarbon: 'Empreinte carbone', + blockchainBlockType: 'Type', + blockchainBlockTimestamp: 'Horodatage', + blockchainBlockContent: 'Bloc', + blockchainBlockURL: 'URL :', + blockchainContent: 'Bloc', + blockchainContentPreview: 'Aperçu du contenu du bloc', + blockchainLatestDatagram: 'Dernier Datagramme', + blockchainDatagram: 'Datagramme', + blockchainDetails: 'Voir les détails du bloc', + blockchainViewBlockexplorer: "Voir dans le blockexplorer", + blockchainBlockInfo: 'Informations du bloc', + blockchainBlockDetails: 'Détails du bloc sélectionné', + blockchainBack: 'Retour à l’explorateur', + banking: 'Banque', + bankingTitle: 'Banque', + bankingDescription: 'Explorez la valeur actuelle d’ECOin et l’allocation RBU correspondante, distribuée chaque semaine en fonction de la participation et de la confiance.', + bankOverview: 'Aperçu', + bankEpochs: 'Époques', + bankRules: 'Règles', + pending: 'En attente', + closed: 'Fermées', + bankBack: 'Retour à la Banque', + bankViewTx: 'Voir la Tx', + bankClaimNow: 'Réclamer', + bankClaimUBI: 'Réclamer RBU !', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'Aucune allocation RBU en attente pour cette époque.', + bankEpoch: 'Époque', + bankPool: 'Fonds (cette époque)', + bankWeightsSum: 'Somme des poids', + bankAllocations: 'Attributions', + bankNoAllocations: 'Aucune attribution trouvée.', + bankNoEpochs: 'Aucune époque trouvée.', + bankEpochAllocations: 'Attributions de l’époque', + bankAllocId: 'ID d’attribution', + bankAllocDate: 'Date', + bankAllocConcept: 'Concept', + bankAllocFrom: 'De', + bankAllocTo: 'À', + bankAllocAmount: 'Montant', + bankAllocStatus: 'État', + bankEpochId: 'ID de l’époque', + bankRuleHash: 'Hash de l’instantané des règles', + bankViewEpoch: 'Voir l’époque', + bankUserBalance: 'Votre solde', + ecoWalletNotConfigured: 'Portefeuille ECOin non configuré', + editWallet: 'Modifier le portefeuille', + addWallet: 'Ajouter un portefeuille', + bankAddresses: 'Adresses', + bankNoAddresses: 'Aucune adresse trouvée.', + bankUser: 'ID d’Oasis', + bankAddress: 'Adresse', + bankAddAddressTitle: 'Ajouter une adresse ECOIN', + bankAddAddressUser: 'ID d’Oasis', + bankAddAddressAddress: 'Adresse ECOIN', + bankAddAddressSave: 'Enregistrer', + bankAddressAdded: 'Adresse ajoutée', + bankAddressUpdated: 'Adresse mise à jour', + bankAddressExists: 'L’adresse existe déjà', + bankAddressInvalid: 'Adresse non valide', + bankAddressDeleted: 'Adresse supprimée', + bankAddressNotFound: 'Adresse introuvable', + bankAddressForbidden: 'Vous ne pouvez configurer que votre propre adresse de paiement', + bankAddressTotal: 'Total des adresses', + bankAddressSearch: 'Rechercher @habitant ou adresse', + bankAddressActions: 'Actions', + bankAddressDelete: 'Supprimer', + bankAddressSource: 'Source', + bankAddressDeleteConfirm: 'Supprimer cette adresse ?', + search: 'Rechercher !', + bankLocal: 'Local', + bankFromOasis: 'Oasis', + bankMyAddress: 'Votre adresse', + bankRemoveMyAddress: 'Supprimer mon adresse', + bankNotRemovableOasis: 'Les adresses ne peuvent pas être supprimées localement', + bankingUserEngagementScore: "Score KARMA", + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "PAS DE FONDS!", + bankUbiAvailableOk: "DISPONIBLE!", + bankUbiAvailability: "Disponibilité UBI", + bankAlreadyClaimedThisMonth: "Déjà réclamé ce mois-ci", + bankUbiThisMonth: "RBU (ce mois)", + bankUbiLastClaimed: "RBU (dernière réclamation)", + bankUbiNeverClaimed: "Jamais réclamé", + bankUbiTotalClaimed: "RBU (total réclamé)", + bankUbiPub: "PUB", + bankUbiInhabitant: "HABITANT", + bankUbiClaimedAmount: "RÉCLAMÉ (ECO)", + typeBankUbiResult: "BANCAIRE - RBU", + bankNoPubConfigured: "Aucun PUB configuré. Définissez votre ID PUB dans les Paramètres.", + shopsTitle: "Boutiques", + shopDescription: "Découvrez et gérez les boutiques du réseau.", + shopTitle: "Boutique", + shopFilterAll: "TOUTES", + shopFilterMine: "MIENNES", + shopFilterRecent: "RÉCENTES", + shopFilterTop: "TOP", + shopFilterProducts: "PRODUITS", + shopFilterPrices: "PRIX", + shopFilterFavorites: "FAVORIS", + shopUpload: "Créer Boutique", + shopAllSectionTitle: "Toutes les Boutiques", + shopMineSectionTitle: "Mes Boutiques", + shopRecentSectionTitle: "Boutiques Récentes", + shopTopSectionTitle: "Meilleures Boutiques", + shopProductsSectionTitle: "Meilleurs Produits", + shopPricesSectionTitle: "Produits par Prix", + shopFavoritesSectionTitle: "Favoris", + shopCreateSectionTitle: "Créer Boutique", + shopUpdateSectionTitle: "Modifier Boutique", + shopCreate: "Créer", + shopUpdate: "Modifier", + shopDelete: "Supprimer", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Publier sur le Clearnet", + shopClearnetUnpublish: "Retirer du Clearnet", + shopClearnetUrlLabel: "URL Clearnet", + shopClearnetWarning: "Publier sur le Clearnet rend cette boutique indexable par les moteurs de recherche et robots externes.", + shopAddFavorite: "Ajouter Favori", + shopRemoveFavorite: "Retirer Favori", + shopOpen: "OUVERTE", + shopClosed: "FERMÉE", + shopOpenShop: "Ouvrir Boutique", + shopCloseShop: "Fermer Boutique", + shopMakePrivate: "RENDRE PRIVÉE (E2E)", + shopMakePublic: "RENDRE PUBLIQUE", + shopProducts: "Produits", + shopProductAdd: "Ajouter Produit", + shopProductTitle: "Produit", + shopProductUpdate: "Modifier Produit", + shopProductPrice: "Prix (ECO)", + shopProductStock: "Stock", + shopProductUntitled: "Produit sans titre", + shopUntitled: "Boutique sans titre", + shopNoItems: "Aucune boutique trouvée.", + shopNoProducts: "Pas encore de produits.", + shopOutOfStock: "Épuisé", + shopBuy: "Acheter", + shopBuyEncryptedNote: "Vos coordonnées de livraison sont chiffrées et visibles uniquement par le propriétaire de la boutique.", + shopBuyDeliveryAddress: "Adresse de livraison", + shopBuyContact: "Contact", + shopBuyContactPlaceholder: "e-mail, téléphone, etc.", + shopBuyNotes: "Notes", + shopBuyDeliveryRequired: "L'adresse de livraison est obligatoire.", + shopOrdersTitle: "Commandes", + shopOrdersDescription: "Commandes reçues par cette boutique.", + shopOrdersEmpty: "Aucune commande pour le moment.", + shopOrderProduct: "Produit", + shopOrderPrice: "Prix", + shopOrderBuyer: "Acheteur", + shopBackToShop: "Retour à la Boutique", + shopShareUrl: "URL de partage", + shopVisitShop: "VISITER BOUTIQUE", + marketVisitItem: "VOIR L’ARTICLE", + shopStatus: "STATUT", + shopCreatedAt: "CRÉÉ", + shopSearchPlaceholder: "Rechercher des boutiques...", + shopUrl: "URL", + shopLocation: "Localisation", + shopTags: "Étiquettes", + shopVisibility: "Visibilité", + shopImage: "Image", + shopShortDescription: "Description Courte", + shopShortDescriptionPlaceholder: "Description breve pour les cartes (max 160 caracteres)", + shopTitlePlaceholder: "Nom de votre boutique", + shopDescriptionPlaceholder: "Description détaillée de votre boutique", + shopUrlPlaceholder: "https://url-de-votre-boutique.com", + shopLocationPlaceholder: "Ville, Pays", + shopTagsPlaceholder: "tag1, tag2, tag3", + mapTitlePlaceholder: "Titre de la carte", + shopProductFeatured: "Produit Vedette", + shopSendToMarket: "Send to Market", + typeShop: "BOUTIQUES", + typeShopProduct: "PRODUIT BOUTIQUE", + bankExchange: 'Échange', + bankExchangeCurrentValue: 'Valeur d’ECOin (1h)', + bankTotalSupply: 'Offre totale d’ECOin', + bankHoursOfWork: 'heures', + bankUnitMs: "ms", + bankUnitSeconds: "secondes", + bankUnitMinutes: "minutes", + bankUnitDays: "jours", + bankExchangeNoData: 'Aucune donnée disponible', + bankExchangeIndex: 'Valeur d’ECOin (1h)', + bankInflation: 'Inflation d’ECOin (anual)', + bankInflationMonthly: 'Inflation d’ECOin (mensual)', + bankExchangeChartTitle: 'Progression de la valeur d’ECOin au fil du temps', + bankExchangeChartValue: 'Valeur (ECO/h)', + bankExchangeChartSupply: 'Offre', + bankExchangeChartInflation: 'Inflation %', + bankExchangeChartEmpty: 'Pas encore assez d’échantillons — reviens plus tard', + bankCurrentSupply: 'Offre actuelle d’ECOin', + bankingSyncStatus: 'État d’ECOin', + bankingSyncStatusSynced: 'Synchronisé', + bankingSyncStatusOutdated: 'Obsolète', + statsTitle: 'Statistiques', + statistics: "Statistiques", + statsInhabitant: "Statistiques de l'habitant", + statsDescription: "Découvrez des statistiques sur votre réseau.", + ALLButton: "TOUT", + MINEButton: "MIENS", + TOMBSTONEButton: "SUPPRESSIONS", + statsYou: "Vous", + statsUserId: "ID Oasis", + statsCreatedAt: "Créé le", + statsYourContent: "Contenu", + statsYourOpinions: "Avis", + statsYourTombstone: "Suppressions", + statsNetwork: "Réseau", + statsTotalInhabitants: "Habitants", + statsDiscoveredTribes: "Tribus (publiques)", + statsPrivateDiscoveredTribes: "Tribus (privées)", + statsNetworkContent: "Contenu", + statsYourMarket: "Marché", + statsYourJob: "Emplois", + statsYourProject: "Projets", + statsYourTransfer: "Transferts", + statsYourForum: "Forums", + statsNetworkOpinions: "Avis", + statsDiscoveredMarket: "Marché", + statsDiscoveredJob: "Emplois", + statsDiscoveredProject: "Projets", + statsBankingTitle: "Banque", + statsEcoWalletLabel: "Portefeuille ECOIN", + statsEcoWalletNotConfigured: "Non configuré !", + statsTotalEcoAddresses: "Adresses totales", + statsDiscoveredTransfer: "Transferts", + statsDiscoveredForum: "Forums", + statsNetworkTombstone: "Suppressions", + statsBookmark: "Signets", + statsEvent: "Événements", + statsTask: "Tâches", + statsVotes: "Votes", + statsMarket: "Marché", + statsForum: "Forums", + statsJob: "Emplois", + statsReport: "Rapports", + statsFeed: "Flux", + statsTribe: "Tribus", + statsImage: "Images", + statsAudio: "Audios", + statsVideo: "Vidéos", + statsDocument: "Documents", + statsMap: "Cartes", + statsShop: "Boutiques", + statsShopProduct: "Produits de boutique", + statsTransfer: "Transferts", + statsAiExchange: "IA", + statsPUBs: 'PUBs', + statsPost: "Publications", + statsOasisID: "ID Oasis", + statsSize: "Total (taille)", + statsBlockchainSize: "Blockchain (taille)", + statsBlobsSize: "Blobs (taille)", + statsActivity7d: "Activité (7 derniers jours)", + statsActivity7dTotal: "Total 7 jours", + statsActivity30dTotal: "Total 30 jours", + statsKarmaScore: "Score KARMA", + statsPublic: "Public", + statsPrivate: "Privé", + day: "Jour", + messages: "Messages", + statsProject: "Projets", + statsProjectsTitle: "Projets", + statsProjectsTotal: "Projets au total", + statsProjectsActive: "Actifs", + statsProjectsCompleted: "Terminés", + statsProjectsPaused: "En pause", + statsProjectsCancelled: "Annulés", + statsProjectsGoalTotal: "Objectif total", + statsProjectsPledgedTotal: "Montant promis total", + statsProjectsSuccessRate: "Taux de réussite", + statsProjectsAvgProgress: "Progression moyenne", + statsProjectsMedianProgress: "Progression médiane", + statsProjectsActiveFundingAvg: "Financement actif moyen", + statsJobsTitle: "Emplois", + statsJobsTotal: "Emplois au total", + statsJobsOpen: "Ouverts", + statsJobsClosed: "Fermés", + statsJobsOpenVacants: "Postes ouverts", + statsJobsSubscribersTotal: "Abonnés au total", + statsJobsAvgSalary: "Salaire moyen", + statsJobsMedianSalary: "Salaire médian", + statsMarketTitle: "Marché", + statsMarketTotal: "Articles au total", + statsMarketForSale: "À vendre", + statsMarketReserved: "Réservés", + statsMarketClosed: "Fermés", + statsMarketSold: "Vendus", + statsMarketRevenue: "Chiffre d'affaires", + statsMarketAvgSoldPrice: "Prix de vente moyen", + statsUsersTitle: "Habitants", + user: "Habitant", + statsTombstoneTitle: "Suppressions", + statsNetworkTombstones: "Suppressions du réseau", + statsTombstoneRatio: "Taux de suppressions (%)", + statsParliamentCandidature: "Candidatures au parlement", + statsParliamentTerm: "Mandats parlementaires", + statsParliamentProposal: "Propositions parlementaires", + statsParliamentRevocation: "Révocations parlementaires", + statsParliamentLaw: "Lois parlementaires", + statsCourtsCase: "Affaires judiciaires", + statsCourtsEvidence: "Preuves judiciaires", + statsCourtsAnswer: "Réponses judiciaires", + statsCourtsVerdict: "Verdicts judiciaires", + statsCourtsSettlement: "Accords judiciaires", + statsCourtsSettlementProposal: "Propositions d'accord", + statsCourtsSettlementAccepted: "Accords acceptés", + statsCourtsNomination: "Nominations de juges", + statsCourtsNominationVote: "Votes de nomination", + ai: "IA", + aiTitle: "IA", + aiDescription: "Une Intelligence Artificielle Collective (IAC) nommée « 42 » qui apprend de votre réseau.", + aiUserQuestion: "Question", + aiResponseTitle: "Réponse", + aiSubmitButton: "Envoyer !", + aiSettingsDescription: "Configurez votre prompt (max 128 caractères) pour le modèle d’IA.", + aiPrompt: "Fournissez une réponse informative et précise.", + aiConfiguration: "Configurer le prompt", + aiPromptUsed: "Invite", + aiClearHistory: "Effacer l’historique du chat", + aiSharePrompt: "Ajouter cette réponse à l’entraînement collectif ?", + aiShareYes: "Oui", + aiShareNo: "Non", + aiSharedLabel: "Ajoutée à l’entraînement", + aiRejectedLabel: "Non ajoutée à l’entraînement", + aiServerError: "L’IA n’a pas pu répondre. Réessayez.", + aiInputPlaceholder: "Qu'est-ce qu'Oasis ?", + typeAiExchange: "IA", + aiApproveTrain: "Ajouter à l’entraînement collectif", + aiRejectTrain: "Ne pas entraîner", + aiTrainPending: "En attente d’approbation", + aiTrainApproved: "Approuvé pour l’entraînement", + aiTrainRejected: "Rejeté pour l’entraînement", + aiSnippetsUsed: "Fragments utilisés", + aiSnippetsLearned: "Fragments appris", + statsAITraining: "Entraînement de l’IA", + aiApproveCustomTrain: "Entraîner avec cette réponse personnalisée", + aiCustomAnswerPlaceholder: "Écrivez votre réponse personnalisée…", + statsAIExchanges: "Échanges de modèles", + marketMineSectionTitle: "Vos articles", + marketCreateSectionTitle: "Créer un article", + marketUpdateSectionTitle: "Mettre à jour", + marketAllSectionTitle: "Marché", + marketRecentSectionTitle: "Marché récent", + marketTitle: "Marché", + marketDescription: "Un marché pour échanger des biens ou des services dans votre réseau.", + marketFilterAll: "TOUS", + marketFilterMine: "MIENS", + marketFilterAuctions: "ENCHÈRES", + marketFilterItems: "ÉCHANGE", + marketFilterNew: "NEUF", + marketFilterUsed: "D'OCCASION", + marketFilterBroken: "CASSÉ", + marketFilterForSale: "À VENDRE", + marketFilterSold: "VENDU", + marketFilterDiscarded: "JETÉ", + marketFilterRecent: "RÉCENT", + marketFilterMyBids: "ENCHÈRES", + marketCreateButton: "Créer un article", + marketItemType: "Type", + marketItemTitle: "Titre", + marketItemAvailable: "Date limite", + marketItemDescription: "Description", + marketItemDescriptionPlaceholder: "Décrivez l'article que vous vendez", + marketItemStatus: "Statut", + marketShopLabel: "Boutique", + marketItemCondition: "État", + marketItemPrice: "Prix", + marketItemTags: "Étiquettes", + marketItemTagsPlaceholder: "Entrez des tags séparés par des virgules", + marketItemDeadline: "Date limite", + marketItemIncludesShipping: "Livraison incluse ?", + marketItemHighestBid: "Meilleure offre", + marketItemHighestBidder: "Meilleur enchérisseur", + marketItemStock: "Réserve", + marketOutOfStock: "Rupture de stock", + marketItemBidTime: "Date de l'enchère", + marketActionsUpdate: "Mettre à jour", + marketUpdateButton: "Mettre à jour", + marketActionsDelete: "Supprimer", + marketActionsSold: "Marquer comme vendu", + marketActionsChangeStatus: "CHANGER LE STATUT", + marketActionsBuy: "ACHETER !", + marketAuctionBids: "Offres en cours", + marketPlaceBidButton: "Enchérir", + marketItemSeller: "Vendeur", + marketNoItems: "Aucun article disponible pour le moment.", + marketYourBid: "Votre offre", + marketCreateFormImageLabel: "Télécharger un média (max: 50 Mo)", + marketSearchLabel: "Rechercher", + marketSearchPlaceholder: "Rechercher par titre ou tags", + marketMinPriceLabel: "Prix minimum", + marketMaxPriceLabel: "Prix maximum", + marketSortLabel: "Trier par", + marketSortRecent: "Les plus récents", + marketSortPrice: "Prix", + marketSortDeadline: "Date limite", + marketSearchButton: "Rechercher", + marketAuctionEndsIn: "Se termine", + marketAuctionEnded: "Terminé", + marketMyBidBadge: "Vous avez enchéri", + marketNoItemsMatch: "Aucun article ne correspond à votre recherche.", + jobsTitle: "Offres d’emploi", + jobsCandidatesTitle: "Candidats suggérés", + jobsCandidatesDescription: "Habitants dont les compétences correspondent à cet emploi. Envoyez-leur un message privé.", + jobsCandidatesPmBody: "Bonjour, je pense que votre profil correspond à mon offre d’emploi", + jobsDescription: "Découvrez et gérez des offres d’emploi dans votre réseau.", + jobsFilterRecent: "RÉCENTS", + jobsFilterMine: "MIENS", + jobsFilterAll: "TOUS", + jobsFilterRemote: "À DISTANCE", + jobsFilterOpen: "OUVERTS", + jobsFilterClosed: "FERMÉS", + jobsCV: "CVs", + jobsCreateJob: "Publier un emploi", + jobsRecentTitle: "Emplois récents", + jobsMineTitle: "Vos emplois", + jobsAllTitle: "Offres d’emploi", + jobsRemoteTitle: "Emplois à distance", + jobsOpenTitle: "Emplois ouverts", + jobsClosedTitle: "Emplois fermés", + jobsCVTitle: "CVs", + jobsFilterPresencial: "PRÉSENTIEL", + jobsFilterFreelancer: "FREELANCE", + jobsFilterEmployee: "EMPLOYÉ", + jobsPresencialTitle: "Emplois présentiels", + jobsFreelancerTitle: "Emplois freelance", + jobsEmployeeTitle: "Emplois salariés", + jobTitle: "Titre", + jobLocation: "Localisation", + jobSalary: "Salaire (ECO/1h)", + jobVacants: "Postes vacants", + jobDescription: "Description", + jobRequirements: "Exigences", + jobLanguages: "Langues", + jobStatus: "État", + jobStatusOPEN: "OUVERTE", + jobStatusCLOSED: "FERMÉE", + jobSetClosed: "Marquer comme FERMÉE", + jobSubscribeButton: "Rejoindre cette offre !", + jobUnsubscribeButton: "Quitter cette offre !", + jobTitlePlaceholder: "Saisissez le titre de l’emploi", + jobDescriptionPlaceholder: "Décrivez le poste", + jobRequirementsPlaceholder: "Saisissez les exigences", + jobLanguagesPlaceholder: "Anglais, Français, Espagnol, Basque...", + jobTasksPlaceholder: "Liste des tâches", + jobLocationPresencial: "Présentiel", + jobLocationRemote: "À distance", + jobVacantsPlaceholder: "Nombre de postes vacants", + jobSalaryPlaceholder: "Salaire en ECO pour 1 heure", + jobImage: "Télécharger un média (max: 50 Mo)", + jobTasks: "Tâches", + jobType: "Type d’emploi", + jobTime: "Temps de travail", + jobSubscribers: "Abonnés", + noSubscribers: "Sans abonnés", + jobsFilterTop: "TOP", + jobsTopTitle: "Emplois les mieux payés", + createJobButton: "Publier un emploi", + viewDetailsButton: "Voir les détails", + noJobsFound: "Aucune offre d’emploi trouvée.", + jobAuthor: "Par", + jobTypeFreelance: "Autonome", + jobTypeSalary: "Employé", + jobTypeExchange: "Échange d’heures", + jobTypeEXCHANGE: "ÉCHANGE D’HEURES", + jobsFilterExchange: "ÉCHANGE", + jobsExchangeTitle: "Emplois en échange d’heures", + jobsHoursOffered: "Heures offertes", + jobsHoursRequested: "Heures demandées en échange", + jobsExchangeSkill: "Compétence demandée en échange", + jobsHoursOfferedPlaceholder: "ex. 4", + jobsHoursRequestedPlaceholder: "ex. 4", + jobsExchangeSkillPlaceholder: "ex. menuiserie, design", + jobExchangeHint: "Pour les emplois en échange d’heures, remplissez les champs ci-dessous au lieu du salaire.", + jobTimePartial: "Partiel", + jobTimeComplete: "Complet", + jobsDeleteButton: "SUPPRIMER", + jobsUpdateButton: "METTRE À JOUR", + jobsFilterApplied: "POSTULÉ", + jobsAppliedTitle: "Mes candidatures", + jobsAppliedBadge: "Candidaté", + jobsFilterFavs: "Favoris", + jobsFavsTitle: "Favoris", + jobsFilterNeeds: "Besoin d'aide", + jobsNeedsTitle: "Besoin d'aide", + jobsSearchLabel: "Recherche", + jobsSearchPlaceholder: "Rechercher titre, tags, description...", + jobsMinSalaryLabel: "Salaire min.", + jobsMaxSalaryLabel: "Salaire max.", + jobsSortLabel: "Trier par", + jobsSortRecent: "Plus récents", + jobsSortSalary: "Salaire le plus élevé", + jobsSortSubscribers: "Plus de candidats", + jobsSearchButton: "Rechercher", + jobsFavoriteButton: "Favori", + jobsUnfavoriteButton: "Retirer favori", + jobsMessageAuthorButton: "MP", + jobsApplicants: "Candidats", + jobsUpdatedAt: "Mis à jour", + jobSetOpen: "Marquer ouvert", + jobNewBadge: "NOUVEAU", + jobsTagsLabel: "Étiquettes", + jobsTagsPlaceholder: "tag1, tag2, tag3", + noJobsMatch: "Aucune offre ne correspond à votre recherche.", + projectsTitle: "Projets", + projectsDescription: "Créez, financez et suivez des projets communautaires dans votre réseau.", + projectCreateProject: "Créer un projet", + projectCreateButton: "Créer un projet", + projectUpdateButton: "METTRE À JOUR", + projectDeleteButton: "SUPPRIMER", + projectNoProjectsFound: "Aucun projet trouvé.", + projectFilterAll: "TOUS", + projectFilterMine: "MES PROJETS", + projectFilterActive: "ACTIFS", + projectFilterPaused: "EN PAUSE", + projectFilterCompleted: "COMPLÉTÉS", + projectFilterFollowing: "SUIVIS", + projectFilterRecent: "RÉCENTS", + projectFilterTop: "TOP", + projectAllTitle: "Projets", + projectMineTitle: "Vos Projets", + projectActiveTitle: "Projets Actifs", + projectPausedTitle: "Projets en Pause", + projectCompletedTitle: "Projets Complétés", + projectFollowingTitle: "Projets Suivis", + projectRecentTitle: "Projets Récents", + projectTopTitle: "Mieux Financé", + projectTitlePlaceholder: "Nom du projet", + projectImage: "Télécharger un média (max: 50 Mo)", + projectDescription: "Description", + projectDescriptionPlaceholder: "Racontez l'histoire et les objectifs…", + projectGoal: "Objectif (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Date limite", + projectProgress: "Progrès initial (%)", + projectStatus: "État", + projectFunding: "Financement", + projectPledged: "Engagé", + projectSetStatus: "Définir l'état", + projectSetProgress: "Mettre à jour le progrès", + projectFollowButton: "SUIVRE", + projectUnfollowButton: "SE DÉSINSCRIRE", + projectStatusACTIVE: "ACTIF", + projectStatusPAUSED: "EN PAUSE", + projectStatusCOMPLETED: "COMPLÉTÉ", + projectStatusCANCELLED: "ANNULÉ", + projectPledgeTitle: "Soutenez ce projet", + projectPledgePlaceholder: "Montant en ECO", + projectBounties: "Récompenses", + projectBountiesInputLabel: "Récompenses (une par ligne : Titre|Montant [ECO]|Description)", + projectBountiesPlaceholder: "Réparer un bug UI|100|Lien vers le problème\nRédiger de la documentation|250|Exemples d'utilisation", + projectNoBounties: "Aucune récompense trouvée.", + projectTitle: "Titre", + projectAddBountyTitle: "Ajouter une nouvelle récompense", + projectBountyTitle: "Titre de la récompense", + projectBountyAmount: "Montant (ECO)", + projectBountyDescription: "Description", + projectMilestoneSelect: "Sélectionner un jalon", + projectBountyCreateButton: "Créer une récompense", + projectBountyStatus: "Statut de la récompense", + projectBountyOpen: "ouverte", + projectBountyClaimed: "réclamée", + projectBountyDone: "complétée", + projectBountyClaimedBy: "réclamée par", + projectBountyClaimButton: "réclamer", + projectBountyCompleteButton: "Marquer comme complétée", + projectMilestones: "Jalons", + projectAddMilestoneTitle: "Nouveau jalon", + projectMilestoneTitle: "Titre du jalon", + projectMilestoneTargetPercent: "Pourcentage (%)", + projectMilestoneDueDate: "Date", + projectMilestoneCreateButton: "Créer un jalon", + projectMilestoneStatus: "Statut du jalon", + projectMilestoneOpen: "ouvert", + projectMilestoneDone: "complété", + projectMilestoneDue: "échéance", + projectNoMilestones: "Aucun jalon trouvé.", + projectMilestoneMarkDone: "Marquer comme fait", + projectMilestoneTitlePlaceholder: "Entrez le titre du jalon", + projectMilestoneDescriptionPlaceholder: "Entrez la description de ce jalon", + projectMilestoneDescription: "Description du jalon", + projectBudgetGoal: "Budget (Objectif)", + projectBudgetAssigned: "Assigné aux récompenses", + projectBudgetRemaining: "Restant", + projectBudgetOver: "⚠ Budget dépassé : ce qui est assigné dépasse l’objectif", + projectFollowers: "Suiveurs", + projectFollowersTitle: "Suiveurs", + projectFollowersNone: "Pas encore de suiveurs.", + projectMore: "plus", + projectYouFollowHint: "Vous suivez ce projet", + projectBackers: "Mécènes", + projectBackersTitle: "Mécènes", + projectBackersTotal: "Total des mécènes", + projectBackersTotalPledged: "Total engagé", + projectBackersYourPledge: "Votre engagement", + projectBackersNone: "Pas encore d'engagement.", + projectNoRemainingBudget: "Aucun budget restant.", + projectFilterApplied: "POSTULÉ", + projectAppliedTitle: "POSTULÉ", + projectFilterBackers: "MÉCÈNES", + projectBackersLeaderboardTitle: "Mécènes en tête", + projectNoBackersFound: "Aucun mécène trouvé.", + projectBackerAmount: "Total engagé", + projectBackerPledges: "Engagements", + projectBackerProjects: "Projets", + projectPledgeAmount: "Montant", + projectSelectMilestoneOrBounty: "Sélectionner un jalon ou une récompense", + projectPledgeButton: "Soutenir", + footerLicense: "GPLv3", + footerPackage: "Paquet", + footerVersion: "Version", + modulesModuleName: "Nom", + modulesModuleDescription: "Description", + modulesModuleStatus: "Statut", + modulesTotalModulesLabel: "Modules chargés", + modulesEnabledModulesLabel: "Activés", + modulesDisabledModulesLabel: "Désactivés", + modulesPopularLabel: "Populaire", + modulesPopularDescription: "Module pour recevoir les publications tendance, les plus vues ou les plus commentées.", + modulesTopicsLabel: "Sujets", + modulesTopicsDescription: "Module pour recevoir des catégories de discussion basées sur des intérêts partagés.", + modulesSummariesLabel: "Résumé", + modulesSummariesDescription: "Module pour recevoir des résumés de discussions ou publications longues.", + modulesLatestLabel: "Derniers", + modulesLatestDescription: "Module pour recevoir les publications et discussions les plus récentes.", + modulesThreadsLabel: "Fils", + modulesThreadsDescription: "Module pour recevoir des conversations groupées par sujet ou question.", + modulesMultiverseLabel: "Multivers", + modulesMultiverseDescription: "Module pour recevoir du contenu d’autres pairs fédérés.", + modulesInvitesLabel: "Invitations", + modulesInvitesDescription: "Module pour gérer et appliquer des codes d'invitation.", + modulesWalletLabel: "Portefeuille", + modulesWalletDescription: "Module pour gérer vos actifs numériques (ECOin).", + modulesLegacyLabel: "Héritage", + modulesLegacyDescription: "Module pour gérer rapidement et en toute sécurité votre secret (clé privée).", + modulesCipherLabel: "Chiffreur", + modulesCipherDescription: "Module pour crypter et décrypter votre texte de manière symétrique (en utilisant un mot de passe partagé).", + modulesBookmarksLabel: "Signets", + modulesBookmarksDescription: "Module pour découvrir et gérer les signets.", + modulesVideosLabel: "Vidéos", + modulesVideosDescription: "Module pour découvrir et gérer les vidéos.", + modulesDocsLabel: "Documents", + modulesDocsDescription: "Module pour découvrir et gérer les documents.", + modulesAudiosLabel: "Audios", + modulesAudiosDescription: "Module pour découvrir et gérer les audios.", + modulesTagsLabel: "Étiquettes", + modulesTagsDescription: "Module pour découvrir et explorer les modèles de taxonomie (étiquettes).", + modulesImagesLabel: "Images", + modulesImagesDescription: "Module pour découvrir et gérer les images.", + modulesTrendingLabel: "Tendances", + modulesTrendingDescription: "Module pour explorer le contenu le plus populaire.", + modulesEventsLabel: "Événements", + modulesEventsDescription: "Module pour découvrir et gérer les événements.", + modulesTasksLabel: "Tâches", + modulesTasksDescription: "Module pour découvrir et gérer les tâches.", + modulesMarketLabel: "Marché", + modulesMarketDescription: "Module pour échanger des biens ou services.", + modulesShopsLabel: "Boutiques", + modulesShopsDescription: "Module pour gérer et découvrir des boutiques.", + modulesTribesLabel: "Tribus", + modulesTribesDescription: "Module pour explorer ou créer des tribus (groupes).", + modulesVotationsLabel: "Votations", + modulesVotationsDescription: "Module pour découvrir et gérer les votations.", + modulesParliamentLabel: "Parlement", + modulesParliamentDescription: "Module pour élire des gouvernements et voter des lois.", + modulesCourtsLabel: "Tribunaux", + modulesCourtsDescription: "Module pour résoudre des conflits et émettre des verdicts.", + modulesReportsLabel: "Rapports", + modulesReportsDescription: "Module pour gérer et suivre les rapports liés aux problèmes, erreurs, abus et avertissements de contenu.", + modulesOpinionsLabel: "Opinions", + modulesOpinionsDescription: "Module pour découvrir et voter des opinions.", + modulesTransfersLabel: "Transferts", + modulesTransfersDescription: "Module pour découvrir et gérer les contrats intelligents (transferts).", + modulesFediverseLabel: "Fediverse", + modulesFediverseDescription: "Gérez vos autres comptes du fédiverse, y compris l'envoi et la réception de contenu.", + modulesFeedLabel: "Fil", + modulesFeedDescription: "Module pour découvrir et partager des textes courts (flux).", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Module pour dessiner sur une grille collaborative.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Module pour générer et partager le « son » de votre blockchain.", + modulesAgendaLabel: "Agenda", + modulesAgendaDescription: "Module pour gérer tous vos éléments assignés.", + modulesAILabel: "IA", + modulesAIDescription: "Module pour discuter avec un LLM appelé « 42 ».", + modulesForumLabel: "Forums", + modulesForumDescription: "Module pour découvrir et gérer les forums.", + modulesJobsLabel: "Emplois", + modulesJobsDescription: "Module pour découvrir et gérer les offres d’emploi.", + modulesProjectsLabel: "Projets", + modulesProjectsDescription: "Module pour explorer, financer et gérer des projets.", + modulesBankingLabel: "Banque", + modulesBankingDescription: "Module pour connaître la valeur réelle de ECOIN et distribuer une RBU en utilisant la trésorerie commune.", + modulesFavoritesLabel: "Favoris", + modulesFavoritesDescription: "Module pour gérer votre contenu favori.", + fileTooLargeTitle: "Fichier trop volumineux", + fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.", + goBack: "Retour", + errorPageTitle: "Une erreur est survenue", + visibilityLabel: "Visibilité", + visibilityPublic: "Publique", + visibilityHidden: "Cachée", + visibilityMakePublic: "Rendre publique", + visibilityMakeHidden: "Cacher", + invitesInhabitantsTitle: "Habitants", + invitesInhabitantsFollow: "Suivre", + aiNavPlaceholder: "Où veux-tu aller ?", + aiNavDisabled: "La navigation par IA est désactivée.", + aiNavResultsTitle: "Suggestions de navigation IA", + aiNavQueryLabel: "Requête", + aiNavResultsDescription: "Choisissez l'itinéraire qui correspond le mieux à ce que vous cherchez.", + aiNavResultPath: "Route", + aiNavResultDescription: "Ce que vous pouvez y faire", + aiNavResultMatch: "Correspondance", + aiNavResultsEmpty: "Aucune route correspondante. Essayez /search.", + aiNavSearchInstead: "Rechercher plutôt", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Module pour des requêtes en langage naturel sur le contenu du réseau.", + directConnect: "Connexion Directe", + directConnectDescription: "Connectez-vous directement à un pair en saisissant son adresse IP, port et clé publique. Le pair sera ajouté comme connexion suivie.", + peerHost: "IP", + peerPort: "Port (par défaut : 8008)", + peerPublicKey: "Clé publique (@...ed25519)", + connectAndFollow: "Connecter", + deviceSourceLabel: "Appareil", + modulesPresetTitle: "Configurations Communes", + modulesPreset_minimal: "Minimal", + modulesPreset_basic: "Basique", + modulesPreset_social: "Social", + modulesPreset_economy: "Économie", + modulesPreset_full: "Complet", + statsCarbonFootprintTitle: "Empreinte Carbone", + statsCarbonFootprintNetwork: "Empreinte carbone du réseau", + statsCarbonFootprintYours: "Votre empreinte carbone", + statsCarbonTombstone: "Empreinte du tombstoning", + feedSuccessMsg: "Feed publié avec succès !", + dominantOpinionLabel: "Opinion dominante", + uploadMedia: "Télécharger un média (max: 50 Mo)", + invitesUnfollow: "Ne plus suivre", + invitesFollow: "Suivre", + invitesUnfollowedInvites: "Non fédérés", + invitesNoUnfollowed: "Aucun pub non fédéré.", + blockchainContentDeleted: "Ce contenu a été supprimé", + visitContent: "Voir le contenu", + bankEcoinHours: "Équivalence ECOin en temps", + mapsLabel: "Cartes", + mapTitle: "Cartes", + mapDescription: "Explorez et gérez des cartes hors ligne dans votre réseau.", + mapMineSectionTitle: "Vos Cartes", + mapCreateSectionTitle: "Créer une Carte", + mapUpdateSectionTitle: "Modifier la Carte", + mapAllSectionTitle: "Cartes", + mapRecentSectionTitle: "Cartes Récentes", + mapFavoritesSectionTitle: "Favoris", + mapFilterAll: "TOUS", + mapFilterMine: "MES", + mapFilterRecent: "RÉCENTS", + mapFilterFavorites: "FAVORIS", + mapUploadButton: "Créer Carte", + mapCreateButton: "Créer Carte", + mapUpdateButton: "Modifier", + mapDeleteButton: "Supprimer", + mapAddFavoriteButton: "Ajouter aux favoris", + mapRemoveFavoriteButton: "Retirer des favoris", + mapLatLabel: "Latitude", + mapLatPlaceholder: "ex. 48.8566", + mapLngLabel: "Longitude", + mapLngPlaceholder: "ex. 2.3522", + mapDescriptionLabel: "Description", + mapDescriptionPlaceholder: "Décrivez la carte ou l'emplacement...", + mapTypeLabel: "Type de carte", + mapTypeSingle: "UNIQUE (emplacement initial uniquement)", + mapTypeOpen: "OUVERT (tout le monde peut ajouter des marqueurs)", + mapTypeClosed: "FERMÉ (seul le créateur ajoute des marqueurs)", + mapTagsLabel: "Étiquettes", + mapTagsPlaceholder: "Entrez des étiquettes séparées par des virgules", + mapUrlLabel: "URL de la carte", + mapPickCoordLabel: "Ou sélectionnez un emplacement sur la grille :", + mapMarkersLabel: "marqueurs", + mapMarkersTitle: "Marqueurs", + mapMarkerDefault: "Marqueur", + mapMarkerLatLabel: "Latitude du marqueur", + mapMarkerLngLabel: "Longitude du marqueur", + mapMarkerLabelField: "Étiquette du marqueur", + mapMarkerLabelPlaceholder: "Décrivez ce marqueur...", + markerImageLabel: "Image du Marqueur", + mapAddMarkerTitle: "Ajouter un Marqueur", + mapAddMarkerButton: "Ajouter un Marqueur", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Appliquer Zoom", + mapSearchPlaceholder: "Chercher description, étiquettes, auteur...", + mapSearchButton: "Chercher", + mapUpdatedAt: "Mis à jour", + mapNoMatch: "Aucune carte ne correspond à votre recherche.", + noMaps: "Aucune carte disponible.", + mapLocationTitle: "Emplacement", + mapVisitLabel: "Visiter la carte", + typeMap: "CARTES", + typeMapMarker: "MARQUEUR DE CARTE", + modulesMapLabel: "Cartes", + modulesMapDescription: "Module pour gérer et partager des cartes hors ligne.", + padsTitle: "Pads", + padTitle: "Pad", + modulesPadsLabel: "Pads", + modulesPadsDescription: "Module pour gérer les éditeurs de texte collaboratifs.", + padFilterAll: "TOUS", + padFilterMine: "MES PADS", + padFilterRecent: "RÉCENT", + padFilterOpen: "OUVERT", + padFilterClosed: "FERMÉ", + padCreate: "Créer Pad", + padUpdate: "Modifier Pad", + padDelete: "Supprimer Pad", + padTitleLabel: "Titre", + padTitlePlaceholder: "Entrez le titre du pad...", + padStatusLabel: "Statut", + padStatusOpen: "OUVERT", + padStatusInviteOnly: "SUR INVITATION", + padStatusClosed: "FERMÉ", + padDeadlineLabel: "Date limite", + padTagsLabel: "Tags", + padTagsPlaceholder: "tag1, tag2, ...", + padMembersLabel: "Membres", + padVisitPad: "Visiter le Pad", + padShareUrl: "Partager URL", + padCreated: "Créé", + padAuthor: "Auteur", + padGenerateCode: "Générer un Code", + padInviteCodeLabel: "Code d'Invitation", + padInviteCodePlaceholder: "Entrez le code d'invitation...", + padValidateInvite: "Valider", + padStartEditing: "COMMENCER À ÉDITER !", + padEditorPlaceholder: "Commencez à écrire...", + padSubmitEntry: "Envoyer", + padNoEntries: "Aucune entrée pour l'instant.", + padAllSectionTitle: "Tous les Pads", + padMineSectionTitle: "Mes Pads", + padsDescription: "Gérez des éditeurs de texte chiffrés collaboratifs dans votre réseau.", + padRecentSectionTitle: "Pads Récents", + padOpenSectionTitle: "Pads Ouverts", + padClosedSectionTitle: "Pads Fermés", + padCreateSectionTitle: "Créer un Nouveau Pad", + padUpdateSectionTitle: "Modifier le Pad", + padInviteGenerated: "Code d'Invitation Généré", + typePad: "PADS", + padNew: "NOUVEAU", + padAddFavorite: "Ajouter aux Favoris", + padRemoveFavorite: "Retirer des Favoris", + padClose: "Fermer le pad", + padBackToEditor: "Retour à l'éditeur", + padSearchPlaceholder: "Rechercher des pads...", + + modulesChatsLabel: "Chats", + modulesChatsDescription: "Module pour découvrir et gérer les chats chiffrés.", + typeChat: "CHATS", + typeChatMessage: "MESSAGE CHAT", + chatLabel: "CHATS", + chatMessageLabel: "MESSAGES CHAT", + chatsTitle: "Chats", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "Favoris", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "Description", + chatCategory: "Catégorie", + chatStatus: "STATUT", + chatFilterAll: "TOUS", + chatFilterMine: "MIEN", + chatFilterRecent: "RÉCENT", + chatFilterFavorites: "FAVORIS", + chatFilterOpen: "OUVERT", + chatFilterClosed: "FERMÉ", + chatCreate: "Créer Chat", + chatUpdate: "Modifier Chat", + chatDelete: "Supprimer Chat", + chatClose: "Fermer Chat", + chatVisitChat: "VOIR CHAT", + chatUntitled: "Chat sans titre", + chatNoItems: "Aucun chat trouvé.", + chatParticipants: "Participants", + chatStartChatting: "COMMENCER À CHATTER!", + chatGenerateCode: "Générer Code", + chatShareUrl: "Partager URL", + chatCreatedAt: "CRÉÉ", + chatSearchPlaceholder: "Rechercher chats...", + chatStatusOpen: "OUVERT", + chatStatusInviteOnly: "SUR INVITATION", + chatStatusClosed: "FERMÉ", + chatSendMessage: "Envoyer", + chatMessagePlaceholder: "Tapez votre message...", + chatNoMessages: "Pas encore de messages.", + chatLeave: "Quitter Chat", + chatInviteCodeLabel: "Entrez le code d'invitation", + chatJoinByInvite: "Rejoindre", + chatTitlePlaceholder: "Titre du chat", + chatDescriptionPlaceholder: "Description du chat", + chatTagsPlaceholder: "tag1, tag2, tag3", + chatImageLabel: "Sélectionnez un fichier image (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Code d'Invitation", + chatAuthor: "Auteur", + chatCreated: "Créé", + chatAddFavorite: "Ajouter aux Favoris", + chatRemoveFavorite: "Retirer des Favoris", + chatPM: "MP", + chatStatusLabel: "Statut", + chatCategoryLabel: "Catégorie", + chatParticipantsLabel: "Participants", + gamesTitle: "Jeux", + gamesDescription: "Découvrez et jouez à des jeux.", + gamesFilterAll: "TOUS", + gamesPlayButton: "JOUER!", + gamesBackToGames: "Retour aux Jeux", + modulesGamesLabel: "Jeux", + modulesGamesDescription: "Module pour découvrir et jouer à des jeux.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Module pour explorer le réseau comme une carte interactive des nœuds.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Connectez les PUBs aux habitants via validateurs, boutiques et accumulateurs. Résistez à la menace CBDC !", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Infiltrez la grille, collectez les données confidentielles, évitez les drones et échappez-vous. Combien de niveaux pouvez-vous passer?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "Arrêtez l'invasion extraterrestre! Abattez les vagues d'envahisseurs.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Cassez toutes les briques avec votre raquette et votre balle. Un défi arcade classique.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Ping-pong classique contre une IA. Le premier à 5 points gagne.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "Course contre la montre! Évitez les obstacles et atteignez l'arrivée à temps.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Pilotez votre vaisseau dans un champ d'astéroïdes. Détruisez-les avant qu'ils ne vous touchent.", + gamesRockPaperScissorsTitle: "Pierre Feuille Ciseaux", + gamesRockPaperScissorsDesc: "Pierre, papier, ciseaux contre une IA. Le meilleur des trois manches gagne.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Morpion classique contre l'IA. Alignez trois symboles pour gagner.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Lancez une pièce et pariez sur pile ou face. Quelle est votre chance?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "Calendriers", + calendarTitle: "Calendrier", + modulesCalendarsLabel: "Calendriers", + modulesCalendarsDescription: "Module pour découvrir et gérer les calendriers.", + typeCalendar: "CALENDRIERS", + calendarFilterAll: "TOUS", + calendarFilterMine: "LES MIENS", + calendarFilterOpen: "OUVERT", + calendarFilterClosed: "FERMÉ", + calendarFilterRecent: "RÉCENTS", + calendarFilterFavorites: "FAVORIS", + calendarCreate: "Créer un calendrier", + calendarUpdate: "Mettre à jour", + calendarDelete: "Supprimer", + calendarTitleLabel: "Titre", + calendarTitlePlaceholder: "Titre du calendrier...", + calendarStatusLabel: "Statut", + calendarStatusOpen: "OUVERT", + calendarStatusClosed: "FERMÉ", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Étiquettes", + calendarTagsPlaceholder: "tag1, tag2...", + calendarParticipantsLabel: "Participants", + calendarParticipantsCount: "Participants", + calendarVisitCalendar: "Visiter le calendrier", + calendarCreated: "Créé", + calendarAuthor: "Auteur", + calendarJoin: "Rejoindre", + calendarGenerateInvite: "Générer une invitation", + calendarInviteCodePlaceholder: "Entrez le code d'invitation...", + calendarValidateInvite: "Valider le code", + calendarJoined: "Rejoint", + calendarAddDate: "Ajouter une date", + calendarAddNote: "Ajouter une note", + calendarDateLabel: "Date", + calendarDatePlaceholder: "Décrivez cette date...", + calendarNoteLabel: "Note", + calendarNotePlaceholder: "Ajouter une note...", + calendarFirstDateLabel: "Date", + calendarFirstNoteLabel: "Notes", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Description", + calendarNoDates: "Aucune date ajoutée.", + calendarNoNotes: "Aucune note.", + calendarsNoItems: "Aucun calendrier trouvé.", + calendarsDescription: "Découvrez et gérez les calendriers de votre réseau.", + calendarMonthPrev: "← Précédent", + calendarMonthNext: "Suivant →", + calendarMonthLabel: "Dates", + calendarsShareUrl: "URL de partage", + calendarAllSectionTitle: "Calendriers", + calendarRecentSectionTitle: "Calendriers Récents", + calendarFavoritesSectionTitle: "Favoris", + calendarMineSectionTitle: "Vos Calendriers", + calendarOpenSectionTitle: "Calendriers ouverts", + calendarClosedSectionTitle: "Calendriers fermés", + calendarCreateSectionTitle: "Créer un calendrier", + calendarUpdateSectionTitle: "Mettre à jour le calendrier", + calendarAddFavorite: "Ajouter aux favoris", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Retirer des favoris", + calendarSearchPlaceholder: "Rechercher des calendriers...", + calendarAddEntry: "Ajouter une entrée", + calendarLeave: "Quitter le calendrier", + statsCalendar: "Calendriers", + statsCalendarDate: "Dates de calendrier", + statsCalendarNote: "Notes de calendrier", + chatAccessDenied: "Vous n'avez pas accès à ce chat. Demandez une invitation pour accéder au contenu.", + padAccessDenied: "Vous n'avez pas accès à ce pad. Demandez une invitation pour accéder au contenu.", + contentAccessDenied: "Vous n'avez pas accès à ce contenu.", + blockAccessRestricted: "Accès restreint", + tribeContentAccessDenied: "Accès Refusé", + tribeContentAccessDeniedMsg: "Ce contenu appartient à une tribu. Vous devez en être membre pour y accéder.", + tribeViewTribes: "Voir les Tribus", + torrentsTitle: "Torrents", + torrentsDescription: "Explorez et gérez les torrents de votre réseau.", + torrentAllSectionTitle: "Torrents", + torrentMineSectionTitle: "Vos Torrents", + torrentRecentSectionTitle: "Torrents Récents", + torrentTopSectionTitle: "Meilleurs Torrents", + torrentFavoritesSectionTitle: "Favoris", + torrentFilterAll: "TOUS", + torrentFilterMine: "MES", + torrentFilterRecent: "RÉCENTS", + torrentFilterTop: "MEILLEURS", + torrentFilterFavorites: "FAVORIS", + torrentCreateSectionTitle: "Téléverser un Torrent", + torrentUpdateSectionTitle: "Mettre à jour le Torrent", + torrentCreateButton: "Téléverser un Torrent", + torrentUpdateButton: "Mettre à jour", + torrentDeleteButton: "Supprimer", + torrentAddFavoriteButton: "Ajouter aux Favoris", + torrentRemoveFavoriteButton: "Retirer des Favoris", + torrentFileLabel: "Sélectionner un fichier torrent (.torrent)", + torrentTitleLabel: "Titre", + torrentTitlePlaceholder: "Titre", + torrentDescriptionLabel: "Description", + torrentDescriptionPlaceholder: "Description", + torrentTagsLabel: "Étiquettes", + torrentTagsPlaceholder: "Entrez des étiquettes séparées par des virgules", + torrentSizeLabel: "Taille", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "Aucun fichier torrent", + noTorrents: "Aucun torrent trouvé.", + torrentSearchPlaceholder: "Rechercher titre, étiquettes, auteur...", + torrentSearchButton: "Rechercher", + torrentSortRecent: "Plus récents", + torrentSortOldest: "Plus anciens", + torrentSortTop: "Plus votés", + torrentNoMatch: "Aucun torrent correspondant.", + torrentMessageAuthorButton: "Envoyer un Message à l'Auteur", + torrentUpdatedAt: "Mis à jour", + statsTorrent: "Torrents", + typeTorrent: "TORRENTS", + modulesTorrentsLabel: "Torrents", + modulesTorrentsDescription: "Module pour découvrir et gérer les torrents.", + favoritesFilterTorrents: "TORRENTS", + favoritesFilterMarket: "MARCHÉ", + favoritesFilterShopProducts: "ARTICLES BOUTIQUE", + tribeSectionTorrents: "TORRENTS", + tribeCreateTorrent: "Téléverser un Torrent", + tribeMediaTypeTorrent: "Torrent", + settingsWishTitle: "Souhait", + settingsWishDesc: "Configurez le souhait de votre Avatar.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Messages Privés", + settingsPmVisibilityDesc: "Configurez votre niveau d'exposition aux messages privés.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "L'envoi est un garde-fou UX. La réception est un filtre d'attention.", + pmBlockedNonMutual: "Vous ne pouvez envoyer des MP qu'aux habitants en soutien mutuel.", + inhabitantsPendingFollowsTitle: "Demandes de soutien en attente", + inhabitantsPendingAccept: "Accepter", + inhabitantsPendingReject: "Refuser", + bxEncrypted: "CHIFFRÉ", + bxEncryptedHexLabel: "Texte chiffré (aperçu)", + tribeSectionGovernance: "GOUVERNANCE", + tribeSubStatusPublic: "PUBLIQUE", + tribeSubStatusPrivate: "PRIVÉE", + tribeSubInheritedPrivate: "PRIVÉE (héritée de la tribu principale)", + tribePadInviteRequired: "Vous n'avez pas accès au pad. Demandez une invitation.", + tribeChatInviteRequired: "Vous n'avez pas accès au chat. Demandez une invitation.", + tribeGovernanceDesc: "Gouvernance interne de cette tribu.", + tribeGovernanceNoGov: "Pas de gouvernement actif", + tribeGovernanceNoGovDesc: "Cette tribu n'a pas encore de gouvernement.", + tribeGovCardTitle: "Gouvernement Actuel", + tribeGovCycleSince: "DÉBUT DU CYCLE", + tribeGovCycleEnd: "FIN DU CYCLE", + tribeGovTimeRemaining: "TEMPS RESTANT", + tribeGovPopulation: "POPULATION", + tribeGovMethod: "MÉTHODE", + tribeGovVotesReceived: "VOTES REÇUS", + tribeGovLeader: "DIRIGEANT", + tribeGovFilterGovernment: "GOUVERNEMENT", + tribeGovFilterCandidatures: "CANDIDATURES", + tribeGovFilterLaws: "LOIS", + tribeGovCandidatureId: "Candidature", + tribeGovCandidatureMethod: "Méthode", + tribeGovCandidatureProposeBtn: "Publier la candidature", + tribeGovRuleTitle: "Titre de la règle", + tribeGovRuleBody: "Corps de la règle", + tribeGovProposals: "PROPOSITIONS", + tribeGovRevocations: "RÉVOCATIONS", + tribeGovHistorical: "HISTORIQUE", + tribeGovRules: "RÈGLES", + tribeGovernanceAlreadyPublished: "Cette tribu a déjà une candidature ouverte dans le cycle actuel.", + tribeGovernanceProposeInternal: "Proposer une candidature interne", + tribeGovernanceInternalCandidatures: "Candidatures internes", + tribeGovernanceNoCandidatures: "Aucune candidature ouverte.", + tribeGovernanceAddRule: "Ajouter une règle", + tribeGovernanceNoRules: "Aucune règle pour le moment.", + tribeGovernanceNoLeaders: "Aucun leader élu.", + tribeGovernanceComingSoon: "Bientôt dans le module de gouvernance.", + tribeGovNoProposals: "Aucune proposition pour l’instant", + tribeGovNoLaws: "Aucune loi pour l’instant", + tribeGovNoRevocations: "Aucune révocation pour l’instant", + tribeGovNoHistorical: "Aucun enregistrement pour l’instant", + logsTitle: "Journal", + logsDescription: "Enregistrez votre expérience sur le réseau.", + logsReadMore: "Lire la suite", + logsViewTitle: "Journal", + logsColumnType: "Type", + logsView: "Voir", + logsManualPrompt: "Écrivez votre journal", + logsUpdateButton: "Mettre à jour", + logsEditTitle: "Éditer une entrée", + logsCreateDescription: "Enregistrez votre expérience...", + logsCreateTitle: "Créer une entrée", + logsWriteButton: "Écrire", + logsTextPlaceholder: "Décrivez vos expériences...", + logsTextField: "Texte", + logsLabelPlaceholder: "Libellé...", + logsLabelField: "Écrivez votre journal (libellé)", + logsAiDisabledWarn: "Activez le module IA dans /modules pour utiliser les entrées écrites par l'IA.", + logsAiContextValue: "Jour de blockchain", + logsAiContext: "Contexte", + logsAiModStatus: "AImod", + logsModeApply: "Appliquer !", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Manuel", + logsModeAI: "IA", + logsColumnDelete: "Supprimer", + logsColumnEdit: "Éditer", + logsColumnLog: "Journal", + logsColumnDate: "Date", + logsDelete: "Supprimer", + logsEdit: "Éditer", + logsFilterAlways: "TOUJOURS", + logsFilterYear: "ANNÉE", + logsFilterMonth: "MOIS", + logsFilterWeek: "SEMAINE", + logsFilterToday: "AUJOURD'HUI", + logsFilterRecent: "RÉCENTS", + logsFilterAll: "TOUS", + logsCreate: "Créer une entrée", + logsExport: "Exporter le journal", + logsExportOne: "Exporter", + logsViewDetails: "Voir les détails", + logsGenerateButton: "Générer le texte", + logsSearchText: "Chercher dans les journaux...", + logsSearchAnyType: "Tout type", + logsSearchButton: "Chercher", + logsEmpty: "Aucune entrée pour l'instant.", + modulesLogsLabel: "Journal", + modulesLogsDescription: "Module pour enregistrer (via un assistant IA) vos expériences.", + statsLogsTitle: "Journal", + statsLogsEntries: "Entrées", + typeLog: "JOURNAL", + blockchainCycle: "Cycle", + + larpTitle: "L.A.R.P.", + larpDescription: "Une couche de jeu de rôle grandeur nature pour l'expérimentation collaborative.", + larpAllHouses: "Maisons :", + larpFilterRuling: "RÈGNE", + larpFilterHouses: "MAISONS", + larpFilterRules: "FAQ", + larpRulesTitle: "FAQ du L.A.R.P.", + larpRulesEntryTitle: "Maison de départ", + larpRulesEntryText: "Chaque habitant commence dans ACADEMIA par défaut. Depuis ACADEMIA, choisis une maison dans le panneau \"Rejoindre une maison\" : cela ouvre son test d'entrée. Réponds aux 10 questions et soumets. Si tu dépasses le seuil (7/10), tu es admis automatiquement dans cette maison ; sinon, tu es rejeté et tu restes dans ACADEMIA. Dans tous les cas, le système enregistre ton OasisID et le cycle de la tentative et empêche un nouvel essai jusqu'à la fin du délai de 30 cycles.", + larpRulesLeaveText: "Les membres de toute maison peuvent retourner à ACADEMIA à tout moment depuis leur page de maison ; cela réinitialise leur appartenance mais ne lève pas le délai du test.", + larpRulesGovernanceTitle: "Mur de gouvernance", + larpRulesGovernanceText: "Pendant son cycle, la maison régnante arbore l'insigne \"Règne\" et est la seule dont le Mur est exposé publiquement sous l'onglet RÈGNE.", + larpRulesSignTitle: "Emblème L.A.R.P.", + larpRulesSignText: "Les habitants peuvent l'activer (dans /profile/edit > Capteurs) pour afficher l'image de leur maison actuelle comme un sceau sur leur profil, leur fiche d'auteur et d'habitant. Le sceau renvoie à la page de la maison.", + larpPostsTitle: "Mur", + larpPostsEmpty: "Aucune publication pour le moment.", + larpPostLabel: "Nouvelle publication", + larpPostPlaceholder: "Qu'a cette maison à dire ?", + larpPostPublic: "Public (visible par tous, sinon réservé aux membres)", + larpPostSubmit: "Publier", + larpPostMembersOnly: "Membres seulement", + larpCycleLabel: "Cycle :", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + parliamentRevocationPublish: "Publish Revocation", + reportsAcceptanceCriteriaLabel: "Acceptance criteria", + reportsActualBehaviorPlaceholder: "What actually happens?", + reportsEvidenceLinksPlaceholder: "Paste links, message IDs, screenshots (if applicable), etc.", + reportsUserStoryLabel: "Inhabitant story", + reportsUserStoryPlaceholder: "As a , I want , so that .", + reportsWhatHappenedLabel: "What happened?", + reportsWhatHappenedPlaceholder: "Describe the incident with context and approximate dates.", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + typeGameScore: "GAME SCORES", + bankTaxesLookupNote: "Entre un ID de bloc pour le rechercher dans ton flux local.", + bankTaxesUserNoteChipsClick: "Clique sur n'importe quel chip pour inspecter son bloc dans le blockexplorer.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "Code d'invitation", + larpRulesInviteEntryText: "Les membres des maisons peuvent générer des codes d'invitation qui donnent un accès direct à la maison.", + larpRulesOneHouseText: "Tu ne peux appartenir qu'à une seule maison à la fois. Si tu n'appartiens à aucune, tu es dans ACADEMIA. Chaque page de maison (sauf ACADEMIA) affiche un bouton « Quitter la Maison » à ses membres qui remet l'appartenance à ACADEMIA. Quitter n'annule PAS le délai du test.", + larpRulesOneHouseTitle: "Une maison à la fois", + larpRulesTestEntry: "Test WILL", + larpRulesTestEntryText: "Chaque option pondère une ou plusieurs maisons ; lors de l'envoi, le système additionne les scores et t'admet automatiquement dans la maison ayant le score le plus élevé.", + larpWillTestHint: "Une fois terminé, on t'assignera la maison qui correspond le mieux à tes réponses. Tes réponses individuelles sont traitées localement et ne sont jamais stockées — seule l'attribution de maison résultante est publiée dans ton flux.", + larpWillTestTitle: "Test WILL", + settingsLanDesc: "Annoncer périodiquement ce pair aux autres instances d'Oasis sur le même réseau local. Désactiver pour arrêter les diffusions UDP.", + settingsLanEnable: "Activer la diffusion LAN", + settingsLanTitle: "Diffusion LAN", + settingsReplicationTitle: "Réplication", + settingsReplicationDesc: "Configure le nombre de sauts que ton pair suit à partir de ton propre flux lors de la réplication du contenu.", + settingsReplicationHopsLabel: "Sauts", + aiApproveTagsLabel: "Étiquettes (séparées par virgules)", + aiApproveTagsPlaceholder: "ex. oasis, gouvernance, écologie", + aiApproveRatingLabel: "Note", + aiExchangeLang: "Langue", + aiExchangeTags: "Étiquettes", + aiExchangeRating: "Note", + aiExchangeHelpful: "Utile", + aiExchangeMarkHelpful: "+1 utile", + larpCyclesUntilRuling: "Prochain cycle de gouvernance", + larpVisitTribe: "Visiter la tribu", + larpGoverning: "Règne :", + larpMyHouse: "Ma Maison :", + larpMembersCount: "Membres", + larpMembersTitle: "Membres", + larpNoMembers: "Aucun membre pour le moment.", + larpRolesLabel: "Rôles", + larpFunctionLabel: "Fonction", + larpMonthLabel: "Cycle de gouvernance", + larpLeaveToAcademia: "Retour à ACADEMIA", + larpAcademiaJoinTitle: "Rejoindre une maison", + larpAcademiaJoinHint: "Passe le test psychologique de profil pour être affecté à la maison qui te correspond le mieux, ou utilise un code d'invitation partagé par un membre d'une maison.", + larpTakeTestButton: "Passer le test de profil", + larpInviteRedeem: "Rejoindre la Maison", + larpInviteCreate: "Générer un code d'invitation", + larpInvitePlaceholder: "Code d'invitation", + larpInviteBannerTitle: "Nouveau code d'invitation", + larpInviteBannerHint: "Partage ce code avec une personne en ACADEMIA. Il expire dans 30 cycles ou après une utilisation.", + larpTestAssignedHouse: "Maison attribuée", + larpTestRankingTitle: "Score par maison", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "Code d'invitation de maison", + invitesHouseJoinButton: "Rejoindre la Maison", + ecoTaxLabel: "ECO Tax", + ecoinTaxLabel: "ECOin Tax", + bankTaxes: "Impôts", + bankOverviewYourTaxes: "Tes impôts", + bankTaxesNetworkTitle: "Impôts du réseau", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesUserTitle: "Tes impôts", + bankTaxesUserAmount: "Ton ECO Tax (prix à rendre)", + bankTaxesArchTaxUserAmount: "Ton ARCH Tax (prix à rendre)", + bankTaxesArchTaxFirstBlock: "Âge de ton premier bloc", + bankTaxesArchTaxRate: "Taux", + bankTaxesTotalBlocks: "Blocs au total", + bankTaxesTotalBytes: "Octets au total", + bankTaxesTotalCarbon: "Carbone total", + bankTaxesTotalEcoin: "ECO Tax total (à vie)", + bankTaxesEcoTaxLifetime: "ECO Tax (à vie)", + bankTaxesAnnualEcoin: "ECO Tax (annuel)", + bankTaxesMonthlyEcoin: "ECO Tax (mensuel)", + bankTaxesArchTaxLifetime: "ARCH Tax (à vie)", + bankTaxesArchTaxAnnual: "ARCH Tax (annuel)", + bankTaxesArchTaxMonthly: "ARCH Tax (mensuel)", + bankTaxesTotalLifetime: "Total (à vie)", + bankTaxesTotalAnnual: "Total (annuel)", + bankTaxesTotalMonthly: "Total (mensuel)", + bankTaxesRate: "Taux", + bankTaxesSpan: "Période d'échantillonnage", + bankTaxesLookupTitle: "Inspecter un bloc", + bankTaxesLookupPlaceholder: "ID du bloc (ex. %abc...=.sha256)", + bankTaxesLookupButton: "Inspecter", + bankTaxesLookupNotFound: "Aucun bloc trouvé dans ton feed local avec cet ID.", + bankTaxesLookupAuthor: "Auteur", + bankTaxesLookupType: "Type", + bankTaxesLookupSize: "Taille", + bankTaxesLookupCarbon: "Empreinte carbone", + bankTaxesLookupEcoin: "ECO Tax", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annuel)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (mensuel)", + bankTaxesTypesLabel: "Sélectionne les impôts que tu veux payer", + bankTaxesTypesApply: "Définir mes impôts", + bankRulesPoolTitle: "Pool mensuel", + bankRulesShareTitle: "Part par utilisateur", + bankRulesKarmaTitle: "Karma (score d'engagement)", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesChipTitle: "Bande de couleur du chip ECO Tax", + bankRulesEpochKind: "Granularité d'epoch", + bankRulesAlpha: "Alpha (part max. du solde du pub par epoch)", + bankRulesReserveMin: "Réserve minimale (conservée dans le solde du pub)", + bankRulesCapPerEpoch: "Plafond par epoch (absolu)", + bankRulesGraceDays: "Délai de claim", + bankRulesWMin: "Poids minimum (w_min)", + bankRulesWMax: "Poids maximum (w_max)", + bankRulesFloor: "Plancher par utilisateur (brut)", + bankRulesCapUser: "Plafond par utilisateur par epoch", + bankRulesEcoTaxRate: "Taux", + bankRulesArchTaxRate: "Taux", + bankRulesCarbonFactor: "Facteur carbone", + statsEcoTaxTitle: "ECO Tax", + statsTombstoneEmpty: "Aucun tombstone dans le réseau pour le moment.", + blockchainBlockNotAvailable: "Ce bloc n'est pas disponible dans ton feed local. Il appartient peut-être à un peer dont tu ne réplique pas encore les données.", + blockchainBackToBlockexplorer: "Retour au blockexplorer", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeDetailDescription: "Décode la charge utile incrustée et la carte de composition d'origine de la blockchain.", + audioTranscodeStegoTitle: "Incrusté dans la forme d'onde", + audioTranscodeStegoOasisId: "Par", + audioTranscodeStegoTimestamp: "Généré le", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoEmpty: "(aucun)", + audioTranscodeStegoUnknown: "—", + audioTranscodeStegoNotFound: "Aucune charge stéganographique n'a pu être décodée depuis cet audio.", + audioTranscodeCompositionEmpty: "Cet audio ne contient pas de composition blockchain enregistrée.", + audioBackToAudio: "Retour à l'audio", + audioBackToBcs: "Retour à BCS", + audioAuthorLabel: "Auteur", + melodyCompositionTitle: "Carte de composition blockchain", + melodyFilterMine: "MIEN", + melodyByLabel: "Par", + melodyAllEmpty: "Pas encore de compositions BCS d'autres habitants.", + melodyNoteUnavailable: "le bloc n'est pas dans ton feed local", + melodyUploadBcsTitle: "Publier", + melodyUploadBcsButton: "Publier", + melodyUploadStegoLabel: "Message caché (stéganographie)", + melodyUploadStegoMaxLabel: "max. 280 caractères", + melodyUploadStegoPlaceholder: "Facultatif.", + melodyUploadBcsNameNote: "L'audio sera publié sous le nom auto-généré", + larpLastAttemptTitle: "Ta dernière tentative", + larpLastAttemptHouse: "Maison", + larpLastAttemptResult: "Résultat", + larpLastAttemptWhen: "Quand", + larpLastAttemptCooldown: "Prochaine tentative dans", + larpTestTitle: "Test d'entrée", + larpTestIntro: "Réponds aux 10 questions. Il te faut au moins 7 bonnes réponses pour être admis. Tu peux tenter un test tous les 30 cycles, quel que soit le résultat.", + larpTestSubmit: "Rejoindre la maison", + larpTestCooldownActive: "Prochain test disponible dans", + larpTestCooldownDays: "cycles", + larpTestResultTitle: "Résultat du test", + larpTestPassed: "Test réussi — bienvenue !", + larpTestFailed: "Test non réussi", + larpTestScore: "Score", + larpTestNextAttempt: "Tu pourras tenter un autre test dans 30 cycles.", + larpGoToHouse: "Aller à ta maison", + larpBackToAcademia: "Retour à ACADEMIA", + larpBackToHouses: "◀ Maisons", + larpBadgeYou: "Toi", + larpBadgeRuling: "Règne", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Module pour la couche de jeu de rôle grandeur nature d'Oasis (les 9 Maisons).", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "Face à un problème complexe, que fais-tu en premier ?", + larpProfileQ1O1: "Parler aux autres pour trouver un consensus", + larpProfileQ1O2: "Construire un prototype à tester", + larpProfileQ1O3: "Rechercher la littérature", + larpProfileQ1O4: "Saboter le système qui le crée", + larpProfileQ2: "Qu'est-ce qui donne du sens à ton travail quotidien ?", + larpProfileQ2O1: "Défendre celles que j'aime", + larpProfileQ2O2: "Créer quelque chose de tangible", + larpProfileQ2O3: "Soigner ou nourrir la vie", + larpProfileQ2O4: "Travailler les mots et les idées", + larpProfileQ2O5: "Faire rire les autres", + larpProfileQ3: "Quand un conflit surgit dans ton groupe, tu…", + larpProfileQ3O1: "Mènes le dialogue", + larpProfileQ3O2: "Prends parti et tiens bon", + larpProfileQ3O3: "Lances une blague pour désamorcer", + larpProfileQ3O4: "Remets en question la réalité du conflit", + larpProfileQ3O5: "Cherches les causes écologiques racines", + larpProfileQ4: "Ton projet préféré à long terme serait…", + larpProfileQ4O1: "Construire une ville", + larpProfileQ4O2: "Restaurer une forêt", + larpProfileQ4O3: "Rédiger une constitution", + larpProfileQ4O4: "Organiser un festival", + larpProfileQ4O5: "Monter un réseau de défense", + larpProfileQ4O6: "Concevoir une nouvelle machine", + larpProfileQ4O7: "Constituer une archive", + larpProfileQ4O8: "Démonter un ordre injuste", + larpProfileQ5: "Qu'est-ce qui fait une bonne dirigeante ?", + larpProfileQ5O1: "Quelqu'un qui écoute et médie", + larpProfileQ5O2: "Quelqu'un capable de combattre et protéger", + larpProfileQ5O3: "Quelqu'un qui connaît l'histoire", + larpProfileQ5O4: "Quelqu'un qui te fait sourire", + larpProfileQ6: "Ta relation aux règles :", + larpProfileQ6O1: "Les règles émergent du dialogue et de la loi", + larpProfileQ6O2: "Les règles doivent être strictement suivies", + larpProfileQ6O3: "Les règles doivent souvent être brisées", + larpProfileQ6O4: "Les règles doivent servir la vie", + larpProfileQ6O5: "Les règles bâtissent des infrastructures solides", + larpProfileQ7: "Comment gères-tu l'information ?", + larpProfileQ7O1: "Je la conserve et la préserve", + larpProfileQ7O2: "Je la partage via des histoires", + larpProfileQ7O3: "Je questionne ses origines", + larpProfileQ7O4: "J'en extrais l'utile", + larpProfileQ7O5: "Je l'utilise pour soigner", + larpProfileQ8: "Ton idée du succès est…", + larpProfileQ8O1: "Une machine qui fonctionne", + larpProfileQ8O2: "Une communauté paisible", + larpProfileQ8O3: "Un festival animé", + larpProfileQ8O4: "Une famille en sécurité", + larpProfileQ8O5: "Une archive intacte", + larpProfileQ8O6: "Un dogme fissuré", + larpProfileQ8O7: "Une récolte prospère", + larpProfileQ8O8: "Un bâtiment terminé", + larpProfileQ9: "Au réveil, tu veux…", + larpProfileQ9O1: "Entraîner ton corps", + larpProfileQ9O2: "Lire ou écrire", + larpProfileQ9O3: "Jardiner ou cuisiner", + larpProfileQ9O4: "Bricoler quelque chose", + larpProfileQ9O5: "Questionner l'autorité", + larpProfileQ9O6: "Planifier un projet", + larpProfileQ9O7: "Parler à tes amies", + larpProfileQ9O8: "Faire de l'art", + larpProfileQ10: "Ta faiblesse pourrait être :", + larpProfileQ10O1: "Parler trop", + larpProfileQ10O2: "Être trop pragmatique", + larpProfileQ10O3: "Être trop idéaliste", + larpProfileQ10O4: "Être trop disruptive", + larpProfileQ10O5: "Être trop rigide", + larpProfileQ10O6: "Être trop insouciante", + larpProfileQ10O7: "Être trop prudente", + larpProfileQ10O8: "Être trop ambitieuse", + uxModeTitle: "UX", + uxModeDescription: "Choisis le mode de navigation UX que tu veux pour ton interface.", + uxModeMenus: "Blocs", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_hi.js b/src/client/assets/translations/oasis_hi.js new file mode 100644 index 0000000..c56e877 --- /dev/null +++ b/src/client/assets/translations/oasis_hi.js @@ -0,0 +1,3872 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + hi: { + languageName: "हिन्दी", + shopOrderStatusPaid: "भुगतान प्राप्त", + shopOrderStatusReceived: "प्राप्त", + shopOrderMarkPaid: "भुगतान प्राप्त चिह्नित करें", + shopOrderConfirmReceived: "प्राप्ति की पुष्टि करें", + shopMyOrdersTitle: "मेरे ऑर्डर", + shopPurchasesButton: "खरीद", + shopMyOrdersDescription: "आपके खरीद ऑर्डर।", + shopMyOrdersEmpty: "आपके पास अभी कोई खरीद नहीं है।", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "विक्रेता", + shopOrderPaymentConcept: "भुगतान", + shopOrderInvoice: "चालान", + shopOrderInvoiceConcept: "चालान", + shopOrderStatusPending: "लंबित", + shopOrderStatusAccepted: "स्वीकृत", + shopOrderStatusRejected: "अस्वीकृत", + shopOrderStatusShipped: "भेजा गया", + shopOrderStatusDelivered: "वितरित", + shopOrderAccept: "स्वीकार करें", + shopOrderReject: "अस्वीकार करें", + shopOrderMarkShipped: "भेजा गया चिह्नित करें", + shopOrderMarkDelivered: "वितरित चिह्नित करें", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "स्मार्ट अनुबंध", + shopOrderContractConcept: "दुकान ऑर्डर", + shopOrdersPending: "लंबित ऑर्डर", + all: "सभी", + mine: "मेरे", + recent: "हाल के", + anonymous: "गुमनाम", + unnamed: "गुमनाम", + authorLabel: "लेखक", + delete: "हटाएं", + save: "सहेजें", + vote: "मतदान", + url: "यूआरएल", + price: "मूल्य", + priority: "प्राथमिकता", + severity: "गंभीरता", + organizer: "आयोजक", + edited: "संपादित", + isPublic: "सार्वजनिक", + searchIsPublicLabel: "सार्वजनिक", + privacyPrivate: "निजी", + privacyPublic: "सार्वजनिक", + notFound: "नहीं मिला", + no_results: "कोई डेटा नहीं", + searchButton: "खोजें", + mapZoomLabel: "ज़ूम", + modulesTitle: "मॉड्यूल", + viewJson: "JSON देखें", + matchScore: "मिलान स्कोर", + preferencesLabel: "प्राथमिकताएं", + inhabitantProfileTitle: "निवासी प्रोफ़ाइल", + contentWarningLabel: "सामग्री चेतावनी", + invalidPost: "अमान्य सामग्री", + invalidPostHint: "इस संदेश में अमान्य/खाली पाठ है।", + spreadBy: "द्वारा", + spreadChron: "प्रसार", + spreaded: "प्रसारित", + spreadMore: "और", + spreadContentUnavailable: "सामग्री अभी उपलब्ध नहीं (प्रतिकृति लंबित)", + filteredByTag: "टैग द्वारा फ़िल्टर", + filteredByTagTitle: "टैग द्वारा फ़िल्टर", + feedPublishedSuccess: "फ़ीड सफलतापूर्वक प्रकाशित!", + tribeFeedSent: "संदेश सफलतापूर्वक भेजा गया!", + tribeContentEncrypted: "एन्क्रिप्टेड सामग्री", + tribeTorrentsEmpty: "अभी तक कोई टोरेंट नहीं।", + torrentDescription: "विवरण", + torrentDownload: "डाउनलोड", + chatInviteMode: "आमंत्रण", + padInviteMode: "आमंत्रण", + noInviteMode: "कोई आमंत्रण मोड नहीं", + noDeadline: "कोई समय-सीमा नहीं", + noStatus: "कोई स्थिति नहीं", + noSeverity: "कोई गंभीरता नहीं", + calendarDeleteDate: "तारीख हटाएं", + calendarIntervalUntil: "तक", + bookmarkCategory: "श्रेणी", + bookmarkLastVisit: "अंतिम विज़िट", + profileClearnetBookmarksLabel: "बुकमार्क", + forumFilterHot: "लोकप्रिय", + invitesPort: "पोर्ट", + currentlyUnrecheable: "त्रुटि!", + peerHostValidation: "मान्य IPv4 (जैसे 192.168.1.100) या होस्टनाम (जैसे pub.example.com)", + peerPortValidation: "पोर्ट 1-65535", + peerKeyValidation: "SSB ed25519 सार्वजनिक कुंजी (@<44 वर्ण base64>=.ed25519)", + inboxJobUnsubscribedTitle: "आपकी नौकरी से सदस्यता समाप्त", + inboxProjectFollowedTitle: "आपकी परियोजना का नया अनुयायी", + inboxProjectUnfollowedTitle: "आपकी परियोजना को अनफ़ॉलो किया", + pmHasFollowedYourProject: "ने आपकी परियोजना को फ़ॉलो किया", + pmHasUnfollowedYourProject: "ने आपकी परियोजना को अनफ़ॉलो किया", + pmHasUnsubscribedFromYourJobOffer: "ने आपकी नौकरी से सदस्यता समाप्त की", + marketDeleteButton: "हटाएं", + marketNoBids: "अभी तक कोई बोली नहीं", + marketAuctionBidAmount: "बोली राशि", + marketAuctionBidTime: "बोली समय", + marketAuctionUser: "बोली लगाने वाला", + parliamentActorInPower: "शासक जनजाति", + parliamentWinningCandidature: "विजयी उम्मीदवारी", + projectBounty: "इनाम", + projectFollowing: "फ़ॉलो किया", + projectBackerAuthor: "समर्थक", + projectBackerDate: "तारीख", + projectConfirmTransferButton: "स्थानांतरण की पुष्टि करें", + projectPendingTransfersTitle: "लंबित स्थानांतरण", + transfersUpdateSectionTitle: "स्थानांतरण अपडेट करें", + taskUnassignedFrom: "से अनासाइन किया", + taskDescriptionPlaceholder: "कार्य विवरण", + exportPasswordLabel: "निर्यात पासवर्ड", + importPasswordPlaceholder: "पासवर्ड दर्ज करें", + searchCasesPlaceholder: "मामले खोजें…", + shopBuyDeliveryAddressPlaceholder: "डिलीवरी पता", + shopBuyNotesPlaceholder: "नोट्स", + bankTaxesUserNoteBold: "ECO कर को सीधे कम करना", + bankTaxesUserNoteIntro: "आपका ECO कर उस अधिशेष से काटा जाता है जो नेटवर्क आपके UBI के ऊपर उत्पन्न करता है; आधार UBI मान एक अचल न्यूनतम के रूप में निर्धारित है। कर कभी भी प्रत्येक निवासी के UBI की निश्चित राशि से नहीं काटा जाता। कटी हुई राशि धन पुनर्वितरण एल्गोरिद्म को पोषित करती है और अन्य निवासियों को उनके UBI दावों के माध्यम से वापस भेजी जाती है। वे निवासी जिन्हें अपनी परियोजनाओं के लिए अधिक UBI मिलता है, संभवतः आपके ECO कर को कम करने में मदद करने हेतु कोई कार्य रखेंगे। या वे स्वयं को इसमें समर्पित कर सकते हैं ", + bankTaxesUserNoteOutro: ", और नेटवर्क सहमति द्वारा उनका निरंतर योगदान आवश्यक हो सकता है।", + courtsViewDetails: "देखें", + courtsViewDetailsShort: "विवरण", + courtsThDetails: "विवरण", + courtsDetailsAnswersTitle: "उत्तर", + courtsDetailsEvidenceTitle: "साक्ष्य", + courtsDetailsSettlementsTitle: "समझौते", + courtsDetailsVerdictTitle: "फैसला", + courtsDetailsNotPublic: "सार्वजनिक नहीं", + courtsEvidenceSideAccuser: "अभियोक्ता के साक्ष्य", + courtsEvidenceSideRespondent: "बचाव पक्ष के साक्ष्य", + courtsEvidenceSideUnknown: "अज्ञात", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "मामले का समर्थन करें", + courtsSettlementAcceptBtn: "समझौता स्वीकारें", + courtsSupportCount: "समर्थक", + courtsVerdictVoteTitle: "फैसले पर मतदान", + courtsVerdictVoteLabel: "फैसला मतदान", + courtsVerdictVoteAccept: "स्वीकार करें", + courtsVerdictVoteReject: "अस्वीकार करें", + courtsVerdictVoteSubmit: "मत भेजें", + reportsSetStatus: "स्थिति निर्धारित करें", + reportsStatusClosed: "बंद", + statsAvgPerInhabitant: "प्रति निवासी औसत", + statsCarbonMaxAnnual: "वार्षिक अधिकतम अनुमान", + statsCarbonNetwork: "नेटवर्क कुल", + statsCarbonOfEstMax: "अनुमानित अधिकतम क्षमता का", + statsCarbonOfNetwork: "नेटवर्क कुल का", + statsCarbonUser: "आपका फुटप्रिंट", + statsContentCountColumn: "गिनती", + statsContentTypeColumn: "प्रकार", + statsMsgsPerDay: "संदेश/दिन (कुल)", + statsMyShare: "नेटवर्क में आपका हिस्सा", + statsNetworkSpan: "नेटवर्क अवधि", + statsTombstoneRatioLabel: "मिटाए गए का अनुपात", + statsTopTagsTitle: "शीर्ष टैग", + statsTopTypesTitle: "शीर्ष सामग्री प्रकार", + statsTotalMsgs: "कुल संदेश", + feedViewDetails: "विवरण देखें", + eventFileLabel: "छवि संलग्न करें", + taskFileLabel: "छवि संलग्न करें", + courtsRespondentRequired: "प्रतिवादी आईडी आवश्यक है", + extended: "मल्टीवर्स", + extendedDescription: [ + "जब आप किसी का समर्थन करते हैं तो आप उनके द्वारा समर्थित निवासियों की पोस्ट डाउनलोड कर सकते हैं, और वे पोस्ट यहाँ नवीनता के अनुसार क्रमबद्ध दिखाई देती हैं।", + ], + popular: "हाइलाइट्स", + popularDescription: [ + "आपके नेटवर्क के निवासियों की पोस्ट, ", + strong("प्रसार के अनुसार क्रमबद्ध"), + "। सूची प्राप्त करने के लिए समय अवधि चुनें।", + ], + week: "सप्ताह", + month: "महीना", + year: "वर्ष", + latest: "नवीनतम", + latestDescription: [ + strong("पोस्ट"), + " आपकी और आपके द्वारा समर्थित निवासियों की, नवीनता के अनुसार क्रमबद्ध।", + ], + topics: "विषय", + topicsDescription: [ + strong("विषय"), + " आपके और आपके द्वारा समर्थित निवासियों के, नवीनता के अनुसार क्रमबद्ध।", + ], + summaries: "सारांश", + summariesDescription: [ + strong("टिप्पणियों वाले विषय"), + " आपके और आपके द्वारा समर्थित निवासियों के, नवीनता के अनुसार क्रमबद्ध।", + ], + threads: "थ्रेड्स", + threadsDescription: [ + strong("टिप्पणियों वाली पोस्ट"), + " आपके द्वारा समर्थित निवासियों की (मल्टीवर्स से शामिल), नवीनता के अनुसार क्रमबद्ध।", + ], + profile: "अवतार", + inhabitants: "निवासी", + peersReplicatedFeeds: "प्रतिकृत फ़ीड", + graphos: "ग्राफोस", + graphosDescription: "आपके आसपास के नेटवर्क का इंटरैक्टिव मानचित्र।", + graphosViewingGraphOf: "देख रहे", + graphosBackToMine: "← मेरा नेटवर्क", + graphosShowing: "दिखा रहे", + graphosYou: "आप", + graphosTotalNodes: "कुल नोड्स", + manualMode: "मैनुअल मोड", + mentions: "उल्लेख", + mentionsDescription: [ + strong("वे पोस्ट जो आपको @उल्लेख करती हैं"), + ", नवीनता के अनुसार क्रमबद्ध।", + ], + nextPage: "अगला", + previousPage: "पिछला", + noMentions: "आपको अभी तक कोई @उल्लेख नहीं मिला है।", + privateReminders: "रिमाइंडर", + inboxFiltersLabel: "फ़िल्टर:", + inboxToggleToMutuals: "पारस्परिक पर स्विच करें", + inboxToggleToWhole: "सभी पर स्विच करें", + privateFrom: "से", + privateTo: "को", + privateDate: "दिनांक", + pmReply: "उत्तर दें", + pmReplies: "उत्तर", + pmNew: "नया", + pmMarkRead: "पढ़ा हुआ चिह्नित करें", + inReplyTo: "के उत्तर में", + pmPreview: "पूर्वावलोकन", + pmPreviewTitle: "संदेश पूर्वावलोकन", + peers: "पीयर", + searchDescription: "विवरण", + imageSearch: "छवि खोज", + searchFromLabel: "से", + searchToLabel: "तक", + searchMinOpinionsLabel: "न्यून. राय", + searchInhabitantLabel: "निवासी (Oasis ID)", + searchQueryLabel: "क्वेरी", + searchPerPageLabel: "प्रति पृष्ठ परिणाम", + settings: "सेटिंग्स", + continueReading: "पढ़ना जारी रखें", + moreComments: "और टिप्पणी", + readThread: "बाकी थ्रेड पढ़ें", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'ग्रिड पर पिक्सेल बनाएँ और अपने नेटवर्क में दूसरों के साथ सहयोग करें।', + melodyTitle: 'Melody', + melodyDescription: 'अपनी ब्लॉकचेन की धुन बजाएँ — हर ब्लॉक एक स्वर बन जाता है।', + melodyEmpty: 'अभी कोई स्वर नहीं — आपकी ब्लॉकचेन ने अभी कोई ब्लॉक नहीं बनाया है।', + melodyCompositionTitle: 'रचना', + melodyCompositionHelp: 'आपकी ब्लॉकचेन का हर ब्लॉक उसके प्रकार और आकार के अनुसार एक संगीत स्वर बन जाता है।', + melodyStatsTitle: 'प्रकार विश्लेषण', + melodyInhabitantLabel: 'निवासी', + melodyTotalBlocks: 'स्वर', + melodyType: 'प्रकार', + melodyCount: 'ब्लॉक', + melodyFilterMine: 'मेरी', + melodyFilterAll: 'सभी', + melodyFilterShare: 'धुन अपलोड करें', + melodyShareTitle: 'अपनी धुन साझा करें', + melodyShareHelp: 'अपनी मौजूदा धुन का स्नैपशॉट प्रकाशित करें ताकि अन्य सुन और रीमिक्स कर सकें।', + melodyShareTitleLabel: 'शीर्षक (वैकल्पिक)', + melodyShareTitlePlaceholder: 'एक ब्लॉकचेन भूत', + melodyShareSubmit: 'धुन प्रकाशित करें', + melodyNoShared: 'अभी तक कोई ब्लॉकचेन धुन नहीं।', + melodyRegenerate: 'पुनः उत्पन्न करें', + melodyDownload: "BCS डाउनलोड करें", + profileActivityTitle: 'गतिविधि', + profileActivityMore: 'सारी गतिविधि देखें', + profileContentSectionTitle: 'अवतार सामग्री', + profileContentHelp: 'चुनें कि आपके अवतार पर कौन से मॉड्यूल दिखाए जाएँगे।', + profileSensorsHelp: 'आपके अवतार पर दिखाई जाने वाली वैकल्पिक मेट्रिक्स।', + profileClearnetHelp: 'Oasis के बाहर से एक्सेस किए जा सकने वाले मॉड्यूल।', + profileHubAll: 'सभी', + profileHubTitle: 'सार्वजनिक सामग्री', + footerPulseTitle: 'पल्स', + footerPulsePeers: 'पीयर', + footerPulseInbox: 'इनबॉक्स', + footerPulseSync: 'अंतिम सिंक', + footerPulseEcoValue: 'ECO/घंटा', + footerPulseLastActivity: 'अंतिम गतिविधि', + footerLicenseLabel: 'लाइसेंस', + profileSwitchToOasis: 'Oasis पर वापस जाएँ', + profileSwitchToClearnet: 'Clearnet में गोता लगाएँ', + profileVisibilityFediverse: "फ़ेडिवर्स", + profileSwitchToFediverse: "फ़ेडिवर्स में गोता लगाएँ", + coordLabel: 'निर्देशांक (जैसे, A3)', + coordPlaceholder: 'निर्देशांक दर्ज करें', + contributorsTitle: "योगदानकर्ता", + pixeliaBy: "द्वारा", + colorLabel: 'रंग चुनें', + paintButton: 'रंग भरें!', + invalidCoordinate: 'गलत निर्देशांक', + goToMuralButton: "भित्तिचित्र देखें", + totalPixels: 'कुल पिक्सेल', + modules: "मॉड्यूल", + modulesViewTitle: "मॉड्यूल", + modulesViewDescription: "मॉड्यूल सक्षम या अक्षम करके अपना वातावरण सेट करें।", + inbox: "इनबॉक्स", + multiverse: "मल्टीवर्स", + fediverse: "फ़ेडिवर्स", + fediverseDescription: "अपने अन्य फ़ेडिवर्स खातों को प्रबंधित करें, जिसमें सामग्री भेजना और प्राप्त करना शामिल है।", + fediverseStatus: "स्थिति", + fediverseDisconnected: "फ़ेडिवर्स डिस्कनेक्ट है। %LINK% या कनेक्शन स्थिति जाँचें।", + fediverseSettingsTitle: "फ़ेडिवर्स", + fediverseInstanceLabel: "पता", + fediverseTokenLabel: "एक्सेस टोकन", + fediverseTokenHelp: "अपना एक्सेस टोकन सेट करें। इसका उपयोग Oasis के भीतर से आपके Mastodon खाते को प्रबंधित करने के लिए किया जाएगा।", + fediverseConnect: "कनेक्ट करें", + fediverseConnectedAs: "इस रूप में कनेक्टेड", + fediverseDisconnect: "डिस्कनेक्ट करें", + fediverseEmpty: "जब आप अपनी %LINK% से अन्य फ़ेडिवर्स खाते कनेक्ट करेंगे, तो आप उन्हें यहाँ से प्रबंधित कर सकते हैं।", + fediverseEmptyLink: "सेटिंग्स", + fediverseComposePlaceholder: "आप क्या सोच रहे हैं?", + fediverseReplyPlaceholder: "अपना जवाब लिखें…", + fediverseAttach: "मीडिया जोड़ें", + fediversePublish: "प्रकाशित करें", + fediverseReply: "जवाब दें", + fediverseBoosted: "ने बूस्ट किया", + fediverseNoPosts: "आपकी टाइमलाइन खाली है।", + fediverseThread: "थ्रेड", + fediverseTimeline: "टाइमलाइन्स", + fediverseManageTimeline: "टाइमलाइन प्रबंधित करें", + fediverseFollowers: "फ़ॉलोअर्स", + fediverseFollowing: "फ़ॉलोइंग", + fediversePosts: "पोस्ट", + fediverseJoined: "शामिल हुए", + fediverseOverview: "अवलोकन", + fediverseRefresh: "रिफ़्रेश", + fediverseWrite: "लिखें", + fediversePreview: "पूर्वावलोकन", + fediverseEdit: "संपादित करें", + fediverseOpenFeed: "फ़ीड देखें", + fediverseManage: "प्रबंधित करें", + fediverseStatusNotConnected: "कनेक्ट नहीं है", + fediverseError: "Mastodon से संपर्क नहीं हो सका।", + fediverseErrInstance: "अमान्य इंस्टेंस URL।", + fediverseErrAuth: "अमान्य या समाप्त टोकन।", + fediverseErrConnect: "इंस्टेंस से कनेक्ट नहीं हो सका।", + fediverseErrToken: "टोकन आवश्यक है।", + fediverseErrPublic: "सार्वजनिक मोड में उपलब्ध नहीं।", + fediverseErrFetch: "टाइमलाइन लोड नहीं हो सकी।", + fediverseErrPost: "प्रकाशित नहीं हो सका।", + fediverseErrMedia: "फ़ाइल अपलोड नहीं हो सकी।", + fediverseErrEmpty: "कुछ लिखें या फ़ाइल जोड़ें।", + popularLabel: "हाइलाइट्स", + topicsLabel: "विषय", + latestLabel: "नवीनतम", + summariesLabel: "सारांश", + threadsLabel: "थ्रेड्स", + multiverseLabel: "मल्टीवर्स", + inboxLabel: "इनबॉक्स", + invitesLabel: "आमंत्रण", + walletLabel: "वॉलेट", + legacyLabel: "कुंजियाँ", + cipherLabel: "क्रिप्टर", + bookmarksLabel: "बुकमार्क", + videosLabel: "वीडियो", + torrentsLabel: "टोरेंट", + docsLabel: "दस्तावेज़", + audiosLabel: "ऑडियो", + tagsLabel: "टैग", + imagesLabel: "छवियाँ", + inhabitantsLabel: "निवासी", + trendingLabel: "ट्रेंडिंग", + eventsLabel: "कार्यक्रम", + tasksLabel: "कार्य", + apply: "लागू करें", + menuPersonal: "व्यक्तिगत", + menuContent: "सामग्री", + menuBlogs: "ब्लॉग", + menuGovernance: "शासन", + menuOffice: "कार्यालय", + menuMultiverse: "मल्टीवर्स", + menuNetwork: "नेटवर्क", + menuCreative: "रचनात्मक", + menuEconomy: "अर्थव्यवस्था", + menuMedia: "मीडिया", + menuTools: "उपकरण", + comment: "टिप्पणी", + subtopic: "उपविषय", + json: "JSON", + createdBy: "द्वारा", + unfollow: "समर्थन वापस लें", + follow: "समर्थन करें", + block: "ब्लॉक करें", + unblock: "अनब्लॉक करें", + newerPosts: "नई पोस्ट", + olderPosts: "पुरानी पोस्ट", + feedRangeEmpty: "इस फ़ीड के लिए दी गई सीमा खाली है। देखने का प्रयास करें ", + seeFullFeed: "पूरी फ़ीड", + feedEmpty: "Oasis नेटवर्क ने इस खाते से कभी कोई पोस्ट नहीं देखी है।", + beginningOfFeed: "यह फ़ीड की शुरुआत है", + noNewerPosts: "अभी तक कोई नई पोस्ट प्राप्त नहीं हुई है।", + relationshipNotFollowing: "आपका समर्थन नहीं किया गया है", + relationshipTheyFollow: "आपका समर्थन करते हैं", + relationshipMutuals: "पारस्परिक समर्थन", + relationshipFollowing: "आप समर्थन कर रहे हैं", + relationshipYou: "आप", + relationshipBlocking: "आप ब्लॉक कर रहे हैं", + relationshipBlockedBy: "आप ब्लॉक हैं", + relationshipMutualBlock: "पारस्परिक ब्लॉक", + relationshipNone: "आप समर्थन नहीं कर रहे हैं", + relationshipConflict: "विवाद", + relationshipBlockingPost: "ब्लॉक की गई पोस्ट", + viewLikes: "प्रसार देखें", + spreadedDescription: "निवासी द्वारा प्रसारित पोस्ट की सूची।", + totalspreads: "कुल प्रसार", + attachFiles: "फ़ाइलें संलग्न करें", + preview: "पूर्वावलोकन", + publish: "लिखें", + contentWarningPlaceholder: "पोस्ट में विषय जोड़ें (वैकल्पिक)", + privateWarningPlaceholder: "निजी पोस्ट भेजने के लिए निवासी जोड़ें (वैकल्पिक)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "याद रखें: ब्लॉकचेन तकनीक के कारण, एक बार पोस्ट प्रकाशित होने के बाद इसे संपादित या हटाया नहीं जा सकता।", + ], + commentWarning: [ + "याद रखें: ब्लॉकचेन तकनीक के कारण, एक बार पोस्ट प्रकाशित होने के बाद इसे संपादित या हटाया नहीं जा सकता।", + ], + commentPublic: "सार्वजनिक", + commentPrivate: "निजी", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "याद रखें: ब्लॉकचेन तकनीक के कारण, एक बार पोस्ट प्रकाशित होने के बाद इसे संपादित या हटाया नहीं जा सकता।", + ], + replyLabel: ({ markdownUrl }) => [ + "याद रखें: ब्लॉकचेन तकनीक के कारण, एक बार पोस्ट प्रकाशित होने के बाद इसे संपादित या हटाया नहीं जा सकता।", + ], + publishCustomInfo: ({ href }) => [ + "यदि आपके पास अनुभव है, तो आप ", + a({ href }, "एक उन्नत पोस्ट लिख सकते हैं"), + "।", + ], + publishBasicInfo: ({ href }) => [ + "यदि आपके पास अनुभव नहीं है, तो आपको ", + a({ href }, "एक पोस्ट लिखनी चाहिए"), + "।", + ], + publishCustom: "उन्नत पोस्ट लिखें", + subtopicLabel: "इस पोस्ट का उपविषय बनाएँ", + messagePreview: "पोस्ट पूर्वावलोकन", + mentionsMatching: "मिलते-जुलते उल्लेख", + mentionsName: "नाम", + mentionsRelationship: "संबंध", + updateit: "अपडेट प्राप्त करें!", + updateBannerText: "Oasis का एक नया संस्करण उपलब्ध है।", + updateBannerAction: "अभी अपडेट करें →", + info: "जानकारी", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "पहले", + sendTime: "लगभग ", + theme: "थीम", + legacy: "कुंजियाँ", + legacyTitle: "कुंजियाँ", + legacyDescription: "अपनी गुप्त कुंजी (निजी कुंजी) को जल्दी और सुरक्षित रूप से प्रबंधित करें।", + legacyExportButton: "निर्यात", + legacyImportButton: "आयात", + ssbLogStream: "ब्लॉकचेन", + ssbLogStreamDescription: "ब्लॉकचेन स्ट्रीम के लिए संदेश सीमा कॉन्फ़िगर करें।", + saveSettings: "सेटिंग्स सहेजें", + exportTitle: "डेटा निर्यात करें", + exportDescription: "अपनी कुंजी एन्क्रिप्ट करने के लिए पासवर्ड सेट करें (न्यूनतम 32 अक्षर)", + exportDataTitle: "बैकअप", + exportDataDescription: "अपना डेटा डाउनलोड करें (गुप्त कुंजी शामिल नहीं!)", + exportDataButton: "डेटाबेस डाउनलोड करें", + pubWallet: "PUB वॉलेट", + pubWalletDescription: "PUB वॉलेट URL सेट करें। इसका उपयोग PUB लेनदेन (UBI सहित) के लिए किया जाएगा।", + pubWalletConfiguration: "कॉन्फ़िगरेशन सहेजें", + importTitle: "डेटा आयात करें", + importDescription: "अपना अवतार सक्रिय करने के लिए अपनी एन्क्रिप्टेड गुप्त कुंजी (निजी कुंजी) आयात करें", + importAttach: "एन्क्रिप्टेड फ़ाइल संलग्न करें (.enc)", + passwordLengthInfo: "पासवर्ड कम से कम 32 अक्षर लंबा होना चाहिए।", + passwordImport: "डेटा को डिक्रिप्ट करने के लिए अपना पासवर्ड लिखें जो आपके सिस्टम होम पर सहेजा जाएगा (नाम: secret)", + exportPasswordPlaceholder: "लोअरकेस, अपरकेस, संख्याएँ और प्रतीक उपयोग करें", + fileInfo: "आपकी एन्क्रिप्टेड गुप्त कुंजी आपके सिस्टम होम पर सहेजी जाएगी (नाम: oasis.enc)", + themeIntro: + "एक थीम चुनें।", + setTheme: "थीम सेट करें", + language: "भाषा", + languageDescription: + "यदि आप दूसरी भाषा उपयोग करना चाहते हैं, तो यहाँ चुनें।", + setLanguage: "भाषा सेट करें", + peerConnections: "पीयर", + peerConnectionsIntro: "अन्य पीयर के साथ अपने सभी कनेक्शन प्रबंधित करें।", + peerConnectionsTitle: "कनेक्शन", + online: "ऑनलाइन", + offline: "ऑफ़लाइन", + discovered: 'खोजे गए', + unknown: 'अज्ञात', + lanBroadcastLabel: "LAN प्रसारण", + lanBroadcastActive: "सक्रिय (LAN पर UDP मल्टीकास्ट)", + lanBroadcastDisabled: "अक्षम (pub मोड)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "हाल का", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "समर्थित", + recommended: "अनुशंसित", + blocked: "ब्लॉक किए गए", + noConnections: "कोई पीयर कनेक्ट नहीं है।", + noDiscovered: "कोई पीयर नहीं मिला।", + noUnknownPeers: "मित्र-ग्राफ में कोई अज्ञात पीयर नहीं।", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "ताज़ा करें", + peerPruneIdle: "निष्क्रिय हटाएँ", + peerExport: "निर्यात", + peerImport: "आयात", + peerImportTitle: "पीयर सूची आयात करें", + peerImportPlaceholder: "प्रति पंक्ति एक multiserver पता पेस्ट करें, या एक .txt फ़ाइल अपलोड करें…", + noSupportedConnections: "कोई समर्थित पीयर नहीं।", + noBlockedConnections: "कोई ब्लॉक किया गया पीयर नहीं।", + noRecommendedConnections: "कोई अनुशंसित पीयर नहीं।", + connectionActionIntro: + "", + startNetworking: "नेटवर्किंग शुरू करें", + stopNetworking: "नेटवर्किंग बंद करें", + restartNetworking: "नेटवर्किंग पुनः आरंभ करें", + sync: "नेटवर्क सिंक करें", + indexes: "इंडेक्स", + indexesDescription: + "अपने इंडेक्स पुनर्निर्माण करना सुरक्षित है, और कुछ प्रकार की त्रुटियों को ठीक कर सकता है।", + homePageTitle: "होम", + homePageDescription: "चुनें कि कौन सा मॉड्यूल आपका होम पेज हो।", + saveHomePage: "होम सेट करें", + invites: "आमंत्रण", + invitesTitle: "आमंत्रण", + invitesInvites: "आमंत्रण", + invitesDescription: "अपने नेटवर्क में आमंत्रण कोड प्रबंधित करें और लागू करें।", + invitesTribesTitle: "जनजातियाँ", + invitesTribeInviteCodePlaceholder: "जनजाति आमंत्रण कोड दर्ज करें", + invitesTribeJoinButton: "जनजाति में शामिल हों", + invitesChatsTitle: "चैट्स", + invitesChatInviteCodePlaceholder: "चैट आमंत्रण कोड दर्ज करें", + invitesChatJoinButton: "चैट में शामिल हों", + invitesPadsTitle: "पैड्स", + invitesPadInviteCodePlaceholder: "पैड आमंत्रण कोड दर्ज करें", + invitesPadJoinButton: "पैड में शामिल हों", + invitesCalendarsTitle: "कैलेंडर", + invitesCalendarInviteCodePlaceholder: "कैलेंडर आमंत्रण कोड दर्ज करें", + invitesCalendarJoinButton: "कैलेंडर में शामिल हों", + invitesEventsTitle: "कार्यक्रम", + invitesEventInviteCodePlaceholder: "कार्यक्रम आमंत्रण कोड दर्ज करें", + invitesEventJoinButton: "कार्यक्रम में शामिल हों", + invitesForumsTitle: "मंच", + invitesForumInviteCodePlaceholder: "मंच आमंत्रण कोड दर्ज करें", + invitesForumJoinButton: "मंच में शामिल हों", + invitesMapsTitle: "मानचित्र", + invitesMapInviteCodePlaceholder: "मानचित्र आमंत्रण कोड दर्ज करें", + invitesMapJoinButton: "मानचित्र में शामिल हों", + invitesShopsTitle: "दुकानें", + invitesShopInviteCodePlaceholder: "दुकान आमंत्रण कोड दर्ज करें", + invitesShopJoinButton: "दुकान में शामिल हों", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "PUB आमंत्रण कोड दर्ज करें", + invitesAcceptInvite: "PUB में शामिल हों", + invitesAlreadyFederated: "आप पहले से ही इस पब के साथ फेडरेटेड हैं।", + invitesFederationsTitle: "संघ", + invitesAcceptedInvites: "संघीय", + invitesNoInvites: "अभी तक कोई आमंत्रण स्वीकार नहीं किया गया।", + invitesUnfollow: "अनफ़ॉलो करें", + invitesFollow: "फ़ॉलो करें", + invitesUnfollowedInvites: "असंघीय", + invitesNoFederatedPubs: "कोई संघीय pub नहीं।", + invitesNoUnfollowed: "कोई असंघीय pub नहीं।", + invitesUnreachablePubs: "अगम्य", + invitesNoUnreachablePubs: "कोई अगम्य pub नहीं।", + invitesPubsRefresh: "ताज़ा करें", + invitesPubsClearUnreachable: "अगम्य हटाएँ", + invitesPubsExport: "निर्यात", + invitesPubsImport: "आयात", + invitesPubsImportTitle: "Pubs आयात करें", + invitesPubsImportPlaceholder: "प्रति पंक्ति एक multiserver पता या निमंत्रण कोड पेस्ट करें, या एक .txt फ़ाइल अपलोड करें…", + currentlyUnreachable: "त्रुटि!", + errorDetails: "त्रुटि विवरण", + genericError: "एक त्रुटि हुई।", + panicMode: "आपातकालीन मोड!", + encryptData: "अपनी ब्लॉकचेन एन्क्रिप्ट करने के लिए पासवर्ड सेट करें (न्यूनतम 32 अक्षर)", + decryptData: "अपनी ब्लॉकचेन डिक्रिप्ट करने के लिए पासवर्ड दर्ज करें", + panicModeDescription: "अपनी ब्लॉकचेन एन्क्रिप्ट/डिक्रिप्ट या हटाएँ", + removeDataDescription: "चेतावनी: यह प्रक्रिया पूर्ववत नहीं की जा सकती।", + encryptPanicButton: "ब्लॉकचेन एन्क्रिप्ट करें", + decryptPanicButton: "ब्लॉकचेन डिक्रिप्ट करें", + removePanicButton: "अपना सारा डेटा हटाएँ!", + searchTitle: "खोज", + searchDescriptionLabel: "अपने नेटवर्क में सामग्री खोजें।", + searchLanguagesLabel: "भाषाएँ", + searchSkillsLabel: "कौशल", + searchPlaceholder:"सामग्री खोजें...", + searchDateLabel:"दिनांक", + searchLocationLabel:"स्थान", + searchPriceLabel:"मूल्य", + searchUrlLabel:"URL", + searchCategoryLabel:"श्रेणी", + searchStartLabel:"प्रारंभ समय", + searchEndLabel:"समाप्ति समय", + searchPriorityLabel:"प्राथमिकता", + searchStatusLabel:"स्थिति", + statusLabel:"स्थिति", + totalVotesLabel:"कुल मत", + noResultsFound:"कोई परिणाम नहीं मिला।", + votesOption:"मत विकल्प", + voteYesLabel:"हाँ", + voteNoLabel:"नहीं", + allTypesLabel: "असीमित", + ABSTENTIONLabel:"तटस्थता", + YESLabel:"हाँ", + NOLabel:"नहीं", + FOLLOW_MAJORITYLabel: "बहुमत का पालन करें", + CONFUSEDLabel: "भ्रमित", + NOT_INTERESTEDLabel: "रुचि नहीं", + StatusLabel:"स्थिति", + votesOptionYesLabel:"हाँ", + votesOptionNoLabel:"नहीं", + votesOptionAbstentionLabel:"तटस्थता", + votesQuestionLabel:"प्रश्न", + votesCreatedByLabel:"द्वारा बनाया गया", + voteStatusOpen:"खुला", + voteStatusClosed:"बंद", + imageSearchLabel: "छवियों को खोजने के लिए शब्द दर्ज करें।", + commentDescription: ({ parentUrl }) => [ + " ने टिप्पणी की ", + a({ href: parentUrl }, " थ्रेड पर"), + ], + commentTitle: ({ authorName }) => [`@${authorName} की पोस्ट पर टिप्पणी`], + subtopicDescription: ({ parentUrl }) => [ + " ने एक उपविषय बनाया ", + a({ href: parentUrl }, " एक पोस्ट से"), + ], + subtopicTitle: ({ authorName }) => [`@${authorName} की पोस्ट पर उपविषय`], + mysteryDescription: "एक रहस्यमय पोस्ट प्रकाशित की", + oasisDescription: "OASIS परियोजना नेटवर्क", + searchSubmit: "खोजें...", + postLabel: "पोस्ट", + aboutLabel: "निवासी", + feedLabel: "फ़ीड", + votesLabel: "मतदान", + reportLabel: "रिपोर्ट", + imageLabel: "छवियाँ", + videoLabel: "वीडियो", + audioLabel: "ऑडियो", + documentLabel: "दस्तावेज़", + torrentLabel: "टॉरेंट", + pdfFallbackLabel: "PDF दस्तावेज़", + eventLabel: "कार्यक्रम", + taskLabel: "कार्य", + transferLabel: "स्थानांतरण", + curriculumLabel: "पाठ्यक्रम", + bookmarkLabel: "बुकमार्क", + tribeLabel: "जनजातियाँ", + marketLabel: "बाज़ार", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "वॉलेट", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "जमा करें", + subjectLabel: "विषय", + editProfile: "अवतार संपादित करें", + profileQrAlt: "प्रोफ़ाइल QR कोड", + profileQrHint: "इस Oasis ID को कॉपी करने के लिए स्कैन करें।", + oasisIdLabel: "OasisID", + spreadLabel: "फैलाएँ", + spreadHint: "अपने समर्थकों को फैलाएँ (फ़ीड से प्रतिकृति)।", + welcomePmSubject: "Hello World!", + welcomePmBody: "Oasis में आपका स्वागत है — एक स्वतंत्र, पीयर-टू-पीयर, फ़ेडरेटेड और एन्क्रिप्टेड सोशल नेटवर्क, जिसकी वास्तुकला वितरित और केवल-आमंत्रण आधारित है।\n\nशुरुआत के लिए कुछ रोचक स्थान:\n\n- /profile — अपना अवतार, नाम, विवरण और कौन से मॉड्यूल आपके सार्वजनिक पक्ष पर दिखेंगे, संपादित करें।\n- /invites — व्यापक नेटवर्क के साथ फेडरेट करने के लिए एक pub जोड़ें।\n- /peers — देखें कौन जुड़ा है, अपनी LAN फिर से स्कैन करें, पीयर सूचियाँ निर्यात या आयात करें।\n- /inhabitants — दूसरों के अवतार खोजें और देखें।\n- /tribes — एंड-टू-एंड एन्क्रिप्शन वाले निजी समूह बनाएँ या उनमें शामिल हों।\n- /inbox — निजी संदेश पढ़ें और भेजें।\n- /activity — हाल की गतिविधि देखें — आपकी और आपके नेटवर्क की।\n- /publish — पोस्ट लिखें, या चित्र, ऑडियो, वीडियो, दस्तावेज़ साझा करें।\n\nकनेक्ट करने के लिए कुछ दिलचस्प जगहें:\n\n- /fediverse — अपने अन्य फ़ेडिवर्स खातों को प्रबंधित करें, जिसमें सामग्री भेजना और प्राप्त करना शामिल है।\n\nयह संदेश आपके द्वारा निजी रूप से स्वयं को भेजा गया था, इसलिए इसे प्राप्त करने के लिए किसी कनेक्शन की आवश्यकता नहीं थी।", + profileVisibilityTitle: "सार्वजनिक प्रोफ़ाइल दृश्यता", + profileVisibilityHint: "तय करें कि आपकी प्रोफ़ाइल में अन्य निवासी कौन से क्षेत्र देखें। डिफ़ॉल्ट रूप से केवल Karma दिखाया जाता है।", + profileSensorsSectionTitle: "सेंसर", + profileVisibilityActivity: "गतिविधि स्तर", + profileVisibilityDevice: "डिवाइस", + profileDeviceLockedHint: "यह सेंसर हमेशा चालू रहता है: यह दूसरों को दिखाता है कि आप किस डिवाइस से कनेक्ट होते हैं और इसे अक्षम नहीं किया जा सकता।", + profileVisibilityKarma: "KARMA स्कोर", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "ECOIN वॉलेट", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "GPG कुंजी", + profileVisibilityClearnet: "मेरी प्रोफ़ाइल प्रकाशित करें", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "दुकानें", + profileClearnetJobsLabel: "नौकरियाँ", + profileClearnetEventsLabel: "आयोजन", + profileClearnetProjectsLabel: "परियोजनाएँ", + profileClearnetPostsLabel: "ब्लॉग", + profileClearnetAudiosLabel: "ऑडियो", + profileClearnetVideosLabel: "वीडियो", + profileClearnetImagesLabel: "चित्र", + profileClearnetDocumentsLabel: "दस्तावेज़", + profileClearnetTorrentsLabel: "टॉरेंट", + editProfileDescription: + "", + profileName: "नाम", + profileImage: "अवतार छवि", + profileGpgKey: "GPG सार्वजनिक कुंजी (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "डाउनलोड", + profileGpgRemove: "हटाएं", + profileDescription: "विवरण", + hashtagDescription: + "आपके नेटवर्क के निवासियों की पोस्ट जो इस #हैशटैग का संदर्भ देती हैं, नवीनता के अनुसार क्रमबद्ध।", + rebuildName: "डेटाबेस पुनर्निर्माण करें", + wallet: "वॉलेट", + walletAddress: "पता", + walletAmount: "राशि", + walletAddressLine: ({ address }) => `पता: ${address}`, + walletAmountLine: ({ amount }) => `राशि: ${amount} ECO`, + walletBack: "वापस", + walletBalanceTitle: "शेष", + walletWalletSendTitle: "भेजें", + walletReceiveTitle: "प्राप्त करें", + walletHistoryTitle: "इतिहास", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "पुष्टि", + walletConfirm: "पुष्टि करें", + walletDescription: "अपनी डिजिटल संपत्तियाँ प्रबंधित करें, जिसमें ECOin भेजना और प्राप्त करना, अपना शेष देखना और अपना लेनदेन इतिहास शामिल है।", + walletDate: "दिनांक", + walletFee: "शुल्क (शुल्क जितना अधिक होगा, आपका लेनदेन उतनी तेज़ी से संसाधित होगा)", + walletFeeLine: ({ fee }) => `शुल्क: ECO ${fee}`, + walletHistory: "इतिहास", + walletReceive: "प्राप्त करें", + walletReset: "रीसेट", + walletSend: "भेजें", + walletStatus: "स्थिति", + walletDisconnected: [ + "ECOin ", + strong("वॉलेट डिस्कनेक्ट है"), + "। जाँचें ", + a({ href: '/settings#wallet' }, "आपकी सेटिंग्स"), + " या कनेक्शन स्थिति।", + ], + walletSentToLine: ({ destination, amount }) => `ECO ${amount} भेजा गया ${destination} को`, + walletSettingsTitle: "वॉलेट", + walletSettingsDescription: "Oasis को अपने ECOin वॉलेट के साथ एकीकृत करें।", + walletSettingsDocLink: "ECOin स्थापना मार्गदर्शिका", + walletStatusMessages: { + invalid_amount: "अमान्य राशि", + invalid_dest: "अमान्य गंतव्य पता", + invalid_fee: "अमान्य शुल्क", + validation_errors: "सत्यापन त्रुटियाँ", + send_tx_success: "लेनदेन सफल", + }, + walletTitle: "वॉलेट", + walletTotalCostLine: ({ totalCost }) => `कुल लागत: ECO ${totalCost}`, + walletTransactionId: "लेनदेन ID", + walletTxId: "Tx ID", + walletType: "प्रकार", + walletUser: "उपयोगकर्ता नाम", + walletPass: "पासवर्ड", + walletConfiguration: "वॉलेट सेट करें", + cipher: "क्रिप्टर", + cipherTitle: "क्रिप्टर", + cipherDescription: "अपने टेक्स्ट को सममित रूप से एन्क्रिप्ट और डिक्रिप्ट करें (साझा पासवर्ड का उपयोग करके)।", + randomPassword: "यादृच्छिक पासवर्ड", + cipherEncryptTitle: "टेक्स्ट एन्क्रिप्ट करें", + cipherEncryptDescription: "एन्क्रिप्ट करने के लिए टेक्स्ट दर्ज करें", + cipherTextLabel: "एन्क्रिप्ट करने के लिए टेक्स्ट", + cipherTextPlaceholder: "एन्क्रिप्ट करने के लिए टेक्स्ट दर्ज करें...", + cipherPasswordLabel: "अपना टेक्स्ट एन्क्रिप्ट करने के लिए पासवर्ड सेट करें (न्यूनतम 32 अक्षर)", + cipherPasswordDecryptLabel: "अपना टेक्स्ट डिक्रिप्ट करने के लिए पासवर्ड सेट करें (न्यूनतम 32 अक्षर)", + cipherPasswordPlaceholder: "पासवर्ड दर्ज करें...", + cipherEncryptButton: "एन्क्रिप्ट करें", + cipherDecryptTitle: "टेक्स्ट डिक्रिप्ट करें", + cipherDecryptDescription: "डिक्रिप्ट करने के लिए टेक्स्ट दर्ज करें", + cipherEncryptedMessageLabel: "एन्क्रिप्टेड टेक्स्ट", + cipherDecryptedMessageLabel: "डिक्रिप्टेड टेक्स्ट", + cipherPasswordUsedLabel: "एन्क्रिप्ट करने के लिए उपयोग किया गया पासवर्ड (इसे रखें!)", + cipherEncryptedTextPlaceholder: "एन्क्रिप्टेड टेक्स्ट दर्ज करें...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "इनिशियलाइज़ेशन वेक्टर दर्ज करें...", + cipherDecryptButton: "डिक्रिप्ट करें", + password: "पासवर्ड", + text: "टेक्स्ट", + encryptedText: "एन्क्रिप्टेड टेक्स्ट", + iv: "इनिशियलाइज़ेशन वेक्टर (IV)", + encryptTitle: "अपना टेक्स्ट एन्क्रिप्ट करें", + encryptDescription: "वह टेक्स्ट दर्ज करें जिसे आप एन्क्रिप्ट करना चाहते हैं और पासवर्ड प्रदान करें।", + encryptButton: "एन्क्रिप्ट करें", + decryptTitle: "अपना टेक्स्ट डिक्रिप्ट करें", + decryptDescription: "एन्क्रिप्टेड टेक्स्ट दर्ज करें और एन्क्रिप्शन के लिए उपयोग किया गया वही पासवर्ड प्रदान करें।", + decryptButton: "डिक्रिप्ट करें", + passwordLengthError: "पासवर्ड कम से कम 32 अक्षर लंबा होना चाहिए।", + missingFieldsError: "टेक्स्ट, पासवर्ड या IV प्रदान नहीं किया गया।", + encryptionError: "टेक्स्ट एन्क्रिप्ट करने में त्रुटि।", + decryptionError: "टेक्स्ट डिक्रिप्ट करने में त्रुटि।", + bookmarkTitle: "बुकमार्क", + bookmarkDescription: "अपने नेटवर्क में बुकमार्क खोजें और प्रबंधित करें।", + bookmarkAllSectionTitle: "बुकमार्क", + bookmarkMineSectionTitle: "आपके बुकमार्क", + bookmarkRecentSectionTitle: "हाल के बुकमार्क", + bookmarkTopSectionTitle: "शीर्ष बुकमार्क", + bookmarkFavoritesSectionTitle: "पसंदीदा", + bookmarkCreateSectionTitle: "बुकमार्क बनाएँ", + bookmarkUpdateSectionTitle: "बुकमार्क अपडेट करें", + bookmarkFilterAll: "सभी", + bookmarkFilterMine: "मेरे", + bookmarkFilterTop: "शीर्ष", + bookmarkFilterFavorites: "पसंदीदा", + bookmarkFilterRecent: "हाल के", + bookmarkCreateButton: "बुकमार्क बनाएँ", + bookmarkUpdateButton: "अपडेट", + bookmarkDeleteButton: "हटाएँ", + bookmarkAddFavoriteButton: "पसंदीदा जोड़ें", + bookmarkRemoveFavoriteButton: "पसंदीदा हटाएँ", + bookmarkUrlLabel: "लिंक", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "विवरण", + bookmarkDescriptionPlaceholder: "वैकल्पिक", + bookmarkTagsLabel: "टैग", + bookmarkTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + bookmarkCategoryLabel: "श्रेणी", + bookmarkCategoryPlaceholder: "वैकल्पिक", + bookmarkLastVisitLabel: "अंतिम विज़िट", + bookmarkSearchPlaceholder: "URL, टैग, श्रेणी, लेखक खोजें...", + bookmarkSortRecent: "सबसे हाल का", + bookmarkSortOldest: "सबसे पुराना", + bookmarkSortTop: "सर्वाधिक मतदान", + bookmarkSearchButton: "खोजें", + bookmarkUpdatedAt: "अपडेट किया गया", + bookmarkNoMatch: "आपकी खोज से कोई बुकमार्क मेल नहीं खाता।", + noBookmarks: "कोई बुकमार्क उपलब्ध नहीं।", + noUrl: "कोई लिंक नहीं", + noCategory: "कोई श्रेणी नहीं", + noLastVisit: "कोई अंतिम विज़िट नहीं", + videoTitle: "वीडियो", + videoDescription: "अपने नेटवर्क में वीडियो सामग्री खोजें और प्रबंधित करें।", + videoPluginTitle: "शीर्षक", + videoPluginDescription: "विवरण", + videoMineSectionTitle: "आपके वीडियो", + videoCreateSectionTitle: "वीडियो अपलोड करें", + videoUpdateSectionTitle: "वीडियो अपडेट करें", + videoAllSectionTitle: "वीडियो", + videoRecentSectionTitle: "हाल के वीडियो", + videoTopSectionTitle: "शीर्ष वीडियो", + videoFavoritesSectionTitle: "पसंदीदा", + videoFilterAll: "सभी", + videoFilterMine: "मेरे", + videoFilterRecent: "हाल के", + videoFilterTop: "शीर्ष", + videoFilterFavorites: "पसंदीदा", + videoCreateButton: "वीडियो अपलोड करें", + videoUpdateButton: "अपडेट", + videoDeleteButton: "हटाएँ", + videoAddFavoriteButton: "पसंदीदा जोड़ें", + videoRemoveFavoriteButton: "पसंदीदा हटाएँ", + videoFileLabel: "वीडियो फ़ाइल चुनें (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "टैग", + videoTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + videoTitleLabel: "शीर्षक", + videoTitlePlaceholder: "वैकल्पिक", + videoDescriptionLabel: "विवरण", + videoDescriptionPlaceholder: "वैकल्पिक", + videoNoFile: "कोई वीडियो फ़ाइल प्रदान नहीं की गई", + noVideos: "कोई वीडियो उपलब्ध नहीं।", + videoSearchPlaceholder: "शीर्षक, टैग, लेखक खोजें...", + videoSortRecent: "सबसे हाल का", + videoSortOldest: "सबसे पुराना", + videoSortTop: "सर्वाधिक मतदान", + videoSearchButton: "खोजें", + videoMessageAuthorButton: "PM", + videoUpdatedAt: "अपडेट किया गया", + videoNoMatch: "आपकी खोज से कोई वीडियो मेल नहीं खाता।", + documentTitle: "दस्तावेज़", + documentDescription: "अपने नेटवर्क में दस्तावेज़ खोजें और प्रबंधित करें।", + documentAllSectionTitle: "दस्तावेज़", + documentMineSectionTitle: "आपके दस्तावेज़", + documentRecentSectionTitle: "हाल के दस्तावेज़", + documentTopSectionTitle: "शीर्ष दस्तावेज़", + documentFavoritesSectionTitle: "पसंदीदा", + documentCreateSectionTitle: "दस्तावेज़ अपलोड करें", + documentUpdateSectionTitle: "दस्तावेज़ संपादित करें", + documentFilterAll: "सभी", + documentFilterMine: "मेरे", + documentFilterRecent: "हाल के", + documentFilterTop: "शीर्ष", + documentFilterFavorites: "पसंदीदा", + documentCreateButton: "दस्तावेज़ अपलोड करें", + documentUpdateButton: "अपडेट", + documentDeleteButton: "हटाएँ", + documentAddFavoriteButton: "पसंदीदा जोड़ें", + documentRemoveFavoriteButton: "पसंदीदा से हटाएँ", + documentMessageAuthorButton: "PM", + documentFileLabel: "दस्तावेज़ अपलोड करें (.pdf)", + documentTagsLabel: "टैग", + documentTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + documentTitleLabel: "शीर्षक", + documentTitlePlaceholder: "वैकल्पिक", + documentDescriptionLabel: "विवरण", + documentDescriptionPlaceholder: "वैकल्पिक", + documentNoFile: "कोई फ़ाइल नहीं।", + noDocuments: "कोई दस्तावेज़ उपलब्ध नहीं।", + documentSearchPlaceholder: "शीर्षक, टैग, विवरण, लेखक खोजें...", + documentSortRecent: "सबसे हाल का", + documentSortOldest: "सबसे पुराना", + documentSortTop: "सर्वाधिक मतदान", + documentSearchButton: "खोजें", + documentNoMatch: "आपकी खोज से कोई दस्तावेज़ मेल नहीं खाता।", + documentUpdatedAt: "अपडेट किया गया", + audioTitle: "ऑडियो", + audioDescription: "अपने नेटवर्क में ऑडियो सामग्री खोजें और प्रबंधित करें।", + audioPluginTitle: "शीर्षक", + audioPluginDescription: "विवरण", + audioMineSectionTitle: "आपके ऑडियो", + audioCreateSectionTitle: "ऑडियो अपलोड करें", + audioUpdateSectionTitle: "ऑडियो अपडेट करें", + audioAllSectionTitle: "ऑडियो", + audioRecentSectionTitle: "हाल के ऑडियो", + audioTopSectionTitle: "शीर्ष ऑडियो", + audioFilterAll: "सभी", + audioFilterMine: "मेरे", + audioFilterRecent: "हाल के", + audioFilterTop: "शीर्ष", + audioFilterBlockchain: "ब्लॉकचेन", + audioCreateButton: "ऑडियो अपलोड करें", + audioUpdateButton: "अपडेट", + audioDeleteButton: "हटाएँ", + audioAddFavoriteButton: "पसंदीदा जोड़ें", + audioRemoveFavoriteButton: "पसंदीदा हटाएँ", + audioFileLabel: "ऑडियो फ़ाइल चुनें (.mp3, .wav, .ogg)", + audioTagsLabel: "टैग", + audioTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + audioTitleLabel: "शीर्षक", + audioTitlePlaceholder: "वैकल्पिक", + audioDescriptionLabel: "विवरण", + audioDescriptionPlaceholder: "वैकल्पिक", + audioNoFile: "कोई ऑडियो फ़ाइल प्रदान नहीं की गई", + noAudios: "कोई ऑडियो उपलब्ध नहीं।", + audioSearchPlaceholder: "शीर्षक, टैग, लेखक खोजें...", + audioSortRecent: "सबसे हाल का", + audioSortOldest: "सबसे पुराना", + audioSortTop: "सर्वाधिक मतदान", + audioSearchButton: "खोजें", + audioMessageAuthorButton: "PM", + audioUpdatedAt: "अपडेट किया गया", + audioNoMatch: "आपकी खोज से कोई ऑडियो मेल नहीं खाता।", + audioFavoritesSectionTitle: "पसंदीदा", + audioFilterFavorites: "पसंदीदा", + favoritesTitle: "पसंदीदा", + favoritesDescription: "आपकी सभी पसंदीदा सामग्री एक ही स्थान पर।", + favoritesFilterAll: "सभी", + favoritesFilterRecent: "हाल के", + favoritesFilterAudios: "ऑडियो", + favoritesFilterBookmarks: "बुकमार्क", + favoritesFilterDocuments: "दस्तावेज़", + favoritesFilterImages: "छवियाँ", + favoritesFilterMaps: "मानचित्र", + favoritesFilterPads: "पैड", + favoritesFilterChats: "चैट्स", + favoritesFilterCalendars: "कैलेंडर", + favoritesFilterVideos: "वीडियो", + favoritesRemoveButton: "पसंदीदा से हटाएँ", + favoritesNoItems: "अभी तक कोई पसंदीदा नहीं।", + yourContacts: "आपके संपर्क", + allInhabitants: "निवासी", + allCVs: "सभी CVs", + discoverPeople: "अपने नेटवर्क में निवासियों को खोजें।", + allInhabitantsButton: "सभी", + contactsButton: "समर्थन", + CVsButton: "बायोडाटा", + matchSkills: "कौशल मिलान", + matchSkillsButton: "कौशल मिलान", + suggestedButton: "सुझाए गए", + searchInhabitantsPlaceholder: "नाम से निवासी फ़िल्टर करें …", + filterLocation: "स्थान से निवासी फ़िल्टर करें …", + filterLanguage: "भाषा से निवासी फ़िल्टर करें …", + filterSkills: "कौशल से निवासी फ़िल्टर करें …", + applyFilters: "फ़िल्टर लागू करें", + locationLabel: "स्थान", + languagesLabel: "भाषाएँ", + skillsLabel: "कौशल", + commonSkills: "सामान्य कौशल", + mutualFollowers: "पारस्परिक अनुयायी", + suggestedFollowsYou: "आपको फ़ॉलो करता है", + latestInteractions: "नवीनतम बातचीत", + viewAvatar: "अवतार देखें", + viewCV: "CV देखें", + suggestedSectionTitle: "सुझाए गए", + topkarmaSectionTitle: "शीर्ष कर्मा", + topecoSectionTitle: "शीर्ष इको", + topactivitySectionTitle: "शीर्ष गतिविधि", + blockedSectionTitle: "ब्लॉक किए गए", + gallerySectionTitle: "गैलरी", + blockedButton: "ब्लॉक किए गए", + blockedLabel: "ब्लॉक किया गया उपयोगकर्ता", + inhabitantviewDetails: "विवरण देखें", + keepReading: "पढ़ते रहें...", + oasisId: "ID", + noInhabitantsFound: "अभी तक कोई निवासी नहीं मिला।", + inhabitantActivityLevel: "गतिविधि स्तर", + lifespanLabel: "जीवनकाल", + deviceLabel: "उपकरण", + parliamentTitle: "संसद", + parliamentDescription: "शासन के रूपों और सामूहिक प्रबंधन कानूनों का अन्वेषण करें।", + parliamentFilterGovernment: "सरकार", + parliamentFilterCandidatures: "उम्मीदवारी", + parliamentFilterProposals: "प्रस्ताव", + parliamentFilterLaws: "कानून", + parliamentFilterHistorical: "ऐतिहासिक", + parliamentFilterLeaders: "नेता", + parliamentFilterRules: "नियम", + parliamentGovernmentCard: "वर्तमान सरकार", + parliamentActorInPowerInhabitant: 'सत्तारूढ़ निवासी', + parliamentActorInPowerTribe: 'सत्तारूढ़ जनजाति', + parliamentHistoricalGovernmentsTitle: 'सरकारें', + parliamentHistoricalElectionsTitle: 'चुनाव चक्र', + parliamentHistoricalLawsTitle: 'ऐतिहासिक', + parliamentHistoricalLeadersTitle: 'नेता', + parliamentThCycles: 'चक्र', + parliamentThTimesInPower: 'सत्ता में', + parliamentThTotalCandidatures: 'उम्मीदवारी', + parliamentThProposed: 'प्रस्तावित कानून', + parliamentThApproved: 'स्वीकृत कानून', + parliamentThDeclined: 'अस्वीकृत कानून', + parliamentThDiscarded: 'निरस्त कानून', + parliamentMembers: "सदस्य", + parliamentLegSince: "चक्र प्रारंभ", + parliamentLegEnd: "चक्र समाप्ति", + parliamentPoliciesProposal: "कानून प्रस्ताव", + parliamentPoliciesApproved: "स्वीकृत कानून", + parliamentPoliciesDeclined: "अस्वीकृत कानून", + parliamentPoliciesDiscarded: "निरस्त कानून", + parliamentEfficiency: "% दक्षता", + parliamentFilterRevocations: 'निरसन', + parliamentRevocationFormTitle: 'कानून निरसन', + parliamentRevocationLaw: 'कानून', + parliamentRevocationTitle: 'शीर्षक', + parliamentRevocationReasons: 'कारण', + parliamentRevocationPublish: 'निरसन प्रकाशित करें', + parliamentCurrentRevocationsTitle: 'वर्तमान निरसन', + parliamentFutureRevocationsTitle: 'भविष्य के निरसन', + parliamentPoliciesRevocated: 'निरसित कानून', + parliamentPoliciesTitle: 'ऐतिहासिक', + parliamentLawsTitle: 'स्वीकृत कानून', + parliamentRulesRevocations: 'किसी भी स्वीकृत कानून को वर्तमान शासन पद्धति का उपयोग करके निरसित किया जा सकता है।', + parliamentNoStableGov: "अभी तक कोई सरकार नहीं चुनी गई।", + parliamentNoGovernments: "अभी तक कोई सरकार नहीं है।", + parliamentCandidatureFormTitle: "उम्मीदवारी प्रस्तावित करें", + parliamentCandidatureIdPh: "Oasis ID (@...) या जनजाति का नाम", + parliamentCandidatureSlogan: "नारा (अधिकतम 140 अक्षर)", + parliamentCandidatureSloganPh: "एक छोटा आदर्श वाक्य", + parliamentCandidatureMethod: "पद्धति", + parliamentCandidatureProposeBtn: "उम्मीदवारी प्रकाशित करें", + parliamentThDate: "प्रस्ताव तिथि", + parliamentThSlogan: "नारा", + parliamentThSince: "प्रोफ़ाइल से", + parliamentThVotes: "प्राप्त मत", + parliamentThVoteAction: "मत दें", + parliamentTypeUser: "निवासी", + parliamentTypeTribe: "जनजाति", + parliamentVoteBtn: "मत दें", + parliamentProposalFormTitle: "कानून प्रस्तावित करें", + parliamentProposalDescription: "विवरण (≤1000)", + parliamentProposalPublish: "प्रस्ताव प्रकाशित करें", + parliamentFinalize: "अंतिम रूप दें", + parliamentDeadline: "समय सीमा", + parliamentNoProposals: "अभी तक कोई कानून प्रस्ताव नहीं है।", + parliamentNoLaws: "अभी तक कोई स्वीकृत कानून नहीं है।", + parliamentMethodDEMOCRACY: "लोकतंत्र", + parliamentMethodMAJORITY: "बहुमत (80%)", + parliamentMethodMINORITY: "अल्पमत (20%)", + parliamentMethodDICTATORSHIP: "तानाशाही", + parliamentMethodKARMATOCRACY: "कर्मतंत्र", + parliamentThId: "ID", + parliamentThProposalDate: "प्रस्ताव तिथि", + parliamentThMethod: "पद्धति", + parliamentThKarma: "कर्म", + parliamentThSupports: "समर्थन", + parliamentThVote: "मत", + parliamentCurrentProposalsTitle: "वर्तमान प्रस्ताव", + parliamentVotesSlashTotal: "मत/कुल", + parliamentVotesNeeded: "आवश्यक मत", + parliamentFutureLawsTitle: "भविष्य के कानून", + parliamentNoFutureLaws: "अभी तक कोई भविष्य के कानून नहीं।", + parliamentVoteAction: "मत दें", + parliamentLeadersTitle: "नेता", + parliamentThLeader: "अवतार", + parliamentPopulation: "जनसंख्या", + parliamentThType: "प्रकार", + parliamentThInPower: "सत्ता में", + parliamentThPresented: "उम्मीदवारी", + parliamentNoLeaders: "अभी तक कोई नेता नहीं।", + parliamentCandidaturesListTitle: "उम्मीदवारी सूची", + parliamentTimeRemaining: "शेष समय", + parliamentNoLeader: "अभी तक कोई विजयी उम्मीदवारी नहीं।", + parliamentElectionsStatusTitle: "अगली सरकार", + parliamentProposalDeadlineLabel: "समय सीमा", + parliamentProposalTimeLeft: "शेष समय", + parliamentProposalOnTrack: "पारित होने की राह पर", + parliamentProposalOffTrack: "अभी तक पर्याप्त समर्थन नहीं", + parliamentRulesTitle: "संसद कैसे काम करती है", + parliamentRulesIntro: "चुनाव हर 2 महीने में हल होते हैं; उम्मीदवारी निरंतर होती है और सरकार चुने जाने पर रीसेट हो जाती है।", + parliamentRulesCandidates: "कोई भी निवासी अपने आप को, किसी अन्य निवासी को, या किसी जनजाति को प्रस्तावित कर सकता है। प्रत्येक निवासी प्रति चक्र अधिकतम 3 उम्मीदवारी प्रस्तावित कर सकता है; एक ही चक्र में दोहराव अस्वीकार किए जाते हैं।", + parliamentRulesElection: "विजेता वह उम्मीदवारी है जिसे समाधान के समय सबसे अधिक मत प्राप्त हों।", + parliamentRulesTies: "टाई-ब्रेक क्रम: सर्वोच्च निवासी कर्म; यदि बराबर, सबसे पुरानी प्रोफ़ाइल; यदि अभी भी बराबर, सबसे पहला प्रस्ताव; फिर शब्दकोषीय ID द्वारा।", + parliamentRulesFallback: "यदि कोई मत नहीं देता, तो नवीनतम प्रस्तावित उम्मीदवारी जीतती है। यदि कोई उम्मीदवारी नहीं है, तो एक यादृच्छिक जनजाति चुनी जाती है।", + parliamentRulesTerm: "सरकार टैब वर्तमान सरकार और आँकड़े दिखाता है।", + parliamentRulesMethods: "रूप: अराजकता (साधारण बहुमत), लोकतंत्र (50%+1), बहुमत (80%), अल्पमत (20%), कर्मतंत्र (सर्वोच्च-कर्म प्रस्ताव), तानाशाही (तत्काल अनुमोदन)।", + parliamentRulesAnarchy: "अराजकता डिफ़ॉल्ट मोड है: यदि समाधान पर कोई उम्मीदवारी नहीं चुनी जाती, तो अराजकता घोषित होती है। अराजकता के तहत, कोई भी निवासी कानून प्रस्तावित कर सकता है।", + parliamentRulesProposals: "यदि आप सत्तारूढ़ निवासी हैं या सत्तारूढ़ जनजाति के सदस्य हैं, तो आप कानून प्रस्ताव प्रकाशित कर सकते हैं। गैर-तानाशाही पद्धतियाँ सार्वजनिक मतदान बनाती हैं।", + parliamentRulesLimit: "प्रत्येक निवासी प्रति चक्र अधिकतम 3 कानून प्रस्ताव प्रकाशित कर सकता है।", + parliamentRulesLaws: "जब कोई प्रस्ताव अपनी सीमा पूरी करता है, तो वह कानून बन जाता है और कानून टैब में लागू होने की तिथि के साथ दिखाई देता है।", + parliamentRulesHistorical: "ऐतिहासिक टैब में आप हर सरकारी चक्र और उसके प्रबंधन का डेटा देख सकते हैं।", + parliamentRulesLeaders: "नेता टैब में आप उन निवासियों/जनजातियों की रैंकिंग देख सकते हैं जिन्होंने शासन किया है (या उम्मीदवार रहे हैं), दक्षता के अनुसार क्रमबद्ध।", + parliamentProposalVoteStatusLabel: "मत स्थिति", + parliamentProposalOnTrackYes: "सीमा पूरी हुई", + parliamentProposalOnTrackNo: "सीमा से नीचे", + courtsTitle: "न्यायालय", + courtsDescription: "विवाद समाधान और सामूहिक न्याय प्रबंधन के रूपों का अन्वेषण करें।", + courtsFilterCases: "मामले", + courtsFilterMyCases: "मेरे", + courtsFilterJudges: "न्यायाधीश", + courtsFilterHistory: "इतिहास", + courtsFilterRules: "नियम", + courtsCaseFormTitle: "मामला खोलें", + courtsCaseRespondent: "आरोपी / प्रतिवादी", + courtsCaseRespondentPh: "Oasis ID (@...) या जनजाति का नाम", + courtsCaseMediatorsAccuser: "मध्यस्थ (वादी)", + courtsCaseMethod: "समाधान पद्धति", + courtsCaseDescription: "विवरण (अधिकतम 1000 अक्षर)", + courtsCaseEvidenceTitle: "मामले के साक्ष्य", + courtsCaseEvidenceHelp: "अपने मामले का समर्थन करने वाली छवियाँ, ऑडियो, दस्तावेज़ (PDF) या वीडियो संलग्न करें।", + courtsCaseSubmit: "मामला दायर करें", + courtsNominateJudge: "न्यायाधीश नामांकित करें", + courtsJudgeId: "न्यायाधीश", + courtsJudgeIdPh: "Oasis ID (@...) या निवासी का नाम", + courtsNominateBtn: "नामांकित करें", + courtsAddEvidence: "साक्ष्य जोड़ें", + courtsEvidenceText: "टेक्स्ट", + courtsEvidenceLink: "लिंक", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "संलग्न करें", + courtsAnswerText: "प्रतिक्रिया संक्षिप्त", + courtsAnswerSubmit: "प्रतिक्रिया भेजें", + courtsVerdictTitle: "फैसला जारी करें", + courtsVerdictOrdersPh: "कार्रवाई, समय सीमा, पुनर्स्थापनात्मक कदम ...", + courtsIssueVerdict: "फैसला जारी करें", + courtsMediationPropose: "समझौता प्रस्तावित करें", + courtsSettlementProposeBtn: "प्रस्तावित करें", + courtsNominationsTitle: "न्यायिक नामांकन", + courtsThJudge: "न्यायाधीश", + courtsThDate: "दिनांक", + courtsThVote: "मत", + courtsNoNominations: "अभी तक कोई नामांकन नहीं।", + courtsCaseMediatorsRespondentTitle: "बचाव मध्यस्थ जोड़ें", + courtsCaseMediatorsRespondent: "मध्यस्थ (बचाव)", + courtsMediatorsAccuserLabel: "मध्यस्थ (वादी)", + courtsMediatorsRespondentLabel: "मध्यस्थ (बचाव)", + courtsMediatorsSubmit: "मध्यस्थ सहेजें", + courtsNoMyCases: "आपका अभी तक कोई विवाद नहीं है।", + courtsNoHistory: "अभी तक कोई दर्ज मुकदमा नहीं।", + courtsMethodDICTATOR: "तानाशाह", + courtsMethodPOPULAR: "लोकप्रिय", + courtsMethodKARMATOCRACY: "कर्मतंत्र", + courtsRulesTitle: "न्यायालय कैसे काम करते हैं", + courtsRulesIntro: "न्यायालय विवादों को हल करने और पुनर्स्थापनात्मक न्याय को बढ़ावा देने की एक सामुदायिक प्रक्रिया है। संवाद, स्पष्ट साक्ष्य और आनुपातिक उपायों को प्राथमिकता दी जाती है।", + courtsRulesLifecycle: "प्रक्रिया: 1) मामला खोलें 2) पद्धति चुनें 3) साक्ष्य प्रस्तुत करें 4) सुनवाई और विचार-विमर्श 5) फैसला और उपाय 6) अनुपालन और समापन 7) अपील (यदि लागू हो)।", + courtsRulesRoles: "वादी: मामला खोलता है। बचाव: आरोपित व्यक्ति या जनजाति। पद्धति: समुदाय द्वारा चुनी गई व्यवस्था जो सुविधा प्रदान करती है, साक्ष्य का मूल्यांकन करती है, और फैसला जारी करती है। गवाह: गवाही या साक्ष्य प्रदान करता है। मध्यस्थ: वादी और/या बचाव द्वारा आमंत्रित तटस्थ लोग, जिनकी सभी विवरणों तक पहुँच है, जो विवाद को शांत करने और समझौते सह-निर्माण करने में मदद करते हैं।", + courtsRulesEvidence: "विवरण अधिकतम 1000 अक्षर। प्रासंगिक और वैध छवियाँ, ऑडियो, वीडियो और PDF दस्तावेज़ संलग्न करें। सहमति के बिना संवेदनशील निजी डेटा साझा न करें।", + courtsRulesDeliberation: "सुनवाई सार्वजनिक या निजी हो सकती है। न्यायाधीश सम्मान सुनिश्चित करते हैं, स्पष्टीकरण का अनुरोध करते हैं, और अप्रासंगिक या गैरकानूनी सामग्री को हटा सकते हैं।", + courtsRulesVerdict: "पुनर्स्थापना को प्राथमिकता दी जाती है: क्षमा याचना, मध्यस्थता समझौते, सामग्री मॉडरेशन, अस्थायी प्रतिबंध, या अन्य आनुपातिक उपाय। तर्क दर्ज किया जाना चाहिए।", + courtsRulesAppeals: "अपील: नए साक्ष्य या स्पष्ट प्रक्रियात्मक त्रुटि होने पर अनुमति है। अन्यथा कहा न जाए तो 7 दिनों के भीतर दायर की जानी चाहिए।", + courtsRulesPrivacy: "गोपनीयता और सुरक्षा का सम्मान करें। डॉक्सिंग, घृणा, या धमकियाँ हटा दी जाती हैं। न्यायाधीश जोखिम वाले लोगों की सुरक्षा के लिए रिकॉर्ड के भागों को संपादित या सील कर सकते हैं।", + courtsRulesMisconduct: "उत्पीड़न, हेरफेर, या मनगढ़ंत साक्ष्य तत्काल नकारात्मक समाधान का कारण बन सकते हैं।", + courtsRulesGlossary: "मामला: विवाद का रिकॉर्ड। साक्ष्य: दावों का समर्थन करने वाली सामग्री। फैसला: उपाय के साथ निर्णय। अपील: फैसले की समीक्षा का अनुरोध।", + courtsFilterActions: "कार्रवाई", + courtsNoActions: "आपकी भूमिका के लिए कोई लंबित कार्रवाई नहीं।", + courtsCaseTitlePlaceholder: "विवाद का संक्षिप्त विवरण", + courtsCaseSeverity: "गंभीरता", + courtsCaseSeverityNone: "कोई गंभीरता टैग नहीं", + courtsCaseSeverityLOW: "कम", + courtsCaseSeverityMEDIUM: "मध्यम", + courtsCaseSeverityHIGH: "उच्च", + courtsCaseSeverityCRITICAL: "गंभीर", + courtsCaseSubject: "विषय", + courtsCaseSubjectNone: "कोई विषय टैग नहीं", + courtsCaseSubjectBEHAVIOUR: "व्यवहार", + courtsCaseSubjectCONTENT: "सामग्री", + courtsCaseSubjectGOVERNANCE: "शासन / नियम", + courtsCaseSubjectFINANCIAL: "वित्तीय / संसाधन", + courtsCaseSubjectOTHER: "अन्य", + courtsHiddenRespondent: "छुपा हुआ (केवल संबंधित भूमिकाओं को दिखाई देता है)।", + courtsThRole: "भूमिका", + courtsRoleAccuser: "वादी", + courtsRoleDefence: "बचाव", + courtsRoleMediator: "मध्यस्थ", + courtsRoleJudge: "न्यायाधीश", + courtsRoleDictator: "तानाशाह", + courtsAssignJudgeTitle: "न्यायाधीश चुनें", + courtsAssignJudgeBtn: "न्यायाधीश चुनें", + trendingTitle: "ट्रेंडिंग", + exploreTrending: "अपने नेटवर्क में सबसे लोकप्रिय सामग्री खोजें।", + bookmarkButton: "बुकमार्क", + transferButton: "स्थानांतरण", + eventButton: "कार्यक्रम", + taskButton: "कार्य", + votesButton: "मतदान", + reportButton: "रिपोर्ट", + feedButton: "फ़ीड", + marketButton: "बाज़ार", + imageButton: "छवियाँ", + audioButton: "ऑडियो", + videoButton: "वीडियो", + documentButton: "दस्तावेज़", + torrentButton: "टॉरेंट", + noTrendingFound: "कोई ट्रेंडिंग सामग्री नहीं मिली।", + noContentMessage: "अभी तक कोई ट्रेंडिंग सामग्री उपलब्ध नहीं।", + trendingDescription: "विवरण", + trendingDate: "दिनांक", + trendingLocation: "स्थान", + trendingPrice: "मूल्य", + trendingUrl: "URL", + trendingCategory: "श्रेणी", + trendingStart: "प्रारंभ", + trendingEnd: "समाप्ति", + trendingPriority: "प्राथमिकता", + trendingStatus: "स्थिति", + trendingFrom: "से", + trendingTo: "को", + trendingConcept: "अवधारणा", + trendingAmount: "राशि", + trendingDeadline: "समय सीमा", + trendingItemStatus: "आइटम स्थिति", + trendingTotalVotes: "कुल मत", + trendingTotalOpinions: "कुल राय", + trendingNoContentMessage: "अभी तक कोई ट्रेंडिंग सामग्री उपलब्ध नहीं।", + trendingAuthor: "द्वारा", + trendingCreatedAtLabel: "बनाया गया", + trendingTotalCount: "कुल संख्या", + tasksTitle: "कार्य", + tasksDescription: "अपने नेटवर्क में कार्य खोजें और प्रबंधित करें।", + taskTitleLabel: "शीर्षक", + taskDescriptionLabel: "विवरण", + taskStartTimeLabel: "प्रारंभ समय", + taskEndTimeLabel: "समाप्ति समय", + taskPriorityLabel: "प्राथमिकता", + taskPrioritySelect: "प्राथमिकता चुनें", + taskPriorityUrgent: "अत्यावश्यक", + taskPriorityHigh: "उच्च", + taskPriorityMedium: "मध्यम", + taskPriorityLow: "कम", + taskLocationLabel: "स्थान", + taskTagsLabel: "टैग", + taskVisibilityLabel: "दृश्यता", + taskPublic: "सार्वजनिक", + taskPrivate: "निजी", + taskCreatedAt: "बनाया गया", + taskBy: "द्वारा", + taskStatus: "स्थिति", + taskStatusOpen: "खुला", + taskStatusInProgress: "प्रगति में", + taskStatusClosed: "बंद", + taskAssignedTo: "को सौंपा गया", + taskAssignees: "सौंपे गए", + taskAssignButton: "मुझे सौंपें", + taskUnassignButton: "सौंपना रद्द करें", + taskCreateButton: "कार्य बनाएँ", + taskUpdateButton: "अपडेट", + taskDeleteButton: "हटाएँ", + taskFilterAll: "सभी", + taskFilterMine: "मेरे", + taskFilterOpen: "खुले", + taskFilterInProgress: "प्रगति में", + taskFilterClosed: "बंद", + taskFilterAssigned: "सौंपे गए", + taskFilterArchived: "संग्रहीत", + taskFilterUrgent: "अत्यावश्यक", + taskFilterHigh: "उच्च", + taskFilterMedium: "मध्यम", + taskFilterLow: "कम", + taskAllSectionTitle: "कार्य", + taskMineSectionTitle: "आपके कार्य", + taskCreateSectionTitle: "कार्य बनाएँ", + taskUpdateSectionTitle: "अपडेट", + taskOpenTitle: "खुले कार्य", + taskInProgressTitle: "प्रगति में कार्य", + taskClosedTitle: "बंद कार्य", + taskAssignedTitle: "सौंपे गए कार्य", + taskArchivedTitle: "संग्रहीत कार्य", + taskPublicTitle: "सार्वजनिक कार्य", + taskPrivateTitle: "निजी कार्य", + notasks: "कोई कार्य उपलब्ध नहीं।", + noLocation: "कोई स्थान निर्दिष्ट नहीं", + taskSetStatus: "स्थिति सेट करें", + eventTitle: "कार्यक्रम", + eventDateLabel: "दिनांक", + eventsTitle: "कार्यक्रम", + eventsDescription: "अपने नेटवर्क में कार्यक्रम खोजें और प्रबंधित करें।", + eventDescription: "विवरण", + eventPrice: "मूल्य", + eventStatus: "स्थिति", + eventOrganizer: "आयोजक", + eventAllSectionTitle: "कार्यक्रम", + eventMineSectionTitle: "आपके कार्यक्रम", + eventArchivedTitle: "संग्रहीत कार्यक्रम", + eventCreateSectionTitle: "कार्यक्रम बनाएँ", + eventUpdateSectionTitle: "कार्यक्रम अपडेट करें", + eventDeleteButton: "हटाएँ", + eventCreateButton: "कार्यक्रम बनाएँ", + eventAddToCalendar: "कैलेंडर में जोड़ें", + eventVisitCalendar: "कैलेंडर देखें", + viewTask: "कार्य देखें", + viewReport: "रिपोर्ट देखें", + viewTransfer: "स्थानांतरण देखें", + viewItem: "आइटम देखें", + viewShop: "दुकान देखें", + viewProject: "परियोजना देखें", + viewVotation: "मतदान देखें", + voteCastTitle: "मतदान करें", + voteResults: "परिणाम", + eventTitleLabel: "शीर्षक", + eventDescriptionLabel: "विवरण", + eventDescriptionPlaceholder: "कार्यक्रम विवरण दर्ज करें...", + eventUpdateButton: "अपडेट", + eventAttendeesLabel: "उपस्थित", + eventAttendeesPlaceholder: "अल्पविराम से अलग करके उपस्थित दर्ज करें...", + eventTagsLabel: "टैग", + eventTagsPlaceholder: "अल्पविराम से अलग करके कार्यक्रम टैग दर्ज करें...", + eventTags: "टैग", + eventPriceLabel: "मूल्य", + eventUrlLabel: "URL", + eventAttendees: "उपस्थित", + noAttendees: "अभी तक कोई उपस्थित नहीं", + eventCreatedAt: "बनाया गया", + eventLocation: "स्थान", + eventLocationLabel: "स्थान", + eventNoLocation: "कोई स्थान निर्दिष्ट नहीं", + eventNoURL: "कोई URL निर्दिष्ट नहीं", + eventBy: "द्वारा", + noevents: "कोई कार्यक्रम उपलब्ध नहीं।", + eventDate: "दिनांक", + eventDateFormat: "DD:MM:YYYY HH:mm", + eventAttendButton: "कार्यक्रम में शामिल हों", + eventUnattendButton: "कार्यक्रम छोड़ें", + eventCreatedBy: "द्वारा बनाया गया", + eventAttendeesCount: "उपस्थित संख्या", + eventCreatedByYou: "आपने यह कार्यक्रम बनाया", + eventFilterAll: "सभी", + eventFilterMine: "मेरे", + eventFilterToday: "आज", + eventFilterWeek: "इस सप्ताह", + eventFilterMonth: "इस महीने", + eventFilterYear: "इस वर्ष", + eventFilterArchived: "संग्रहीत", + eventTodayTitle: "आज के कार्यक्रम", + eventThisWeekTitle: "इस सप्ताह के कार्यक्रम", + eventThisMonthTitle: "इस महीने के कार्यक्रम", + eventThisYearTitle: "इस वर्ष के कार्यक्रम", + eventPrivacyLabel: "दृश्यता", + eventPublic: "सार्वजनिक", + eventPrivate: "निजी", + eventPublicTitle: "सार्वजनिक कार्यक्रम", + eventNoPrice: "निःशुल्क कार्यक्रम", + eventNoImage: "कोई छवि अपलोड नहीं की गई", + eventAttendConfirmation: "अब आप इस कार्यक्रम में शामिल हैं", + eventUnattendConfirmation: "अब आप इस कार्यक्रम में शामिल नहीं हैं", + eventAttended: "शामिल हुए", + eventUnattended: "शामिल नहीं", + eventStatusOpen: "खुला", + eventStatusClosed: "बंद", + tagsTitle: "टैग", + tagsDescription: "अपने नेटवर्क में वर्गीकरण पैटर्न खोजें और अन्वेषण करें।", + tagsAllSectionTitle: "टैग", + tagsTopSectionTitle: "शीर्ष टैग", + tagsCloudSectionTitle: "टैग क्लाउड", + tagsFilterAll: "सभी", + tagsFilterTop: "शीर्ष", + tagsFilterCloud: "क्लाउड", + tagsNoItems: "कोई टैग उपलब्ध नहीं।", + tagsTableHeaderTag: "टैग/लिंक:", + tagsTableHeaderCount: "गणना:", + transfersTitle: "स्थानांतरण", + transfersDescription: "अपने नेटवर्क में स्थानांतरण खोजें और प्रबंधित करें।", + transfersFrom: "से", + transfersTo: "को", + transfersFilterAll: "सभी", + transfersFilterMine: "मेरे", + transfersFilterUBI: "UBI", + transfersFilterMarket: "बाज़ार", + transfersFilterTop: "शीर्ष", + transfersFilterPending: "लंबित", + transfersFilterUnconfirmed: "अपुष्ट", + transfersFilterClosed: "बंद", + transfersFilterDiscarded: "निरस्त", + transfersFilterEconomic: "आर्थिक", + transfersFilterTime: "समय", + transfersFilterTrust: "भरोसा", + transfersCategory: "श्रेणी", + transfersCategoryEconomic: "आर्थिक", + transfersCategoryTime: "समय", + transfersCategoryTrust: "भरोसा", + transfersCategoryApply: "लागू करें", + transfersUnitEco: "ECO", + transfersUnitHours: "घं", + transfersUnitTrust: "भरोसा", + transfersEconomicSectionTitle: "आर्थिक स्थानांतरण", + transfersTimeSectionTitle: "समय स्थानांतरण", + transfersTrustSectionTitle: "भरोसा स्थानांतरण", + transfersCreateButton: "स्थानांतरण बनाएँ", + transfersUpdateButton: "अपडेट", + transfersDeleteButton: "हटाएँ", + transfersToUser: "ओएसिस ID", + transfersToUserValidation: "वैध Oasis ID, जैसे @…=.ed25519", + transfersConcept: "अवधारणा", + transfersAmount: "राशि", + transfersDeadline: "समय सीमा", + transfersTags: "टैग", + transfersStatus: "स्थिति", + transfersStatusUnconfirmed: "अपुष्ट", + transfersStatusClosed: "बंद", + transfersStatusDiscarded: "निरस्त", + transfersCreatedAt: "बनाया गया", + transfersConfirmations: "पुष्टि", + transfersContainingBlock: "धारक ब्लॉक", + transfersExportContract: "अनुबंध बनाएँ", + transfersConfirmButton: "स्थानांतरण पुष्टि करें", + transfersNoItems: "कोई स्थानांतरण नहीं मिला।", + transfersMineSectionTitle: "आपके स्थानांतरण", + transfersUBISectionTitle: "UBI स्थानांतरण", + transfersMarketSectionTitle: "बाज़ार स्थानांतरण", + transfersTopSectionTitle: "शीर्ष स्थानांतरण", + transfersPendingSectionTitle: "लंबित स्थानांतरण", + transfersUnconfirmedSectionTitle: "अपुष्ट स्थानांतरण", + transfersClosedSectionTitle: "बंद स्थानांतरण", + transfersDiscardedSectionTitle: "निरस्त स्थानांतरण", + transfersCreateSectionTitle: "स्थानांतरण बनाएँ", + transfersAllSectionTitle: "स्थानांतरण", + transfersFilterFavs: "पसंदीदा", + transfersFavsSectionTitle: "पसंदीदा स्थानांतरण", + transfersSearchLabel: "खोज", + transfersSearchPlaceholder: "अवधारणा, टैग, उपयोगकर्ता खोजें...", + transfersMinAmountLabel: "न्यूनतम राशि", + transfersMaxAmountLabel: "अधिकतम राशि", + transfersSortLabel: "क्रमबद्ध करें", + transfersSortRecent: "सबसे हाल का", + transfersSortAmount: "सबसे अधिक राशि", + transfersSortDeadline: "निकटतम समय सीमा", + transfersSearchButton: "खोजें", + transfersFavoriteButton: "पसंदीदा", + transfersUnfavoriteButton: "पसंदीदा हटाएँ", + transfersMessageUserButton: "संदेश", + transfersExpiringSoonBadge: "समाप्त हो रहा है", + transfersExpiredBadge: "समाप्त", + transfersUpdatedAt: "अपडेट किया गया", + transfersNoMatch: "आपकी खोज से कोई स्थानांतरण मेल नहीं खाता।", + votationsTitle: "मतदान", + votationsDescription: "अपने नेटवर्क में मतदान खोजें और प्रबंधित करें।", + voteMineSectionTitle: "आपके मतदान", + voteCreateSectionTitle: "मतदान बनाएँ", + voteUpdateSectionTitle: "अपडेट", + voteOpenTitle: "खुले मतदान", + voteClosedTitle: "बंद मतदान", + voteAllSectionTitle: "मतदान", + voteCreateButton: "मतदान बनाएँ", + voteUpdateButton: "अपडेट", + voteDeleteButton: "हटाएँ", + voteOptionYes: "हाँ", + voteOptionNo: "नहीं", + voteOptionAbstention: "तटस्थता", + voteConfused: "भ्रमित", + voteFollowMajority: "बहुमत का पालन करें", + voteNotInterested: "रुचि नहीं", + voteFilterAll: "सभी", + voteFilterMine: "मेरे", + voteFilterOpen: "खुले", + voteFilterClosed: "बंद", + voteQuestionLabel: "प्रश्न", + voteDeadlineLabel: "समय सीमा", + voteOptionsLabel: "आपका मत", + voteBreakdown: "विश्लेषण", + voteFinalResult: "परिणाम", + voteNoQuorum: "कोरम नहीं", + voteTagsLabel: "टैग", + voteDeadline: "समय सीमा", + voteStatus: "स्थिति", + voteTags: "टैग", + voteOpinions: "राय", + novotes: "कोई मतदान प्रस्ताव उपलब्ध नहीं।", + voteBy: "द्वारा", + voteCreatedAt: "बनाया गया", + voteNoQuestion: "कोई प्रश्न प्रदान नहीं किया गया", + voteUnknownCreator: "अज्ञात निर्माता", + voteUnknownDate: "अज्ञात दिनांक", + errorVoteNotFound: "मत नहीं मिला", + errorAlreadyVoted: "आपने पहले ही राय दे दी है।", + errorVoteClosedCannotEdit: "आप बंद मतदान को संपादित नहीं कर सकते", + errorVoteDeadlinePassed: "इस मतदान की समय सीमा बीत चुकी है", + errorRetrievingVote: "मत प्राप्त करने में त्रुटि", + errorCreatingVote: "मत बनाने में त्रुटि", + errorVoteAlreadyVoted: "राय दिए जाने के बाद संपादित नहीं किया जा सकता", + errorDeletingOldVote: "पुरानी राय हटाने में त्रुटि", + errorCreatingUpdatedVote: "अपडेटेड राय बनाने में त्रुटि", + errorCreatingTombstone: "टॉम्बस्टोन बनाने में त्रुटि", + voteDetailSectionTitle: 'मत विवरण', + voteCommentsLabel: 'टिप्पणियाँ', + voteCommentsForumButton: 'चर्चा खोलें', + voteCommentsSectionTitle: 'चर्चा खोलें', + voteNoCommentsYet: 'अभी तक कोई टिप्पणी नहीं है। पहले उत्तर देने वाले बनें।', + voteNewCommentPlaceholder: 'अपनी टिप्पणी यहाँ लिखें…', + voteNewCommentButton: 'टिप्पणी पोस्ट करें', + voteNewCommentLabel: 'टिप्पणी जोड़ें', + cvTitle: "CV", + cvLabel: "बायोडाटा (CV)", + cvEditSectionTitle: "CV संपादित करें", + cvCreateSectionTitle: "CV बनाएँ", + cvDescription: "अपने पेशेवर कौशल और जानकारी प्रबंधित करें और साझा करें।", + cvNameLabel: "पूरा नाम", + cvDescriptionLabel: "सारांश", + cvPhotoLabel: "फ़ोटो", + cvPersonalExperiencesLabel: "व्यक्तिगत अनुभव", + cvPersonalSkillsLabel: "व्यक्तिगत कौशल (अल्पविराम से अलग)", + cvOasisExperiencesLabel: "Oasis योगदान अनुभव", + cvOasisSkillsLabel: "Oasis योगदान कौशल (अल्पविराम से अलग)", + cvEducationExperiencesLabel: "शैक्षिक अनुभव", + cvEducationalSkillsLabel: "शैक्षिक कौशल (अल्पविराम से अलग)", + cvProfessionalExperiencesLabel: "पेशेवर अनुभव", + cvProfessionalSkillsLabel: "पेशेवर कौशल (अल्पविराम से अलग)", + cvLanguagesLabel: "भाषाएँ", + cvLocationLabel: "स्थान", + cvStatusLabel: "स्थिति", + cvPreferencesLabel: "प्राथमिकताएँ", + cvOasisContributorLabel: "Oasis योगदानकर्ता", + cvPersonal: "व्यक्तिगत", + cvOasis: "Oasis योगदानकर्ता (वैकल्पिक)", + cvOasisContributorView: "Oasis योगदान", + cvEducational: "शैक्षिक (वैकल्पिक)", + cvEducationalView: "शैक्षिक", + cvProfessional: "पेशेवर (वैकल्पिक)", + cvProfessionalView: "पेशेवर", + cvAvailability: "उपलब्धता (वैकल्पिक)", + cvAvailabilityView: "उपलब्धता", + cvUpdateButton: "अपडेट", + cvCreateButton: "CV बनाएँ", + cvContactLabel: "संपर्क", + cvCreatedAt: "बनाया गया", + cvUpdatedAt: "अपडेट किया गया", + cvEditButton: "अपडेट", + cvDeleteButton: "हटाएँ", + cvNoCV: "कोई CV नहीं मिला।", + blogSubject: "विषय", + blogMessage: "मुख्य भाग 8096 वर्णों तक सीमित।", + blogImage: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", + blogPublish: "पूर्वावलोकन", + noPopularMessages: "अभी तक कोई लोकप्रिय संदेश प्रकाशित नहीं हुआ", + forumTitle: "मंच", + forumCategoryLabel: "श्रेणी", + forumTitleLabel: "शीर्षक", + forumTitlePlaceholder: "मंच शीर्षक...", + forumCreateButton: "मंच बनाएँ", + forumCreateSectionTitle: "मंच बनाएँ", + forumDescription: "अपने नेटवर्क के अन्य निवासियों से खुलकर बात करें।", + forumFilterAll: "सभी", + forumFilterMine: "मेरे", + forumFilterRecent: "हाल के", + forumFilterTop: "शीर्ष", + forumMineSectionTitle: "आपके मंच", + forumRecentSectionTitle: "हाल के मंच", + forumAllSectionTitle: "मंच", + forumDeleteButton: "हटाएँ", + forumParticipants: "प्रतिभागी", + forumMessages: "संदेश", + forumLastMessage: "अंतिम संदेश", + forumMessageLabel: "संदेश", + forumMessagePlaceholder: "अपना संदेश लिखें...", + forumSendButton: "भेजें", + forumVisitForum: "मंच देखें", + noForums: "कोई मंच नहीं मिला।", + forumVisitButton: "मंच देखें", + forumCatGENERAL: "सामान्य", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "राजनीति", + forumCatTECH: "प्रौद्योगिकी", + forumCatSCIENCE: "विज्ञान", + forumCatMUSIC: "संगीत", + forumCatART: "कला", + forumCatGAMING: "गेमिंग", + forumCatBOOKS: "पुस्तकें", + forumCatFILMS: "फ़िल्में", + forumCatPHILOSOPHY: "दर्शन", + forumCatSOCIETY: "समाज", + forumCatPRIVACY: "गोपनीयता", + forumCatCYBERWARFARE: "साइबरयुद्ध", + forumCatSURVIVALISM: "जीवनवाद", + imageTitle: "छवियाँ", + imageDescription: "अपने नेटवर्क में छवि सामग्री खोजें और प्रबंधित करें।", + imagePluginTitle: "शीर्षक", + imagePluginDescription: "विवरण", + imageMineSectionTitle: "आपकी छवियाँ", + imageCreateSectionTitle: "छवि अपलोड करें", + imageUpdateSectionTitle: "छवि अपडेट करें", + imageAllSectionTitle: "छवियाँ", + imageRecentSectionTitle: "हाल की छवियाँ", + imageTopSectionTitle: "शीर्ष छवियाँ", + imageFavoritesSectionTitle: "पसंदीदा", + imageGallerySectionTitle: "गैलरी", + imageMemeSectionTitle: "मीम्स", + imageFilterAll: "सभी", + imageFilterMine: "मेरे", + imageFilterRecent: "हाल के", + imageFilterTop: "शीर्ष", + imageFilterFavorites: "पसंदीदा", + imageFilterGallery: "गैलरी", + imageFilterMeme: "मीम्स", + imageCreateButton: "छवि अपलोड करें", + imageUpdateButton: "अपडेट", + imageDeleteButton: "हटाएँ", + imageAddFavoriteButton: "पसंदीदा जोड़ें", + imageRemoveFavoriteButton: "पसंदीदा हटाएँ", + imageFileLabel: "छवि फ़ाइल चुनें (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "टैग", + imageTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + imageTitleLabel: "शीर्षक", + imageTitlePlaceholder: "वैकल्पिक", + imageDescriptionLabel: "विवरण", + imageDescriptionPlaceholder: "वैकल्पिक", + imageMemeLabel: "मीम के रूप में चिह्नित करें", + imageNoFile: "कोई छवि फ़ाइल प्रदान नहीं की गई", + noImages: "कोई छवि उपलब्ध नहीं।", + imageSearchPlaceholder: "शीर्षक, टैग, विवरण, लेखक खोजें...", + imageSortRecent: "सबसे हाल का", + imageSortOldest: "सबसे पुराना", + imageSortTop: "सर्वाधिक मतदान", + imageSearchButton: "खोजें", + imageMessageAuthorButton: "संदेश", + imageUpdatedAt: "अपडेट किया गया", + imageNoMatch: "आपकी खोज से कोई छवि मेल नहीं खाती।", + feedTitle: "फ़ीड", + createFeedTitle: "फ़ीड बनाएँ", + createFeedButton: "फ़ीड भेजें!", + feedPlaceholder: "क्या हो रहा है? (अधिकतम 280 अक्षर)", + TODAYButton: "आज", + CREATEButton: "फ़ीड बनाएँ", + totalOpinions: "कुल राय", + moreVoted: "अधिक मतदान", + noFeedsFound: "कोई फ़ीड नहीं मिली।", + createdAtLabel: "बनाया गया", + FeedshareYourOpinions: "अपने नेटवर्क में लघु-पाठ खोजें और साझा करें।", + refeedButton: "रीफ़ीड", + alreadyRefeeded: "आपने इसे पहले ही रीफ़ीड कर दिया है।", + activityTitle: "गतिविधि", + yourActivity: "आपकी गतिविधि", + globalActivity: "वैश्विक गतिविधि", + activityList: "गतिविधि", + activityDesc: "अपने नेटवर्क में नवीनतम गतिविधि देखें।", + allButton: "सभी", + mineButton: "मेरे", + noActions: "कोई गतिविधि उपलब्ध नहीं।", + performed: "→", + from: "से", + to: "को", + amount: "राशि", + concept: "अवधारणा", + description: "विवरण", + meme: "मीम", + activityContact: "संपर्क", + activityBy: "नाम", + activityPixelia: "नया पिक्सेल जोड़ा गया", + viewImage: "छवि देखें", + playAudio: "ऑडियो चलाएँ", + playVideo: "वीडियो चलाएँ", + typeRecent: "हाल के", + errorActivity: "गतिविधि प्राप्त करने में त्रुटि", + typePost: "ब्लॉग्स", + recentButton: "हाल का", + typeTribe: "जनजातियाँ", + typeLarp: "L.A.R.P.", + typeInhabitants: "निवासी", + activityProfileUpdated: "ने अपनी प्रोफ़ाइल अपडेट की", + typeAbout: "निवासी", + typeCurriculum: "CVS", + typeImage: "छवियाँ", + typeBookmark: "बुकमार्क", + typeDocument: "दस्तावेज़", + typeVotes: "मतदान", + typeAudio: "ऑडियो", + typeMarket: "बाज़ार", + typeJob: "नौकरियाँ", + typeProject: "परियोजनाएँ", + typeVideo: "वीडियो", + typeVote: "प्रसार", + typeEvent: "कार्यक्रम", + typeTransfer: "स्थानांतरण", + typeTask: "कार्य", + typePixelia: "पिक्सेलिया", + typeForum: "मंच", + typeReport: "रिपोर्ट", + typeFeed: "फ़ीड्स", + typeContact: "संपर्क", + typePub: "PUB", + typeTombstone: "टॉम्बस्टोन", + typeBanking: "बैंकिंग", + typeBankWallet: "बैंकिंग/वॉलेट", + typeBankClaim: "बैंकिंग/UBI", + typeKarmaScore: "कर्मा", + typeParliament: "संसद", + typeSpread: "प्रसार", + typeParliamentCandidature: "संसद · उम्मीदवारी", + typeParliamentTerm: "संसद · कार्यकाल", + typeParliamentProposal:"संसद · प्रस्ताव", + typeParliamentRevocation:"संसद · निरसन", + typeParliamentLaw: "संसद · नया कानून", + typeCourts: "न्यायालय", + typeCourtsCase: "न्यायालय · मामला", + typeCourtsEvidence: "न्यायालय · साक्ष्य", + typeCourtsAnswer: "न्यायालय · उत्तर", + typeCourtsVerdict: "न्यायालय · फैसला", + typeCourtsSettlement: "न्यायालय · समझौता", + typeCourtsSettlementProposal: "न्यायालय · समझौता प्रस्ताव", + typeCourtsSettlementAccepted: "न्यायालय · समझौता स्वीकृत", + typeCourtsNomination: "न्यायालय · नामांकन", + typeCourtsNominationVote: "न्यायालय · नामांकन मत", + activitySupport: "नया गठबंधन बना", + activityJoin: "नए PUB में शामिल हुए", + question: "प्रश्न", + deadline: "समय सीमा", + status: "स्थिति", + votes: "मत", + totalVotes: "कुल मत", + voteTotalVotes: "कुल मत", + name: "नाम", + skills: "कौशल", + tags: "टैग", + title: "शीर्षक", + date: "दिनांक", + category: "श्रेणी", + attendees: "उपस्थित", + activitySpread: "->", + visitLink: "लिंक देखें", + viewDocument: "दस्तावेज़ देखें", + location: "स्थान", + contentWarning: "विषय", + personName: "निवासी का नाम", + bankWalletConnected: "ECOin वॉलेट", + bankUbiReceived: "UBI प्राप्त", + bankTx: "Tx", + bankEpochShort: "युग", + bankingUserEngagementScore: "कर्म स्कोरिंग", + viewDetails: "विवरण देखें", + link: "लिंक", + activityProjectFollow: "%OASIS% अब इस परियोजना %PROJECT% को %ACTION% कर रहा है", + activityProjectUnfollow: "%OASIS% अब इस परियोजना %PROJECT% को %ACTION% कर रहा है", + activityProjectPledged: "%OASIS% ने परियोजना %PROJECT% को %AMOUNT% %ACTION% किया है", + following: "फ़ॉलो कर रहे हैं", + unfollowing: "अनफ़ॉलो कर रहे हैं", + pledged: "प्रतिज्ञा", + parliamentCandidatureId: "उम्मीदवारी", + parliamentGovMethod: "पद्धति", + parliamentVotesReceived: "प्राप्त मत", + parliamentMethodANARCHY: "अराजकता", + parliamentMethodVOTE: "सामुदायिक मत", + parliamentMethodRANKED: "रैंक्ड चॉइस", + parliamentMethodPLURALITY: "बहुलता", + parliamentMethodCOUNCIL: "परिषद", + parliamentMethodJURY: "जूरी", + parliamentAnarchy: "अराजकता", + parliamentElectionsStart: "चुनाव प्रारंभ", + parliamentElectionsEnd: "चुनाव समाप्ति", + parliamentCurrentLeader: "विजयी उम्मीदवारी", + parliamentProposalTitle: "शीर्षक", + parliamentOpenVote: "मतदान खोलें", + parliamentStatus: "स्थिति", + parliamentLawQuestion: "प्रश्न", + parliamentLawMethod: "पद्धति", + parliamentLawProposer: "प्रस्तावक", + parliamentLawEnacted: "लागू किया गया", + parliamentLawVotes: "मत", + createdAt: "बनाया गया", + courtsCaseTitle: "मामला", + courtsMethod: "पद्धति", + courtsMethodJUDGE: "न्यायाधीश", + courtsMethodJUDGES: "न्यायाधीश पैनल", + courtsMethodSINGLE_JUDGE: "एकल न्यायाधीश", + courtsMethodJURY: "जूरी", + courtsMethodCOUNCIL: "परिषद", + courtsMethodCOMMUNITY: "समुदाय", + courtsMethodARBITRATION: "मध्यस्थता", + courtsMethodVOTE: "सामुदायिक मत", + courtsAccuser: "वादी", + courtsRespondent: "प्रतिवादी", + courtsThStatus: "स्थिति", + courtsThAnswerBy: "उत्तर द्वारा", + courtsThEvidenceBy: "साक्ष्य द्वारा", + courtsThDecisionBy: "निर्णय द्वारा", + courtsVotesNeeded: "आवश्यक मत", + courtsVotesSlashTotal: "हाँ/कुल", + courtsOpenVote: "मतदान खोलें", + courtsAnswerTitle: "उत्तर", + courtsStanceADMIT: "स्वीकार", + courtsStanceDENY: "अस्वीकार", + courtsStancePARTIAL: "आंशिक", + courtsStanceCOUNTERCLAIM: "प्रतिदावा", + courtsStanceNEUTRAL: "तटस्थ", + courtsVerdictResult: "परिणाम", + courtsVerdictOrders: "आदेश", + courtsSettlementText: "समझौता", + courtsSettlementAccepted: "स्वीकृत", + courtsSettlementPending: "लंबित", + courtsJudge: "न्यायाधीश", + courtsThSupports: "समर्थन", + courtsFilterOpenCase: 'मामला खोलें', + courtsEvidenceFileLabel: 'साक्ष्य फ़ाइल (छवि, ऑडियो, वीडियो या PDF)', + courtsCaseMediators: 'मध्यस्थ', + courtsCaseMediatorsPh: 'मध्यस्थ Oasis IDs, अल्पविराम से अलग', + courtsMediatorsLabel: 'मध्यस्थ', + courtsThCase: 'मामला', + courtsThCreatedAt: 'प्रारंभ तिथि', + courtsThActions: 'कार्रवाई', + courtsPublicPrefLabel: 'समाधान के बाद दृश्यता', + courtsPublicPrefYes: 'मैं सहमत हूँ कि यह मामला पूर्ण रूप से सार्वजनिक हो सकता है', + courtsPublicPrefNo: 'मैं विवरण निजी रखना पसंद करता हूँ', + courtsPublicPrefSubmit: 'दृश्यता प्राथमिकता सहेजें', + courtsMethodMEDIATION: 'मध्यस्थता', + courtsNoCases: 'कोई मामला नहीं।', + reportsTitle: "रिपोर्ट", + reportsDescription: "अपने नेटवर्क में मुद्दों, बग, दुरुपयोग और सामग्री चेतावनियों से संबंधित रिपोर्ट प्रबंधित करें और ट्रैक करें।", + reportsFilterAll: "सभी", + reportsFilterMine: "मेरे", + reportsFilterFeatures: "सुविधाएँ", + reportsFilterBugs: "बग", + reportsFilterAbuse: "दुरुपयोग", + reportsFilterContent: "सामग्री", + reportsFilterConfirmed: "पुष्ट", + reportsFilterResolved: "हल किया गया", + reportsFilterOpen: "खुले", + reportsFilterUnderReview: "समीक्षाधीन", + reportsFilterInvalid: "अमान्य", + reportsCreateButton: "रिपोर्ट बनाएँ", + reportsTitleLabel: "शीर्षक", + reportsDescriptionLabel: "विवरण", + reportsDescriptionPlaceholder: "कृपया विस्तृत विवरण प्रदान करें।", + reportsCategory: "श्रेणी", + reportsCategoryLabel: "श्रेणी", + reportsCategoryFeatures: "सुविधाएँ", + reportsCategoryBugs: "बग", + reportsCategoryAbuse: "दुरुपयोग", + reportsCategoryContent: "सामग्री समस्याएँ", + reportsUpdateButton: "अपडेट", + reportsDeleteButton: "हटाएँ", + reportsDateLabel: "दिनांक", + reportsUploadFile: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", + reportsMineSectionTitle: "आपकी रिपोर्ट", + reportsFeaturesSectionTitle: "सुविधा अनुरोध", + reportsBugsSectionTitle: "बग", + reportsAbuseSectionTitle: "दुरुपयोग रिपोर्ट", + reportsContentSectionTitle: "सामग्री समस्याएँ", + reportsAllSectionTitle: "रिपोर्ट", + reportsNoItems: "कोई रिपोर्ट उपलब्ध नहीं।", + reportsValidationTitle: "कृपया एक वैध शीर्षक दर्ज करें।", + reportsValidationDescription: "विवरण खाली नहीं हो सकता।", + reportsValidationCategory: "कृपया एक श्रेणी चुनें।", + reportsCreatedAt: "बनाया गया", + reportsCreatedBy: "द्वारा", + reportsSeverity: "गंभीरता", + reportsSeverityLow: "कम", + reportsSeverityMedium: "मध्यम", + reportsSeverityHigh: "उच्च", + reportsSeverityCritical: "गंभीर", + reportsStatus: "स्थिति", + reportsStatusOpen: "खुला", + reportsStatusUnderReview: "समीक्षाधीन", + reportsStatusResolved: "हल किया गया", + reportsStatusInvalid: "अमान्य", + reportsUpdateStatusButton: "स्थिति अपडेट करें", + reportsAnonymityOption: "गुमनाम रूप से जमा करें", + reportsAnonymousAuthor: "गुमनाम", + reportsConfirmButton: "रिपोर्ट पुष्टि करें!", + reportsConfirmations: "पुष्टि", + reportsConfirmedSectionTitle: "पुष्ट रिपोर्ट", + reportsCreateTaskButton: "कार्य बनाएँ", + reportsOpenSectionTitle: "खुली रिपोर्ट", + reportsUnderReviewSectionTitle: "समीक्षाधीन रिपोर्ट", + reportsResolvedSectionTitle: "हल की गई रिपोर्ट", + reportsInvalidSectionTitle: "अमान्य रिपोर्ट", + reportsTemplateSectionTitle: 'रिपोर्ट टेम्पलेट', + reportsBugTemplateTitle: 'पुनरुत्पादन विवरण (बग)', + reportsFeatureTemplateTitle: 'विवरण (सुविधाएँ)', + reportsAbuseTemplateTitle: 'विवरण (दुरुपयोग)', + reportsContentTemplateTitle: 'विवरण (सामग्री समस्याएँ)', + reportsStepsToReproduceLabel: 'पुनरुत्पादन के चरण', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'अपेक्षित परिणाम', + reportsExpectedBehaviorPlaceholder: 'क्या होना चाहिए?', + reportsActualBehaviorLabel: 'वास्तविक परिणाम', + reportsActualBehaviorPlaceholder: 'वास्तव में क्या होता है?', + reportsEnvironmentLabel: 'वातावरण', + reportsEnvironmentPlaceholder: 'संस्करण, उपकरण, OS, ब्राउज़र, सेटिंग्स, लॉग, आदि।', + reportsReproduceRateLabel: 'पुनरुत्पादन दर', + reportsReproduceRateAlways: 'हमेशा', + reportsReproduceRateOften: 'अक्सर', + reportsReproduceRateSometimes: 'कभी-कभी', + reportsReproduceRateRarely: 'शायद ही कभी', + reportsReproduceRateUnable: 'पुनरुत्पादन करने में असमर्थ', + reportsReproduceRateUnknown: 'निर्दिष्ट नहीं', + reportsProblemStatementLabel: 'समस्या / आवश्यकता', + reportsProblemStatementPlaceholder: 'यह सुविधा कौन सी समस्या हल करती है?', + reportsUserStoryLabel: 'निवासी कहानी', + reportsUserStoryPlaceholder: 'एक <निवासी> के रूप में, मैं <कार्रवाई> चाहता हूँ, ताकि <लाभ>।', + reportsAcceptanceCriteriaLabel: 'स्वीकृति मानदंड', + reportsAcceptanceCriteriaPlaceholder: '- दिया गया...\n- जब...\n- तब...', + reportsWhatHappenedLabel: 'क्या हुआ?', + reportsWhatHappenedPlaceholder: 'संदर्भ और अनुमानित तिथियों के साथ घटना का वर्णन करें।', + reportsReportedUserLabel: 'रिपोर्ट किया गया निवासी / संस्था', + reportsReportedUserPlaceholder: '@निवासी, फ़ीड, ID, लिंक, आदि।', + reportsEvidenceLinksLabel: 'साक्ष्य / लिंक', + reportsEvidenceLinksPlaceholder: 'लिंक, संदेश IDs, स्क्रीनशॉट (यदि लागू हो), आदि पेस्ट करें।', + reportsContentLocationLabel: 'सामग्री कहाँ है', + reportsContentLocationPlaceholder: 'लिंक, ID, चैनल, थ्रेड, लेखक, आदि।', + reportsWhyInappropriateLabel: "यह अनुचित क्यों है", + reportsWhyInappropriatePlaceholder: 'कारण और प्रभाव बताएँ।', + reportsRequestedActionLabel: 'अनुरोधित कार्रवाई', + reportsRequestedActionPlaceholder: 'हटाएँ, छुपाएँ, टैग करें, चेतावनी दें, आदि।', + tribesTitle: "जनजातियाँ", + tribeAllSectionTitle: "जनजातियाँ", + tribeMineSectionTitle: "आपकी जनजातियाँ", + tribeCreateSectionTitle: "जनजाति बनाएँ", + tribeUpdateSectionTitle: "जनजाति अपडेट करें", + tribeGallerySectionTitle: "जनजाति गैलरी", + tribeRecentSectionTitle: "हाल की जनजातियाँ", + tribeTopSectionTitle: "लोकप्रिय जनजातियाँ", + tribeviewTribeButton: "जनजाति देखें", + tribeviewSubTribeButton: "उप-जनजाति देखें", + tribeRootLabel: "मूल", + tribeDescription: "अपने नेटवर्क में जनजातियाँ खोजें या बनाएँ।", + tribeFilterAll: "सभी", + tribeFilterMine: "मेरे", + tribeFilterMembership: "सदस्यता", + tribeFilterRecent: "हाल के", + tribeFilterTop: "शीर्ष", + tribeFilterSubtribes: "उप-जनजातियाँ", + tribeFilterGallery: "गैलरी", + tribeMainTribeLabel: "मुख्य जनजाति", + tribeCreateButton: "जनजाति बनाएँ", + tribeUpdateButton: "अपडेट", + tribeDeleteButton: "हटाएँ", + tribeImageLabel: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", + tribeTitleLabel: "शीर्षक", + searchTribesPlaceholder: "नाम से जनजातियाँ फ़िल्टर करें …", + tribeTitlePlaceholder: "जनजाति का नाम", + tribeDescriptionLabel: "विवरण", + tribeDescriptionPlaceholder: "इस जनजाति का वर्णन करें", + tribeLocationLabel: "स्थान", + tribeLocationPlaceholder: "यह जनजाति कहाँ स्थित है?", + tribeTagsLabel: "टैग", + tribeTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + tribeInviteMode: "आमंत्रण मोड", + tribeModeLabel: "मोड", + tribeIsAnonymousLabel: "स्थिति", + tribeMembersCount: "सदस्य", + tribeInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें", + tribeJoinByCodeButton: "कोड से जुड़ें", + tribeJoinButton: "शामिल हों", + tribeEnterInvite: "कोड दर्ज करें", + tribeLeaveButton: "छोड़ें", + tribeYes: "हाँ", + tribeNo: "नहीं", + tribePublic: "सार्वजनिक", + tribePrivate: "निजी", + tribeGenerateInvite: "एकल निमंत्रण", + tribeOpenInvitation: "खुला निमंत्रण", + tribeJoinOpen: "ट्राइब में शामिल हों", + tribeRemoveInvitation: "निमंत्रण हटाएँ", + tribeCreatedAt: "बनाया गया", + tribeAuthor: "द्वारा", + tribeAuthorLabel: "लेखक", + tribeStrict: "सख़्त", + tribeOpen: "खुला", + tribeFeedFilterRECENT: "हाल के", + tribeFeedFilterMINE: "मेरे", + tribeFeedFilterALL: "सभी", + tribeFeedFilterTOP: "शीर्ष", + tribeFeedRefeeds: "रीफ़ीड", + tribeFeedRefeed: "रीफ़ीड", + tribeFeedMessagePlaceholder: "एक फ़ीड लिखें…", + tribeFeedSend: "भेजें", + tribeFeedEmpty: "अभी तक कोई फ़ीड संदेश उपलब्ध नहीं।", + noTribes: "अभी तक कोई जनजाति नहीं मिली।", + tribeNotFound: "जनजाति नहीं मिली!", + createTribeTitle: "जनजाति बनाएँ", + updateTribeTitle: "जनजाति अपडेट करें", + tribeSectionOverview: "अवलोकन", + tribeSectionInhabitants: "निवासी", + tribeSectionVotations: "मतदान", + tribeSectionEvents: "कार्यक्रम", + tribeSectionReports: "रिपोर्ट", + tribeSectionTasks: "कार्य", + tribeSectionFeed: "फ़ीड", + tribeSectionForum: "मंच", + tribeSectionMarket: "बाज़ार", + tribeSectionJobs: "नौकरियाँ", + tribeSectionProjects: "परियोजनाएँ", + tribeSectionMedia: "मीडिया", + tribeSectionImages: "छवियाँ", + tribeSectionAudios: "ऑडियो", + tribeSectionVideos: "वीडियो", + tribeSectionDocuments: "दस्तावेज़", + tribeSectionBookmarks: "बुकमार्क", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "इस जनजाति में अभी तक कोई निवासी नहीं।", + tribeEventCreate: "कार्यक्रम बनाएँ", + tribeEventsEmpty: "अभी तक कोई कार्यक्रम नहीं।", + tribeEventTitle: "शीर्षक", + tribeEventDescription: "विवरण", + tribeEventDate: "दिनांक", + tribeEventLocation: "स्थान", + tribeEventAttend: "शामिल हों", + tribeEventUnattend: "छोड़ें", + tribeEventAttendees: "उपस्थित", + tribeTaskCreate: "कार्य बनाएँ", + tribeTasksEmpty: "अभी तक कोई कार्य नहीं।", + tribeTaskTitle: "शीर्षक", + tribeTaskDescription: "विवरण", + tribeTaskPriority: "प्राथमिकता", + tribeTaskDeadline: "समय सीमा", + tribeTaskAssignees: "सौंपे गए", + tribeTaskStatusInProgress: "प्रगति में", + tribeTaskStatusClosed: "बंद करें", + tribeTaskAssign: "सौंपें", + tribeTaskUnassign: "सौंपना रद्द करें", + tribeReportCreate: "रिपोर्ट बनाएँ", + tribeReportsEmpty: "अभी तक कोई रिपोर्ट नहीं।", + tribeReportTitle: "शीर्षक", + tribeReportDescription: "विवरण", + tribeReportCategory: "श्रेणी", + tribeVotationCreate: "मतदान बनाएँ", + tribeVotationsEmpty: "अभी तक कोई मतदान नहीं।", + tribeVotationTitle: "शीर्षक", + tribeVotationDescription: "विवरण", + tribeVotationOptions: "विकल्प", + tribeVotationDeadline: "समय सीमा", + tribeVotationVote: "मत दें", + tribeVotationResults: "मत", + tribeVotationClose: "मतदान बंद करें", + tribeVotationOptionPlaceholder: "विकल्प", + tribeForumCreate: "मंच बनाएँ", + tribeForumEmpty: "अभी तक कोई थ्रेड नहीं।", + tribeForumTitle: "शीर्षक", + tribeForumText: "संदेश", + tribeForumCategory: "श्रेणी", + tribeForumReply: "उत्तर दें", + tribeForumReplies: "उत्तर", + tribeMarketCreate: "लिस्टिंग बनाएँ", + tribeMarketEmpty: "अभी तक कोई लिस्टिंग नहीं।", + tribeMarketTitle: "शीर्षक", + tribeMarketDescription: "विवरण", + tribeMarketPrice: "मूल्य", + tribeMarketImage: "छवि", + tribeMarketCategory: "श्रेणी", + tribeJobCreate: "नौकरी बनाएँ", + tribeJobsEmpty: "अभी तक कोई नौकरी नहीं।", + tribeJobTitle: "शीर्षक", + tribeJobDescription: "विवरण", + tribeJobLocation: "स्थान", + tribeJobSalary: "वेतन", + tribeJobDeadline: "समय सीमा", + tribeProjectCreate: "परियोजना बनाएँ", + tribeProjectsEmpty: "अभी तक कोई परियोजना नहीं।", + tribeProjectTitle: "शीर्षक", + tribeProjectDescription: "विवरण", + tribeProjectGoal: "लक्ष्य", + tribeProjectFunded: "वित्तपोषित", + tribeProjectDeadline: "समय सीमा", + tribeMediaUpload: "मीडिया अपलोड करें", + readDocument: "दस्तावेज़ पढ़ें", + tribeCreateImage: "छवि बनाएँ", + tribeCreateAudio: "ऑडियो बनाएँ", + tribeCreateVideo: "वीडियो बनाएँ", + tribeCreateDocument: "दस्तावेज़ बनाएँ", + tribeCreateBookmark: "बुकमार्क बनाएँ", + tribeMediaEmpty: "अभी तक कोई मीडिया नहीं।", + tribeMediaTitle: "शीर्षक", + tribeMediaDescription: "विवरण", + tribeMediaType: "प्रकार", + tribeMediaTypeImage: "छवि", + tribeMediaTypeVideo: "वीडियो", + tribeMediaTypeAudio: "ऑडियो", + tribeMediaTypeDocument: "दस्तावेज़", + tribeMediaTypeBookmark: "बुकमार्क", + tribeContentDelete: "हटाएँ", + tribeInviteCodeText: "आमंत्रण कोड: ", + oasisVersionLabel: "Oasis संस्करण", + tribeInviteCodeHint: "इस कोड को साझा करें जिसे आप आमंत्रित करना चाहते हैं। वे /invites → Tribes से शामिल हो सकते हैं।", + tribeGroupTribe: "जनजाति", + tribeGroupOffice: "कार्यालय", + tribeGroupNetwork: "नेटवर्क", + tribeGroupEconomy: "अर्थव्यवस्था", + tribeGroupMedia: "मीडिया", + tribeStatusOpen: "खुला", + tribeStatusClosed: "बंद", + tribeStatusInProgress: "प्रगति में", + tribePriorityLow: "कम", + tribePriorityMedium: "मध्यम", + tribePriorityHigh: "उच्च", + tribePriorityCritical: "गंभीर", + tribeTaskFilterAll: "सभी", + tribeMediaFilterAll: "सभी", + tribeReportCatBug: "बग", + tribeReportCatAbuse: "दुरुपयोग", + tribeReportCatContent: "सामग्री", + tribeReportCatOther: "अन्य", + tribeForumCatGeneral: "सामान्य", + tribeForumCatProposal: "प्रस्ताव", + tribeForumCatQuestion: "प्रश्न", + tribeForumCatAnnouncement: "घोषणा", + tribeMarketCatGoods: "सामान", + tribeMarketCatServices: "सेवाएँ", + tribeMarketCatFood: "भोजन", + tribeMarketCatOther: "अन्य", + tribeStatusLabel: "स्थिति", + tribeSubTribes: "उप-जनजातियाँ", + tribeSubTribesCreate: "उप-जनजाति बनाएँ", + tribeSubTribesStrictDenied: "जनजाति का सख्त मोड आपको नई उप-जनजातियाँ बनाने की अनुमति नहीं देता। कृपया प्रशासक से संपर्क करें।", + tribeSubTribesEmpty: "अभी तक कोई उप-जनजाति नहीं बनाई गई।", + tribeActivityJoined: "शामिल हुए", + tribeActivityLeft: "छोड़ दिया", + tribeActivityFeed: "फ़ीड", + tribeActivityRefeed: "रीफ़ीड", + tribeGroupAnalytics: "विश्लेषिकी", + tribeGroupCreative: "रचनात्मक", + tribeSectionActivity: "गतिविधि", + tribeSectionTrending: "ट्रेंडिंग", + tribeSectionOpinions: "राय", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "टैग", + tribeSectionSearch: "खोज", + tribeActivityEmpty: "अभी तक कोई गतिविधि नहीं।", + tribeActivityCreated: "बनाया", + tribeActivityPosted: "पोस्ट किया", + tribeActivityReplied: "उत्तर दिया", + tribeTrendingEmpty: "अभी तक कोई ट्रेंडिंग सामग्री नहीं।", + tribeTrendingPeriodDay: "आज", + tribeTrendingPeriodWeek: "इस सप्ताह", + tribeTrendingPeriodAll: "सभी समय", + tribeTrendingEngagement: "सहभागिता", + tribeOpinionsEmpty: "अभी तक कोई राय नहीं।", + tribeOpinionsCast: "मत दें", + tribeOpinionsRankings: "रैंकिंग", + tribeOpinionsAlreadyVoted: "पहले ही मतदान किया", + tribeTopCategory: "अधिक मतदान", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "सहयोगी पिक्सेल कला कैनवास।", + tribePixeliaPaint: "रंग भरें", + tribePixeliaContributors: "योगदानकर्ता", + tribePixeliaTotalPixels: "रंगे गए पिक्सेल", + tribeTagsEmpty: "कोई टैग नहीं मिला।", + tribeTagsCloud: "टैग क्लाउड", + tribeTagsContentWith: "टैग वाली सामग्री", + tribeSearchPlaceholder: "जनजाति सामग्री खोजें...", + tribeSearchEmpty: "कोई परिणाम नहीं मिला।", + tribeSearchResults: "परिणाम", + tribeSearchMinChars: "खोजने के लिए कम से कम 2 अक्षर दर्ज करें।", + agendaTitle: "एजेंडा", + agendaDescription: "यहाँ आप अपने सभी सौंपे गए आइटम पा सकते हैं।", + agendaFilterAll: "सभी", + agendaFilterToday: "आज", + agendaFilterUpcoming: "आगामी", + agendaFilterOverdue: "अतिदेय", + agendaFilterOpen: "खुले", + agendaFilterClosed: "बंद", + agendaFilterTasks: "कार्य", + agendaFilterMarket: "बाज़ार", + agendaFilterTribes: "जनजातियाँ", + agendaFilterEvents: "कार्यक्रम", + agendaFilterReports: "रिपोर्ट", + agendaFilterTransfers: "स्थानांतरण", + agendaFilterJobs: "नौकरियाँ", + agendaFilterProjects: "परियोजनाएँ", + agendaFilterCalendars: "कैलेंडर", + agendaNoItems: "कोई सौंपे गए आइटम नहीं मिले।", + agendaAuthor: "द्वारा", + agendaDiscardButton: "निरस्त करें", + agendaRestoreButton: "पुनर्स्थापित करें", + agendaCreatedAt: "बनाया गया", + agendaTitleLabel: "शीर्षक", + agendaMembersCount: "सदस्य", + agendaDescriptionLabel: "विवरण", + agendaStatus: "स्थिति", + agendaVisibility: "दृश्यता", + agendaEventDate: "कार्यक्रम तिथि", + agendaEventLocation: "स्थान", + agendaEventPrice: "मूल्य", + agendaEventUrl: "URL", + agendaTaskStart: "प्रारंभ समय", + agendaLocationLabel: "स्थान", + agendaYes: "हाँ", + agendaNo: "नहीं", + agendaInviteModeLabel: "स्थिति", + agendaAnonymousLabel: "गुमनाम", + agendaMembersLabel: "सदस्य", + agendareportCategory: "श्रेणी", + agendareportSeverity: "गंभीरता", + agendareportStatus: "स्थिति", + agendareportDescription: "विवरण", + agendaTaskEnd: "समाप्ति समय", + agendaTaskPriority: "प्राथमिकता", + agendaTransferFrom: "से", + agendaTransferTo: "को", + agendaTransferConcept: "अवधारणा", + agendaTransferAmount: "राशि", + agendaTransferDeadline: "समय सीमा", + opinionsTitle: "राय", + shareYourOpinions: "अपने नेटवर्क में राय खोजें और मतदान करें।", + author: "द्वारा", + voteNow: "अभी मतदान करें", + alreadyVoted: "आपने पहले ही राय दे दी है।", + noOpinionsFound: "कोई राय नहीं मिली।", + RECENTButton: "हाल के", + TOPButton: "शीर्ष", + interestingButton: "दिलचस्प", + necessaryButton: "आवश्यक", + funnyButton: "मज़ेदार", + disgustingButton: "घृणित", + sensibleButton: "समझदार", + propagandaButton: "प्रचार", + adultOnlyButton: "केवल वयस्क", + boringButton: "उबाऊ", + confusingButton: "भ्रमित करने वाला", + usefulButton: "उपयोगी", + informativeButton: "सूचनाप्रद", + wellResearchedButton: "अच्छी तरह शोधित", + accurateButton: "सटीक", + needsSourcesButton: "स्रोत चाहिए", + wrongButton: "गलत", + lowQualityButton: "निम्न गुणवत्ता", + creativeButton: "रचनात्मक", + insightfulButton: "अंतर्दृष्टिपूर्ण", + actionableButton: "कार्रवाई योग्य", + inspiringButton: "प्रेरणादायक", + loveButton: "प्यार", + clearButton: "स्पष्ट", + upliftingButton: "उत्थानकारी", + unnecessaryButton: "अनावश्यक", + rejectedButton: "अस्वीकृत", + misleadingButton: "भ्रामक", + offTopicButton: "विषय से हटकर", + duplicateButton: "डुप्लिकेट", + clickbaitButton: "क्लिकबेट", + spamButton: "स्पैम", + trollButton: "ट्रोल", + nsfwButton: "NSFW", + violentButton: "हिंसक", + toxicButton: "विषाक्त", + harassmentButton: "उत्पीड़न", + hateButton: "घृणा", + scamButton: "धोखाधड़ी", + triggeringButton: "उत्तेजक", + opinionsCreatedAt: "बनाया गया", + opinionsTotalCount: "कुल राय", + voteInteresting: "दिलचस्प", + voteNecessary: "आवश्यक", + voteUseful: "उपयोगी", + voteInformative: "सूचनाप्रद", + voteWellResearched: "अच्छी तरह शोधित", + voteNeedsSources: "स्रोत चाहिए", + voteWrong: "गलत", + voteLowQuality: "निम्न गुणवत्ता", + voteLove: "प्यार", + voteClear: "स्पष्ट", + voteMisleading: "भ्रामक", + voteOffTopic: "विषय से हटकर", + voteDuplicate: "डुप्लिकेट", + voteClickbait: "क्लिकबेट", + votePropaganda: "प्रचार", + voteFunny: "मज़ेदार", + voteInspiring: "प्रेरणादायक", + voteUplifting: "उत्थानकारी", + voteUnnecessary: "अनावश्यक", + voteRejected: "अस्वीकृत", + voteConfusing: "भ्रमित करने वाला", + voteTroll: "ट्रोल", + voteNsfw: "NSFW", + voteViolent: "हिंसक", + voteToxic: "विषाक्त", + voteHarassment: "उत्पीड़न", + voteHate: "घृणा", + voteScam: "धोखाधड़ी", + voteTriggering: "उत्तेजक", + voteInsightful: "अंतर्दृष्टिपूर्ण", + voteAccurate: "सटीक", + voteActionable: "कार्रवाई योग्य", + voteCreative: "रचनात्मक", + voteSpam: "स्पैम", + voteAdultOnly: "केवल वयस्क", + publishBlog: "ब्लॉग प्रकाशित करें", + privateMessage: "PM", + pmSendTitle: "निजी संदेश", + pmSend: "भेजें!", + pmDescription: "अन्य निवासियों को एन्क्रिप्टेड संदेश भेजने के लिए इस फ़ॉर्म का उपयोग करें।", + pmRecipients: "प्राप्तकर्ता", + pmRecipientsHint: "अल्पविराम से अलग Oasis IDs दर्ज करें", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "विषय", + pmSubjectHint: "संदेश विषय दर्ज करें", + pmText: "संदेश", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "डिक्रिप्ट करें", + pmCrypterBadKey: "साझा कुंजी गलत है!", + pmCrypterTooLong: "यह संदेश Crypter से एन्क्रिप्ट करने के लिए बहुत लंबा है। कृपया इसे छोटा करके पुनः प्रयास करें।", + pmCrypterCipherLabel: "पुनः एन्क्रिप्टेड संदेश", + pmCrypterCharsLabel: "अक्षर", + pmInvalidRecipients: "अमान्य Oasis ID (यह @…=.ed25519 जैसा होना चाहिए)।", + pmEncryptionLabel: "एन्क्रिप्शन", + pmFile: "संलग्नक", + private: "निजी", + privateDescription: "आपके एन्क्रिप्टेड संदेश।", + privateInbox: "इनबॉक्स", + privateSent: "भेजे गए", + privateDelete: "हटाएँ", + pmCreateButton: "PM लिखें", + noPrivateMessages: "कोई निजी संदेश नहीं।", + pmFromLabel: "से:", + pmToLabel: "को:", + pmInvalidMessage: "अमान्य संदेश", + pmNoSubject: "(कोई विषय नहीं)", + pmSubjectLabel: "विषय:", + pmBodyLabel: "मुख्य भाग", + pmBotJobs: "42-JobsBOT", + pmBotProjects: "42-ProjectsBOT", + pmBotMarket: "42-MarketBOT", + inboxJobSubscribedTitle: "आपके नौकरी प्रस्ताव के लिए नई सदस्यता", + pmInhabitantWithId: "OASIS ID वाला निवासी:", + pmHasSubscribedToYourJobOffer: "ने आपके नौकरी प्रस्ताव की सदस्यता ली है", + inboxProjectCreatedTitle: "नई परियोजना बनाई गई", + pmHasCreatedAProject: "ने एक परियोजना बनाई है", + inboxMarketItemSoldTitle: "आइटम बिक गया", + pmYourItem: "आपका आइटम", + pmHasBeenSoldTo: "बेचा गया", + pmFor: "के लिए", + inboxProjectPledgedTitle: "आपकी परियोजना के लिए नई प्रतिज्ञा", + pmHasPledged: "ने प्रतिज्ञा की है", + pmToYourProject: "आपकी परियोजना को", + blockchain: 'BlockExplorer', + blockchainTitle: 'BlockExplorer', + blockchainDescription: 'ब्लॉकचेन में ब्लॉक खोजें और विज़ुअलाइज़ करें।', + blockchainNoBlocks: 'ब्लॉकचेन में कोई ब्लॉक नहीं मिला।', + blockchainStatsTitle: 'आँकड़े', + blockchainStatsTypeBreakdown: 'प्रकार के अनुसार वितरण', + blockchainStatsTopAuthors: 'शीर्ष लेखक', + blockchainStatsCount: 'ब्लॉक', + blockchainBlockID: 'ब्लॉक ID', + blockchainBlockAuthor: 'लेखक', + blockchainBlockCarbon: 'कार्बन फुटप्रिंट', + blockchainBlockType: 'प्रकार', + blockchainBlockTimestamp: 'टाइमस्टैम्प', + blockchainBlockContent: 'ब्लॉक', + blockchainBlockURL: 'URL:', + blockchainContent: 'ब्लॉक', + blockchainContentPreview: 'ब्लॉक सामग्री का पूर्वावलोकन', + blockchainLatestDatagram: 'नवीनतम डेटाग्राम', + blockchainDatagram: 'डेटाग्राम', + blockchainDetails: 'ब्लॉक विवरण देखें', + blockchainViewBlockexplorer: "ब्लॉकएक्सप्लोरर में देखें", + blockchainBlockInfo: 'ब्लॉक जानकारी', + blockchainBlockDetails: 'चयनित ब्लॉक का विवरण', + blockchainBack: 'BlockExplorer पर वापस जाएँ', + blockchainContentDeleted: "यह सामग्री टॉम्बस्टोन कर दी गई है", + visitContent: "सामग्री देखें", + banking: 'बैंकिंग', + bankingTitle: 'बैंकिंग', + bankingDescription: 'ECOin का वर्तमान मूल्य और संबंधित UBI आवंटन खोजें, जो भागीदारी और विश्वास के आधार पर प्रति युग वितरित होता है।', + bankOverview: 'अवलोकन', + bankEpochs: 'युग', + bankRules: 'नियम', + pending: 'लंबित', + closed: 'बंद', + bankBack: 'बैंकिंग पर वापस जाएँ', + bankViewTx: 'Tx देखें', + bankClaimNow: 'अभी दावा करें', + bankClaimUBI: 'UBI का दावा करें!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'इस अवधि के लिए कोई लंबित UBI आवंटन नहीं।', + bankEpoch: 'युग', + bankPool: 'पूल (यह युग)', + bankWeightsSum: 'भारों का योग', + bankAllocations: 'आवंटन', + bankNoAllocations: 'कोई आवंटन नहीं मिला।', + bankNoEpochs: 'कोई युग नहीं मिला।', + bankEpochAllocations: 'युग आवंटन', + bankAllocId: 'आवंटन ID', + bankAllocDate: 'दिनांक', + bankAllocConcept: 'अवधारणा', + bankAllocFrom: 'से', + bankAllocTo: 'को', + bankAllocAmount: 'राशि', + bankAllocStatus: 'स्थिति', + bankEpochId: 'युग ID', + bankRuleHash: 'नियम स्नैपशॉट हैश', + bankViewEpoch: 'युग देखें', + bankUserBalance: 'आपका शेष', + ecoWalletNotConfigured: 'ECOin वॉलेट कॉन्फ़िगर नहीं है', + editWallet: 'वॉलेट संपादित करें', + addWallet: 'वॉलेट जोड़ें', + bankAddresses: 'पते', + bankNoAddresses: 'कोई पता नहीं मिला।', + bankUser: 'Oasis ID', + bankAddress: 'पता', + bankAddAddressTitle: 'ECOIN पता जोड़ें', + bankAddAddressUser: 'Oasis ID', + bankAddAddressAddress: 'ECOIN पता', + bankAddAddressSave: 'सहेजें', + bankAddressAdded: 'पता जोड़ा गया', + bankAddressUpdated: 'पता अपडेट किया गया', + bankAddressExists: 'पता पहले से मौजूद है', + bankAddressInvalid: 'अमान्य पता', + bankAddressDeleted: 'पता हटाया गया', + bankAddressNotFound: 'पता नहीं मिला', + bankAddressForbidden: 'आप केवल अपना भुगतान पता सेट कर सकते हैं', + bankAddressTotal: 'कुल पते', + bankAddressSearch: '@निवासी या पता खोजें', + bankAddressActions: 'कार्रवाई', + bankAddressDelete: 'हटाएँ', + bankAddressSource: 'स्रोत', + bankAddressDeleteConfirm: 'यह पता हटाएँ?', + search: 'खोजें!', + bankLocal: 'स्थानीय', + bankFromOasis: 'Oasis', + bankMyAddress: 'आपका पता', + bankRemoveMyAddress: 'मेरा पता हटाएँ', + bankNotRemovableOasis: 'पते स्थानीय रूप से नहीं हटाए जा सकते', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "कोई धन नहीं!", + bankUbiAvailableOk: "उपलब्ध!", + bankUbiAvailability: "UBI उपलब्धता", + bankAlreadyClaimedThisMonth: "इस महीने पहले ही दावा किया गया", + bankUbiThisMonth: "UBI (इस महीने)", + bankUbiLastClaimed: "UBI (अंतिम दावा)", + bankUbiNeverClaimed: "कभी दावा नहीं किया", + bankUbiTotalClaimed: "UBI (कुल दावा)", + bankUbiPub: "PUB", + bankUbiInhabitant: "निवासी", + bankUbiClaimedAmount: "दावा (ECO)", + typeBankUbiResult: "बैंकिंग - UBI", + bankNoPubConfigured: "कोई PUB कॉन्फ़िगर नहीं है। सेटिंग में अपना PUB ID सेट करें।", + shopsTitle: "दुकानें", + shopDescription: "नेटवर्क में दुकानें खोजें और प्रबंधित करें।", + shopTitle: "दुकान", + shopFilterAll: "सभी", + shopFilterMine: "मेरी", + shopFilterRecent: "हालिया", + shopFilterTop: "शीर्ष", + shopFilterProducts: "उत्पाद", + shopFilterPrices: "मूल्य", + shopFilterFavorites: "पसंदीदा", + shopUpload: "दुकान बनाएं", + shopAllSectionTitle: "सभी दुकानें", + shopMineSectionTitle: "मेरी दुकानें", + shopRecentSectionTitle: "हालिया दुकानें", + shopTopSectionTitle: "शीर्ष दुकानें", + shopProductsSectionTitle: "शीर्ष उत्पाद", + shopPricesSectionTitle: "मूल्य अनुसार उत्पाद", + shopFavoritesSectionTitle: "पसंदीदा", + shopCreateSectionTitle: "दुकान बनाएं", + shopUpdateSectionTitle: "दुकान अपडेट", + shopCreate: "बनाएं", + shopUpdate: "अपडेट", + shopDelete: "हटाएं", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Clearnet पर प्रकाशित करें", + shopClearnetUnpublish: "Clearnet से हटाएं", + shopClearnetUrlLabel: "Clearnet URL", + shopClearnetWarning: "Clearnet पर प्रकाशित करने से यह दुकान बाहरी सर्च इंजन और क्रॉलर द्वारा अनुक्रमणीय हो जाती है।", + shopAddFavorite: "पसंदीदा जोड़ें", + shopRemoveFavorite: "पसंदीदा हटाएं", + shopOpen: "खुली", + shopClosed: "बंद", + shopOpenShop: "दुकान खोलें", + shopCloseShop: "दुकान बंद करें", + shopMakePrivate: "निजी बनाएं (E2E)", + shopMakePublic: "सार्वजनिक बनाएं", + shopProducts: "उत्पाद", + shopProductAdd: "उत्पाद जोड़ें", + shopProductTitle: "उत्पाद", + shopProductUpdate: "उत्पाद अपडेट", + shopProductPrice: "मूल्य (ECO)", + shopProductStock: "स्टॉक", + shopProductUntitled: "बिना शीर्षक उत्पाद", + shopUntitled: "बिना शीर्षक दुकान", + shopNoItems: "कोई दुकान नहीं मिली।", + shopNoProducts: "अभी तक कोई उत्पाद नहीं।", + shopOutOfStock: "स्टॉक में नहीं", + shopBuy: "खरीदें", + shopBuyEncryptedNote: "आपके डिलीवरी विवरण एन्क्रिप्टेड भेजे जाते हैं और केवल दुकान मालिक देख सकता है।", + shopBuyDeliveryAddress: "डिलीवरी पता", + shopBuyContact: "संपर्क", + shopBuyContactPlaceholder: "ईमेल, फोन, आदि।", + shopBuyNotes: "नोट्स", + shopBuyDeliveryRequired: "डिलीवरी पता आवश्यक है।", + shopOrdersTitle: "ऑर्डर", + shopOrdersDescription: "इस दुकान को प्राप्त ऑर्डर।", + shopOrdersEmpty: "अभी कोई ऑर्डर नहीं।", + shopOrderProduct: "उत्पाद", + shopOrderPrice: "मूल्य", + shopOrderBuyer: "खरीदार", + shopBackToShop: "दुकान पर वापस", + shopShareUrl: "शेयर URL", + shopVisitShop: "दुकान देखें", + marketVisitItem: "आइटम देखें", + shopStatus: "स्थिति", + shopCreatedAt: "बनाया गया", + shopSearchPlaceholder: "दुकानें खोजें...", + shopUrl: "URL", + shopLocation: "स्थान", + shopTags: "टैग", + shopVisibility: "दृश्यता", + shopImage: "छवि", + shopShortDescription: "संक्षिप्त विवरण", + shopShortDescriptionPlaceholder: "दुकान कार्ड के लिए संक्षिप्त विवरण (अधिकतम 160 अक्षर)", + shopTitlePlaceholder: "आपकी दुकान का नाम", + shopDescriptionPlaceholder: "आपकी दुकान का विस्तृत विवरण", + shopUrlPlaceholder: "https://aapki-dukan-url.com", + shopLocationPlaceholder: "शहर, देश", + shopTagsPlaceholder: "टैग1, टैग2, टैग3", + mapTitlePlaceholder: "मानचित्र शीर्षक", + shopProductFeatured: "विशेष उत्पाद", + shopSendToMarket: "Send to Market", + typeShop: "दुकानें", + typeShopProduct: "दुकान उत्पाद", + bankExchange: 'विनिमय', + bankExchangeCurrentValue: 'ECOin मूल्य (1h)', + bankTotalSupply: 'ECOin कुल आपूर्ति', + bankEcoinHours: "ECOin समय समतुल्यता", + bankHoursOfWork: 'घंटे', + bankUnitMs: "मि.से.", + bankUnitSeconds: "सेकंड", + bankUnitMinutes: "मिनट", + bankUnitDays: "दिन", + bankExchangeNoData: 'कोई डेटा उपलब्ध नहीं', + bankExchangeIndex: 'ECOin मूल्य (1h)', + bankInflation: 'ECOin मुद्रास्फीति (anual)', + bankInflationMonthly: 'ECOin मुद्रास्फीति (mensual)', + bankExchangeChartTitle: 'समय के साथ ECOin मूल्य की प्रगति', + bankExchangeChartValue: 'मूल्य (ECO/घंटा)', + bankExchangeChartSupply: 'आपूर्ति', + bankExchangeChartInflation: 'मुद्रास्फीति %', + bankExchangeChartEmpty: 'अभी पर्याप्त नमूने नहीं — बाद में लौटें', + bankCurrentSupply: 'ECOin वर्तमान आपूर्ति', + bankingSyncStatus: 'ECOin स्थिति', + bankingSyncStatusSynced: 'सिंक किया गया', + bankingSyncStatusOutdated: 'पुराना', + statsTitle: 'सांख्यिकी', + statistics: "सांख्यिकी", + statsInhabitant: "निवासी सांख्यिकी", + statsDescription: "अपने नेटवर्क के बारे में सांख्यिकी खोजें।", + ALLButton: "सभी", + MINEButton: "मेरे", + TOMBSTONEButton: "टॉम्बस्टोन", + statsYou: "आप", + statsUserId: "Oasis ID", + statsCreatedAt: "बनाया गया", + statsYourContent: "सामग्री", + statsYourOpinions: "राय", + statsYourTombstone: "टॉम्बस्टोन", + statsNetwork: "नेटवर्क", + statsTotalInhabitants: "निवासी", + statsDiscoveredTribes: "जनजातियाँ (सार्वजनिक)", + statsPrivateDiscoveredTribes: "जनजातियाँ (निजी)", + statsNetworkContent: "सामग्री", + statsYourMarket: "बाज़ार", + statsYourJob: "नौकरियाँ", + statsYourProject: "परियोजनाएँ", + statsYourTransfer: "स्थानांतरण", + statsYourForum: "मंच", + statsNetworkOpinions: "राय", + statsDiscoveredMarket: "बाज़ार", + statsDiscoveredJob: "नौकरियाँ", + statsDiscoveredProject: "परियोजनाएँ", + statsBankingTitle: "बैंकिंग", + statsEcoWalletLabel: "ECOIN वॉलेट", + statsEcoWalletNotConfigured: "कॉन्फ़िगर नहीं!", + statsTotalEcoAddresses: "कुल पते", + statsDiscoveredTransfer: "स्थानांतरण", + statsDiscoveredForum: "मंच", + statsNetworkTombstone: "टॉम्बस्टोन", + statsBookmark: "बुकमार्क", + statsEvent: "कार्यक्रम", + statsTask: "कार्य", + statsVotes: "मत", + statsMarket: "बाज़ार", + statsForum: "मंच", + statsJob: "नौकरियाँ", + statsReport: "रिपोर्ट", + statsFeed: "फ़ीड", + statsTribe: "जनजातियाँ", + statsImage: "छवियाँ", + statsAudio: "ऑडियो", + statsVideo: "वीडियो", + statsDocument: "दस्तावेज़", + statsMap: "मानचित्र", + statsShop: "दुकानें", + statsShopProduct: "दुकान उत्पाद", + statsTransfer: "स्थानांतरण", + statsAiExchange: "AI", + statsPUBs: 'PUBs', + statsPost: "पोस्ट", + statsOasisID: "Oasis ID", + statsSize: "कुल (आकार)", + statsBlockchainSize: "ब्लॉकचेन (आकार)", + statsBlobsSize: "ब्लॉब (आकार)", + statsActivity7d: "गतिविधि (पिछले 7 दिन)", + statsActivity7dTotal: "7-दिन कुल", + statsActivity30dTotal: "30-दिन कुल", + statsKarmaScore: "कर्म स्कोर", + statsPublic: "सार्वजनिक", + statsPrivate: "निजी", + day: "दिन", + messages: "संदेश", + statsProject: "परियोजनाएँ", + statsProjectsTitle: "परियोजनाएँ", + statsProjectsTotal: "कुल परियोजनाएँ", + statsProjectsActive: "सक्रिय", + statsProjectsCompleted: "पूर्ण", + statsProjectsPaused: "रुकी हुई", + statsProjectsCancelled: "रद्द", + statsProjectsGoalTotal: "कुल लक्ष्य", + statsProjectsPledgedTotal: "कुल प्रतिज्ञा", + statsProjectsSuccessRate: "सफलता दर", + statsProjectsAvgProgress: "औसत प्रगति", + statsProjectsMedianProgress: "मध्यांश प्रगति", + statsProjectsActiveFundingAvg: "औसत सक्रिय वित्तपोषण", + statsJobsTitle: "नौकरियाँ", + statsJobsTotal: "कुल नौकरियाँ", + statsJobsOpen: "खुली", + statsJobsClosed: "बंद", + statsJobsOpenVacants: "खुली रिक्तियाँ", + statsJobsSubscribersTotal: "कुल सदस्य", + statsJobsAvgSalary: "औसत वेतन", + statsJobsMedianSalary: "मध्यांश वेतन", + statsMarketTitle: "बाज़ार", + statsMarketTotal: "कुल आइटम", + statsMarketForSale: "बिक्री के लिए", + statsMarketReserved: "आरक्षित", + statsMarketClosed: "बंद", + statsMarketSold: "बेचा गया", + statsMarketRevenue: "राजस्व", + statsMarketAvgSoldPrice: "औसत बिक्री मूल्य", + statsUsersTitle: "निवासी", + user: "निवासी", + statsTombstoneTitle: "टॉम्बस्टोन", + statsNetworkTombstones: "नेटवर्क टॉम्बस्टोन", + statsTombstoneRatio: "टॉम्बस्टोन अनुपात (%)", + statsParliamentCandidature: "संसद उम्मीदवारी", + statsParliamentTerm: "संसद कार्यकाल", + statsParliamentProposal: "संसद प्रस्ताव", + statsParliamentRevocation: "संसद निरसन", + statsParliamentLaw: "संसद कानून", + statsCourtsCase: "न्यायालय मामले", + statsCourtsEvidence: "न्यायालय साक्ष्य", + statsCourtsAnswer: "न्यायालय उत्तर", + statsCourtsVerdict: "न्यायालय फैसले", + statsCourtsSettlement: "न्यायालय समझौते", + statsCourtsSettlementProposal: "समझौता प्रस्ताव", + statsCourtsSettlementAccepted: "स्वीकृत समझौते", + statsCourtsNomination: "न्यायाधीश नामांकन", + statsCourtsNominationVote: "नामांकन मत", + ai: "AI", + aiTitle: "AI", + aiDescription: "'42' नामक एक सामूहिक कृत्रिम बुद्धिमत्ता (CAI) जो आपके नेटवर्क से सीखती है।", + aiUserQuestion: "प्रश्न", + aiResponseTitle: "उत्तर", + aiSubmitButton: "भेजें!", + aiSettingsDescription: "AI मॉडल के लिए अपना प्रॉम्प्ट सेट करें (अधिकतम 128 अक्षर)।", + aiPrompt: "एक सूचनाप्रद और सटीक प्रतिक्रिया प्रदान करें।", + aiConfiguration: "प्रॉम्प्ट सेट करें", + aiPromptUsed: "प्रॉम्प्ट", + aiClearHistory: "चैट इतिहास साफ़ करें", + aiSharePrompt: "यह उत्तर सामूहिक प्रशिक्षण में जोड़ें?", + aiShareYes: "हाँ", + aiShareNo: "नहीं", + aiSharedLabel: "प्रशिक्षण में जोड़ा गया", + aiRejectedLabel: "प्रशिक्षण में नहीं जोड़ा गया", + aiServerError: "AI उत्तर नहीं दे सका। कृपया पुनः प्रयास करें।", + aiInputPlaceholder: "Oasis क्या है?", + typeAiExchange: "AI", + aiApproveTrain: "सामूहिक प्रशिक्षण में जोड़ें", + aiRejectTrain: "प्रशिक्षण न करें", + aiTrainPending: "अनुमोदन लंबित", + aiTrainApproved: "प्रशिक्षण के लिए अनुमोदित", + aiTrainRejected: "प्रशिक्षण के लिए अस्वीकृत", + aiSnippetsUsed: "उपयोग किए गए अंश", + aiSnippetsLearned: "सीखे गए अंश", + statsAITraining: "AI प्रशिक्षण", + aiApproveCustomTrain: "इस कस्टम उत्तर का उपयोग करके प्रशिक्षित करें", + aiCustomAnswerPlaceholder: "अपना कस्टम उत्तर लिखें…", + statsAIExchanges: "मॉडल आदान-प्रदान", + marketMineSectionTitle: "आपके आइटम", + marketCreateSectionTitle: "आइटम बनाएँ", + marketUpdateSectionTitle: "अपडेट", + marketAllSectionTitle: "बाज़ार", + marketRecentSectionTitle: "हाल का बाज़ार", + marketTitle: "बाज़ार", + marketDescription: "अपने नेटवर्क में सामान या सेवाओं के आदान-प्रदान के लिए एक बाज़ार।", + marketFilterAll: "सभी", + marketFilterMine: "मेरे", + marketFilterAuctions: "नीलामी", + marketFilterItems: "विनिमय", + marketFilterNew: "नया", + marketFilterUsed: "प्रयुक्त", + marketFilterBroken: "टूटा हुआ", + marketFilterForSale: "बिक्री के लिए", + marketFilterSold: "बिका हुआ", + marketFilterDiscarded: "निरस्त", + marketFilterRecent: "हाल के", + marketFilterMyBids: "बोलियाँ", + marketCreateButton: "आइटम बनाएँ", + marketItemType: "प्रकार", + marketItemTitle: "शीर्षक", + marketItemAvailable: "समय सीमा", + marketItemDescription: "विवरण", + marketItemDescriptionPlaceholder: "जो आइटम आप बेच रहे हैं उसका वर्णन करें", + marketItemStatus: "स्थिति", + marketShopLabel: "दुकान", + marketItemCondition: "स्थिति", + marketItemPrice: "मूल्य", + marketItemTags: "टैग", + marketItemTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + marketItemDeadline: "समय सीमा", + marketItemIncludesShipping: "शिपिंग शामिल है?", + marketItemHighestBid: "सबसे ऊँची बोली", + marketItemHighestBidder: "सबसे ऊँची बोली लगाने वाला", + marketItemStock: "स्टॉक", + marketOutOfStock: "स्टॉक में नहीं", + marketItemBidTime: "बोली समय", + marketActionsUpdate: "अपडेट", + marketUpdateButton: "अपडेट", + marketActionsDelete: "हटाएँ", + marketActionsSold: "बिका हुआ चिह्नित करें", + marketActionsChangeStatus: "स्थिति बदलें", + marketActionsBuy: "खरीदें!", + marketAuctionBids: "वर्तमान बोलियाँ", + marketPlaceBidButton: "बोली लगाएँ", + marketItemSeller: "विक्रेता", + marketNoItems: "अभी तक कोई आइटम उपलब्ध नहीं।", + marketYourBid: "आपकी बोली", + marketCreateFormImageLabel: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", + marketSearchLabel: "खोज", + marketSearchPlaceholder: "शीर्षक या टैग खोजें", + marketMinPriceLabel: "न्यूनतम मूल्य", + marketMaxPriceLabel: "अधिकतम मूल्य", + marketSortLabel: "क्रमबद्ध करें", + marketSortRecent: "सबसे हाल का", + marketSortPrice: "मूल्य", + marketSortDeadline: "समय सीमा", + marketSearchButton: "खोजें", + marketAuctionEndsIn: "समाप्त होता है", + marketAuctionEnded: "समाप्त हो गया", + marketMyBidBadge: "आपकी बोली", + marketNoItemsMatch: "आपकी खोज से कोई आइटम मेल नहीं खाता।", + jobsTitle: "नौकरियाँ", + jobsCandidatesTitle: "सुझाए गए उम्मीदवार", + jobsCandidatesDescription: "ऐसे निवासी जिनके कौशल इस नौकरी से मेल खाते हैं। उन्हें आमंत्रित करने के लिए निजी संदेश भेजें।", + jobsCandidatesPmBody: "नमस्ते, मुझे लगता है कि आपकी प्रोफ़ाइल मेरी नौकरी की पेशकश से मेल खाती है", + jobsDescription: "अपने नेटवर्क में नौकरियाँ खोजें और प्रबंधित करें।", + jobsFilterRecent: "हाल के", + jobsFilterMine: "मेरे", + jobsFilterAll: "सभी", + jobsFilterRemote: "दूरस्थ", + jobsFilterOpen: "खुले", + jobsFilterClosed: "बंद", + jobsCV: "बायोडाटा", + jobsCreateJob: "नौकरी बनाएँ", + jobsRecentTitle: "हाल की नौकरियाँ", + jobsMineTitle: "आपकी नौकरियाँ", + jobsAllTitle: "नौकरियाँ", + jobsRemoteTitle: "दूरस्थ नौकरियाँ", + jobsOpenTitle: "खुली नौकरियाँ", + jobsClosedTitle: "बंद नौकरियाँ", + jobsCVTitle: "बायोडाटा", + jobsFilterPresencial: "उपस्थित", + jobsFilterFreelancer: "फ्रीलांसर", + jobsFilterEmployee: "कर्मचारी", + jobsPresencialTitle: "उपस्थित नौकरियाँ", + jobsFreelancerTitle: "फ्रीलांस नौकरियाँ", + jobsEmployeeTitle: "कर्मचारी नौकरियाँ", + jobTitle: "शीर्षक", + jobLocation: "स्थान", + jobSalary: "वेतन (ECO/1h)", + jobVacants: "रिक्तियाँ", + jobDescription: "विवरण", + jobRequirements: "आवश्यकताएँ", + jobLanguages: "भाषाएँ", + jobStatus: "स्थिति", + jobStatusOPEN: "खुली", + jobStatusCLOSED: "बंद", + jobSetClosed: "बंद सेट करें", + jobSubscribeButton: "इस प्रस्ताव में शामिल हों!", + jobUnsubscribeButton: "इस प्रस्ताव से हटें!", + jobTitlePlaceholder: "नौकरी शीर्षक दर्ज करें", + jobDescriptionPlaceholder: "नौकरी का वर्णन करें", + jobRequirementsPlaceholder: "आवश्यकताएँ दर्ज करें", + jobLanguagesPlaceholder: "अंग्रेज़ी, स्पेनिश, फ्रेंच, बास्क...", + jobTasksPlaceholder: "कार्य सूचीबद्ध करें", + jobLocationPresencial: "स्थानीय", + jobLocationRemote: "दूरस्थ", + jobVacantsPlaceholder: "पदों की संख्या", + jobSalaryPlaceholder: "1 समर्पित घंटे के लिए ECO में वेतन", + jobImage: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", + jobTasks: "कार्य", + jobType: "नौकरी प्रकार", + jobTime: "नौकरी समय", + jobSubscribers: "सदस्य", + noSubscribers: "कोई सदस्य नहीं", + jobsFilterTop: "शीर्ष", + jobsTopTitle: "शीर्ष वेतन नौकरियाँ", + createJobButton: "नौकरी प्रकाशित करें", + viewDetailsButton: "विवरण देखें", + noJobsFound: "कोई नौकरी प्रस्ताव नहीं मिला।", + jobAuthor: "द्वारा", + jobTypeFreelance: "फ्रीलांसर", + jobTypeSalary: "कर्मचारी", + jobTypeExchange: "घंटों का आदान-प्रदान", + jobTypeEXCHANGE: "घंटों का आदान-प्रदान", + jobsFilterExchange: "आदान-प्रदान", + jobsExchangeTitle: "घंटों के आदान-प्रदान वाली नौकरियाँ", + jobsHoursOffered: "दिए गए घंटे", + jobsHoursRequested: "बदले में माँगे गए घंटे", + jobsExchangeSkill: "बदले में चाहिए कौशल", + jobsHoursOfferedPlaceholder: "जैसे 4", + jobsHoursRequestedPlaceholder: "जैसे 4", + jobsExchangeSkillPlaceholder: "जैसे बढ़ईगिरी, डिज़ाइन", + jobExchangeHint: "घंटों के आदान-प्रदान वाली नौकरियों के लिए, वेतन के बजाय नीचे के फ़ील्ड भरें।", + jobTimePartial: "अंशकालिक", + jobTimeComplete: "पूर्णकालिक", + jobsDeleteButton: "हटाएँ", + jobsUpdateButton: "अपडेट", + jobsFilterApplied: "आवेदित", + jobsAppliedTitle: "मेरे आवेदन", + jobsAppliedBadge: "आवेदित", + jobsFilterFavs: "पसंदीदा", + jobsFavsTitle: "पसंदीदा", + jobsFilterNeeds: "मदद चाहिए", + jobsNeedsTitle: "मदद चाहिए", + jobsSearchLabel: "खोज", + jobsSearchPlaceholder: "शीर्षक, टैग, विवरण खोजें...", + jobsMinSalaryLabel: "न्यूनतम वेतन", + jobsMaxSalaryLabel: "अधिकतम वेतन", + jobsSortLabel: "क्रमबद्ध करें", + jobsSortRecent: "सबसे हाल का", + jobsSortSalary: "सबसे अधिक वेतन", + jobsSortSubscribers: "सबसे अधिक आवेदक", + jobsSearchButton: "खोजें", + jobsFavoriteButton: "पसंदीदा", + jobsUnfavoriteButton: "पसंदीदा हटाएँ", + jobsMessageAuthorButton: "PM", + jobsApplicants: "आवेदक", + jobsUpdatedAt: "अपडेट किया गया", + jobSetOpen: "खुली सेट करें", + jobNewBadge: "नया", + jobsTagsLabel: "टैग", + jobsTagsPlaceholder: "tag1, tag2, tag3", + noJobsMatch: "आपकी खोज से कोई नौकरी मेल नहीं खाती।", + projectsTitle: "परियोजनाएँ", + projectsDescription: "अपने नेटवर्क में समुदाय-संचालित परियोजनाएँ बनाएँ, वित्तपोषित करें और अनुसरण करें।", + projectCreateProject: "परियोजना बनाएँ", + projectCreateButton: "परियोजना बनाएँ", + projectUpdateButton: "अपडेट", + projectDeleteButton: "हटाएँ", + projectNoProjectsFound: "कोई परियोजना नहीं मिली।", + projectFilterAll: "सभी", + projectFilterMine: "मेरे", + projectFilterActive: "सक्रिय", + projectFilterPaused: "रुकी हुई", + projectFilterCompleted: "पूर्ण", + projectFilterFollowing: "अनुसरण कर रहे हैं", + projectFilterRecent: "हाल के", + projectFilterTop: "शीर्ष", + projectAllTitle: "परियोजनाएँ", + projectMineTitle: "आपकी परियोजनाएँ", + projectActiveTitle: "सक्रिय परियोजनाएँ", + projectPausedTitle: "रुकी हुई परियोजनाएँ", + projectCompletedTitle: "पूर्ण परियोजनाएँ", + projectFollowingTitle: "अनुसरण कर रही परियोजनाएँ", + projectRecentTitle: "हाल की परियोजनाएँ", + projectTopTitle: "शीर्ष वित्तपोषित", + projectTitlePlaceholder: "परियोजना का नाम", + projectImage: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", + projectDescription: "विवरण", + projectDescriptionPlaceholder: "कहानी और लक्ष्य बताएँ…", + projectGoal: "लक्ष्य (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "समय सीमा", + projectProgress: "प्रारंभिक प्रगति (%)", + projectStatus: "स्थिति", + projectFunding: "वित्तपोषण", + projectPledged: "प्रतिज्ञा", + projectSetStatus: "स्थिति सेट करें", + projectSetProgress: "प्रगति अपडेट करें", + projectFollowButton: "अनुसरण करें", + projectUnfollowButton: "अनुसरण बंद करें", + projectStatusACTIVE: "सक्रिय", + projectStatusPAUSED: "रुकी हुई", + projectStatusCOMPLETED: "पूर्ण", + projectStatusCANCELLED: "रद्द", + projectPledgeTitle: "इस परियोजना का समर्थन करें", + projectPledgePlaceholder: "ECO में राशि", + projectBounties: "इनाम", + projectBountiesInputLabel: "इनाम (प्रति पंक्ति एक: शीर्षक|राशि [ECO]|विवरण)", + projectBountiesPlaceholder: "UI बग ठीक करें|100|समस्या का लिंक\nडॉक्स लिखें|250|उपयोग उदाहरण रूपरेखा", + projectNoBounties: "कोई इनाम नहीं मिला।", + projectTitle: "शीर्षक", + projectAddBountyTitle: "नया इनाम", + projectBountyTitle: "इनाम शीर्षक", + projectBountyAmount: "राशि (ECO)", + projectBountyDescription: "विवरण", + projectMilestoneSelect: "मील का पत्थर चुनें", + projectBountyCreateButton: "इनाम बनाएँ", + projectBountyStatus: "इनाम स्थिति", + projectBountyOpen: "खुला", + projectBountyClaimed: "दावा किया गया", + projectBountyDone: "पूर्ण", + projectBountyClaimedBy: "द्वारा दावा किया गया", + projectBountyClaimButton: "दावा करें", + projectBountyCompleteButton: "पूर्ण चिह्नित करें", + projectMilestones: "मील के पत्थर", + projectAddMilestoneTitle: "नया मील का पत्थर", + projectMilestoneTitle: "मील का पत्थर शीर्षक", + projectMilestoneTargetPercent: "प्रतिशत (%)", + projectMilestoneDueDate: "दिनांक", + projectMilestoneCreateButton: "मील का पत्थर बनाएँ", + projectMilestoneStatus: "मील का पत्थर स्थिति", + projectMilestoneOpen: "खुला", + projectMilestoneDone: "पूर्ण", + projectMilestoneDue: "देय", + projectNoMilestones: "कोई मील का पत्थर नहीं मिला।", + projectMilestoneMarkDone: "पूर्ण चिह्नित करें", + projectMilestoneTitlePlaceholder: "मील का पत्थर शीर्षक दर्ज करें", + projectMilestoneDescriptionPlaceholder: "इस मील के पत्थर का विवरण दर्ज करें", + projectMilestoneDescription: "मील का पत्थर विवरण", + projectBudgetGoal: "बजट (लक्ष्य)", + projectBudgetAssigned: "इनामों को सौंपा गया", + projectBudgetRemaining: "शेष", + projectBudgetOver: "बजट से अधिक: सौंपा गया लक्ष्य से अधिक है", + projectFollowers: "अनुसरणकर्ता", + projectFollowersTitle: "अनुसरणकर्ता", + projectFollowersNone: "अभी तक कोई अनुसरणकर्ता नहीं।", + projectMore: "अधिक", + projectYouFollowHint: "आप इस परियोजना का अनुसरण करते हैं", + projectBackers: "समर्थक", + projectBackersTitle: "समर्थक", + projectBackersTotal: "कुल समर्थक", + projectBackersTotalPledged: "कुल प्रतिज्ञा", + projectBackersYourPledge: "आपकी प्रतिज्ञा", + projectBackersNone: "अभी तक कोई प्रतिज्ञा नहीं।", + projectNoRemainingBudget: "कोई शेष बजट नहीं।", + projectFilterBackers: "समर्थक", + projectFilterApplied: "आवेदित", + projectAppliedTitle: "आवेदित", + projectBackersLeaderboardTitle: "शीर्ष समर्थक", + projectNoBackersFound: "कोई समर्थक नहीं मिला।", + projectBackerAmount: "कुल योगदान", + projectBackerPledges: "प्रतिज्ञाएँ", + projectBackerProjects: "परियोजनाएँ", + projectPledgeAmount: "राशि", + projectSelectMilestoneOrBounty: "मील का पत्थर या इनाम चुनें", + projectPledgeButton: "प्रतिज्ञा करें", + footerLicense: "GPLv3", + footerPackage: "पैकेज", + footerVersion: "संस्करण", + modulesModuleName: "नाम", + modulesModuleDescription: "विवरण", + modulesModuleStatus: "स्थिति", + modulesTotalModulesLabel: "लोड किए गए मॉड्यूल", + modulesEnabledModulesLabel: "सक्षम", + modulesDisabledModulesLabel: "अक्षम", + modulesPopularLabel: "लोकप्रिय", + modulesPopularDescription: "ट्रेंडिंग, सबसे अधिक देखी गई या सबसे अधिक टिप्पणी की गई पोस्ट प्राप्त करने का मॉड्यूल।", + modulesTopicsLabel: "विषय", + modulesTopicsDescription: "साझा रुचियों पर आधारित चर्चा श्रेणियाँ प्राप्त करने का मॉड्यूल।", + modulesSummariesLabel: "सारांश", + modulesSummariesDescription: "लंबी चर्चाओं या पोस्ट के सारांश प्राप्त करने का मॉड्यूल।", + modulesLatestLabel: "नवीनतम", + modulesLatestDescription: "सबसे हाल की पोस्ट और चर्चाएँ प्राप्त करने का मॉड्यूल।", + modulesThreadsLabel: "थ्रेड्स", + modulesThreadsDescription: "विषय या प्रश्न के अनुसार समूहीकृत बातचीत प्राप्त करने का मॉड्यूल।", + modulesMultiverseLabel: "मल्टीवर्स", + modulesMultiverseDescription: "अन्य संघीय पीयर से सामग्री प्राप्त करने का मॉड्यूल।", + modulesInvitesLabel: "आमंत्रण", + modulesInvitesDescription: "आमंत्रण कोड प्रबंधित और लागू करने का मॉड्यूल।", + modulesWalletLabel: "वॉलेट", + modulesWalletDescription: "अपनी डिजिटल संपत्तियाँ (ECOin) प्रबंधित करने का मॉड्यूल।", + modulesLegacyLabel: "विरासत", + modulesLegacyDescription: "अपनी गुप्त कुंजी (निजी कुंजी) को जल्दी और सुरक्षित रूप से प्रबंधित करने का मॉड्यूल।", + modulesCipherLabel: "सिफर", + modulesCipherDescription: "अपने टेक्स्ट को सममित रूप से एन्क्रिप्ट और डिक्रिप्ट करने का मॉड्यूल (साझा पासवर्ड का उपयोग करके)।", + modulesBookmarksLabel: "बुकमार्क", + modulesBookmarksDescription: "बुकमार्क खोजने और प्रबंधित करने का मॉड्यूल।", + modulesVideosLabel: "वीडियो", + modulesVideosDescription: "वीडियो खोजने और प्रबंधित करने का मॉड्यूल।", + modulesDocsLabel: "दस्तावेज़", + modulesDocsDescription: "दस्तावेज़ खोजने और प्रबंधित करने का मॉड्यूल।", + modulesAudiosLabel: "ऑडियो", + modulesAudiosDescription: "ऑडियो खोजने और प्रबंधित करने का मॉड्यूल।", + modulesTagsLabel: "टैग", + modulesTagsDescription: "वर्गीकरण पैटर्न (टैग) खोजने और अन्वेषण करने का मॉड्यूल।", + modulesImagesLabel: "छवियाँ", + modulesImagesDescription: "छवियाँ खोजने और प्रबंधित करने का मॉड्यूल।", + modulesTrendingLabel: "ट्रेंडिंग", + modulesTrendingDescription: "सबसे लोकप्रिय सामग्री खोजने का मॉड्यूल।", + modulesEventsLabel: "कार्यक्रम", + modulesEventsDescription: "कार्यक्रम खोजने और प्रबंधित करने का मॉड्यूल।", + modulesTasksLabel: "कार्य", + modulesTasksDescription: "कार्य खोजने और प्रबंधित करने का मॉड्यूल।", + modulesMarketLabel: "बाज़ार", + modulesMarketDescription: "सामान या सेवाओं के आदान-प्रदान का मॉड्यूल।", + modulesShopsLabel: "दुकानें", + modulesShopsDescription: "दुकानों को प्रबंधित और खोजने के लिए मॉड्यूल।", + modulesTribesLabel: "जनजातियाँ", + modulesTribesDescription: "जनजातियाँ (समूह) खोजने या बनाने का मॉड्यूल।", + modulesVotationsLabel: "मतदान", + modulesVotationsDescription: "मतदान खोजने और प्रबंधित करने का मॉड्यूल।", + modulesReportsLabel: "रिपोर्ट", + modulesReportsDescription: "मुद्दों, बग, दुरुपयोग और सामग्री चेतावनियों से संबंधित रिपोर्ट प्रबंधित और ट्रैक करने का मॉड्यूल।", + modulesOpinionsLabel: "राय", + modulesOpinionsDescription: "राय खोजने और मतदान करने का मॉड्यूल।", + modulesTransfersLabel: "स्थानांतरण", + modulesTransfersDescription: "स्मार्ट-कॉन्ट्रैक्ट (स्थानांतरण) खोजने और प्रबंधित करने का मॉड्यूल।", + modulesFediverseLabel: "फ़ेडिवर्स", + modulesFediverseDescription: "अपने अन्य फ़ेडिवर्स खातों को प्रबंधित करें, जिसमें सामग्री भेजना और प्राप्त करना शामिल है।", + modulesFeedLabel: "फ़ीड", + modulesFeedDescription: "लघु-पाठ (फ़ीड) खोजने और साझा करने का मॉड्यूल।", + modulesParliamentLabel: "संसद", + modulesParliamentDescription: "सरकारों का चुनाव करने और कानूनों पर मतदान करने का मॉड्यूल।", + modulesCourtsLabel: "न्यायालय", + modulesCourtsDescription: "विवादों को हल करने और फैसले जारी करने का मॉड्यूल।", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "सहयोगी ग्रिड पर चित्र बनाने का मॉड्यूल।", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "आपकी ब्लॉकचेन की \"ध्वनि\" उत्पन्न करने और साझा करने के लिए मॉड्यूल।", + modulesAgendaLabel: "एजेंडा", + modulesAgendaDescription: "अपने सभी सौंपे गए आइटम प्रबंधित करने का मॉड्यूल।", + modulesAILabel: "AI", + modulesAIDescription: "'42' नामक LLM से बात करने का मॉड्यूल।", + modulesForumLabel: "मंच", + modulesForumDescription: "मंच खोजने और प्रबंधित करने का मॉड्यूल।", + modulesJobsLabel: "नौकरियाँ", + modulesJobsDescription: "नौकरियाँ खोजने और प्रबंधित करने का मॉड्यूल।", + modulesProjectsLabel: "परियोजनाएँ", + modulesProjectsDescription: "परियोजनाएँ खोजने, क्राउड-फंडिंग करने और प्रबंधित करने का मॉड्यूल।", + modulesBankingLabel: "बैंकिंग", + modulesBankingDescription: "ECOIN का वास्तविक मूल्य निर्धारित करने और सामान्य कोषागार का उपयोग करके UBI वितरित करने का मॉड्यूल।", + modulesFavoritesLabel: "पसंदीदा", + modulesFavoritesDescription: "अपनी पसंदीदा सामग्री प्रबंधित करने का मॉड्यूल।", + fileTooLargeTitle: "फ़ाइल बहुत बड़ी", + fileTooLargeMessage: "फ़ाइल अधिकतम अनुमत आकार (50 MB) से अधिक है। कृपया एक छोटी फ़ाइल चुनें।", + goBack: "वापस जाएँ", + errorPageTitle: "कुछ गलत हो गया", + visibilityLabel: "दृश्यता", + visibilityPublic: "सार्वजनिक", + visibilityHidden: "छिपा हुआ", + visibilityMakePublic: "सार्वजनिक करें", + visibilityMakeHidden: "छिपाएँ", + invitesInhabitantsTitle: "निवासी", + invitesInhabitantsFollow: "फॉलो करें", + aiNavPlaceholder: "आप कहाँ जाना चाहते हैं?", + aiNavDisabled: "AI नेविगेशन अक्षम है।", + aiNavResultsTitle: "AI नेविगेशन सुझाव", + aiNavQueryLabel: "प्रश्न", + aiNavResultsDescription: "अपने अनुरूप मार्ग चुनें।", + aiNavResultPath: "मार्ग", + aiNavResultDescription: "क्या किया जा सकता है", + aiNavResultMatch: "मेल", + aiNavResultsEmpty: "कोई मेल खाता मार्ग नहीं। /search आज़माएँ।", + aiNavSearchInstead: "इसके बजाय खोजें", + modulesAINavLabel: "AINav", + modulesAINavDescription: "नेटवर्क की सामग्री पर प्राकृतिक भाषा में प्रश्न करने का मॉड्यूल।", + directConnect: "सीधा कनेक्शन", + directConnectDescription: "किसी पीयर से सीधे कनेक्ट करने के लिए उनका IP पता, पोर्ट और सार्वजनिक कुंजी दर्ज करें। पीयर को एक अनुसरित कनेक्शन के रूप में जोड़ा जाएगा।", + peerHost: "IP", + peerPort: "पोर्ट (डिफ़ॉल्ट: 8008)", + peerPublicKey: "सार्वजनिक कुंजी (@...ed25519)", + connectAndFollow: "कनेक्ट करें", + deviceSourceLabel: "डिवाइस स्रोत", + modulesPresetTitle: "सामान्य कॉन्फ़िगरेशन", + modulesPreset_minimal: "न्यूनतम", + modulesPreset_basic: "बुनियादी", + modulesPreset_social: "सामाजिक", + modulesPreset_economy: "अर्थव्यवस्था", + modulesPreset_full: "पूर्ण", + statsCarbonFootprintTitle: "कार्बन फुटप्रिंट", + statsCarbonFootprintNetwork: "नेटवर्क कार्बन फुटप्रिंट", + statsCarbonFootprintYours: "आपका कार्बन फुटप्रिंट", + statsCarbonTombstone: "टॉम्बस्टोनिंग फुटप्रिंट", + feedSuccessMsg: "फ़ीड सफलतापूर्वक प्रकाशित!", + dominantOpinionLabel: "प्रमुख राय", + uploadMedia: "मीडिया अपलोड करें (अधिकतम-आकार: 50MB)", + feedOpenDiscussion: "चर्चा खोलें", + feedDetailTitle: "फ़ीड", + feedPostComment: "टिप्पणी पोस्ट करें", + courtsRespondentInvalid: "एक वैध SSB ID होनी चाहिए (@...ed25519)", + noComments: "अभी तक कोई टिप्पणी नहीं", + mapsLabel: "मानचित्र", + mapTitle: "मानचित्र", + mapDescription: "अपने नेटवर्क में ऑफ़लाइन मानचित्र खोजें और प्रबंधित करें।", + mapMineSectionTitle: "आपके मानचित्र", + mapCreateSectionTitle: "मानचित्र बनाएं", + mapUpdateSectionTitle: "मानचित्र अपडेट करें", + mapAllSectionTitle: "मानचित्र", + mapRecentSectionTitle: "हाल के मानचित्र", + mapFavoritesSectionTitle: "पसंदीदा", + mapFilterAll: "सभी", + mapFilterMine: "मेरे", + mapFilterRecent: "हाल के", + mapFilterFavorites: "पसंदीदा", + mapUploadButton: "मानचित्र बनाएं", + mapCreateButton: "मानचित्र बनाएं", + mapUpdateButton: "अपडेट करें", + mapDeleteButton: "हटाएं", + mapAddFavoriteButton: "पसंदीदा में जोड़ें", + mapRemoveFavoriteButton: "पसंदीदा से हटाएं", + mapLatLabel: "अक्षांश", + mapLatPlaceholder: "उदा. 28.6139", + mapLngLabel: "देशांतर", + mapLngPlaceholder: "उदा. 77.2090", + mapDescriptionLabel: "विवरण", + mapDescriptionPlaceholder: "मानचित्र या स्थान का वर्णन करें...", + mapTypeLabel: "मानचित्र प्रकार", + mapTypeSingle: "एकल (केवल प्रारंभिक स्थान)", + mapTypeOpen: "खुला (कोई भी मार्कर जोड़ सकता है)", + mapTypeClosed: "बंद (केवल निर्माता मार्कर जोड़ता है)", + mapTagsLabel: "टैग", + mapTagsPlaceholder: "अल्पविराम से अलग टैग दर्ज करें", + mapUrlLabel: "मानचित्र URL", + mapPickCoordLabel: "या ग्रिड पर स्थान चुनें:", + mapMarkersLabel: "मार्कर", + mapMarkersTitle: "मार्कर", + mapMarkerDefault: "मार्कर", + mapMarkerLatLabel: "मार्कर अक्षांश", + mapMarkerLngLabel: "मार्कर देशांतर", + mapMarkerLabelField: "मार्कर लेबल", + mapMarkerLabelPlaceholder: "इस मार्कर का वर्णन करें...", + markerImageLabel: "मार्कर छवि", + mapAddMarkerTitle: "मार्कर जोड़ें", + mapAddMarkerButton: "मार्कर जोड़ें", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "ज़ूम लागू करें", + mapSearchPlaceholder: "विवरण, टैग, लेखक खोजें...", + mapSearchButton: "खोजें", + mapUpdatedAt: "अपडेट किया गया", + mapNoMatch: "आपकी खोज से मेल खाने वाला कोई मानचित्र नहीं।", + noMaps: "कोई मानचित्र उपलब्ध नहीं।", + mapLocationTitle: "स्थान", + mapVisitLabel: "मानचित्र देखें", + typeMap: "मानचित्र", + typeMapMarker: "मानचित्र मार्कर", + modulesMapLabel: "मानचित्र", + modulesMapDescription: "ऑफ़लाइन मानचित्रों को प्रबंधित और साझा करने का मॉड्यूल।", + padsTitle: "पैड्स", + padTitle: "पैड", + modulesPadsLabel: "पैड्स", + modulesPadsDescription: "सहयोगी पाठ संपादकों को प्रबंधित करने का मॉड्यूल।", + padFilterAll: "सभी", + padFilterMine: "मेरे", + padFilterRecent: "हालिया", + padFilterOpen: "खुला", + padFilterClosed: "बंद", + padCreate: "पैड बनाएं", + padUpdate: "पैड अपडेट करें", + padDelete: "पैड हटाएं", + padTitleLabel: "शीर्षक", + padTitlePlaceholder: "पैड का शीर्षक दर्ज करें...", + padStatusLabel: "स्थिति", + padStatusOpen: "खुला", + padStatusInviteOnly: "केवल आमंत्रण", + padStatusClosed: "बंद", + padDeadlineLabel: "समय सीमा", + padTagsLabel: "टैग", + padTagsPlaceholder: "टैग1, टैग2, ...", + padMembersLabel: "सदस्य", + padVisitPad: "पैड देखें", + padShareUrl: "URL साझा करें", + padCreated: "बनाया गया", + padAuthor: "लेखक", + padGenerateCode: "कोड बनाएं", + padInviteCodeLabel: "आमंत्रण कोड", + padInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें...", + padValidateInvite: "सत्यापित करें", + padStartEditing: "संपादन शुरू करें!", + padEditorPlaceholder: "लिखना शुरू करें...", + padSubmitEntry: "सबमिट करें", + padNoEntries: "अभी कोई प्रविष्टि नहीं।", + padAllSectionTitle: "सभी पैड्स", + padMineSectionTitle: "मेरे पैड्स", + padsDescription: "अपने नेटवर्क में सहयोगी एन्क्रिप्टेड टेक्स्ट संपादक प्रबंधित करें।", + padRecentSectionTitle: "हालिया पैड्स", + padOpenSectionTitle: "खुले पैड्स", + padClosedSectionTitle: "बंद पैड्स", + padCreateSectionTitle: "नया पैड बनाएं", + padUpdateSectionTitle: "पैड अपडेट करें", + padInviteGenerated: "आमंत्रण कोड बनाया गया", + typePad: "पैड्स", + padNew: "नया", + padAddFavorite: "पसंदीदा में जोड़ें", + padRemoveFavorite: "पसंदीदा से हटाएं", + padClose: "पैड बंद करें", + padBackToEditor: "संपादक पर वापस", + padSearchPlaceholder: "पैड खोजें...", + + modulesChatsLabel: "चैट्स", + modulesChatsDescription: "एन्क्रिप्टेड चैट खोजने और प्रबंधित करने का मॉड्यूल।", + typeChat: "चैट्स", + typeChatMessage: "चैट संदेश", + chatLabel: "चैट्स", + chatMessageLabel: "चैट संदेश", + chatsTitle: "चैट्स", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "पसंदीदा", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "विवरण", + chatCategory: "श्रेणी", + chatStatus: "स्थिति", + chatFilterAll: "सभी", + chatFilterMine: "मेरे", + chatFilterRecent: "हाल के", + chatFilterFavorites: "पसंदीदा", + chatFilterOpen: "खुले", + chatFilterClosed: "बंद", + chatCreate: "चैट बनाएं", + chatUpdate: "चैट अपडेट करें", + chatDelete: "चैट हटाएं", + chatClose: "चैट बंद करें", + chatVisitChat: "चैट देखें", + chatUntitled: "बिना शीर्षक चैट", + chatNoItems: "कोई चैट नहीं मिली।", + chatParticipants: "प्रतिभागी", + chatStartChatting: "चैट शुरू करें!", + chatGenerateCode: "कोड बनाएं", + chatShareUrl: "URL शेयर करें", + chatCreatedAt: "बनाया गया", + chatSearchPlaceholder: "चैट खोजें...", + chatStatusOpen: "खुला", + chatStatusInviteOnly: "केवल आमंत्रण", + chatStatusClosed: "बंद", + chatSendMessage: "भेजें", + chatMessagePlaceholder: "संदेश लिखें...", + chatNoMessages: "अभी तक कोई संदेश नहीं।", + chatLeave: "चैट छोड़ें", + chatInviteCodeLabel: "आमंत्रण कोड दर्ज करें", + chatJoinByInvite: "शामिल हों", + chatTitlePlaceholder: "चैट का शीर्षक", + chatDescriptionPlaceholder: "चैट का विवरण", + chatTagsPlaceholder: "टैग1, टैग2", + chatImageLabel: "छवि फ़ाइल चुनें (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "आमंत्रण कोड", + chatAuthor: "लेखक", + chatCreated: "बनाया", + chatAddFavorite: "पसंदीदा में जोड़ें", + chatRemoveFavorite: "पसंदीदा से हटाएं", + chatPM: "PM", + chatStatusLabel: "स्थिति", + chatCategoryLabel: "श्रेणी", + chatParticipantsLabel: "प्रतिभागी", + gamesTitle: "खेल", + gamesDescription: "कुछ खेल खोजें और खेलें।", + gamesFilterAll: "सभी", + gamesPlayButton: "खेलें!", + gamesBackToGames: "खेलों पर वापस जाएं", + modulesGamesLabel: "खेल", + modulesGamesDescription: "कुछ खेल खोजने और खेलने का मॉड्यूल।", + modulesGraphosLabel: "ग्राफोस", + modulesGraphosDescription: "नोड्स के इंटरैक्टिव मानचित्र के रूप में नेटवर्क का अन्वेषण करने का मॉड्यूल।", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "PUBs को validators, shops और accumulators के ज़रिए habitants से जोड़ें। CBDC के खतरे से बचें!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "ग्रिड में घुसें, गोपनीय डेटा इकट्ठा करें, सुरक्षा ड्रोन से बचें और भागें. कितने लेवल साफ कर सकते हैं?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "एलियन आक्रमण रोकें! आक्रमणकारियों की लहरों को मार गिराएं।", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "अपने पैडल और गेंद से सभी ईंटें तोड़ें। एक क्लासिक आर्केड चुनौती।", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "AI के खिलाफ क्लासिक पिंग-पोंग। 5 पॉइंट पहले पाने वाला जीतता है।", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "समय के खिलाफ दौड़! यातायात से बचें और समय से पहले मंजिल पर पहुंचें।", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "क्षुद्रग्रह क्षेत्र में अपना यान उड़ाएं। उन्हें आने से पहले नष्ट करें।", + gamesRockPaperScissorsTitle: "पत्थर कागज कैंची", + gamesRockPaperScissorsDesc: "AI के खिलाफ पत्थर, कागज या कैंची। तीन में से दो जीतने वाला विजेता।", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "AI के खिलाफ क्लासिक टिक-टैक-टो। तीन एक पंक्ति में जीत है।", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "एक सिक्का उछालें और हेड या टेल पर दांव लगाएं। आप कितने भाग्यशाली हैं?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "कैलेंडर", + calendarTitle: "कैलेंडर", + modulesCalendarsLabel: "कैलेंडर", + modulesCalendarsDescription: "कैलेंडर खोजने और प्रबंधित करने का मॉड्यूल।", + typeCalendar: "कैलेंडर", + calendarFilterAll: "सभी", + calendarFilterMine: "मेरे", + calendarFilterOpen: "खुला", + calendarFilterClosed: "बंद", + calendarFilterRecent: "हाल के", + calendarFilterFavorites: "पसंदीदा", + calendarCreate: "कैलेंडर बनाएं", + calendarUpdate: "अपडेट करें", + calendarDelete: "हटाएं", + calendarTitleLabel: "शीर्षक", + calendarTitlePlaceholder: "कैलेंडर शीर्षक...", + calendarStatusLabel: "स्थिति", + calendarStatusOpen: "खुला", + calendarStatusClosed: "बंद", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "टैग", + calendarTagsPlaceholder: "टैग1, टैग2...", + calendarParticipantsLabel: "प्रतिभागी", + calendarParticipantsCount: "प्रतिभागी", + calendarVisitCalendar: "कैलेंडर देखें", + calendarCreated: "बनाया गया", + calendarAuthor: "लेखक", + calendarJoin: "जुड़ें", + calendarGenerateInvite: "आमंत्रण बनाएं", + calendarInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें...", + calendarValidateInvite: "कोड सत्यापित करें", + calendarJoined: "जुड़े हुए", + calendarAddDate: "तारीख जोड़ें", + calendarAddNote: "नोट जोड़ें", + calendarDateLabel: "तारीख", + calendarDatePlaceholder: "इस तारीख का वर्णन करें...", + calendarNoteLabel: "नोट", + calendarNotePlaceholder: "नोट जोड़ें...", + calendarFirstDateLabel: "तारीख", + calendarFirstNoteLabel: "नोट्स", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "विवरण", + calendarNoDates: "कोई तारीख नहीं जोड़ी गई।", + calendarNoNotes: "कोई नोट नहीं।", + calendarsNoItems: "कोई कैलेंडर नहीं मिला।", + calendarsDescription: "अपने नेटवर्क में कैलेंडर खोजें और प्रबंधित करें।", + calendarMonthPrev: "← पिछला", + calendarMonthNext: "अगला →", + calendarMonthLabel: "तारीखें", + calendarsShareUrl: "शेयर URL", + calendarAllSectionTitle: "कैलेंडर", + calendarRecentSectionTitle: "हाल के कैलेंडर", + calendarFavoritesSectionTitle: "पसंदीदा", + calendarMineSectionTitle: "आपके कैलेंडर", + calendarOpenSectionTitle: "खुले कैलेंडर", + calendarClosedSectionTitle: "बंद कैलेंडर", + calendarCreateSectionTitle: "नया कैलेंडर बनाएं", + calendarUpdateSectionTitle: "कैलेंडर अपडेट करें", + calendarAddFavorite: "पसंदीदा में जोड़ें", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "पसंदीदा से हटाएं", + calendarSearchPlaceholder: "कैलेंडर खोजें...", + calendarAddEntry: "प्रविष्टि जोड़ें", + calendarLeave: "कैलेंडर छोड़ें", + statsCalendar: "कैलेंडर", + statsCalendarDate: "कैलेंडर तारीखें", + statsCalendarNote: "कैलेंडर नोट", + chatAccessDenied: "आपके पास इस चैट तक पहुंच नहीं है। सामग्री तक पहुंचने के लिए आमंत्रण मांगें।", + padAccessDenied: "आपके पास इस पैड तक पहुंच नहीं है। सामग्री तक पहुंचने के लिए आमंत्रण मांगें।", + contentAccessDenied: "आपके पास इस सामग्री तक पहुंच नहीं है।", + blockAccessRestricted: "पहुंच प्रतिबंधित", + tribeContentAccessDenied: "पहुंच अस्वीकृत", + tribeContentAccessDeniedMsg: "यह सामग्री एक जनजाति की है। इसे देखने के लिए आपको सदस्य होना चाहिए।", + tribeViewTribes: "जनजातियाँ देखें", + torrentsTitle: "टॉरेंट", + torrentsDescription: "अपने नेटवर्क में टॉरेंट खोजें और प्रबंधित करें।", + torrentAllSectionTitle: "टॉरेंट", + torrentMineSectionTitle: "आपके टॉरेंट", + torrentRecentSectionTitle: "हालिया टॉरेंट", + torrentTopSectionTitle: "शीर्ष टॉरेंट", + torrentFavoritesSectionTitle: "पसंदीदा", + torrentFilterAll: "सभी", + torrentFilterMine: "मेरे", + torrentFilterRecent: "हालिया", + torrentFilterTop: "शीर्ष", + torrentFilterFavorites: "पसंदीदा", + torrentCreateSectionTitle: "टॉरेंट अपलोड करें", + torrentUpdateSectionTitle: "टॉरेंट अपडेट करें", + torrentCreateButton: "टॉरेंट अपलोड करें", + torrentUpdateButton: "अपडेट करें", + torrentDeleteButton: "हटाएं", + torrentAddFavoriteButton: "पसंदीदा में जोड़ें", + torrentRemoveFavoriteButton: "पसंदीदा से हटाएं", + torrentFileLabel: "टॉरेंट फ़ाइल चुनें (.torrent)", + torrentTitleLabel: "शीर्षक", + torrentTitlePlaceholder: "शीर्षक", + torrentDescriptionLabel: "विवरण", + torrentDescriptionPlaceholder: "विवरण", + torrentTagsLabel: "टैग", + torrentTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें", + torrentSizeLabel: "आकार", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "कोई टॉरेंट फ़ाइल नहीं", + noTorrents: "कोई टॉरेंट नहीं मिला।", + torrentSearchPlaceholder: "शीर्षक, टैग, लेखक खोजें...", + torrentSearchButton: "खोजें", + torrentSortRecent: "सबसे हालिया", + torrentSortOldest: "सबसे पुराने", + torrentSortTop: "सबसे अधिक वोट", + torrentNoMatch: "कोई मिलान टॉरेंट नहीं।", + torrentMessageAuthorButton: "लेखक को संदेश भेजें", + torrentUpdatedAt: "अपडेट किया गया", + statsTorrent: "टॉरेंट", + typeTorrent: "टॉरेंट", + modulesTorrentsLabel: "टॉरेंट", + modulesTorrentsDescription: "टॉरेंट खोजने और प्रबंधित करने का मॉड्यूल।", + favoritesFilterTorrents: "टॉरेंट", + favoritesFilterMarket: "मार्केट", + favoritesFilterShopProducts: "दुकान वस्तुएं", + tribeSectionTorrents: "टॉरेंट", + tribeCreateTorrent: "टॉरेंट अपलोड करें", + tribeMediaTypeTorrent: "टॉरेंट", + settingsWishTitle: "इच्छा", + settingsWishDesc: "अपने अवतार की इच्छा कॉन्फ़िगर करें।", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "निजी संदेश", + settingsPmVisibilityDesc: "नेटवर्क में निजी संदेश एक्सपोज़र स्तर कॉन्फ़िगर करें।", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "आउटबाउंड UX गार्डरेल है। इनबाउंड ध्यान फ़िल्टर है।", + pmBlockedNonMutual: "केवल पारस्परिक समर्थन वाले निवासियों को PM भेज सकते हैं।", + inhabitantsPendingFollowsTitle: "लंबित समर्थन अनुरोध", + inhabitantsPendingAccept: "स्वीकार", + inhabitantsPendingReject: "अस्वीकार", + bxEncrypted: "एन्क्रिप्टेड", + bxEncryptedHexLabel: "सिफरटेक्स्ट (पूर्वावलोकन)", + tribeSectionGovernance: "शासन", + tribeSubStatusPublic: "सार्वजनिक", + tribeSubStatusPrivate: "निजी", + tribeSubInheritedPrivate: "निजी (मुख्य जनजाति से विरासत)", + tribePadInviteRequired: "पैड तक पहुंच नहीं। निमंत्रण मांगें।", + tribeChatInviteRequired: "चैट तक पहुंच नहीं। निमंत्रण मांगें।", + tribeGovernanceDesc: "इस जनजाति का आंतरिक शासन।", + tribeGovernanceNoGov: "कोई सक्रिय सरकार नहीं", + tribeGovernanceNoGovDesc: "इस जनजाति ने अभी सरकार नहीं चुनी।", + tribeGovCardTitle: "वर्तमान सरकार", + tribeGovCycleSince: "चक्र शुरू", + tribeGovCycleEnd: "चक्र समाप्त", + tribeGovTimeRemaining: "शेष समय", + tribeGovPopulation: "जनसंख्या", + tribeGovMethod: "विधि", + tribeGovVotesReceived: "प्राप्त वोट", + tribeGovLeader: "नेता", + tribeGovFilterGovernment: "सरकार", + tribeGovFilterCandidatures: "उम्मीदवारी", + tribeGovFilterLaws: "कानून", + tribeGovCandidatureId: "उम्मीदवारी", + tribeGovCandidatureMethod: "विधि", + tribeGovCandidatureProposeBtn: "उम्मीदवारी प्रकाशित करें", + tribeGovRuleTitle: "नियम का शीर्षक", + tribeGovRuleBody: "नियम का शरीर", + tribeGovProposals: "प्रस्ताव", + tribeGovRevocations: "निरसन", + tribeGovHistorical: "इतिहास", + tribeGovRules: "नियम", + tribeGovernanceAlreadyPublished: "इस जनजाति की पहले से ही एक खुली उम्मीदवारी है।", + tribeGovernanceProposeInternal: "आंतरिक उम्मीदवारी प्रस्तावित करें", + tribeGovernanceInternalCandidatures: "आंतरिक उम्मीदवारियां", + tribeGovernanceNoCandidatures: "कोई खुली उम्मीदवारी नहीं।", + tribeGovernanceAddRule: "नियम जोड़ें", + tribeGovernanceNoRules: "अभी कोई नियम नहीं।", + tribeGovernanceNoLeaders: "कोई नेता निर्वाचित नहीं।", + tribeGovernanceComingSoon: "शासन मॉड्यूल में जल्द ही।", + tribeGovNoProposals: "अभी तक कोई प्रस्ताव नहीं", + tribeGovNoLaws: "अभी तक कोई कानून नहीं", + tribeGovNoRevocations: "अभी तक कोई निरसन नहीं", + tribeGovNoHistorical: "अभी तक कोई रिकॉर्ड नहीं", + logsTitle: "लॉग", + logsDescription: "नेटवर्क पर अपना अनुभव रिकॉर्ड करें।", + logsReadMore: "और पढ़ें", + logsViewTitle: "लॉग", + logsColumnType: "प्रकार", + logsView: "देखें", + logsManualPrompt: "अपनी लॉग लिखें", + logsUpdateButton: "अपडेट करें", + logsEditTitle: "प्रविष्टि संपादित करें", + logsCreateDescription: "अपना अनुभव रिकॉर्ड करें...", + logsCreateTitle: "प्रविष्टि बनाएँ", + logsWriteButton: "लिखें", + logsTextPlaceholder: "अपने अनुभव वर्णित करें...", + logsTextField: "पाठ", + logsLabelPlaceholder: "लेबल...", + logsLabelField: "अपनी लॉग लिखें (लेबल)", + logsAiDisabledWarn: "एआई लिखित लॉग के लिए /modules में एआई मॉड्यूल सक्षम करें।", + logsAiContextValue: "ब्लॉकचेन दिन", + logsAiContext: "संदर्भ", + logsAiModStatus: "AImod", + logsModeApply: "लागू करें!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "मैन्युअल", + logsModeAI: "एआई", + logsColumnDelete: "हटाएँ", + logsColumnEdit: "संपादित करें", + logsColumnLog: "लॉग", + logsColumnDate: "दिनांक", + logsDelete: "हटाएँ", + logsEdit: "संपादित करें", + logsFilterAlways: "हमेशा", + logsFilterYear: "पिछला वर्ष", + logsFilterMonth: "पिछला महीना", + logsFilterWeek: "पिछला सप्ताह", + logsFilterToday: "आज", + logsFilterRecent: "हाल के", + logsFilterAll: "सभी", + logsCreate: "प्रविष्टि बनाएँ", + logsExport: "लॉग निर्यात करें", + logsExportOne: "निर्यात", + logsViewDetails: "विवरण देखें", + logsGenerateButton: "पाठ जनरेट करें", + logsSearchText: "लॉग में खोजें...", + logsSearchAnyType: "कोई भी प्रकार", + logsSearchButton: "खोजें", + logsEmpty: "कोई प्रविष्टियाँ नहीं।", + modulesLogsLabel: "लॉग", + modulesLogsDescription: "(एआई सहायक के माध्यम से) अपने अनुभव रिकॉर्ड करने के लिए मॉड्यूल।", + statsLogsTitle: "लॉग", + statsLogsEntries: "प्रविष्टियाँ", + typeLog: "लॉग", + blockchainCycle: "चक्र", + + larpTitle: "L.A.R.P.", + larpDescription: "सहयोगात्मक प्रयोग के लिए एक लाइव-एक्शन रोल-प्लेइंग परत।", + larpAllHouses: "गृह:", + larpFilterRuling: "शासन", + larpFilterHouses: "गृह", + larpFilterRules: "FAQ", + larpRulesTitle: "L.A.R.P. FAQ", + larpRulesEntryTitle: "प्रारंभिक गृह", + larpRulesEntryText: "हर निवासी डिफ़ॉल्ट रूप से ACADEMIA में शुरू होता है। ACADEMIA से, \"गृह में शामिल हों\" पैनल में कोई गृह चुनें: इससे उसका प्रवेश परीक्षण खुलता है। 10 प्रश्नों के उत्तर दें और सबमिट करें। यदि आप सीमा (7/10) पार करते हैं, तो आपको स्वचालित रूप से उस गृह में स्वीकार कर लिया जाता है; नहीं तो अस्वीकार कर ACADEMIA में रखा जाता है। दोनों ही मामलों में सिस्टम आपके OasisID और प्रयास के चक्र को दर्ज करता है और 30 चक्रों के कूलडाउन समाप्त होने तक पुनः प्रयास से रोकता है।", + larpRulesLeaveText: "किसी भी गृह के सदस्य अपने गृह पृष्ठ से कभी भी ACADEMIA में लौट सकते हैं; इससे उनकी सदस्यता रीसेट हो जाती है, परंतु परीक्षण कूलडाउन समाप्त नहीं होता।", + larpRulesGovernanceTitle: "शासन दीवार", + larpRulesGovernanceText: "अपने चक्र के दौरान, शासक गृह \"शासन\" बैज पहनता है और एकमात्र है जिसकी दीवार शासन टैब के तहत सार्वजनिक रूप से दिखाई जाती है।", + larpRulesSignTitle: "L.A.R.P. प्रतीक", + larpRulesSignText: "निवासी इसे चालू कर सकते हैं (/profile/edit > सेंसर) ताकि उनके वर्तमान गृह की छवि उनके प्रोफ़ाइल, लेखक और निवासी कार्ड पर मुहर के रूप में दिखे। मुहर गृह पृष्ठ से जुड़ी होती है।", + larpPostsTitle: "दीवार", + larpPostsEmpty: "अभी कोई पोस्ट नहीं।", + larpPostLabel: "नई पोस्ट", + larpPostPlaceholder: "यह गृह क्या कहना चाहता है?", + larpPostPublic: "सार्वजनिक (किसी को भी दिखे, अन्यथा केवल सदस्यों को)", + larpPostSubmit: "प्रकाशित करें", + larpPostMembersOnly: "केवल सदस्य", + larpCycleLabel: "चक्र:", + audioBackToBcs: "Back to BCS", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeCompositionEmpty: "This audio does not include a stored blockchain composition.", + audioTranscodeDetailDescription: "Decode the embedded payload and the original blockchain composition map.", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeStegoEmpty: "(none)", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoNotFound: "No steganographic payload could be decoded from this audio.", + audioTranscodeStegoOasisId: "By", + audioTranscodeStegoTimestamp: "Generated at", + audioTranscodeStegoUnknown: "—", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (monthly)", + bankOverviewYourTaxes: "Your taxes", + bankRulesAlpha: "Alpha (max share of pub balance per epoch)", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesArchTaxRate: "Rate", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesCapPerEpoch: "Cap per epoch (absolute)", + bankRulesCapUser: "Cap per user per epoch", + bankRulesCarbonFactor: "Carbon factor", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesChipTitle: "ECO Tax chip color band", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesEcoTaxRate: "Rate", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesEpochKind: "Epoch granularity", + bankRulesFloor: "Floor per user (gross)", + bankRulesGraceDays: "Claim grace period", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesKarmaTitle: "Karma (user engagement score)", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesPoolTitle: "Monthly pool", + bankRulesReserveMin: "Reserve minimum (kept in pub balance)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesShareTitle: "Per-user share", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankRulesWMax: "Maximum weight (w_max)", + bankRulesWMin: "Minimum weight (w_min)", + bankTaxes: "Taxes", + bankTaxesAnnualEcoin: "ECO Tax (annual)", + bankTaxesArchTaxAnnual: "ARCH Tax (annual)", + bankTaxesArchTaxFirstBlock: "Your first block age", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxLifetime: "ARCH Tax (lifetime)", + bankTaxesArchTaxMonthly: "ARCH Tax (monthly)", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxRate: "Rate", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesArchTaxUserAmount: "Your ARCH tax (price to return)", + bankTaxesEcoTaxLifetime: "ECO Tax (lifetime)", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesLookupAuthor: "Author", + bankTaxesLookupButton: "Inspect", + bankTaxesLookupCarbon: "Carbon footprint", + bankTaxesLookupEcoin: "ECO Tax", + bankTaxesLookupNotFound: "No block found in your local feed with that ID.", + bankTaxesLookupPlaceholder: "Block ID (e.g. %abc...=.sha256)", + bankTaxesLookupSize: "Size", + bankTaxesLookupTitle: "Inspect a block", + bankTaxesLookupType: "Type", + bankTaxesMonthlyEcoin: "ECO Tax (monthly)", + bankTaxesRate: "Rate", + bankTaxesSpan: "Sampling span", + bankTaxesTotalAnnual: "Total (annual)", + bankTaxesTotalBlocks: "Total blocks", + bankTaxesTotalBytes: "Total bytes", + bankTaxesTotalCarbon: "Total carbon", + bankTaxesTotalEcoin: "Total ECO tax (lifetime)", + bankTaxesTotalLifetime: "Total (lifetime)", + bankTaxesTotalMonthly: "Total (monthly)", + bankTaxesTypesApply: "Set my taxes", + bankTaxesTypesLabel: "Select which taxes you want to pay", + bankTaxesUserAmount: "Your ECO tax (price to return)", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + blockchainBackToBlockexplorer: "Back to blockexplorer", + blockchainBlockNotAvailable: "This block is not available in your local feed. It may belong to a peer you are not yet replicating from.", + ecoTaxLabel: "ECO Tax", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + melodyAllEmpty: "No BCS compositions from other inhabitants yet.", + melodyByLabel: "By", + melodyNoteUnavailable: "block not in your local feed", + melodyUploadBcsButton: "Publish", + melodyUploadBcsNameNote: "The audio will be published with the auto-generated name", + melodyUploadBcsTitle: "Publish", + melodyUploadStegoLabel: "Hidden message (steganography)", + melodyUploadStegoMaxLabel: "max 280 characters", + melodyUploadStegoPlaceholder: "Optional.", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + statsTombstoneEmpty: "No tombstones in the network yet.", + typeGameScore: "GAME SCORES", + bankTaxesLookupNote: "अपने स्थानीय फ़ीड में देखने के लिए ब्लॉक ID दर्ज करें।", + bankTaxesUserNoteChipsClick: "ब्लॉकexplorer में इसके ब्लॉक का निरीक्षण करने के लिए किसी भी चिप पर क्लिक करें।", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "आमंत्रण कोड", + larpRulesInviteEntryText: "गृह सदस्य आमंत्रण कोड बना सकते हैं जो गृह तक सीधी पहुंच देते हैं।", + larpRulesOneHouseText: "आप किसी भी समय अधिकतम एक गृह से संबंध रख सकते हैं। यदि आप किसी से संबंधित नहीं हैं, तो आप ACADEMIA में हैं। हर गैर-ACADEMIA गृह पृष्ठ अपने सदस्यों को \"गृह छोड़ें\" बटन दिखाता है जो सदस्यता को ACADEMIA पर रीसेट कर देता है। छोड़ने से परीक्षण कूलडाउन समाप्त नहीं होता।", + larpRulesOneHouseTitle: "एक समय में एक गृह", + larpRulesTestEntry: "WILL परीक्षण", + larpRulesTestEntryText: "प्रत्येक विकल्प एक या अधिक गृहों को वज़न देता है; जमा करने पर, सिस्टम अंकों को जोड़ता है और आपको सबसे अधिक अंक वाले गृह में स्वत: स्वीकार करता है।", + larpWillTestHint: "पूरा होने पर, आपको वह गृह सौंपा जाएगा जो आपके उत्तरों के साथ सबसे अच्छा मेल खाता है। आपके व्यक्तिगत उत्तरों को स्थानीय रूप से संसाधित किया जाता है और कभी संग्रहीत नहीं किया जाता — केवल परिणामी गृह असाइनमेंट आपकी फ़ीड पर प्रकाशित होता है।", + larpWillTestTitle: "WILL परीक्षण", + settingsLanDesc: "इस पीयर को समय-समय पर उसी स्थानीय नेटवर्क में अन्य Oasis इंस्टेंस को घोषित करें। UDP प्रसारण रोकने के लिए अक्षम करें।", + settingsLanEnable: "LAN प्रसारण सक्षम करें", + settingsLanTitle: "LAN प्रसारण", + settingsReplicationTitle: "प्रतिकृति", + settingsReplicationDesc: "सामग्री की प्रतिकृति बनाते समय आपके पीयर द्वारा आपके अपने फ़ीड से आगे जाने वाले हॉप की संख्या कॉन्फ़िगर करें।", + settingsReplicationHopsLabel: "हॉप", + aiApproveTagsLabel: "टैग (अल्पविराम से अलग)", + aiApproveTagsPlaceholder: "जैसे oasis, शासन, पारिस्थितिकी", + aiApproveRatingLabel: "रेटिंग", + aiExchangeLang: "भाषा", + aiExchangeTags: "टैग", + aiExchangeRating: "रेटिंग", + aiExchangeHelpful: "सहायक", + aiExchangeMarkHelpful: "+1 सहायक", + larpCyclesUntilRuling: "अगला शासन चक्र", + larpVisitTribe: "जनजाति देखें", + larpGoverning: "शासन:", + larpMyHouse: "मेरा गृह:", + larpMembersCount: "सदस्य", + larpMembersTitle: "सदस्य", + larpNoMembers: "अभी कोई सदस्य नहीं।", + larpRolesLabel: "भूमिकाएँ", + larpFunctionLabel: "कार्य", + larpMonthLabel: "शासन चक्र", + larpLeaveToAcademia: "ACADEMIA लौटें", + larpAcademiaJoinTitle: "गृह में शामिल हों", + larpAcademiaJoinHint: "मनोवैज्ञानिक प्रोफ़ाइल परीक्षण दें ताकि आपको आपके लिए सबसे उपयुक्त गृह सौंपा जाए, या किसी गृह सदस्य द्वारा साझा किया गया आमंत्रण कोड भुनाएँ।", + larpTakeTestButton: "प्रोफ़ाइल परीक्षण दें", + larpInviteRedeem: "गृह में शामिल हों", + larpInviteCreate: "आमंत्रण कोड बनाएँ", + larpInvitePlaceholder: "आमंत्रण कोड", + larpInviteBannerTitle: "नया आमंत्रण कोड", + larpInviteBannerHint: "इस कोड को ACADEMIA में किसी के साथ साझा करें। यह 30 चक्रों या एक उपयोग के बाद समाप्त हो जाता है।", + larpTestAssignedHouse: "नियत गृह", + larpTestRankingTitle: "गृह के अनुसार स्कोर", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "गृह आमंत्रण कोड", + invitesHouseJoinButton: "गृह में शामिल हों", + larpLastAttemptTitle: "आपका पिछला प्रयास", + larpLastAttemptHouse: "गृह", + larpLastAttemptResult: "परिणाम", + larpLastAttemptWhen: "कब", + larpLastAttemptCooldown: "अगला प्रयास", + larpTestTitle: "प्रवेश परीक्षण", + larpTestIntro: "10 प्रश्नों के उत्तर दें। स्वीकार होने के लिए कम से कम 7 सही उत्तर चाहिए। परिणाम चाहे जो भी हो, आप हर 30 चक्रों में एक परीक्षण दे सकते हैं।", + larpTestSubmit: "गृह में शामिल हों", + larpTestCooldownActive: "अगला परीक्षण उपलब्ध", + larpTestCooldownDays: "चक्र", + larpTestResultTitle: "परीक्षण परिणाम", + larpTestPassed: "परीक्षण पास — स्वागत है!", + larpTestFailed: "परीक्षण पास नहीं हुआ", + larpTestScore: "स्कोर", + larpTestNextAttempt: "आप 30 चक्रों के बाद एक और परीक्षण दे सकते हैं।", + larpGoToHouse: "अपने गृह पर जाएँ", + larpBackToAcademia: "ACADEMIA लौटें", + larpBackToHouses: "◀ गृह", + larpBadgeYou: "आप", + larpBadgeRuling: "शासन", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Oasis की लाइव-एक्शन रोल-प्लेइंग परत (9 गृह) के लिए मॉड्यूल।", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + ecoinTaxLabel: "ECOin Tax", + bankTaxesNetworkTitle: "Network Taxes", + bankTaxesUserTitle: "Your taxes", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + statsEcoTaxTitle: "ECO Tax", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + audioTranscodeStegoTitle: "Embedded in the waveform", + audioBackToAudio: "Back to audio", + audioAuthorLabel: "Author", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "जब आप किसी जटिल समस्या का सामना करते हैं, तो पहले क्या करते हैं?", + larpProfileQ1O1: "सहमति खोजने के लिए दूसरों से बात करना", + larpProfileQ1O2: "परीक्षण के लिए प्रोटोटाइप बनाना", + larpProfileQ1O3: "साहित्य पर शोध करना", + larpProfileQ1O4: "उस तंत्र को बाधित करना जो इसे बनाता है", + larpProfileQ2: "आपके दैनिक कार्य को क्या अर्थ देता है?", + larpProfileQ2O1: "जिनसे मैं प्यार करता हूँ उनकी रक्षा करना", + larpProfileQ2O2: "कुछ ठोस बनाना", + larpProfileQ2O3: "जीवन को सहेजना या पोषित करना", + larpProfileQ2O4: "शब्दों और विचारों को गढ़ना", + larpProfileQ2O5: "दूसरों को हँसाना", + larpProfileQ3: "जब आपके समूह में संघर्ष होता है, तो आप…", + larpProfileQ3O1: "संवाद का नेतृत्व करते हैं", + larpProfileQ3O2: "पक्ष लेते हैं और दृढ़ रहते हैं", + larpProfileQ3O3: "तनाव कम करने के लिए चुटकुला सुनाते हैं", + larpProfileQ3O4: "प्रश्न करते हैं कि क्या संघर्ष वास्तविक है", + larpProfileQ3O5: "पारिस्थितिक मूल कारण ढूँढते हैं", + larpProfileQ4: "आपकी पसंदीदा दीर्घकालिक परियोजना होगी…", + larpProfileQ4O1: "एक शहर बनाना", + larpProfileQ4O2: "एक जंगल को पुनर्स्थापित करना", + larpProfileQ4O3: "एक संविधान लिखना", + larpProfileQ4O4: "एक उत्सव आयोजित करना", + larpProfileQ4O5: "रक्षा नेटवर्क स्थापित करना", + larpProfileQ4O6: "नई मशीन डिज़ाइन करना", + larpProfileQ4O7: "अभिलेखागार संग्रहित करना", + larpProfileQ4O8: "अन्यायपूर्ण व्यवस्था को बाधित करना", + larpProfileQ5: "एक अच्छा नेता क्या बनाता है?", + larpProfileQ5O1: "जो सुनता है और मध्यस्थता करता है", + larpProfileQ5O2: "जो लड़ सकता है और रक्षा कर सकता है", + larpProfileQ5O3: "जो इतिहास जानता है", + larpProfileQ5O4: "जो आपको मुस्कुराने पर मजबूर करता है", + larpProfileQ6: "नियमों के साथ आपका संबंध:", + larpProfileQ6O1: "नियम संवाद और कानून से उभरते हैं", + larpProfileQ6O2: "नियमों का सख्ती से पालन होना चाहिए", + larpProfileQ6O3: "नियम अक्सर तोड़े जाने चाहिए", + larpProfileQ6O4: "नियम जीवन की सेवा करें", + larpProfileQ6O5: "नियम ठोस बुनियादी ढाँचा बनाते हैं", + larpProfileQ7: "आप जानकारी को कैसे संभालते हैं?", + larpProfileQ7O1: "मैं इसे संग्रहित और संरक्षित करता हूँ", + larpProfileQ7O2: "मैं इसे कहानियों के माध्यम से साझा करता हूँ", + larpProfileQ7O3: "मैं इसके मूल पर प्रश्न करता हूँ", + larpProfileQ7O4: "मैं उपयोगी हिस्से निकालता हूँ", + larpProfileQ7O5: "मैं इसे सहेजने के लिए उपयोग करता हूँ", + larpProfileQ8: "सफलता का आपका विचार है…", + larpProfileQ8O1: "एक कार्यशील मशीन", + larpProfileQ8O2: "एक शांतिपूर्ण समुदाय", + larpProfileQ8O3: "एक जीवंत उत्सव", + larpProfileQ8O4: "एक सुरक्षित परिवार", + larpProfileQ8O5: "एक अखंडित अभिलेखागार", + larpProfileQ8O6: "एक टूटा हुआ हठधर्म", + larpProfileQ8O7: "एक समृद्ध फसल", + larpProfileQ8O8: "एक पूर्ण भवन", + larpProfileQ9: "जब आप जागते हैं, आप चाहते हैं…", + larpProfileQ9O1: "अपने शरीर को प्रशिक्षित करना", + larpProfileQ9O2: "पढ़ना या लिखना", + larpProfileQ9O3: "बागवानी करना या पकाना", + larpProfileQ9O4: "कुछ छेड़छाड़ करना", + larpProfileQ9O5: "प्राधिकरण पर प्रश्न करना", + larpProfileQ9O6: "एक परियोजना की योजना बनाना", + larpProfileQ9O7: "दोस्तों से बात करना", + larpProfileQ9O8: "कला बनाना", + larpProfileQ10: "आपकी कमज़ोरी हो सकती है:", + larpProfileQ10O1: "बहुत अधिक बोलना", + larpProfileQ10O2: "बहुत व्यावहारिक होना", + larpProfileQ10O3: "बहुत आदर्शवादी होना", + larpProfileQ10O4: "बहुत विघटनकारी होना", + larpProfileQ10O5: "बहुत कठोर होना", + larpProfileQ10O6: "बहुत हल्के दिल वाला होना", + larpProfileQ10O7: "बहुत सावधान होना", + larpProfileQ10O8: "बहुत महत्वाकांक्षी होना", + uxModeTitle: "UX", + uxModeDescription: "अपने इंटरफ़ेस के लिए UX नेविगेशन मोड चुनें।", + uxModeMenus: "ब्लॉक", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_it.js b/src/client/assets/translations/oasis_it.js new file mode 100644 index 0000000..5c32782 --- /dev/null +++ b/src/client/assets/translations/oasis_it.js @@ -0,0 +1,3873 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + it: { + languageName: "Italiano", + shopOrderStatusPaid: "Pagamento ricevuto", + shopOrderStatusReceived: "Ricevuto", + shopOrderMarkPaid: "Segna pagamento ricevuto", + shopOrderConfirmReceived: "Conferma ricezione", + shopMyOrdersTitle: "I miei ordini", + shopPurchasesButton: "Acquisti", + shopMyOrdersDescription: "I tuoi ordini di acquisto.", + shopMyOrdersEmpty: "Non hai ancora acquisti.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Venditore", + shopOrderPaymentConcept: "Pagamento", + shopOrderInvoice: "Fattura", + shopOrderInvoiceConcept: "Fattura", + shopOrderStatusPending: "In attesa", + shopOrderStatusAccepted: "Accettato", + shopOrderStatusRejected: "Rifiutato", + shopOrderStatusShipped: "Spedito", + shopOrderStatusDelivered: "Consegnato", + shopOrderAccept: "Accetta", + shopOrderReject: "Rifiuta", + shopOrderMarkShipped: "Segna spedito", + shopOrderMarkDelivered: "Segna consegnato", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Contratto", + shopOrderContractConcept: "Ordine negozio", + shopOrdersPending: "Ordini in sospeso", + all: "Tutti", + mine: "I miei", + recent: "Recenti", + anonymous: "Anonimo", + unnamed: "Anonimo", + authorLabel: "AUTORE", + delete: "Elimina", + save: "Salva", + vote: "Vota", + url: "URL", + price: "Prezzo", + priority: "Priorità", + severity: "Gravità", + organizer: "Organizzatore", + edited: "modificato", + isPublic: "Pubblico", + searchIsPublicLabel: "Pubblico", + privacyPrivate: "PRIVATO", + privacyPublic: "PUBBLICO", + notFound: "Non trovato", + no_results: "Nessun dato", + searchButton: "Cerca", + mapZoomLabel: "Zoom", + modulesTitle: "Moduli", + viewJson: "Vedi JSON", + matchScore: "Punteggio di corrispondenza", + preferencesLabel: "Preferenze", + inhabitantProfileTitle: "Profilo abitante", + contentWarningLabel: "Avviso sul contenuto", + invalidPost: "Contenuto non valido", + invalidPostHint: "Questo messaggio ha testo vuoto/non valido.", + spreadBy: "Da", + spreadChron: "Diffusione", + spreaded: "Diffuso", + spreadMore: "altro", + spreadContentUnavailable: "Contenuto non ancora disponibile (replica in sospeso)", + filteredByTag: "Filtrato per tag", + filteredByTagTitle: "Filtrato per tag", + feedPublishedSuccess: "Feed pubblicato con successo!", + tribeFeedSent: "Messaggio inviato con successo!", + tribeContentEncrypted: "Contenuto cifrato", + tribeTorrentsEmpty: "Ancora nessun torrent.", + torrentDescription: "Descrizione", + torrentDownload: "Scarica", + chatInviteMode: "Invita", + padInviteMode: "Invita", + noInviteMode: "Nessuna modalità invito", + noDeadline: "Nessuna scadenza", + noStatus: "Nessuno stato", + noSeverity: "Nessuna gravità", + calendarDeleteDate: "Elimina data", + calendarIntervalUntil: "Fino a", + bookmarkCategory: "Categoria", + bookmarkLastVisit: "Ultima visita", + profileClearnetBookmarksLabel: "Segnalibri", + forumFilterHot: "Caldi", + invitesPort: "Porta", + currentlyUnrecheable: "ERRORE!", + peerHostValidation: "IPv4 valido (es. 192.168.1.100) o hostname (es. pub.example.com)", + peerPortValidation: "Porta 1-65535", + peerKeyValidation: "Chiave pubblica SSB ed25519 (@<44 caratteri base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Disiscrizione dalla tua offerta di lavoro", + inboxProjectFollowedTitle: "Nuovo follower del tuo progetto", + inboxProjectUnfollowedTitle: "Ha smesso di seguire il tuo progetto", + pmHasFollowedYourProject: "ha seguito il tuo progetto", + pmHasUnfollowedYourProject: "ha smesso di seguire il tuo progetto", + pmHasUnsubscribedFromYourJobOffer: "si è disiscritto dalla tua offerta di lavoro", + marketDeleteButton: "Elimina", + marketNoBids: "Ancora nessuna offerta", + marketAuctionBidAmount: "Importo dell’offerta", + marketAuctionBidTime: "Ora dell’offerta", + marketAuctionUser: "Offerente", + parliamentActorInPower: "TRIBÙ AL GOVERNO", + parliamentWinningCandidature: "Candidatura vincente", + projectBounty: "Ricompensa", + projectFollowing: "SEGUI", + projectBackerAuthor: "Sostenitore", + projectBackerDate: "Data", + projectConfirmTransferButton: "Conferma trasferimento", + projectPendingTransfersTitle: "Trasferimenti in sospeso", + transfersUpdateSectionTitle: "Aggiorna trasferimento", + taskUnassignedFrom: "Rimosso da", + taskDescriptionPlaceholder: "Descrizione attività", + exportPasswordLabel: "Password di esportazione", + importPasswordPlaceholder: "Inserisci la password", + searchCasesPlaceholder: "Cerca casi…", + shopBuyDeliveryAddressPlaceholder: "Indirizzo di consegna", + shopBuyNotesPlaceholder: "Note", + bankTaxesUserNoteBold: "ridurre direttamente la tassa ECO", + bankTaxesUserNoteIntro: "La tua tassa ECO viene detratta dal surplus che la rete genera oltre il tuo RBU; il valore base del RBU è fissato come minimo inamovibile. La tassa non viene mai detratta dall’importo fisso che spetta a ciascun abitante come RBU. I fondi detratti alimentano l’algoritmo di redistribuzione della ricchezza e vengono incanalati verso altri abitanti tramite le loro richieste di RBU. Quegli altri abitanti che ricevono più RBU per i loro progetti avranno probabilmente qualche compito dedicato ad aiutarti a ridurre la tua tassa ECO. Oppure possono dedicarsi a ", + bankTaxesUserNoteOutro: ", e il loro contributo continuo può essere richiesto dal consenso della rete.", + courtsViewDetails: "Vedi", + courtsViewDetailsShort: "Dettagli", + courtsThDetails: "Dettagli", + courtsDetailsAnswersTitle: "Risposte", + courtsDetailsEvidenceTitle: "Prove", + courtsDetailsSettlementsTitle: "Accordi", + courtsDetailsVerdictTitle: "Verdetto", + courtsDetailsNotPublic: "Non pubblico", + courtsEvidenceSideAccuser: "Prove dell’accusatore", + courtsEvidenceSideRespondent: "Prove della difesa", + courtsEvidenceSideUnknown: "Sconosciuto", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Sostieni il caso", + courtsSettlementAcceptBtn: "Accetta l’accordo", + courtsSupportCount: "Sostenitori", + courtsVerdictVoteTitle: "Vota sul verdetto", + courtsVerdictVoteLabel: "Voto sul verdetto", + courtsVerdictVoteAccept: "Accetta", + courtsVerdictVoteReject: "Rifiuta", + courtsVerdictVoteSubmit: "Invia voto", + reportsSetStatus: "Imposta stato", + reportsStatusClosed: "CHIUSO", + statsAvgPerInhabitant: "Media per abitante", + statsCarbonMaxAnnual: "Stima max annuale", + statsCarbonNetwork: "Totale rete", + statsCarbonOfEstMax: "della capacità max stimata", + statsCarbonOfNetwork: "del totale della rete", + statsCarbonUser: "La tua impronta", + statsContentCountColumn: "Conteggio", + statsContentTypeColumn: "Tipo", + statsMsgsPerDay: "Messaggi/giorno (totale)", + statsMyShare: "La tua quota della rete", + statsNetworkSpan: "Durata della rete", + statsTombstoneRatioLabel: "Rapporto eliminazioni", + statsTopTagsTitle: "Tag principali", + statsTopTypesTitle: "Tipi di contenuto principali", + statsTotalMsgs: "Messaggi totali", + feedViewDetails: "Vedi dettagli", + eventFileLabel: "Allega immagine", + taskFileLabel: "Allega immagine", + courtsRespondentRequired: "L'ID del convenuto è obbligatorio", + extended: "Multiverso", + extendedDescription: [ + "Quando supporti qualcuno, puoi scaricare i post degli abitanti che supportano, e questi appaiono qui, ordinati per data.", + ], + popular: "In evidenza", + popularDescription: [ + "Post dagli abitanti nella tua rete, ", + strong("ordinati per diffusione"), + ". Seleziona il periodo di tempo per ottenere una lista.", + ], + week: "SETTIMANA", + month: "MESE", + year: "ANNO", + latest: "Più recenti", + latestDescription: [ + strong("Post"), + " da te e dagli abitanti che supporti, ordinati per data.", + ], + topics: "Argomenti", + topicsDescription: [ + strong("Argomenti"), + " da te e dagli abitanti che supporti, ordinati per data.", + ], + summaries: "Riassunti", + summariesDescription: [ + strong("Argomenti con commenti"), + " da te e dagli abitanti che supporti, ordinati per data.", + ], + threads: "Discussioni", + threadsDescription: [ + strong("Post con commenti"), + " dagli abitanti che supporti (incluso dal multiverso), ordinati per data.", + ], + profile: "Avatar", + inhabitants: "Abitanti", + peersReplicatedFeeds: "Feed replicati", + graphos: "Graphos", + graphosDescription: "Mappa interattiva della rete attorno a te.", + graphosViewingGraphOf: "Visualizzando", + graphosBackToMine: "← La mia rete", + graphosShowing: "Mostrando", + graphosYou: "Tu", + graphosTotalNodes: "Nodi totali", + manualMode: "Modalità manuale", + mentions: "Menzioni", + mentionsDescription: [ + strong("Post che ti @menzionano"), + ", ordinati per data.", + ], + nextPage: "Avanti", + previousPage: "Indietro", + noMentions: "Non hai ancora ricevuto @menzioni.", + privateReminders: "PROMEMORIA", + inboxFiltersLabel: "Filtri:", + inboxToggleToMutuals: "Passa a reciproci", + inboxToggleToWhole: "Passa a tutti", + privateFrom: "Da", + privateTo: "A", + privateDate: "Data", + pmReply: "Rispondi", + pmReplies: "risposte", + pmNew: "nuove", + pmMarkRead: "Segna come letto", + inReplyTo: "IN RISPOSTA A", + pmPreview: "Anteprima", + pmPreviewTitle: "Anteprima messaggio", + peers: "Peer", + searchDescription: "Descrizione", + imageSearch: "Ricerca immagini", + searchFromLabel: "Da", + searchToLabel: "A", + searchMinOpinionsLabel: "Min opinioni", + searchInhabitantLabel: "Abitante (Oasis ID)", + searchQueryLabel: "Ricerca", + searchPerPageLabel: "Risultati per pagina", + settings: "Impostazioni", + continueReading: "Continua a leggere", + moreComments: "altri commenti", + readThread: "leggi il resto della discussione", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Disegna pixel sulla griglia e collabor-ARTe con altri nella tua rete.', + melodyTitle: 'Melody', + melodyDescription: 'Riproduce la melodia della tua blockchain — ogni blocco diventa una nota.', + melodyEmpty: 'Nessuna nota ancora — la tua blockchain non ha ancora prodotto blocchi.', + melodyCompositionHelp: 'Ogni blocco della tua blockchain diventa una nota musicale in base al tipo e alla dimensione.', + melodyStatsTitle: 'Suddivisione per tipo', + melodyInhabitantLabel: 'Abitante', + melodyTotalBlocks: 'Note', + melodyType: 'Tipo', + melodyCount: 'Blocchi', + melodyFilterAll: 'TUTTE', + melodyFilterShare: 'Carica melodia', + melodyShareTitle: 'Condividi la tua melodia', + melodyShareHelp: 'Pubblica un’istantanea della tua melodia attuale per farla ascoltare e remixare ad altri.', + melodyShareTitleLabel: 'Titolo (opzionale)', + melodyShareTitlePlaceholder: 'Un fantasma di blockchain', + melodyShareSubmit: 'Pubblica melodia', + melodyNoShared: 'Nessuna melodia di blockchain ancora.', + melodyRegenerate: 'Rigenera', + melodyDownload: "Scarica BCS", + profileActivityTitle: 'Attività', + profileActivityMore: 'Vedi tutta l’attività', + profileContentSectionTitle: 'Contenuto dell’Avatar', + profileContentHelp: 'Scegli quali moduli mostrare nel tuo avatar.', + profileSensorsHelp: 'Metriche opzionali mostrate nel tuo avatar.', + profileClearnetHelp: 'Moduli accessibili dall’esterno di Oasis.', + profileHubAll: 'Tutto', + profileHubTitle: 'Contenuto Pubblico', + footerPulseTitle: 'Pulse', + footerPulsePeers: 'Peer', + footerPulseInbox: 'Inbox', + footerPulseSync: 'Ultima sinc.', + footerPulseEcoValue: 'ECO/h', + footerPulseLastActivity: 'Ultima attività', + footerLicenseLabel: 'Licenza', + profileSwitchToOasis: 'Torna a Oasis', + profileSwitchToClearnet: 'Immergiti in Clearnet', + profileVisibilityFediverse: "Fediverse", + profileSwitchToFediverse: "Tuffati nel fediverso", + coordLabel: 'Coordinate (es. A3)', + coordPlaceholder: 'Inserisci coordinata', + contributorsTitle: "Contributori", + pixeliaBy: "di", + colorLabel: 'Scegli un colore', + paintButton: 'Dipingi!', + invalidCoordinate: 'Coordinata errata', + goToMuralButton: "Vedi murale", + totalPixels: 'Pixel totali', + modules: "Moduli", + modulesViewTitle: "Moduli", + modulesViewDescription: "Configura il tuo ambiente attivando o disattivando i moduli.", + inbox: "Posta in arrivo", + multiverse: "Multiverso", + fediverse: "Fediverse", + fediverseDescription: "Gestisci i tuoi altri account del fediverso, inclusi l'invio e la ricezione di contenuti.", + fediverseStatus: "Stato", + fediverseDisconnected: "Fediverso disconnesso. Controlla %LINK% o lo stato della connessione.", + fediverseSettingsTitle: "Fediverse", + fediverseInstanceLabel: "Indirizzo", + fediverseTokenLabel: "Token di accesso", + fediverseTokenHelp: "Imposta il tuo token di accesso. Sarà usato per gestire il tuo account Mastodon da Oasis.", + fediverseConnect: "Connetti", + fediverseConnectedAs: "Connesso come", + fediverseDisconnect: "Disconnetti", + fediverseEmpty: "Quando colleghi altri account del fediverso dalle %LINK%, puoi gestirli da qui.", + fediverseEmptyLink: "impostazioni", + fediverseComposePlaceholder: "A cosa stai pensando?", + fediverseReplyPlaceholder: "Scrivi la tua risposta…", + fediverseAttach: "Allega media", + fediversePublish: "Pubblica", + fediverseReply: "Rispondi", + fediverseBoosted: "ha rilanciato", + fediverseNoPosts: "La tua timeline è vuota.", + fediverseThread: "Discussione", + fediverseTimeline: "Cronologie", + fediverseManageTimeline: "Gestisci la cronologia", + fediverseFollowers: "Follower", + fediverseFollowing: "Seguiti", + fediversePosts: "Post", + fediverseJoined: "Iscritto", + fediverseOverview: "Panoramica", + fediverseRefresh: "Aggiorna", + fediverseWrite: "Scrivi", + fediversePreview: "Anteprima", + fediverseEdit: "Modifica", + fediverseOpenFeed: "Visita il feed", + fediverseManage: "Gestisci", + fediverseStatusNotConnected: "Non connesso", + fediverseError: "Impossibile contattare Mastodon.", + fediverseErrInstance: "URL dell'istanza non valido.", + fediverseErrAuth: "Token non valido o scaduto.", + fediverseErrConnect: "Impossibile connettersi all'istanza.", + fediverseErrToken: "Token obbligatorio.", + fediverseErrPublic: "Non disponibile in modalità pubblica.", + fediverseErrFetch: "Impossibile caricare la timeline.", + fediverseErrPost: "Impossibile pubblicare.", + fediverseErrMedia: "Impossibile caricare il file.", + fediverseErrEmpty: "Scrivi qualcosa o allega un file.", + popularLabel: "In evidenza", + topicsLabel: "Argomenti", + latestLabel: "Più recenti", + summariesLabel: "Riassunti", + threadsLabel: "Discussioni", + multiverseLabel: "Multiverso", + inboxLabel: "Posta in arrivo", + invitesLabel: "Inviti", + walletLabel: "Portafoglio", + legacyLabel: "Chiavi", + cipherLabel: "Crittografia", + bookmarksLabel: "Segnalibri", + videosLabel: "Video", + torrentsLabel: "Torrents", + docsLabel: "Documenti", + audiosLabel: "Audio", + tagsLabel: "Tag", + imagesLabel: "Immagini", + inhabitantsLabel: "Abitanti", + trendingLabel: "Tendenze", + eventsLabel: "Eventi", + tasksLabel: "Compiti", + apply: "Applica", + menuPersonal: "Personale", + menuContent: "Contenuto", + menuBlogs: "Blog", + menuGovernance: "Governance", + menuOffice: "Ufficio", + menuMultiverse: "Multiverso", + menuNetwork: "Rete", + menuCreative: "Creativo", + menuEconomy: "Economia", + menuMedia: "Media", + menuTools: "Strumenti", + comment: "Commento", + subtopic: "Sotto-argomento", + json: "JSON", + createdBy: "di", + unfollow: "Rimuovi supporto", + follow: "Supporta", + block: "Blocca", + unblock: "Sblocca", + newerPosts: "Post più recenti", + olderPosts: "Post più vecchi", + feedRangeEmpty: "L'intervallo indicato è vuoto per questo feed. Prova a visualizzare il ", + seeFullFeed: "feed completo", + feedEmpty: "La rete Oasis non ha mai visto post da questo account.", + beginningOfFeed: "Questo è l'inizio del feed", + noNewerPosts: "Non sono ancora stati ricevuti post più recenti.", + relationshipNotFollowing: "Non ti supporta", + relationshipTheyFollow: "Ti supporta", + relationshipMutuals: "Supporto reciproco", + relationshipFollowing: "Stai supportando", + relationshipYou: "Tu", + relationshipBlocking: "Stai bloccando", + relationshipBlockedBy: "Ti ha bloccato", + relationshipMutualBlock: "Blocco reciproco", + relationshipNone: "Non stai supportando", + relationshipConflict: "Conflitto", + relationshipBlockingPost: "Post bloccato", + viewLikes: "Vedi diffusioni", + spreadedDescription: "Lista dei post diffusi dall'abitante.", + totalspreads: "Diffusioni totali", + attachFiles: "Allega file", + preview: "Anteprima", + publish: "Pubblica", + contentWarningPlaceholder: "Aggiungi un oggetto al post (opzionale)", + privateWarningPlaceholder: "Aggiungi abitanti per inviare un post privato (opzionale)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "RICORDA: A causa della tecnologia blockchain, una volta pubblicato un post non può essere modificato né eliminato.", + ], + commentWarning: [ + "RICORDA: A causa della tecnologia blockchain, una volta pubblicato un post non può essere modificato né eliminato.", + ], + commentPublic: "pubblico", + commentPrivate: "privato", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "RICORDA: A causa della tecnologia blockchain, una volta pubblicato un post non può essere modificato né eliminato.", + ], + replyLabel: ({ markdownUrl }) => [ + "RICORDA: A causa della tecnologia blockchain, una volta pubblicato un post non può essere modificato né eliminato.", + ], + publishCustomInfo: ({ href }) => [ + "Se hai esperienza, puoi anche ", + a({ href }, "scrivere un post avanzato"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "Se non hai esperienza, dovresti ", + a({ href }, "scrivere un post"), + ".", + ], + publishCustom: "Scrivi un post avanzato", + subtopicLabel: "Crea un sotto-argomento di questo post", + messagePreview: "Anteprima post", + mentionsMatching: "Menzioni corrispondenti", + mentionsName: "Nome", + mentionsRelationship: "Relazione", + updateit: "AGGIORNAMENTI!", + updateBannerText: "È disponibile una nuova versione di Oasis.", + updateBannerAction: "Aggiorna ora →", + info: "Info", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "fa", + sendTime: "circa ", + theme: "Tema", + legacy: "Chiavi", + legacyTitle: "Chiavi", + legacyDescription: "Gestisci il tuo segreto (chiave privata) in modo rapido e sicuro.", + legacyExportButton: "Esporta", + legacyImportButton: "Importa", + ssbLogStream: "Blokchain", + ssbLogStreamDescription: "Configura il limite di messaggi per i flussi della Blockchain.", + saveSettings: "Salva impostazioni", + exportTitle: "Esporta dati", + exportDescription: "Imposta una password (minimo 32 caratteri) per crittografare la tua chiave", + exportDataTitle: "Backup", + exportDataDescription: "Scarica i tuoi dati (chiave segreta esclusa!)", + exportDataButton: "Scarica database", + pubWallet: "Portafoglio PUB", + pubWalletDescription: "Imposta l'URL del portafoglio PUB. Verrà utilizzato per le transazioni PUB (inclusa la UBI).", + pubWalletConfiguration: "Salva configurazione", + importTitle: "Importa dati", + importDescription: "Importa il tuo segreto crittografato (chiave privata) per attivare il tuo avatar", + importAttach: "Allega file crittografato (.enc)", + passwordLengthInfo: "La password deve avere almeno 32 caratteri.", + passwordImport: "Scrivi la tua password per decifrare i dati che verranno salvati nella home del sistema (nome: secret)", + exportPasswordPlaceholder: "Usa minuscole, maiuscole, numeri e simboli", + fileInfo: "La tua chiave segreta crittografata verrà salvata nella home del sistema (nome: oasis.enc)", + themeIntro: + "Scegli un tema.", + setTheme: "Imposta tema", + language: "Lingua", + languageDescription: + "Se desideri usare un'altra lingua, selezionala qui.", + setLanguage: "Imposta lingua", + peerConnections: "Peer", + peerConnectionsIntro: "Gestisci tutte le tue connessioni con altri peer.", + peerConnectionsTitle: "Connessioni", + online: "Online", + offline: "Offline", + discovered: 'Scoperti', + unknown: 'Sconosciuto', + lanBroadcastLabel: "Broadcast LAN", + lanBroadcastActive: "Attivo (multicast UDP sulla LAN)", + lanBroadcastDisabled: "Disattivato (modalità pub)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Recente", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Supportati", + recommended: "Consigliati", + blocked: "Bloccati", + noConnections: "Nessun peer connesso.", + noDiscovered: "Nessun peer scoperto.", + noUnknownPeers: "Nessun peer sconosciuto nel grafo delle amicizie.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Aggiorna", + peerPruneIdle: "Rimuovi inattivi", + peerExport: "Esporta", + peerImport: "Importa", + peerImportTitle: "Importa lista peer", + peerImportPlaceholder: "Incolla un indirizzo multiserver per riga, o carica un file .txt…", + noSupportedConnections: "Nessun peer supportato.", + noBlockedConnections: "Nessun peer bloccato.", + noRecommendedConnections: "Nessun peer consigliato.", + connectionActionIntro: + "", + startNetworking: "Avvia rete", + stopNetworking: "Ferma rete", + restartNetworking: "Riavvia rete", + sync: "Sincronizza rete", + indexes: "Indici", + indexesDescription: + "Ricostruire gli indici è sicuro e può risolvere alcuni tipi di errori.", + homePageTitle: "Home", + homePageDescription: "Seleziona quale modulo vuoi come pagina iniziale.", + saveHomePage: "Imposta home", + invites: "Inviti", + invitesTitle: "Inviti", + invitesInvites: "Inviti", + invitesDescription: "Gestisci e applica codici invito nella tua rete.", + invitesTribesTitle: "Tribù", + invitesTribeInviteCodePlaceholder: "Inserisci codice invito tribù", + invitesTribeJoinButton: "Unisciti alla tribù", + invitesChatsTitle: "Chat", + invitesChatInviteCodePlaceholder: "Inserisci codice invito chat", + invitesChatJoinButton: "Unisciti alla chat", + invitesPadsTitle: "Pad", + invitesPadInviteCodePlaceholder: "Inserisci codice invito pad", + invitesPadJoinButton: "Unisciti al pad", + invitesCalendarsTitle: "Calendari", + invitesCalendarInviteCodePlaceholder: "Inserisci codice invito calendario", + invitesCalendarJoinButton: "Unisciti al calendario", + invitesEventsTitle: "Eventi", + invitesEventInviteCodePlaceholder: "Inserisci codice invito evento", + invitesEventJoinButton: "Unisciti all'evento", + invitesForumsTitle: "Forum", + invitesForumInviteCodePlaceholder: "Inserisci codice invito forum", + invitesForumJoinButton: "Unisciti al forum", + invitesMapsTitle: "Mappe", + invitesMapInviteCodePlaceholder: "Inserisci codice invito mappa", + invitesMapJoinButton: "Unisciti alla mappa", + invitesShopsTitle: "Negozi", + invitesShopInviteCodePlaceholder: "Inserisci il codice di invito del negozio", + invitesShopJoinButton: "Unisciti al negozio", + invitesPubsTitle: "PUB", + invitesPubInviteCodePlaceholder: "Inserisci codice invito PUB", + invitesAcceptInvite: "Unisciti al PUB", + invitesAlreadyFederated: "Sei già federato con questo pub.", + invitesFederationsTitle: "Federazioni", + invitesAcceptedInvites: "Federate", + invitesNoInvites: "Nessun invito accettato ancora.", + invitesUnfollow: "Smetti di seguire", + invitesFollow: "Segui", + invitesUnfollowedInvites: "Non federate", + invitesNoFederatedPubs: "Nessun pub federato.", + invitesNoUnfollowed: "Nessun pub non federato.", + invitesUnreachablePubs: "Irraggiungibili", + invitesNoUnreachablePubs: "Nessun pub irraggiungibile.", + invitesPubsRefresh: "Aggiorna", + invitesPubsClearUnreachable: "Rimuovi irraggiungibili", + invitesPubsExport: "Esporta", + invitesPubsImport: "Importa", + invitesPubsImportTitle: "Importa pub", + invitesPubsImportPlaceholder: "Incolla un indirizzo multiserver o un codice invito per riga, o carica un file .txt…", + currentlyUnreachable: "ERRORE!", + errorDetails: "Dettagli errore", + genericError: "Si è verificato un errore.", + panicMode: "Modalità panico!", + encryptData: "Imposta una password (minimo 32 caratteri) per crittografare la tua blockchain", + decryptData: "Inserisci la password per decifrare la tua blockchain", + panicModeDescription: "Crittografa/Decifra o ELIMINA la tua blockchain", + removeDataDescription: "ATTENZIONE: Questo processo non può essere annullato.", + encryptPanicButton: "Crittografa blockchain", + decryptPanicButton: "Decifra blockchain", + removePanicButton: "ELIMINA TUTTI I TUOI DATI!", + searchTitle: "Cerca", + searchDescriptionLabel: "Cerca contenuti nella tua rete.", + searchLanguagesLabel: "Lingue", + searchSkillsLabel: "Competenze", + searchPlaceholder:"Cerca contenuti...", + searchDateLabel:"Data", + searchLocationLabel:"Posizione", + searchPriceLabel:"Prezzo", + searchUrlLabel:"URL", + searchCategoryLabel:"Categoria", + searchStartLabel:"Inizio", + searchEndLabel:"Fine", + searchPriorityLabel:"Priorità", + searchStatusLabel:"Stato", + statusLabel:"Stato", + totalVotesLabel:"Voti totali", + noResultsFound:"Nessun risultato trovato.", + votesOption:"Opzioni di voto", + voteYesLabel:"Sì", + voteNoLabel:"No", + allTypesLabel: "TUTTI", + ABSTENTIONLabel:"ASTENSIONE", + YESLabel:"SÌ", + NOLabel:"NO", + FOLLOW_MAJORITYLabel: "SEGUI MAGGIORANZA", + CONFUSEDLabel: "CONFUSO", + NOT_INTERESTEDLabel: "NON INTERESSATO", + StatusLabel:"Stato", + votesOptionYesLabel:"Sì", + votesOptionNoLabel:"No", + votesOptionAbstentionLabel:"Astensione", + votesQuestionLabel:"Domanda", + votesCreatedByLabel:"Creato da", + voteStatusOpen:"APERTO", + voteStatusClosed:"CHIUSO", + imageSearchLabel: "Inserisci parole per cercare immagini etichettate con esse.", + commentDescription: ({ parentUrl }) => [ + " ha commentato in ", + a({ href: parentUrl }, " discussione"), + ], + commentTitle: ({ authorName }) => [`Commento al post di @${authorName}`], + subtopicDescription: ({ parentUrl }) => [ + " ha creato un sotto-argomento da ", + a({ href: parentUrl }, " un post"), + ], + subtopicTitle: ({ authorName }) => [`Sotto-argomento del post di @${authorName}`], + mysteryDescription: "ha pubblicato un post misterioso", + oasisDescription: "Rete del Progetto OASIS", + searchSubmit: "Cerca...", + postLabel: "POST", + aboutLabel: "ABITANTI", + feedLabel: "FEED", + votesLabel: "VOTAZIONI", + reportLabel: "SEGNALAZIONI", + imageLabel: "IMMAGINI", + videoLabel: "VIDEO", + audioLabel: "AUDIO", + documentLabel: "DOCUMENTI", + torrentLabel: "TORRENT", + pdfFallbackLabel: "Documento PDF", + eventLabel: "EVENTI", + taskLabel: "COMPITI", + transferLabel: "TRASFERIMENTI", + curriculumLabel: "CURRICULUM", + bookmarkLabel: "SEGNALIBRI", + tribeLabel: "TRIBÙ", + marketLabel: "MERCATO", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "PORTAFOGLI", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Invia", + subjectLabel: "Oggetto", + editProfile: "Modifica avatar", + profileQrAlt: "Codice QR del profilo", + profileQrHint: "Scansiona per copiare questo Oasis ID.", + oasisIdLabel: "OasisID", + spreadLabel: "Diffondi", + spreadHint: "Diffondi questo a chi ti supporta (replica via il tuo feed).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Benvenuto su Oasis — un social network libero, peer-to-peer, federato e cifrato, con un'architettura distribuita solo su invito.\n\nAlcuni posti interessanti da cui partire:\n\n- /profile — Modifica il tuo avatar, nome, descrizione e quali moduli appaiono sul tuo lato pubblico.\n- /invites — Aggiungi un pub per federarti con il resto della rete.\n- /peers — Vedi chi è connesso, riscansiona la LAN, esporta o importa liste di peer.\n- /inhabitants — Scopri e visita gli avatar di altre persone.\n- /tribes — Crea o unisciti a gruppi privati cifrati end-to-end.\n- /inbox — Leggi e invia messaggi privati.\n- /activity — Vedi l'attività recente, tua e della tua rete.\n- /publish — Scrivi un post o condividi un'immagine, un audio, un video o un documento.\n\nAlcuni luoghi interessanti per connettersi:\n\n- /fediverse — Gestisci i tuoi altri account del fediverso, inclusi l'invio e la ricezione di contenuti.\n\nQuesto messaggio è stato inviato privatamente da te a te stesso, perciò non serviva alcuna connessione per riceverlo.", + profileVisibilityTitle: "Visibilità del profilo pubblico", + profileVisibilityHint: "Scegli quali campi mostrano gli altri abitanti nel tuo profilo. Per impostazione predefinita appare solo Karma.", + profileSensorsSectionTitle: "Sensori", + profileVisibilityActivity: "Livello di attività", + profileVisibilityDevice: "Dispositivo", + profileDeviceLockedHint: "Questo sensore è sempre attivo: mostra agli altri da quale dispositivo ti connetti e non può essere disattivato.", + profileVisibilityKarma: "Punteggio KARMA", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "Portafoglio ECOIN", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "Chiave GPG", + profileVisibilityClearnet: "Pubblica il mio profilo", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Negozi", + profileClearnetJobsLabel: "Lavori", + profileClearnetEventsLabel: "Eventi", + profileClearnetProjectsLabel: "Progetti", + profileClearnetPostsLabel: "Blog", + profileClearnetAudiosLabel: "Audio", + profileClearnetVideosLabel: "Video", + profileClearnetImagesLabel: "Immagini", + profileClearnetDocumentsLabel: "Documenti", + profileClearnetTorrentsLabel: "Torrent", + editProfileDescription: + "", + profileName: "Nome", + profileImage: "Immagine Avatar", + profileGpgKey: "Chiave Pubblica GPG (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Scarica", + profileGpgRemove: "Rimuovi", + profileDescription: "Descrizione", + hashtagDescription: + "Post dagli abitanti nella tua rete che fanno riferimento a questo #hashtag, ordinati per data.", + rebuildName: "Ricostruisci database", + wallet: "Portafoglio", + walletAddress: "Indirizzo", + walletAmount: "Importo", + walletAddressLine: ({ address }) => `Indirizzo: ${address}`, + walletAmountLine: ({ amount }) => `Importo: ${amount} ECO`, + walletBack: "Indietro", + walletBalanceTitle: "Saldo", + walletWalletSendTitle: "Invia", + walletReceiveTitle: "Ricevi", + walletHistoryTitle: "Cronologia", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Conf.", + walletConfirm: "Conferma", + walletDescription: "Gestisci i tuoi asset digitali, incluso inviare e ricevere ECOin, visualizzare il saldo e accedere alla cronologia delle transazioni.", + walletDate: "Data", + walletFee: "Commissione (più alta è la commissione, più veloce sarà l'elaborazione della transazione)", + walletFeeLine: ({ fee }) => `Commissione: ECO ${fee}`, + walletHistory: "Cronologia", + walletReceive: "Ricevi", + walletReset: "Ripristina", + walletSend: "Invia", + walletStatus: "Stato", + walletDisconnected: [ + "Portafoglio ECOin ", + strong("disconnesso"), + ". Controlla ", + a({ href: '/settings#wallet' }, "le tue impostazioni"), + " o lo stato della connessione.", + ], + walletSentToLine: ({ destination, amount }) => `Inviati ECO ${amount} a ${destination}`, + walletSettingsTitle: "Portafoglio", + walletSettingsDescription: "Integra Oasis con il tuo portafoglio ECOin.", + walletSettingsDocLink: "Guida all'installazione di ECOin", + walletStatusMessages: { + invalid_amount: "Importo non valido", + invalid_dest: "Indirizzo di destinazione non valido", + invalid_fee: "Commissione non valida", + validation_errors: "Errori di validazione", + send_tx_success: "Transazione completata", + }, + walletTitle: "Portafoglio", + walletTotalCostLine: ({ totalCost }) => `Costo totale: ECO ${totalCost}`, + walletTransactionId: "ID transazione", + walletTxId: "ID Tx", + walletType: "Tipo", + walletUser: "Nome utente", + walletPass: "Password", + walletConfiguration: "Imposta portafoglio", + cipher: "Crittografia", + cipherTitle: "Crittografia", + cipherDescription: "Crittografa e decifra il tuo testo simmetricamente (usando una password condivisa).", + randomPassword: "Password casuale", + cipherEncryptTitle: "Crittografa testo", + cipherEncryptDescription: "Inserisci il testo da crittografare", + cipherTextLabel: "Testo da crittografare", + cipherTextPlaceholder: "Inserisci il testo da crittografare...", + cipherPasswordLabel: "Imposta una password (minimo 32 caratteri) per crittografare il testo", + cipherPasswordDecryptLabel: "Imposta una password (minimo 32 caratteri) per decifrare il testo", + cipherPasswordPlaceholder: "Inserisci una password...", + cipherEncryptButton: "Crittografa", + cipherDecryptTitle: "Decifra testo", + cipherDecryptDescription: "Inserisci il testo da decifrare", + cipherEncryptedMessageLabel: "Testo crittografato", + cipherDecryptedMessageLabel: "Testo decifrato", + cipherPasswordUsedLabel: "Password usata per crittografare (conservala!)", + cipherEncryptedTextPlaceholder: "Inserisci il testo crittografato...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "Inserisci il vettore di inizializzazione...", + cipherDecryptButton: "Decifra", + password: "Password", + text: "Testo", + encryptedText: "Testo crittografato", + iv: "Vettore di inizializzazione (IV)", + encryptTitle: "Crittografa il tuo testo", + encryptDescription: "Inserisci il testo che vuoi crittografare e fornisci una password.", + encryptButton: "Crittografa", + decryptTitle: "Decifra il tuo testo", + decryptDescription: "Inserisci il testo crittografato e fornisci la stessa password usata per la crittografia.", + decryptButton: "Decifra", + passwordLengthError: "La password deve avere almeno 32 caratteri.", + missingFieldsError: "Testo, password o IV non forniti.", + encryptionError: "Errore durante la crittografia del testo.", + decryptionError: "Errore durante la decifratura del testo.", + bookmarkTitle: "Segnalibri", + bookmarkDescription: "Scopri e gestisci i segnalibri nella tua rete.", + bookmarkAllSectionTitle: "Segnalibri", + bookmarkMineSectionTitle: "I tuoi segnalibri", + bookmarkRecentSectionTitle: "Segnalibri recenti", + bookmarkTopSectionTitle: "Segnalibri più votati", + bookmarkFavoritesSectionTitle: "Preferiti", + bookmarkCreateSectionTitle: "Crea segnalibro", + bookmarkUpdateSectionTitle: "Aggiorna segnalibro", + bookmarkFilterAll: "TUTTI", + bookmarkFilterMine: "MIEI", + bookmarkFilterTop: "TOP", + bookmarkFilterFavorites: "PREFERITI", + bookmarkFilterRecent: "RECENTI", + bookmarkCreateButton: "Crea segnalibro", + bookmarkUpdateButton: "Aggiorna", + bookmarkDeleteButton: "Elimina", + bookmarkAddFavoriteButton: "Aggiungi preferito", + bookmarkRemoveFavoriteButton: "Rimuovi preferito", + bookmarkUrlLabel: "Link", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "Descrizione", + bookmarkDescriptionPlaceholder: "Opzionale", + bookmarkTagsLabel: "Tag", + bookmarkTagsPlaceholder: "Inserisci tag separati da virgole", + bookmarkCategoryLabel: "Categoria", + bookmarkCategoryPlaceholder: "Opzionale", + bookmarkLastVisitLabel: "Ultima visita", + bookmarkSearchPlaceholder: "Cerca URL, tag, categoria, autore...", + bookmarkSortRecent: "Più recenti", + bookmarkSortOldest: "Più vecchi", + bookmarkSortTop: "Più votati", + bookmarkSearchButton: "Cerca", + bookmarkUpdatedAt: "Aggiornato", + bookmarkNoMatch: "Nessun segnalibro corrisponde alla ricerca.", + noBookmarks: "Nessun segnalibro disponibile.", + noUrl: "Nessun link", + noCategory: "Nessuna categoria", + noLastVisit: "Nessuna ultima visita", + videoTitle: "Video", + videoDescription: "Esplora e gestisci contenuti video nella tua rete.", + videoPluginTitle: "Titolo", + videoPluginDescription: "Descrizione", + videoMineSectionTitle: "I tuoi video", + videoCreateSectionTitle: "Carica video", + videoUpdateSectionTitle: "Aggiorna video", + videoAllSectionTitle: "Video", + videoRecentSectionTitle: "Video recenti", + videoTopSectionTitle: "Video più votati", + videoFavoritesSectionTitle: "Preferiti", + videoFilterAll: "TUTTI", + videoFilterMine: "MIEI", + videoFilterRecent: "RECENTI", + videoFilterTop: "TOP", + videoFilterFavorites: "PREFERITI", + videoCreateButton: "Carica video", + videoUpdateButton: "Aggiorna", + videoDeleteButton: "Elimina", + videoAddFavoriteButton: "Aggiungi preferito", + videoRemoveFavoriteButton: "Rimuovi preferito", + videoFileLabel: "Seleziona un file video (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Tag", + videoTagsPlaceholder: "Inserisci tag separati da virgole", + videoTitleLabel: "Titolo", + videoTitlePlaceholder: "Opzionale", + videoDescriptionLabel: "Descrizione", + videoDescriptionPlaceholder: "Opzionale", + videoNoFile: "Nessun file video fornito", + noVideos: "Nessun video disponibile.", + videoSearchPlaceholder: "Cerca titolo, tag, autore...", + videoSortRecent: "Più recenti", + videoSortOldest: "Più vecchi", + videoSortTop: "Più votati", + videoSearchButton: "Cerca", + videoMessageAuthorButton: "PM", + videoUpdatedAt: "Aggiornato", + videoNoMatch: "Nessun video corrisponde alla ricerca.", + documentTitle: "Documenti", + documentDescription: "Scopri e gestisci documenti nella tua rete.", + documentAllSectionTitle: "Documenti", + documentMineSectionTitle: "I tuoi documenti", + documentRecentSectionTitle: "Documenti recenti", + documentTopSectionTitle: "Documenti più votati", + documentFavoritesSectionTitle: "Preferiti", + documentCreateSectionTitle: "Carica documento", + documentUpdateSectionTitle: "Modifica documento", + documentFilterAll: "TUTTI", + documentFilterMine: "MIEI", + documentFilterRecent: "RECENTI", + documentFilterTop: "TOP", + documentFilterFavorites: "PREFERITI", + documentCreateButton: "Carica documento", + documentUpdateButton: "Aggiorna", + documentDeleteButton: "Elimina", + documentAddFavoriteButton: "Aggiungi preferito", + documentRemoveFavoriteButton: "Rimuovi dai preferiti", + documentMessageAuthorButton: "PM", + documentFileLabel: "Carica documento (.pdf)", + documentTagsLabel: "Tag", + documentTagsPlaceholder: "Inserisci tag separati da virgole", + documentTitleLabel: "Titolo", + documentTitlePlaceholder: "Opzionale", + documentDescriptionLabel: "Descrizione", + documentDescriptionPlaceholder: "Opzionale", + documentNoFile: "Nessun file.", + noDocuments: "Nessun documento disponibile.", + documentSearchPlaceholder: "Cerca titolo, tag, descrizione, autore...", + documentSortRecent: "Più recenti", + documentSortOldest: "Più vecchi", + documentSortTop: "Più votati", + documentSearchButton: "Cerca", + documentNoMatch: "Nessun documento corrisponde alla ricerca.", + documentUpdatedAt: "Aggiornato", + audioTitle: "Audio", + audioDescription: "Esplora e gestisci contenuti audio nella tua rete.", + audioPluginTitle: "Titolo", + audioPluginDescription: "Descrizione", + audioMineSectionTitle: "I tuoi audio", + audioCreateSectionTitle: "Carica audio", + audioUpdateSectionTitle: "Aggiorna audio", + audioAllSectionTitle: "Audio", + audioRecentSectionTitle: "Audio recenti", + audioTopSectionTitle: "Audio più votati", + audioFilterAll: "TUTTI", + audioFilterMine: "MIEI", + audioFilterRecent: "RECENTI", + audioFilterTop: "TOP", + audioFilterBlockchain: "BLOCKCHAIN", + audioCreateButton: "Carica audio", + audioUpdateButton: "Aggiorna", + audioDeleteButton: "Elimina", + audioAddFavoriteButton: "Aggiungi preferito", + audioRemoveFavoriteButton: "Rimuovi preferito", + audioFileLabel: "Seleziona un file audio (.mp3, .wav, .ogg)", + audioTagsLabel: "Tag", + audioTagsPlaceholder: "Inserisci tag separati da virgole", + audioTitleLabel: "Titolo", + audioTitlePlaceholder: "Opzionale", + audioDescriptionLabel: "Descrizione", + audioDescriptionPlaceholder: "Opzionale", + audioNoFile: "Nessun file audio fornito", + noAudios: "Nessun audio disponibile.", + audioSearchPlaceholder: "Cerca titolo, tag, autore...", + audioSortRecent: "Più recenti", + audioSortOldest: "Più vecchi", + audioSortTop: "Più votati", + audioSearchButton: "Cerca", + audioMessageAuthorButton: "PM", + audioUpdatedAt: "Aggiornato", + audioNoMatch: "Nessun audio corrisponde alla ricerca.", + audioFavoritesSectionTitle: "Preferiti", + audioFilterFavorites: "PREFERITI", + favoritesTitle: "Preferiti", + favoritesDescription: "Tutti i tuoi contenuti preferiti in un unico posto.", + favoritesFilterAll: "TUTTI", + favoritesFilterRecent: "RECENTI", + favoritesFilterAudios: "AUDIO", + favoritesFilterBookmarks: "SEGNALIBRI", + favoritesFilterDocuments: "DOCUMENTI", + favoritesFilterImages: "IMMAGINI", + favoritesFilterMaps: "MAPPE", + favoritesFilterPads: "PADS", + favoritesFilterChats: "CHAT", + favoritesFilterCalendars: "CALENDARI", + favoritesFilterVideos: "VIDEO", + favoritesRemoveButton: "Rimuovi dai preferiti", + favoritesNoItems: "Nessun preferito ancora.", + yourContacts: "I tuoi contatti", + allInhabitants: "Abitanti", + allCVs: "Tutti i CV", + discoverPeople: "Scopri abitanti nella tua rete.", + allInhabitantsButton: "TUTTI", + contactsButton: "SUPPORTATI", + CVsButton: "CV", + matchSkills: "Abbina competenze", + matchSkillsButton: "ABBINA COMPETENZE", + suggestedButton: "SUGGERITI", + searchInhabitantsPlaceholder: "FILTRA abitanti PER NOME …", + filterLocation: "FILTRA abitanti PER POSIZIONE …", + filterLanguage: "FILTRA abitanti PER LINGUA …", + filterSkills: "FILTRA abitanti PER COMPETENZE …", + applyFilters: "Applica filtri", + locationLabel: "Posizione", + languagesLabel: "Lingue", + skillsLabel: "Competenze", + commonSkills: "Competenze comuni", + mutualFollowers: "Follower reciproci", + suggestedFollowsYou: "Ti segue", + latestInteractions: "Ultime interazioni", + viewAvatar: "Vedi avatar", + viewCV: "Vedi CV", + suggestedSectionTitle: "Suggeriti", + topkarmaSectionTitle: "Top Karma", + topecoSectionTitle: "Top Eco", + topactivitySectionTitle: "Top Attività", + blockedSectionTitle: "Bloccati", + gallerySectionTitle: "GALLERIA", + blockedButton: "BLOCCATI", + blockedLabel: "Abitante bloccato", + inhabitantviewDetails: "Vedi dettagli", + keepReading: "Continua a leggere...", + oasisId: "ID", + noInhabitantsFound: "Nessun abitante trovato ancora.", + inhabitantActivityLevel: "Livello di attività", + lifespanLabel: "Durata di vita", + deviceLabel: "Dispositivo", + parliamentTitle: "Parlamento", + parliamentDescription: "Esplora forme di governo e leggi di gestione collettiva.", + parliamentFilterGovernment: "GOVERNO", + parliamentFilterCandidatures: "CANDIDATURE", + parliamentFilterProposals: "PROPOSTE", + parliamentFilterLaws: "LEGGI", + parliamentFilterHistorical: "STORICO", + parliamentFilterLeaders: "LEADER", + parliamentFilterRules: "REGOLE", + parliamentGovernmentCard: "Governo attuale", + parliamentActorInPowerInhabitant: 'ABITANTE AL POTERE', + parliamentActorInPowerTribe: 'TRIBÙ AL POTERE', + parliamentHistoricalGovernmentsTitle: 'GOVERNI', + parliamentHistoricalElectionsTitle: 'CICLI ELETTORALI', + parliamentHistoricalLawsTitle: 'STORICO', + parliamentHistoricalLeadersTitle: 'LEADER', + parliamentThCycles: 'CICLI', + parliamentThTimesInPower: 'AL POTERE', + parliamentThTotalCandidatures: 'CANDIDATURE', + parliamentThProposed: 'LEGGI PROPOSTE', + parliamentThApproved: 'LEGGI APPROVATE', + parliamentThDeclined: 'LEGGI RESPINTE', + parliamentThDiscarded: 'LEGGI SCARTATE', + parliamentMembers: "MEMBRI", + parliamentLegSince: "CICLO DA", + parliamentLegEnd: "FINE CICLO", + parliamentPoliciesProposal: "PROPOSTE DI LEGGE", + parliamentPoliciesApproved: "LEGGI APPROVATE", + parliamentPoliciesDeclined: "LEGGI RESPINTE", + parliamentPoliciesDiscarded: "LEGGI SCARTATE", + parliamentEfficiency: "% EFFICIENZA", + parliamentFilterRevocations: 'REVOCHE', + parliamentRevocationFormTitle: 'Revoca legge', + parliamentRevocationLaw: 'Legge', + parliamentRevocationTitle: 'Titolo', + parliamentRevocationReasons: 'Motivazioni', + parliamentRevocationPublish: 'Pubblica revoca', + parliamentCurrentRevocationsTitle: 'Revoche attuali', + parliamentFutureRevocationsTitle: 'Revoche future', + parliamentPoliciesRevocated: 'LEGGI REVOCATE', + parliamentPoliciesTitle: 'STORICO', + parliamentLawsTitle: 'LEGGI APPROVATE', + parliamentRulesRevocations: 'Qualsiasi legge approvata può essere revocata usando il metodo di governo attuale.', + parliamentNoStableGov: "Nessun governo scelto ancora.", + parliamentNoGovernments: "Non ci sono ancora governi.", + parliamentCandidatureFormTitle: "Proponi candidatura", + parliamentCandidatureIdPh: "Oasis ID (@...) o nome tribù", + parliamentCandidatureSlogan: "Slogan (max 140 caratteri)", + parliamentCandidatureSloganPh: "Un breve motto", + parliamentCandidatureMethod: "Metodo", + parliamentCandidatureProposeBtn: "Pubblica candidatura", + parliamentThDate: "Data proposta", + parliamentThSlogan: "Slogan", + parliamentThSince: "Profilo dal", + parliamentThVotes: "Voti ricevuti", + parliamentThVoteAction: "Vota", + parliamentTypeUser: "Abitante", + parliamentTypeTribe: "Tribù", + parliamentVoteBtn: "Vota", + parliamentProposalFormTitle: "Proponi legge", + parliamentProposalDescription: "Descrizione (≤1000)", + parliamentProposalPublish: "Pubblica proposta", + parliamentFinalize: "Finalizza", + parliamentDeadline: "Scadenza", + parliamentNoProposals: "Non ci sono ancora proposte di legge.", + parliamentNoLaws: "Non ci sono ancora leggi approvate.", + parliamentMethodDEMOCRACY: "Democrazia", + parliamentMethodMAJORITY: "Maggioranza (80%)", + parliamentMethodMINORITY: "Minoranza (20%)", + parliamentMethodDICTATORSHIP: "Dittatura", + parliamentMethodKARMATOCRACY: "Karmatocrazia", + parliamentThId: "ID", + parliamentThProposalDate: "Data proposta", + parliamentThMethod: "Method", + parliamentThKarma: "Karma", + parliamentThSupports: "Supporti", + parliamentThVote: "Voto", + parliamentCurrentProposalsTitle: "Proposte attuali", + parliamentVotesSlashTotal: "Voti/Totale", + parliamentVotesNeeded: "Voti necessari", + parliamentFutureLawsTitle: "Leggi future", + parliamentNoFutureLaws: "Nessuna legge futura, per ora.", + parliamentVoteAction: "Vota", + parliamentLeadersTitle: "Leader", + parliamentThLeader: "AVATAR", + parliamentPopulation: "Popolazione", + parliamentThType: "Tipo", + parliamentThInPower: "Al potere", + parliamentThPresented: "CANDIDATURE", + parliamentNoLeaders: "Nessun leader, per ora.", + parliamentCandidaturesListTitle: "Lista delle candidature", + parliamentTimeRemaining: "Tempo rimanente", + parliamentNoLeader: "Nessuna candidatura vincente, per ora.", + parliamentElectionsStatusTitle: "Prossimo governo", + parliamentProposalDeadlineLabel: "Scadenza", + parliamentProposalTimeLeft: "Tempo rimanente", + parliamentProposalOnTrack: "In linea per l'approvazione", + parliamentProposalOffTrack: "Supporto insufficiente", + parliamentRulesTitle: "Come funziona il Parlamento", + parliamentRulesIntro: "Le elezioni si risolvono ogni 2 mesi; le candidature sono continue e si azzerano quando un governo viene scelto.", + parliamentRulesCandidates: "Qualsiasi abitante può proporre se stesso, un altro abitante o qualsiasi tribù. Ogni abitante può proporre fino a 3 candidature per ciclo; i duplicati nello stesso ciclo vengono rifiutati.", + parliamentRulesElection: "Il vincitore è la candidatura con il maggior numero di voti al momento della risoluzione.", + parliamentRulesTies: "Ordine di spareggio: karma più alto dell'abitante; in caso di parità, profilo più vecchio; se ancora in parità, proposta più antica; poi ordine lessicografico per ID.", + parliamentRulesFallback: "Se nessuno vota, vince l'ultima candidatura proposta. Se non ci sono candidature, viene selezionata una tribù casuale.", + parliamentRulesTerm: "La scheda Governo mostra il governo attuale e le statistiche.", + parliamentRulesMethods: "Forme: Anarchia (maggioranza semplice), Democrazia (50%+1), Maggioranza (80%), Minoranza (20%), Karmatocrazia (proposte con karma più alto), Dittatura (approvazione istantanea).", + parliamentRulesAnarchy: "L'Anarchia è la modalità predefinita: se nessuna candidatura viene eletta alla risoluzione, viene proclamata l'Anarchia. In Anarchia, qualsiasi abitante può proporre leggi.", + parliamentRulesProposals: "Se sei l'abitante al potere o un membro della tribù al potere, puoi pubblicare proposte di legge. I metodi non dittatoriali creano una votazione pubblica.", + parliamentRulesLimit: "Ogni abitante può pubblicare al massimo 3 proposte di legge per ciclo.", + parliamentRulesLaws: "Quando una proposta raggiunge la soglia, diventa una Legge e appare nella scheda Leggi con la data di emanazione.", + parliamentRulesHistorical: "Nella scheda Storico puoi vedere ogni ciclo di governo avvenuto e i dati sulla sua gestione.", + parliamentRulesLeaders: "Nella scheda Leader puoi vedere una classifica degli abitanti/tribù che hanno governato (o si sono candidati), ordinata per efficienza.", + parliamentProposalVoteStatusLabel: "Stato del voto", + parliamentProposalOnTrackYes: "Soglia raggiunta", + parliamentProposalOnTrackNo: "Sotto la soglia", + courtsTitle: "Tribunali", + courtsDescription: "Esplora forme di risoluzione dei conflitti e gestione della giustizia collettiva.", + courtsFilterCases: "CASI", + courtsFilterMyCases: "MIEI", + courtsFilterJudges: "GIUDICI", + courtsFilterHistory: "STORICO", + courtsFilterRules: "REGOLE", + courtsCaseFormTitle: "Apri caso", + courtsCaseRespondent: "Accusato / Convenuto", + courtsCaseRespondentPh: "Oasis ID (@...) o nome tribù", + courtsCaseMediatorsAccuser: "Mediatori (accusatore)", + courtsCaseMethod: "Metodo di risoluzione", + courtsCaseDescription: "Descrizione (max 1000 caratteri)", + courtsCaseEvidenceTitle: "Prove del caso", + courtsCaseEvidenceHelp: "Allega immagini, audio, documenti (PDF) o video a supporto del tuo caso.", + courtsCaseSubmit: "Presenta caso", + courtsNominateJudge: "Nomina giudice", + courtsJudgeId: "Giudice", + courtsJudgeIdPh: "Oasis ID (@...) o nome abitante", + courtsNominateBtn: "Nomina", + courtsAddEvidence: "Aggiungi prova", + courtsEvidenceText: "Testo", + courtsEvidenceLink: "Link", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "Allega", + courtsAnswerText: "Memoria di risposta", + courtsAnswerSubmit: "Invia risposta", + courtsVerdictTitle: "Emetti verdetto", + courtsVerdictOrdersPh: "Azioni, scadenze, misure riparative ...", + courtsIssueVerdict: "Emetti verdetto", + courtsMediationPropose: "Proponi accordo", + courtsSettlementProposeBtn: "Proponi", + courtsNominationsTitle: "Nomine giudiziarie", + courtsThJudge: "Giudice", + courtsThDate: "Data", + courtsThVote: "Voto", + courtsNoNominations: "Nessuna nomina ancora.", + courtsCaseMediatorsRespondentTitle: "Aggiungi mediatori della difesa", + courtsCaseMediatorsRespondent: "Mediatori (difesa)", + courtsMediatorsAccuserLabel: "Mediatori (accusatore)", + courtsMediatorsRespondentLabel: "Mediatori (difesa)", + courtsMediatorsSubmit: "Salva mediatori", + courtsNoMyCases: "Non hai ancora conflitti.", + courtsNoHistory: "Nessun processo registrato ancora.", + courtsMethodDICTATOR: "Dittatore", + courtsMethodPOPULAR: "Popolare", + courtsMethodKARMATOCRACY: "Karmatocrazia", + courtsRulesTitle: "Come funzionano i Tribunali", + courtsRulesIntro: "I Tribunali sono un processo gestito dalla comunità per risolvere i conflitti e promuovere la giustizia riparativa. Dialogo, prove chiare e rimedi proporzionali hanno la priorità.", + courtsRulesLifecycle: "Processo: 1) Apri caso 2) Seleziona metodo 3) Presenta prove 4) Udienza e deliberazione 5) Verdetto e rimedio 6) Adempimento e chiusura 7) Appello (se applicabile).", + courtsRulesRoles: "Accusatore: apre il caso. Difesa: persona o tribù accusata. Metodo: meccanismo scelto dalla comunità per facilitare, valutare le prove ed emettere un verdetto. Testimone: fornisce testimonianza o prove. Mediatori: persone neutrali invitate dall'accusatore e/o dalla difesa, con accesso a tutti i dettagli, che aiutano a de-escalare il conflitto e co-creare accordi.", + courtsRulesEvidence: "Descrizione fino a 1000 caratteri. Allega immagini, audio, video e documenti PDF pertinenti e leciti. Non condividere dati privati sensibili senza consenso.", + courtsRulesDeliberation: "Le udienze possono essere pubbliche o private. I giudici assicurano il rispetto, richiedono chiarimenti e possono scartare materiale irrilevante o illecito.", + courtsRulesVerdict: "La riparazione è prioritaria: scuse, accordi di mediazione, moderazione dei contenuti, restrizioni temporanee o altre misure proporzionali. La motivazione deve essere registrata.", + courtsRulesAppeals: "Appello: consentito quando ci sono nuove prove o un chiaro errore procedurale. Deve essere presentato entro 7 giorni salvo diversa indicazione.", + courtsRulesPrivacy: "Rispetta la privacy e la sicurezza. Doxing, odio o minacce vengono rimossi. I giudici possono modificare o sigillare parti del verbale per proteggere le persone a rischio.", + courtsRulesMisconduct: "Molestie, manipolazione o prove fabbricate possono portare a una risoluzione negativa immediata.", + courtsRulesGlossary: "Caso: registro di un conflitto. Prove: materiali a supporto delle affermazioni. Verdetto: decisione con rimedio. Appello: richiesta di revisione del verdetto.", + courtsFilterActions: "AZIONI", + courtsNoActions: "Nessuna azione in sospeso per il tuo ruolo.", + courtsCaseTitlePlaceholder: "Breve descrizione del conflitto", + courtsCaseSeverity: "Gravità", + courtsCaseSeverityNone: "Nessun livello di gravità", + courtsCaseSeverityLOW: "Bassa", + courtsCaseSeverityMEDIUM: "Media", + courtsCaseSeverityHIGH: "Alta", + courtsCaseSeverityCRITICAL: "Critico", + courtsCaseSubject: "Argomento", + courtsCaseSubjectNone: "Nessun argomento", + courtsCaseSubjectBEHAVIOUR: "Comportamento", + courtsCaseSubjectCONTENT: "Contenuto", + courtsCaseSubjectGOVERNANCE: "Governance / regole", + courtsCaseSubjectFINANCIAL: "Finanze / risorse", + courtsCaseSubjectOTHER: "Altro", + courtsHiddenRespondent: "Nascosto (visibile solo ai ruoli coinvolti).", + courtsThRole: "Ruolo", + courtsRoleAccuser: "Accusatore", + courtsRoleDefence: "Difesa", + courtsRoleMediator: "Mediatore", + courtsRoleJudge: "Giudice", + courtsRoleDictator: "Dittatore", + courtsAssignJudgeTitle: "Scegli giudice", + courtsAssignJudgeBtn: "Scegli giudice", + trendingTitle: "Tendenze", + exploreTrending: "Esplora i contenuti più popolari nella tua rete.", + bookmarkButton: "SEGNALIBRI", + transferButton: "TRASFERIMENTI", + eventButton: "EVENTI", + taskButton: "COMPITI", + votesButton: "VOTAZIONI", + reportButton: "SEGNALAZIONI", + feedButton: "FEED", + marketButton: "MERCATO", + imageButton: "IMMAGINI", + audioButton: "AUDIO", + videoButton: "VIDEO", + documentButton: "DOCUMENTI", + torrentButton: "TORRENT", + noTrendingFound: "Nessun contenuto di tendenza trovato.", + noContentMessage: "Nessun contenuto di tendenza disponibile.", + trendingDescription: "Descrizione", + trendingDate: "Data", + trendingLocation: "Posizione", + trendingPrice: "Prezzo", + trendingUrl: "URL", + trendingCategory: "Categoria", + trendingStart: "Inizio", + trendingEnd: "Fine", + trendingPriority: "Priorità", + trendingStatus: "Stato", + trendingFrom: "Da", + trendingTo: "A", + trendingConcept: "Concetto", + trendingAmount: "Importo", + trendingDeadline: "Scadenza", + trendingItemStatus: "Stato articolo", + trendingTotalVotes: "Voti totali", + trendingTotalOpinions: "Opinioni totali", + trendingNoContentMessage: "Nessun contenuto di tendenza disponibile.", + trendingAuthor: "Di", + trendingCreatedAtLabel: "Creato il", + trendingTotalCount: "Conteggio totale", + tasksTitle: "Compiti", + tasksDescription: "Scopri e gestisci compiti nella tua rete.", + taskTitleLabel: "Titolo", + taskDescriptionLabel: "Descrizione", + taskStartTimeLabel: "Ora di inizio", + taskEndTimeLabel: "Ora di fine", + taskPriorityLabel: "Priorità", + taskPrioritySelect: "Seleziona priorità", + taskPriorityUrgent: "Urgente", + taskPriorityHigh: "Alta", + taskPriorityMedium: "Media", + taskPriorityLow: "Bassa", + taskLocationLabel: "Posizione", + taskTagsLabel: "Tag", + taskVisibilityLabel: "Visibilità", + taskPublic: "pubblico", + taskPrivate: "privato", + taskCreatedAt: "Creato il", + taskBy: "Di", + taskStatus: "Stato", + taskStatusOpen: "Apri", + taskStatusInProgress: "In corso", + taskStatusClosed: "Chiuso", + taskAssignedTo: "Assegnato a", + taskAssignees: "Assegnatari", + taskAssignButton: "Assegna a me", + taskUnassignButton: "Rimuovi assegnazione", + taskCreateButton: "Crea compito", + taskUpdateButton: "Aggiorna", + taskDeleteButton: "Elimina", + taskFilterAll: "TUTTI", + taskFilterMine: "MIEI", + taskFilterOpen: "APERTI", + taskFilterInProgress: "IN CORSO", + taskFilterClosed: "CHIUSI", + taskFilterAssigned: "ASSEGNATI", + taskFilterArchived: "ARCHIVIATI", + taskFilterUrgent: "URGENTE", + taskFilterHigh: "ALTA", + taskFilterMedium: "MEDIA", + taskFilterLow: "BASSA", + taskAllSectionTitle: "Compiti", + taskMineSectionTitle: "I tuoi compiti", + taskCreateSectionTitle: "Crea compito", + taskUpdateSectionTitle: "Aggiorna", + taskOpenTitle: "Compiti aperti", + taskInProgressTitle: "Compiti in corso", + taskClosedTitle: "Compiti chiusi", + taskAssignedTitle: "Compiti assegnati", + taskArchivedTitle: "Compiti archiviati", + taskPublicTitle: "Compiti pubblici", + taskPrivateTitle: "Compiti privati", + notasks: "Nessun compito disponibile.", + noLocation: "Nessuna posizione specificata", + taskSetStatus: "Imposta stato", + eventTitle: "Eventi", + eventDateLabel: "Data", + eventsTitle: "Eventi", + eventsDescription: "Scopri e gestisci eventi nella tua rete.", + eventDescription: "Descrizione", + eventPrice: "Prezzo", + eventStatus: "Stato", + eventOrganizer: "Organizzatore", + eventAllSectionTitle: "Eventi", + eventMineSectionTitle: "I tuoi eventi", + eventArchivedTitle: "Eventi archiviati", + eventCreateSectionTitle: "Crea evento", + eventUpdateSectionTitle: "Aggiorna evento", + eventDeleteButton: "Elimina", + eventCreateButton: "Crea evento", + eventAddToCalendar: "Aggiungi al calendario", + eventVisitCalendar: "Vai al calendario", + viewTask: "Visualizza attività", + viewReport: "Visualizza segnalazione", + viewTransfer: "Visualizza trasferimento", + viewItem: "Visualizza articolo", + viewShop: "Visualizza negozio", + viewProject: "Visualizza progetto", + viewVotation: "Visualizza votazione", + voteCastTitle: "Esprimi il voto", + voteResults: "Risultati", + eventTitleLabel: "Titolo", + eventDescriptionLabel: "Descrizione", + eventDescriptionPlaceholder: "Inserisci descrizione evento...", + eventUpdateButton: "Aggiorna", + eventAttendeesLabel: "Partecipanti", + eventAttendeesPlaceholder: "Inserisci partecipanti, separati da virgole...", + eventTagsLabel: "Tag", + eventTagsPlaceholder: "Inserisci tag evento, separati da virgole...", + eventTags: "Tag", + eventPriceLabel: "Prezzo", + eventUrlLabel: "URL", + eventAttendees: "Partecipanti", + noAttendees: "Nessun partecipante", + eventCreatedAt: "Creato il", + eventLocation: "Posizione", + eventLocationLabel: "Posizione", + eventNoLocation: "Nessuna posizione specificata", + eventNoURL: "Nessun URL specificato", + eventBy: "Di", + noevents: "Nessun evento disponibile.", + eventDate: "Data", + eventDateFormat: "DD:MM:YYYY HH:mm", + eventAttendButton: "Partecipa all'evento", + eventUnattendButton: "Lascia l'evento", + eventCreatedBy: "Creato da", + eventAttendeesCount: "Numero partecipanti", + eventCreatedByYou: "Hai creato questo evento", + eventFilterAll: "TUTTI", + eventFilterMine: "MIEI", + eventFilterToday: "OGGI", + eventFilterWeek: "QUESTA SETTIMANA", + eventFilterMonth: "QUESTO MESE", + eventFilterYear: "QUEST'ANNO", + eventFilterArchived: "ARCHIVIATI", + eventTodayTitle: "Eventi di oggi", + eventThisWeekTitle: "Eventi di questa settimana", + eventThisMonthTitle: "Eventi di questo mese", + eventThisYearTitle: "Eventi di quest'anno", + eventPrivacyLabel: "Visibilità", + eventPublic: "Pubblico", + eventPrivate: "Privato", + eventPublicTitle: "Eventi pubblici", + eventNoPrice: "Evento gratuito", + eventNoImage: "Nessuna immagine caricata", + eventAttendConfirmation: "Stai partecipando a questo evento", + eventUnattendConfirmation: "Non partecipi più a questo evento", + eventAttended: "Partecipato", + eventUnattended: "Non partecipato", + eventStatusOpen: "Aperto", + eventStatusClosed: "Chiuso", + tagsTitle: "Tag", + tagsDescription: "Scopri ed esplora i pattern tassonomici nella tua rete.", + tagsAllSectionTitle: "Tag", + tagsTopSectionTitle: "Tag più usati", + tagsCloudSectionTitle: "Nuvola di tag", + tagsFilterAll: "TUTTI", + tagsFilterTop: "TOP", + tagsFilterCloud: "NUVOLA", + tagsNoItems: "Nessun tag disponibile.", + tagsTableHeaderTag: "TAG/LINK:", + tagsTableHeaderCount: "CONTATORE:", + transfersTitle: "Trasferimenti", + transfersDescription: "Scopri e gestisci trasferimenti nella tua rete.", + transfersFrom: "Da", + transfersTo: "A", + transfersFilterAll: "TUTTI", + transfersFilterMine: "MIEI", + transfersFilterUBI: "RBU", + transfersFilterMarket: "MERCATO", + transfersFilterTop: "TOP", + transfersFilterPending: "IN ATTESA", + transfersFilterUnconfirmed: "NON CONFERMATI", + transfersFilterClosed: "CHIUSI", + transfersFilterDiscarded: "SCARTATI", + transfersFilterEconomic: "ECONOMICA", + transfersFilterTime: "TEMPO", + transfersFilterTrust: "FIDUCIA", + transfersCategory: "Categoria", + transfersCategoryEconomic: "Economica", + transfersCategoryTime: "Tempo", + transfersCategoryTrust: "Fiducia", + transfersCategoryApply: "Applica", + transfersUnitEco: "ECO", + transfersUnitHours: "h", + transfersUnitTrust: "fiducia", + transfersEconomicSectionTitle: "Trasferimenti economici", + transfersTimeSectionTitle: "Trasferimenti di tempo", + transfersTrustSectionTitle: "Trasferimenti di fiducia", + transfersCreateButton: "Crea trasferimento", + transfersUpdateButton: "Aggiorna", + transfersDeleteButton: "Elimina", + transfersToUser: "Oasis ID", + transfersToUserValidation: "Oasis ID valido, es. @…=.ed25519", + transfersConcept: "Concetto", + transfersAmount: "Importo", + transfersDeadline: "Scadenza", + transfersTags: "Tag", + transfersStatus: "Stato", + transfersStatusUnconfirmed: "NON CONFERMATO", + transfersStatusClosed: "CHIUSO", + transfersStatusDiscarded: "SCARTATO", + transfersCreatedAt: "Creato il", + transfersConfirmations: "CONFERME", + transfersContainingBlock: "Blocco contenitore", + transfersExportContract: "Crea contratto", + transfersConfirmButton: "Conferma trasferimento", + transfersNoItems: "Nessun trasferimento trovato.", + transfersMineSectionTitle: "I tuoi trasferimenti", + transfersUBISectionTitle: "Trasferimenti RBU", + transfersMarketSectionTitle: "Trasferimenti di mercato", + transfersTopSectionTitle: "Trasferimenti principali", + transfersPendingSectionTitle: "Trasferimenti in attesa", + transfersUnconfirmedSectionTitle: "Trasferimenti non confermati", + transfersClosedSectionTitle: "Trasferimenti chiusi", + transfersDiscardedSectionTitle: "Trasferimenti scartati", + transfersCreateSectionTitle: "Crea trasferimento", + transfersAllSectionTitle: "Trasferimenti", + transfersFilterFavs: "Preferiti", + transfersFavsSectionTitle: "Trasferimenti preferiti", + transfersSearchLabel: "Cerca", + transfersSearchPlaceholder: "Cerca concetto, tag, abitanti...", + transfersMinAmountLabel: "Importo minimo", + transfersMaxAmountLabel: "Importo massimo", + transfersSortLabel: "Ordina per", + transfersSortRecent: "Più recenti", + transfersSortAmount: "Importo più alto", + transfersSortDeadline: "Scadenza più vicina", + transfersSearchButton: "Cerca", + transfersFavoriteButton: "Preferito", + transfersUnfavoriteButton: "Rimuovi preferito", + transfersMessageUserButton: "Messaggio", + transfersExpiringSoonBadge: "IN SCADENZA", + transfersExpiredBadge: "SCADUTO", + transfersUpdatedAt: "Aggiornato", + transfersNoMatch: "Nessun trasferimento corrisponde alla ricerca.", + votationsTitle: "Votazioni", + votationsDescription: "Scopri e gestisci le votazioni nella tua rete.", + voteMineSectionTitle: "Le tue votazioni", + voteCreateSectionTitle: "Crea votazione", + voteUpdateSectionTitle: "Aggiorna", + voteOpenTitle: "Votazioni aperte", + voteClosedTitle: "Votazioni chiuse", + voteAllSectionTitle: "Votazioni", + voteCreateButton: "Crea votazione", + voteUpdateButton: "Aggiorna", + voteDeleteButton: "Elimina", + voteOptionYes: "SÌ", + voteOptionNo: "NO", + voteOptionAbstention: "ASTENSIONE", + voteConfused: "CONFUSO", + voteFollowMajority: "SEGUI MAGGIORANZA", + voteNotInterested: "NON INTERESSATO", + voteFilterAll: "TUTTI", + voteFilterMine: "MIEI", + voteFilterOpen: "APERTE", + voteFilterClosed: "CHIUSE", + voteQuestionLabel: "Domanda", + voteDeadlineLabel: "Scadenza", + voteOptionsLabel: "Il tuo voto", + voteBreakdown: "Dettaglio", + voteFinalResult: "RISULTATO", + voteNoQuorum: "SENZA QUORUM", + voteTagsLabel: "Tag", + voteDeadline: "Scadenza", + voteStatus: "Stato", + voteTags: "Tag", + voteOpinions: "Opinioni", + novotes: "Nessuna proposta di voto disponibile.", + voteBy: "Di", + voteCreatedAt: "Creato il", + voteNoQuestion: "Nessuna domanda fornita", + voteUnknownCreator: "Creatore sconosciuto", + voteUnknownDate: "Data sconosciuta", + errorVoteNotFound: "Voto non trovato", + errorAlreadyVoted: "Hai già espresso un'opinione.", + errorVoteClosedCannotEdit: "Non puoi modificare una votazione chiusa", + errorVoteDeadlinePassed: "La scadenza per questa votazione è passata", + errorRetrievingVote: "Errore nel recupero del voto", + errorCreatingVote: "Errore nella creazione del voto", + errorVoteAlreadyVoted: "Non puoi modificare dopo aver espresso un'opinione", + errorDeletingOldVote: "Errore nell'eliminazione della vecchia opinione", + errorCreatingUpdatedVote: "Errore nella creazione dell'opinione aggiornata", + errorCreatingTombstone: "Errore nella creazione del tombstone", + voteDetailSectionTitle: 'Dettagli voto', + voteCommentsLabel: 'Commenti', + voteCommentsForumButton: 'Apri discussione', + voteCommentsSectionTitle: 'Apri discussione', + voteNoCommentsYet: 'Non ci sono ancora commenti. Sii il primo a rispondere.', + voteNewCommentPlaceholder: 'Scrivi il tuo commento qui…', + voteNewCommentButton: 'Pubblica commento', + voteNewCommentLabel: 'Aggiungi un commento', + cvTitle: "Curriculum", + cvLabel: "Curriculum Vitae (CV)", + cvEditSectionTitle: "Modifica CV", + cvCreateSectionTitle: "Crea CV", + cvDescription: "Gestisci e condividi le tue competenze e informazioni professionali.", + cvNameLabel: "Nome completo", + cvDescriptionLabel: "Riepilogo", + cvPhotoLabel: "Foto", + cvPersonalExperiencesLabel: "Esperienze personali", + cvPersonalSkillsLabel: "Competenze personali (separate da virgole)", + cvOasisExperiencesLabel: "Esperienze di contributo Oasis", + cvOasisSkillsLabel: "Competenze di contributo Oasis (separate da virgole)", + cvEducationExperiencesLabel: "Esperienze formative", + cvEducationalSkillsLabel: "Competenze formative (separate da virgole)", + cvProfessionalExperiencesLabel: "Esperienze professionali", + cvProfessionalSkillsLabel: "Competenze professionali (separate da virgole)", + cvLanguagesLabel: "Lingue", + cvLocationLabel: "Posizione", + cvStatusLabel: "Stato", + cvPreferencesLabel: "Preferenze", + cvOasisContributorLabel: "Contributore Oasis", + cvPersonal: "Personale", + cvOasis: "Contributore Oasis (opzionale)", + cvOasisContributorView: "Contributo Oasis", + cvEducational: "Formazione (opzionale)", + cvEducationalView: "Formazione", + cvProfessional: "Professionale (opzionale)", + cvProfessionalView: "Professionale", + cvAvailability: "Disponibilità (opzionale)", + cvAvailabilityView: "Disponibilità", + cvUpdateButton: "Aggiorna", + cvCreateButton: "Crea CV", + cvContactLabel: "Contatto", + cvCreatedAt: "Creato il", + cvUpdatedAt: "Aggiornato il", + cvEditButton: "Aggiorna", + cvDeleteButton: "Elimina", + cvNoCV: "Nessun CV trovato.", + blogSubject: "Oggetto", + blogMessage: "Corpo limitato a 8096 caratteri.", + blogImage: "Carica media (max: 50MB)", + blogPublish: "Anteprima", + noPopularMessages: "Nessun messaggio popolare pubblicato ancora", + forumTitle: "Forum", + forumCategoryLabel: "Categoria", + forumTitleLabel: "Titolo", + forumTitlePlaceholder: "Titolo del forum...", + forumCreateButton: "Crea forum", + forumCreateSectionTitle: "Crea forum", + forumDescription: "Parla apertamente con altri abitanti nella tua rete.", + forumFilterAll: "TUTTI", + forumFilterMine: "MIEI", + forumFilterRecent: "RECENTI", + forumFilterTop: "TOP", + forumMineSectionTitle: "I tuoi forum", + forumRecentSectionTitle: "Forum recenti", + forumAllSectionTitle: "Forum", + forumDeleteButton: "Elimina", + forumParticipants: "partecipanti", + forumMessages: "messaggi", + forumLastMessage: "Ultimo messaggio", + forumMessageLabel: "Messaggio", + forumMessagePlaceholder: "Scrivi il tuo messaggio...", + forumSendButton: "Invia", + forumVisitForum: "Visita forum", + noForums: "Nessun forum trovato.", + forumVisitButton: "Visita forum", + forumCatGENERAL: "Generale", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "Politica", + forumCatTECH: "Tecnologia", + forumCatSCIENCE: "Scienza", + forumCatMUSIC: "Musica", + forumCatART: "Arte", + forumCatGAMING: "Gaming", + forumCatBOOKS: "Libri", + forumCatFILMS: "Film", + forumCatPHILOSOPHY: "Filosofia", + forumCatSOCIETY: "Società", + forumCatPRIVACY: "Privacy", + forumCatCYBERWARFARE: "Cyberwarfare", + forumCatSURVIVALISM: "Survivalismo", + imageTitle: "Immagini", + imageDescription: "Esplora e gestisci immagini nella tua rete.", + imagePluginTitle: "Titolo", + imagePluginDescription: "Descrizione", + imageMineSectionTitle: "Le tue immagini", + imageCreateSectionTitle: "Carica immagine", + imageUpdateSectionTitle: "Aggiorna immagine", + imageAllSectionTitle: "Immagini", + imageRecentSectionTitle: "Immagini recenti", + imageTopSectionTitle: "Immagini più votate", + imageFavoritesSectionTitle: "Preferiti", + imageGallerySectionTitle: "Galleria", + imageMemeSectionTitle: "Meme", + imageFilterAll: "TUTTE", + imageFilterMine: "MIE", + imageFilterRecent: "RECENTI", + imageFilterTop: "TOP", + imageFilterFavorites: "PREFERITE", + imageFilterGallery: "GALLERIA", + imageFilterMeme: "MEME", + imageCreateButton: "Carica immagine", + imageUpdateButton: "Aggiorna", + imageDeleteButton: "Elimina", + imageAddFavoriteButton: "Aggiungi preferito", + imageRemoveFavoriteButton: "Rimuovi preferito", + imageFileLabel: "Seleziona un file immagine (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Tag", + imageTagsPlaceholder: "Inserisci tag separati da virgole", + imageTitleLabel: "Titolo", + imageTitlePlaceholder: "Opzionale", + imageDescriptionLabel: "Descrizione", + imageDescriptionPlaceholder: "Opzionale", + imageMemeLabel: "MEME?", + imageNoFile: "Nessun file immagine fornito", + noImages: "Nessuna immagine disponibile.", + imageSearchPlaceholder: "Cerca titolo, tag, descrizione, autore...", + imageSortRecent: "Più recenti", + imageSortOldest: "Più vecchie", + imageSortTop: "Più votate", + imageSearchButton: "Cerca", + imageMessageAuthorButton: "Messaggio", + imageUpdatedAt: "Aggiornato", + imageNoMatch: "Nessuna immagine corrisponde alla ricerca.", + feedTitle: "Feed", + createFeedTitle: "Crea Feed", + createFeedButton: "Invia Feed!", + feedPlaceholder: "Cosa succede? (max 280 caratteri)", + TODAYButton: "OGGI", + CREATEButton: "Crea Feed", + totalOpinions: "Opinioni totali", + moreVoted: "Più votato", + noFeedsFound: "Nessun feed trovato.", + createdAtLabel: "Creato il", + FeedshareYourOpinions: "Scopri e condividi testi brevi nella tua rete.", + refeedButton: "Ricondividi", + alreadyRefeeded: "Hai già fatto il refeed.", + activityTitle: "Attività", + yourActivity: "La tua attività", + globalActivity: "Attività globale", + activityList: "Attività", + activityDesc: "Vedi l'ultima attività nella tua rete.", + allButton: "TUTTI", + mineButton: "MIEI", + noActions: "Nessuna attività disponibile.", + performed: "→", + from: "Da", + to: "A", + amount: "Importo", + concept: "Concetto", + description: "Descrizione", + meme: "Meme", + activityContact: "Contatto", + activityBy: "Nome", + activityPixelia: "Nuovo pixel aggiunto", + viewImage: "Vedi immagine", + playAudio: "Riproduci audio", + playVideo: "Riproduci video", + typeRecent: "RECENTI", + errorActivity: "Errore nel recupero dell'attività", + typePost: "BLOGS", + recentButton: "RECENTI", + typeTribe: "TRIBÙ", + typeLarp: "L.A.R.P.", + typeInhabitants: "ABITANTI", + activityProfileUpdated: "ha aggiornato il profilo", + typeAbout: "ABITANTI", + typeCurriculum: "CVS", + typeImage: "IMMAGINI", + typeBookmark: "SEGNALIBRI", + typeDocument: "DOCUMENTI", + typeVotes: "VOTAZIONI", + typeAudio: "AUDIO", + typeMarket: "MERCATO", + typeJob: "LAVORI", + typeProject: "PROGETTI", + typeVideo: "VIDEO", + typeVote: "DIFFUSIONE", + typeEvent: "EVENTI", + typeTransfer: "TRASFERIMENTI", + typeTask: "COMPITI", + typePixelia: "PIXELIA", + typeForum: "FORUMS", + typeReport: "SEGNALAZIONI", + typeFeed: "FEEDS", + typeContact: "CONTATTO", + typePub: "PUB", + typeTombstone: "TOMBSTONE", + typeBanking: "BANCA", + typeBankWallet: "BANCA/PORTAFOGLIO", + typeBankClaim: "BANCA/UBI", + typeKarmaScore: "KARMA", + typeParliament: "PARLAMENTO", + typeSpread: "DIFFUSIONI", + typeParliamentCandidature: "Parlamento · Candidatura", + typeParliamentTerm: "Parlamento · Mandato", + typeParliamentProposal:"Parlamento · Proposta", + typeParliamentRevocation:"Parlamento · Revoca", + typeParliamentLaw: "Parlamento · Nuova legge", + typeCourts: "COURTS", + typeCourtsCase: "Tribunali · Caso", + typeCourtsEvidence: "Tribunali · Prova", + typeCourtsAnswer: "Tribunali · Risposta", + typeCourtsVerdict: "Tribunali · Verdetto", + typeCourtsSettlement: "Tribunali · Accordo", + typeCourtsSettlementProposal: "Tribunali · Proposta di accordo", + typeCourtsSettlementAccepted: "Tribunali · Accordo accettato", + typeCourtsNomination: "Tribunali · Nomina", + typeCourtsNominationVote: "Tribunali · Voto di nomina", + activitySupport: "Nuova alleanza forgiata", + activityJoin: "Nuovo PUB unito", + question: "Domanda", + deadline: "Scadenza", + status: "Stato", + votes: "Voti", + totalVotes: "Voti totali", + voteTotalVotes: "Voti totali", + name: "Nome", + skills: "Competenze", + tags: "Tag", + title: "Titolo", + date: "Data", + category: "Categoria", + attendees: "Partecipanti", + activitySpread: "->", + visitLink: "Visita link", + viewDocument: "Vedi documento", + location: "Posizione", + contentWarning: "Oggetto", + personName: "Nome abitante", + bankWalletConnected: "Portafoglio ECOin", + bankUbiReceived: "UBI ricevuto", + bankTx: "Tx", + bankEpochShort: "Epoca", + bankingUserEngagementScore: "Punteggio KARMA", + viewDetails: "Vedi dettagli", + link: "Link", + activityProjectFollow: "%OASIS% ora sta %ACTION% questo progetto %PROJECT%", + activityProjectUnfollow: "%OASIS% ora sta %ACTION% questo progetto %PROJECT%", + activityProjectPledged: "%OASIS% ha %ACTION% %AMOUNT% al progetto %PROJECT%", + following: "SEGUENDO", + unfollowing: "NON SEGUENDO", + pledged: "CONTRIBUITO", + parliamentCandidatureId: "Candidature", + parliamentGovMethod: "Method", + parliamentVotesReceived: "Votes received", + parliamentMethodANARCHY: "Anarchy", + parliamentMethodVOTE: "Voto comunitario", + parliamentMethodRANKED: "Scelta graduata", + parliamentMethodPLURALITY: "Pluralità", + parliamentMethodCOUNCIL: "Consiglio", + parliamentMethodJURY: "Giuria", + parliamentAnarchy: "ANARCHIA", + parliamentElectionsStart: "Inizio elezioni", + parliamentElectionsEnd: "Fine elezioni", + parliamentCurrentLeader: "Candidatura vincente", + parliamentProposalTitle: "Titolo", + parliamentOpenVote: "Apri votazione", + parliamentStatus: "Stato", + parliamentLawQuestion: "Domanda", + parliamentLawMethod: "Metodo", + parliamentLawProposer: "Proposto da", + parliamentLawEnacted: "Emanata il", + parliamentLawVotes: "Votazioni", + createdAt: "Creato il", + courtsCaseTitle: "Caso", + courtsMethod: "Metodo", + courtsMethodJUDGE: "Giudice", + courtsMethodJUDGES: "Collegio di giudici", + courtsMethodSINGLE_JUDGE: "Giudice unico", + courtsMethodJURY: "Giuria", + courtsMethodCOUNCIL: "Consiglio", + courtsMethodCOMMUNITY: "Comunità", + courtsMethodARBITRATION: "Arbitrato", + courtsMethodVOTE: "Voto comunitario", + courtsAccuser: "Accusatore", + courtsRespondent: "Convenuto", + courtsThStatus: "Stato", + courtsThAnswerBy: "Risposta di", + courtsThEvidenceBy: "Prove di", + courtsThDecisionBy: "Decisione di", + courtsVotesNeeded: "Voti necessari", + courtsVotesSlashTotal: "SÌ / TOTALE", + courtsOpenVote: "Apri votazione", + courtsAnswerTitle: "Rispondi alla denuncia", + courtsStanceADMIT: "Ammetti", + courtsStanceDENY: "Nega", + courtsStancePARTIAL: "Parziale", + courtsStanceCOUNTERCLAIM: "Controdenuncia", + courtsStanceNEUTRAL: "Neutrale", + courtsVerdictResult: "Risultato", + courtsVerdictOrders: "Ordini", + courtsSettlementText: "Condizioni", + courtsSettlementAccepted: "Accettato", + courtsSettlementPending: "In attesa", + courtsJudge: "Giudice", + courtsThSupports: "Supporti", + courtsFilterOpenCase: 'Apri caso', + courtsEvidenceFileLabel: 'File di prova (immagine, audio, video o PDF)', + courtsCaseMediators: 'Mediatori', + courtsCaseMediatorsPh: 'Oasis ID dei mediatori, separati da virgola', + courtsMediatorsLabel: 'Mediatori', + courtsThCase: 'Caso', + courtsThCreatedAt: 'Data di inizio', + courtsThActions: 'Azioni', + courtsPublicPrefLabel: 'Visibilità dopo la risoluzione', + courtsPublicPrefYes: 'Acconsento che questo caso sia completamente pubblico', + courtsPublicPrefNo: 'Preferisco mantenere i dettagli privati', + courtsPublicPrefSubmit: 'Salva preferenza di visibilità', + courtsMethodMEDIATION: 'Mediazione', + courtsNoCases: 'Nessun caso.', + reportsTitle: "Rapporti", + reportsDescription: "Gestisci e monitora le segnalazioni relative a problemi, bug, abusi e avvisi sui contenuti nella tua rete.", + reportsFilterAll: "TUTTI", + reportsFilterMine: "MIEI", + reportsFilterFeatures: "FUNZIONALITÀ", + reportsFilterBugs: "BUG", + reportsFilterAbuse: "ABUSO", + reportsFilterContent: "CONTENUTO", + reportsFilterConfirmed: "CONFERMATI", + reportsFilterResolved: "RISOLTI", + reportsFilterOpen: "APERTI", + reportsFilterUnderReview: "IN REVISIONE", + reportsFilterInvalid: "NON VALIDI", + reportsCreateButton: "Crea segnalazione", + reportsTitleLabel: "Titolo", + reportsDescriptionLabel: "Descrizione", + reportsDescriptionPlaceholder: "Fornisci una descrizione dettagliata.", + reportsCategory: "Categoria", + reportsCategoryLabel: "Categoria", + reportsCategoryFeatures: "Funzionalità", + reportsCategoryBugs: "Bug", + reportsCategoryAbuse: "Abuso", + reportsCategoryContent: "Problemi di contenuto", + reportsUpdateButton: "Aggiorna", + reportsDeleteButton: "Elimina", + reportsDateLabel: "Data", + reportsUploadFile: "Carica media (max: 50MB)", + reportsMineSectionTitle: "Le tue segnalazioni", + reportsFeaturesSectionTitle: "Richieste di funzionalità", + reportsBugsSectionTitle: "Bug", + reportsAbuseSectionTitle: "Segnalazioni di abuso", + reportsContentSectionTitle: "Problemi di contenuto", + reportsAllSectionTitle: "Rapporti", + reportsNoItems: "Nessuna segnalazione disponibile.", + reportsValidationTitle: "Inserisci un titolo valido.", + reportsValidationDescription: "La descrizione non può essere vuota.", + reportsValidationCategory: "Seleziona una categoria.", + reportsCreatedAt: "Creato il", + reportsCreatedBy: "Di", + reportsSeverity: "Gravità", + reportsSeverityLow: "Bassa", + reportsSeverityMedium: "Media", + reportsSeverityHigh: "Alta", + reportsSeverityCritical: "Critica", + reportsStatus: "Stato", + reportsStatusOpen: "Aperto", + reportsStatusUnderReview: "In revisione", + reportsStatusResolved: "Risolto", + reportsStatusInvalid: "Non valido", + reportsUpdateStatusButton: "Aggiorna stato", + reportsAnonymityOption: "Invia in modo anonimo", + reportsAnonymousAuthor: "Anonimo", + reportsConfirmButton: "CONFERMA SEGNALAZIONE!", + reportsConfirmations: "Conferme", + reportsConfirmedSectionTitle: "Segnalazioni confermate", + reportsCreateTaskButton: "CREA COMPITO", + reportsOpenSectionTitle: "Segnalazioni aperte", + reportsUnderReviewSectionTitle: "Segnalazioni in revisione", + reportsResolvedSectionTitle: "Segnalazioni risolte", + reportsInvalidSectionTitle: "Segnalazioni non valide", + reportsTemplateSectionTitle: 'Modello di segnalazione', + reportsBugTemplateTitle: 'Dettagli di riproduzione (Bug)', + reportsFeatureTemplateTitle: 'Dettagli (Funzionalità)', + reportsAbuseTemplateTitle: 'Dettagli (Abuso)', + reportsContentTemplateTitle: 'Dettagli (Problemi di contenuto)', + reportsStepsToReproduceLabel: 'Passaggi per riprodurre', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'Risultato atteso', + reportsExpectedBehaviorPlaceholder: 'Cosa dovrebbe succedere?', + reportsActualBehaviorLabel: 'Risultato effettivo', + reportsActualBehaviorPlaceholder: 'Cosa succede effettivamente?', + reportsEnvironmentLabel: 'Ambiente', + reportsEnvironmentPlaceholder: 'Versione, dispositivo, SO, browser, impostazioni, log, ecc.', + reportsReproduceRateLabel: 'Frequenza di riproduzione', + reportsReproduceRateAlways: 'Sempre', + reportsReproduceRateOften: 'Spesso', + reportsReproduceRateSometimes: 'A volte', + reportsReproduceRateRarely: 'Raramente', + reportsReproduceRateUnable: 'Impossibile riprodurre', + reportsReproduceRateUnknown: 'Non specificato', + reportsProblemStatementLabel: 'Problema / necessità', + reportsProblemStatementPlaceholder: 'Quale problema risolve questa funzionalità?', + reportsUserStoryLabel: 'Storia abitante', + reportsUserStoryPlaceholder: 'Come , voglio , così che .', + reportsAcceptanceCriteriaLabel: 'Criteri di accettazione', + reportsAcceptanceCriteriaPlaceholder: '- Dato che...\n- Quando...\n- Allora...', + reportsWhatHappenedLabel: 'Cosa è successo?', + reportsWhatHappenedPlaceholder: 'Descrivi l\'incidente con contesto e date approssimative.', + reportsReportedUserLabel: 'Abitante / entità segnalata', + reportsReportedUserPlaceholder: '@abitante, feed, ID, link, ecc.', + reportsEvidenceLinksLabel: 'Prove / link', + reportsEvidenceLinksPlaceholder: 'Incolla link, ID messaggi, screenshot (se applicabile), ecc.', + reportsContentLocationLabel: 'Dove si trova il contenuto', + reportsContentLocationPlaceholder: 'Link, ID, canale, discussione, autore, ecc.', + reportsWhyInappropriateLabel: "Perché è inappropriato", + reportsWhyInappropriatePlaceholder: 'Spiega il motivo e l\'impatto.', + reportsRequestedActionLabel: 'Azione richiesta', + reportsRequestedActionPlaceholder: 'Rimuovi, nascondi, tagga, avvisa, ecc.', + tribesTitle: "Tribù", + tribeAllSectionTitle: "Tribù", + tribeMineSectionTitle: "Le tue tribù", + tribeCreateSectionTitle: "Crea tribù", + tribeUpdateSectionTitle: "Aggiorna tribù", + tribeGallerySectionTitle: "Galleria tribù", + tribeRecentSectionTitle: "Tribù recenti", + tribeTopSectionTitle: "Tribù popolari", + tribeviewTribeButton: "Visita tribù", + tribeviewSubTribeButton: "Visita Sotto-Tribù", + tribeRootLabel: "RADICE", + tribeDescription: "Esplora o crea tribù nella tua rete.", + tribeFilterAll: "TUTTE", + tribeFilterMine: "MIE", + tribeFilterMembership: "ISCRIZIONE", + tribeFilterRecent: "RECENTI", + tribeFilterTop: "TOP", + tribeFilterSubtribes: "SOTTO-TRIBÙ", + tribeFilterGallery: "GALLERIA", + tribeMainTribeLabel: "TRIBÙ PRINCIPALE", + tribeCreateButton: "Crea tribù", + tribeUpdateButton: "Aggiorna", + tribeDeleteButton: "Elimina", + tribeImageLabel: "Carica media (max: 50MB)", + tribeTitleLabel: "Titolo", + searchTribesPlaceholder: "FILTRA tribù PER NOME …", + tribeTitlePlaceholder: "Nome della tribù", + tribeDescriptionLabel: "Descrizione", + tribeDescriptionPlaceholder: "Descrivi questa tribù", + tribeLocationLabel: "Posizione", + tribeLocationPlaceholder: "Dove si trova questa tribù?", + tribeTagsLabel: "Tag", + tribeTagsPlaceholder: "Inserisci tag separati da virgole", + tribeInviteMode: "Modalità invito", + tribeModeLabel: "MODE", + tribeIsAnonymousLabel: "STATO", + tribeMembersCount: "Membri", + tribeInviteCodePlaceholder: "Inserisci codice invito", + tribeJoinByCodeButton: "Unisciti con codice", + tribeJoinButton: "UNISCITI", + tribeEnterInvite: "INSERISCI CODICE", + tribeLeaveButton: "ESCI", + tribeYes: "SÌ", + tribeNo: "NO", + tribePublic: "PUBBLICO", + tribePrivate: "PRIVATO", + tribeGenerateInvite: "INVITO SINGOLO", + tribeOpenInvitation: "INVITO APERTO", + tribeJoinOpen: "UNISCITI ALLA TRIBÙ", + tribeRemoveInvitation: "RIMUOVI INVITO", + tribeCreatedAt: "Creato il", + tribeAuthor: "Di", + tribeAuthorLabel: "AUTORE", + tribeStrict: "Rigoroso", + tribeOpen: "Aperto", + tribeFeedFilterRECENT: "RECENTI", + tribeFeedFilterMINE: "MIEI", + tribeFeedFilterALL: "TUTTI", + tribeFeedFilterTOP: "TOP", + tribeFeedRefeeds: "Ricondivisioni", + tribeFeedRefeed: "Ricondividi", + tribeFeedMessagePlaceholder: "Scrivi un feed…", + tribeFeedSend: "Invia", + tribeFeedEmpty: "Nessun messaggio feed disponibile, per ora.", + noTribes: "Nessuna tribù trovata.", + tribeNotFound: "Tribù non trovata!", + createTribeTitle: "Crea tribù", + updateTribeTitle: "Aggiorna tribù", + tribeSectionOverview: "Panoramica", + tribeSectionInhabitants: "Abitanti", + tribeSectionVotations: "VOTAZIONI", + tribeSectionEvents: "EVENTI", + tribeSectionReports: "Segnalazioni", + tribeSectionTasks: "COMPITI", + tribeSectionFeed: "FEED", + tribeSectionForum: "FORUM", + tribeSectionMarket: "Mercato", + tribeSectionJobs: "Lavori", + tribeSectionProjects: "Progetti", + tribeSectionMedia: "Media", + tribeSectionImages: "IMMAGINI", + tribeSectionAudios: "AUDIO", + tribeSectionVideos: "VIDEO", + tribeSectionDocuments: "DOCUMENTI", + tribeSectionBookmarks: "SEGNALIBRI", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "Nessun abitante in questa tribù.", + tribeEventCreate: "Crea evento", + tribeEventsEmpty: "Nessun evento.", + tribeEventTitle: "Titolo", + tribeEventDescription: "Descrizione", + tribeEventDate: "Data", + tribeEventLocation: "Posizione", + tribeEventAttend: "PARTECIPA", + tribeEventUnattend: "ABBANDONA", + tribeEventAttendees: "Partecipanti", + tribeTaskCreate: "Crea compito", + tribeTasksEmpty: "Nessun compito.", + tribeTaskTitle: "Titolo", + tribeTaskDescription: "Descrizione", + tribeTaskPriority: "Priorità", + tribeTaskDeadline: "Scadenza", + tribeTaskAssignees: "Assegnatari", + tribeTaskStatusInProgress: "IN CORSO", + tribeTaskStatusClosed: "CHIUDI", + tribeTaskAssign: "ASSEGNA", + tribeTaskUnassign: "RIMUOVI", + tribeReportCreate: "Crea segnalazione", + tribeReportsEmpty: "Nessuna segnalazione.", + tribeReportTitle: "Titolo", + tribeReportDescription: "Descrizione", + tribeReportCategory: "Categoria", + tribeVotationCreate: "Crea votazione", + tribeVotationsEmpty: "Nessuna votazione.", + tribeVotationTitle: "Titolo", + tribeVotationDescription: "Descrizione", + tribeVotationOptions: "Opzioni", + tribeVotationDeadline: "Scadenza", + tribeVotationVote: "VOTA", + tribeVotationResults: "Voti", + tribeVotationClose: "CHIUDI VOTAZIONE", + tribeVotationOptionPlaceholder: "Opzione", + tribeForumCreate: "Crea Forum", + tribeForumEmpty: "Nessuna discussione.", + tribeForumTitle: "Titolo", + tribeForumText: "Messaggio", + tribeForumCategory: "Categoria", + tribeForumReply: "Rispondi", + tribeForumReplies: "Risposte", + tribeMarketCreate: "Crea annuncio", + tribeMarketEmpty: "Nessun annuncio.", + tribeMarketTitle: "Titolo", + tribeMarketDescription: "Descrizione", + tribeMarketPrice: "Prezzo", + tribeMarketImage: "Immagine", + tribeMarketCategory: "Categoria", + tribeJobCreate: "Crea lavoro", + tribeJobsEmpty: "Nessun lavoro.", + tribeJobTitle: "Titolo", + tribeJobDescription: "Descrizione", + tribeJobLocation: "Posizione", + tribeJobSalary: "Stipendio", + tribeJobDeadline: "Scadenza", + tribeProjectCreate: "Crea progetto", + tribeProjectsEmpty: "Nessun progetto.", + tribeProjectTitle: "Titolo", + tribeProjectDescription: "Descrizione", + tribeProjectGoal: "Obiettivo", + tribeProjectFunded: "Finanziato", + tribeProjectDeadline: "Scadenza", + tribeMediaUpload: "Carica media", + readDocument: "Leggi Documento", + tribeCreateImage: "Crea Immagine", + tribeCreateAudio: "Crea Audio", + tribeCreateVideo: "Crea Video", + tribeCreateDocument: "Crea Documento", + tribeCreateBookmark: "Crea Segnalibro", + tribeMediaEmpty: "Nessun media.", + tribeMediaTitle: "Titolo", + tribeMediaDescription: "Descrizione", + tribeMediaType: "Tipo", + tribeMediaTypeImage: "Immagine", + tribeMediaTypeVideo: "Video", + tribeMediaTypeAudio: "Audio", + tribeMediaTypeDocument: "Documento", + tribeMediaTypeBookmark: "Segnalibro", + tribeContentDelete: "ELIMINA", + tribeInviteCodeText: "Codice invito: ", + oasisVersionLabel: "Versione di Oasis", + tribeInviteCodeHint: "Condividi questo codice con chi vuoi invitare. Può unirsi tramite /invites → Tribes.", + tribeGroupTribe: "Tribù", + tribeGroupOffice: "Ufficio", + tribeGroupNetwork: "Rete", + tribeGroupEconomy: "Economia", + tribeGroupMedia: "Media", + tribeStatusOpen: "APERTO", + tribeStatusClosed: "CHIUSO", + tribeStatusInProgress: "IN CORSO", + tribePriorityLow: "BASSA", + tribePriorityMedium: "MEDIA", + tribePriorityHigh: "ALTA", + tribePriorityCritical: "CRITICA", + tribeTaskFilterAll: "TUTTI", + tribeMediaFilterAll: "TUTTI", + tribeReportCatBug: "BUG", + tribeReportCatAbuse: "ABUSO", + tribeReportCatContent: "CONTENUTO", + tribeReportCatOther: "ALTRO", + tribeForumCatGeneral: "GENERALE", + tribeForumCatProposal: "PROPOSTA", + tribeForumCatQuestion: "DOMANDA", + tribeForumCatAnnouncement: "ANNUNCIO", + tribeMarketCatGoods: "BENI", + tribeMarketCatServices: "SERVIZI", + tribeMarketCatFood: "CIBO", + tribeMarketCatOther: "ALTRO", + tribeStatusLabel: "Stato", + tribeSubTribes: "SOTTO-TRIBÙ", + tribeSubTribesCreate: "Crea Sotto-Tribù", + tribeSubTribesStrictDenied: "La modalità rigida della tribù non ti consente di creare nuove sotto-tribù. Si prega di contattare l'amministratore.", + tribeSubTribesEmpty: "Nessuna sotto-tribù creata.", + tribeActivityJoined: "ISCRITTO", + tribeActivityLeft: "USCITO", + tribeActivityFeed: "FEED", + tribeActivityRefeed: "RICONDIVISIONE", + tribeGroupAnalytics: "Analisi", + tribeGroupCreative: "Creativo", + tribeSectionActivity: "ATTIVITÀ", + tribeSectionTrending: "TENDENZE", + tribeSectionOpinions: "OPINIONI", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "TAG", + tribeSectionSearch: "RICERCA", + tribeActivityEmpty: "Nessuna attività.", + tribeActivityCreated: "creato", + tribeActivityPosted: "pubblicato", + tribeActivityReplied: "risposto", + tribeTrendingEmpty: "Nessun contenuto di tendenza.", + tribeTrendingPeriodDay: "Oggi", + tribeTrendingPeriodWeek: "Questa settimana", + tribeTrendingPeriodAll: "Sempre", + tribeTrendingEngagement: "coinvolgimento", + tribeOpinionsEmpty: "Nessuna opinione.", + tribeOpinionsCast: "Vota", + tribeOpinionsRankings: "Classifiche", + tribeOpinionsAlreadyVoted: "Già votato", + tribeTopCategory: "Più votato", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Tela collaborativa di pixel art.", + tribePixeliaPaint: "Dipingi", + tribePixeliaContributors: "contributori", + tribePixeliaTotalPixels: "pixel dipinti", + tribeTagsEmpty: "Nessun tag trovato.", + tribeTagsCloud: "Nuvola di tag", + tribeTagsContentWith: "Contenuto con tag", + tribeSearchPlaceholder: "Cerca nei contenuti della tribù...", + tribeSearchEmpty: "Nessun risultato trovato.", + tribeSearchResults: "Risultati", + tribeSearchMinChars: "Inserisci almeno 2 caratteri per cercare.", + agendaTitle: "Agenda", + agendaDescription: "Qui trovi tutti gli elementi assegnati a te.", + agendaFilterAll: "TUTTI", + agendaFilterToday: "OGGI", + agendaFilterUpcoming: "PROSSIMI", + agendaFilterOverdue: "IN RITARDO", + agendaFilterOpen: "APERTI", + agendaFilterClosed: "CHIUSI", + agendaFilterTasks: "COMPITI", + agendaFilterMarket: "MERCATO", + agendaFilterTribes: "TRIBÙ", + agendaFilterEvents: "EVENTI", + agendaFilterReports: "SEGNALAZIONI", + agendaFilterTransfers: "TRASFERIMENTI", + agendaFilterJobs: "LAVORI", + agendaFilterProjects: "PROGETTI", + agendaFilterCalendars: "CALENDARI", + agendaNoItems: "Nessun incarico trovato.", + agendaAuthor: "Di", + agendaDiscardButton: "Scarta", + agendaRestoreButton: "Ripristina", + agendaCreatedAt: "Creato il", + agendaTitleLabel: "Titolo", + agendaMembersCount: "Membri", + agendaDescriptionLabel: "Descrizione", + agendaStatus: "Stato", + agendaVisibility: "Visibilità", + agendaEventDate: "Data evento", + agendaEventLocation: "Posizione", + agendaEventPrice: "Prezzo", + agendaEventUrl: "URL", + agendaTaskStart: "Ora di inizio", + agendaLocationLabel: "Posizione", + agendaYes: "SÌ", + agendaNo: "NO", + agendaInviteModeLabel: "Stato", + agendaAnonymousLabel: "Anonimo", + agendaMembersLabel: "Membri", + agendareportCategory: "Categoria", + agendareportSeverity: "Gravità", + agendareportStatus: "Stato", + agendareportDescription: "Descrizione", + agendaTaskEnd: "Ora di fine", + agendaTaskPriority: "Priorità", + agendaTransferFrom: "Da", + agendaTransferTo: "A", + agendaTransferConcept: "Concetto", + agendaTransferAmount: "Importo", + agendaTransferDeadline: "Scadenza", + opinionsTitle: "Opinioni", + shareYourOpinions: "Scopri e vota opinioni nella tua rete.", + author: "By", + voteNow: "Vota ora", + alreadyVoted: "Hai già espresso un'opinione.", + noOpinionsFound: "Nessuna opinione trovata.", + RECENTButton: "RECENT", + TOPButton: "TOP", + interestingButton: "INTERESSANTE", + necessaryButton: "NECESSARIO", + funnyButton: "DIVERTENTE", + disgustingButton: "DISGUSTOSO", + sensibleButton: "SENSIBILE", + propagandaButton: "PROPAGANDA", + adultOnlyButton: "SOLO ADULTI", + boringButton: "NOIOSO", + confusingButton: "CONFUSO", + usefulButton: "UTILE", + informativeButton: "INFORMATIVO", + wellResearchedButton: "BEN DOCUMENTATO", + accurateButton: "ACCURATO", + needsSourcesButton: "SERVONO FONTI", + wrongButton: "SBAGLIATO", + lowQualityButton: "BASSA QUALITÀ", + creativeButton: "CREATIVO", + insightfulButton: "PERSPICACE", + actionableButton: "ATTUABILE", + inspiringButton: "ISPIRANTE", + loveButton: "AMORE", + clearButton: "CHIARO", + upliftingButton: "EDIFICANTE", + unnecessaryButton: "INUTILE", + rejectedButton: "RIFIUTATO", + misleadingButton: "FUORVIANTE", + offTopicButton: "FUORI TEMA", + duplicateButton: "DUPLICATO", + clickbaitButton: "CLICKBAIT", + spamButton: "SPAM", + trollButton: "TROLL", + nsfwButton: "NSFW", + violentButton: "VIOLENTO", + toxicButton: "TOSSICO", + harassmentButton: "MOLESTIA", + hateButton: "ODIO", + scamButton: "TRUFFA", + triggeringButton: "DISTURBANTE", + opinionsCreatedAt: "Creato il", + opinionsTotalCount: "Opinioni totali", + voteInteresting: "Interessante", + voteNecessary: "Necessario", + voteUseful: "Utile", + voteInformative: "Informativo", + voteWellResearched: "Ben documentato", + voteNeedsSources: "Servono fonti", + voteWrong: "Sbagliato", + voteLowQuality: "Bassa qualità", + voteLove: "Amore", + voteClear: "Cancella", + voteMisleading: "Fuorviante", + voteOffTopic: "Fuori tema", + voteDuplicate: "Duplicato", + voteClickbait: "Clickbait", + votePropaganda: "Propaganda", + voteFunny: "Divertente", + voteInspiring: "Ispirante", + voteUplifting: "Edificante", + voteUnnecessary: "Inutile", + voteRejected: "Rifiutato", + voteConfusing: "Confuso", + voteTroll: "Troll", + voteNsfw: "NSFW", + voteViolent: "Violento", + voteToxic: "Tossico", + voteHarassment: "Molestia", + voteHate: "Odio", + voteScam: "Truffa", + voteTriggering: "Disturbante", + voteInsightful: "Perspicace", + voteAccurate: "Accurato", + voteActionable: "Attuabile", + voteCreative: "Creativo", + voteSpam: "Spam", + voteAdultOnly: "Solo adulti", + publishBlog: "Pubblica post", + privateMessage: "PM", + pmSendTitle: "Messaggi privati", + pmSend: "Invia!", + pmDescription: "Usa questo modulo per inviare un messaggio crittografato ad altri abitanti.", + pmRecipients: "Destinatari", + pmRecipientsHint: "Inserisci Oasis ID separati da virgole", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Oggetto", + pmSubjectHint: "Inserisci l'oggetto del messaggio", + pmText: "Messaggio", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Decifra", + pmCrypterBadKey: "La chiave condivisa è errata!", + pmCrypterTooLong: "Il messaggio è troppo lungo per essere cifrato con il Crypter. Accorcialo e riprova.", + pmCrypterCipherLabel: "Messaggio ricifrato", + pmCrypterCharsLabel: "caratteri", + pmInvalidRecipients: "Oasis ID non valido (dovrebbe essere tipo @…=.ed25519).", + pmEncryptionLabel: "Cifratura", + pmFile: "Allegato", + private: "Privato", + privateDescription: "I tuoi messaggi crittografati.", + privateInbox: "POSTA IN ARRIVO", + privateSent: "INVIATI", + privateDelete: "Elimina", + pmCreateButton: "Scrivi un MP", + noPrivateMessages: "Nessun messaggio privato.", + pmFromLabel: "Da:", + pmToLabel: "A:", + pmInvalidMessage: "Messaggio non valido", + pmNoSubject: "(senza oggetto)", + pmSubjectLabel: "Oggetto:", + pmBodyLabel: "Messaggio", + pmBotJobs: "42-LavoriBOT", + pmBotProjects: "42-ProgettiBOT", + pmBotMarket: "42-MercatoBOT", + inboxJobSubscribedTitle: "Nuova iscrizione alla tua offerta di lavoro", + pmInhabitantWithId: "Abitante con Oasis ID:", + pmHasSubscribedToYourJobOffer: "si è iscritto alla tua offerta di lavoro", + inboxProjectCreatedTitle: "Nuovo progetto creato", + pmHasCreatedAProject: "ha creato un progetto", + inboxMarketItemSoldTitle: "Articolo venduto", + pmYourItem: "Il tuo articolo", + pmHasBeenSoldTo: "è stato venduto a", + pmFor: "per", + inboxProjectPledgedTitle: "Nuovo contributo al tuo progetto", + pmHasPledged: "ha contribuito", + pmToYourProject: "al tuo progetto", + blockchain: 'BlockExplorer', + blockchainTitle: 'BlockExplorer', + blockchainDescription: 'Esplora e visualizza i blocchi nella blockchain.', + blockchainNoBlocks: 'Nessun blocco trovato nella blockchain.', + blockchainStatsTitle: 'Statistiche', + blockchainStatsTypeBreakdown: 'Suddivisione per tipo', + blockchainStatsTopAuthors: 'Autori principali', + blockchainStatsCount: 'Blocchi', + blockchainBlockID: 'ID blocco', + blockchainBlockAuthor: 'Autore', + blockchainBlockCarbon: 'Impronta di carbonio', + blockchainBlockType: 'Tipo', + blockchainBlockTimestamp: 'Data e ora', + blockchainBlockContent: 'Blocco', + blockchainBlockURL: 'URL:', + blockchainContent: 'Blocco', + blockchainContentPreview: 'Anteprima del contenuto del blocco', + blockchainLatestDatagram: 'Ultimo Datagramma', + blockchainDatagram: 'Datagramma', + blockchainDetails: 'Vedi dettagli blocco', + blockchainViewBlockexplorer: "Vedi nel blockexplorer", + blockchainBlockInfo: 'Informazioni blocco', + blockchainBlockDetails: 'Dettagli del blocco selezionato', + blockchainBack: 'Torna al BlockExplorer', + blockchainContentDeleted: "Questo contenuto è stato rimosso (tombstone)", + visitContent: "Visita contenuto", + banking: 'Banking', + bankingTitle: 'Banking', + bankingDescription: 'Esplora il valore attuale di ECOin e la corrispondente allocazione UBI, distribuita per epoca in base a partecipazione e fiducia.', + bankOverview: 'Panoramica', + bankEpochs: 'Epoche', + bankRules: 'Regole', + pending: 'In attesa', + closed: 'Chiuso', + bankBack: 'Torna alla Banca', + bankViewTx: 'Vedi Tx', + bankClaimNow: 'Riscuoti ora', + bankClaimUBI: 'Richiedi RBU!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'Nessuna assegnazione RBU in sospeso per questa epoca.', + bankEpoch: 'Epoca', + bankPool: 'Pool (questa epoca)', + bankWeightsSum: 'Somma dei pesi', + bankAllocations: 'Allocazioni', + bankNoAllocations: 'Nessuna allocazione trovata.', + bankNoEpochs: 'Nessuna epoca trovata.', + bankEpochAllocations: 'Allocazioni epoca', + bankAllocId: 'ID allocazione', + bankAllocDate: 'Data', + bankAllocConcept: 'Concetto', + bankAllocFrom: 'Da', + bankAllocTo: 'A', + bankAllocAmount: 'Importo', + bankAllocStatus: 'Stato', + bankEpochId: 'ID epoca', + bankRuleHash: 'Hash snapshot regole', + bankViewEpoch: 'Vedi epoca', + bankUserBalance: 'Il tuo saldo', + ecoWalletNotConfigured: 'Portafoglio ECOin non configurato', + editWallet: 'Modifica portafoglio', + addWallet: 'Aggiungi portafoglio', + bankAddresses: 'Indirizzi', + bankNoAddresses: 'Nessun indirizzo trovato.', + bankUser: 'Oasis ID', + bankAddress: 'Indirizzo', + bankAddAddressTitle: 'Aggiungi indirizzo ECOIN', + bankAddAddressUser: 'Oasis ID', + bankAddAddressAddress: 'Indirizzo ECOIN', + bankAddAddressSave: 'Salva', + bankAddressAdded: 'Indirizzo aggiunto', + bankAddressUpdated: 'Indirizzo aggiornato', + bankAddressExists: 'Indirizzo già esistente', + bankAddressInvalid: 'Indirizzo non valido', + bankAddressDeleted: 'Indirizzo eliminato', + bankAddressNotFound: 'Indirizzo non trovato', + bankAddressForbidden: 'Puoi impostare solo il tuo indirizzo di pagamento', + bankAddressTotal: 'Indirizzi totali', + bankAddressSearch: 'Cerca @abitante o indirizzo', + bankAddressActions: 'Azioni', + bankAddressDelete: 'Elimina', + bankAddressSource: 'Fonte', + bankAddressDeleteConfirm: 'Eliminare questo indirizzo?', + search: 'Cerca!', + bankLocal: 'Locale', + bankFromOasis: 'Oasis', + bankMyAddress: 'Il tuo indirizzo', + bankRemoveMyAddress: 'Rimuovi il mio indirizzo', + bankNotRemovableOasis: 'Gli indirizzi non possono essere rimossi localmente', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "NESSUN FONDO!", + bankUbiAvailableOk: "DISPONIBILE!", + bankUbiAvailability: "Disponibilità UBI", + bankAlreadyClaimedThisMonth: "Già richiesto questo mese", + bankUbiThisMonth: "RBU (questo mese)", + bankUbiLastClaimed: "RBU (ultima richiesta)", + bankUbiNeverClaimed: "Mai richiesto", + bankUbiTotalClaimed: "RBU (totale richiesto)", + bankUbiPub: "PUB", + bankUbiInhabitant: "ABITANTE", + bankUbiClaimedAmount: "RICHIESTO (ECO)", + typeBankUbiResult: "BANCARIO - RBU", + bankNoPubConfigured: "Nessun PUB configurato. Imposta il tuo ID PUB nelle Impostazioni.", + shopsTitle: "Negozi", + shopDescription: "Scopri e gestisci negozi nella rete.", + shopTitle: "Negozio", + shopFilterAll: "TUTTI", + shopFilterMine: "MIEI", + shopFilterRecent: "RECENTI", + shopFilterTop: "TOP", + shopFilterProducts: "PRODOTTI", + shopFilterPrices: "PREZZI", + shopFilterFavorites: "PREFERITI", + shopUpload: "Crea Negozio", + shopAllSectionTitle: "Tutti i Negozi", + shopMineSectionTitle: "I Miei Negozi", + shopRecentSectionTitle: "Negozi Recenti", + shopTopSectionTitle: "Top Negozi", + shopProductsSectionTitle: "Top Prodotti", + shopPricesSectionTitle: "Prodotti per Prezzo", + shopFavoritesSectionTitle: "Preferiti", + shopCreateSectionTitle: "Crea Negozio", + shopUpdateSectionTitle: "Aggiorna Negozio", + shopCreate: "Crea", + shopUpdate: "Aggiorna", + shopDelete: "Elimina", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Pubblica su Clearnet", + shopClearnetUnpublish: "Rimuovi dal Clearnet", + shopClearnetUrlLabel: "URL Clearnet", + shopClearnetWarning: "Pubblicare su Clearnet rende questo negozio indicizzabile da motori di ricerca e crawler esterni.", + shopAddFavorite: "Aggiungi Preferito", + shopRemoveFavorite: "Rimuovi Preferito", + shopOpen: "APERTO", + shopClosed: "CHIUSO", + shopOpenShop: "Apri Negozio", + shopCloseShop: "Chiudi Negozio", + shopMakePrivate: "RENDI PRIVATO (E2E)", + shopMakePublic: "RENDI PUBBLICO", + shopProducts: "Prodotti", + shopProductAdd: "Aggiungi Prodotto", + shopProductTitle: "Prodotto", + shopProductUpdate: "Aggiorna Prodotto", + shopProductPrice: "Prezzo (ECO)", + shopProductStock: "Disponibilità", + shopProductUntitled: "Prodotto senza titolo", + shopUntitled: "Negozio senza titolo", + shopNoItems: "Nessun negozio trovato.", + shopNoProducts: "Nessun prodotto ancora.", + shopOutOfStock: "Esaurito", + shopBuy: "Acquista", + shopBuyEncryptedNote: "I tuoi dati di consegna vengono inviati cifrati e visibili solo al proprietario del negozio.", + shopBuyDeliveryAddress: "Indirizzo di consegna", + shopBuyContact: "Contatto", + shopBuyContactPlaceholder: "email, telefono, ecc.", + shopBuyNotes: "Note", + shopBuyDeliveryRequired: "L'indirizzo di consegna è obbligatorio.", + shopOrdersTitle: "Ordini", + shopOrdersDescription: "Ordini ricevuti da questo negozio.", + shopOrdersEmpty: "Nessun ordine.", + shopOrderProduct: "Prodotto", + shopOrderPrice: "Prezzo", + shopOrderBuyer: "Acquirente", + shopBackToShop: "Torna al Negozio", + shopShareUrl: "URL di condivisione", + shopVisitShop: "VISITA NEGOZIO", + marketVisitItem: "VEDI OGGETTO", + shopStatus: "STATO", + shopCreatedAt: "CREATO", + shopSearchPlaceholder: "Cerca negozi...", + shopUrl: "URL", + shopLocation: "Posizione", + shopTags: "Tag", + shopVisibility: "Visibilità", + shopImage: "Immagine", + shopShortDescription: "Descrizione Breve", + shopShortDescriptionPlaceholder: "Descrizione breve per le schede (max 160 caratteri)", + shopTitlePlaceholder: "Nome del tuo negozio", + shopDescriptionPlaceholder: "Descrizione dettagliata del tuo negozio", + shopUrlPlaceholder: "https://url-del-tuo-negozio.com", + shopLocationPlaceholder: "Città, Paese", + shopTagsPlaceholder: "tag1, tag2, tag3", + mapTitlePlaceholder: "Titolo della mappa", + shopProductFeatured: "Prodotto in Evidenza", + shopSendToMarket: "Send to Market", + typeShop: "NEGOZI", + typeShopProduct: "PRODOTTO NEGOZIO", + bankExchange: 'Cambio', + bankExchangeCurrentValue: 'Valore ECOin (1h)', + bankTotalSupply: 'Offerta totale ECOin', + bankEcoinHours: "Equivalenza ECOin in tempo", + bankHoursOfWork: 'ore', + bankUnitMs: "ms", + bankUnitSeconds: "secondi", + bankUnitMinutes: "minuti", + bankUnitDays: "giorni", + bankExchangeNoData: 'Nessun dato disponibile', + bankExchangeIndex: 'Valore ECOin (1h)', + bankInflation: 'Inflazione ECOin (anual)', + bankInflationMonthly: 'Inflazione ECOin (mensual)', + bankExchangeChartTitle: 'Progressione del valore ECOin nel tempo', + bankExchangeChartValue: 'Valore (ECO/h)', + bankExchangeChartSupply: 'Offerta', + bankExchangeChartInflation: 'Inflazione %', + bankExchangeChartEmpty: 'Non ci sono ancora abbastanza campioni — torna più tardi', + bankCurrentSupply: 'Offerta attuale ECOin', + bankingSyncStatus: 'Stato ECOin', + bankingSyncStatusSynced: 'Sincronizzato', + bankingSyncStatusOutdated: 'Non aggiornato', + statsTitle: 'Statistiche', + statistics: "Statistiche", + statsInhabitant: "Statistiche abitante", + statsDescription: "Scopri le statistiche della tua rete.", + ALLButton: "ALL", + MINEButton: "MINE", + TOMBSTONEButton: "TOMBSTONES", + statsYou: "Tu", + statsUserId: "Oasis ID", + statsCreatedAt: "Creato il", + statsYourContent: "Contenuto", + statsYourOpinions: "Opinioni", + statsYourTombstone: "Tombstone", + statsNetwork: "Rete", + statsTotalInhabitants: "Abitanti", + statsDiscoveredTribes: "Tribù (Pubbliche)", + statsPrivateDiscoveredTribes: "Tribù (Private)", + statsNetworkContent: "Contenuto", + statsYourMarket: "Mercato", + statsYourJob: "Lavori", + statsYourProject: "Progetti", + statsYourTransfer: "Trasferimenti", + statsYourForum: "Forum", + statsNetworkOpinions: "Opinioni", + statsDiscoveredMarket: "Mercato", + statsDiscoveredJob: "Lavori", + statsDiscoveredProject: "Progetti", + statsBankingTitle: "Banca", + statsEcoWalletLabel: "Portafoglio ECOIN", + statsEcoWalletNotConfigured: "Non configurato!", + statsTotalEcoAddresses: "Indirizzi totali", + statsDiscoveredTransfer: "Trasferimenti", + statsDiscoveredForum: "Forum", + statsNetworkTombstone: "Tombstone", + statsBookmark: "Segnalibri", + statsEvent: "Eventi", + statsTask: "Compiti", + statsVotes: "Voti", + statsMarket: "Mercato", + statsForum: "Forum", + statsJob: "Lavori", + statsReport: "Rapporti", + statsFeed: "Feed", + statsTribe: "Tribù", + statsImage: "Immagini", + statsAudio: "Audio", + statsVideo: "Video", + statsDocument: "Documenti", + statsMap: "Mappe", + statsShop: "Negozi", + statsShopProduct: "Prodotti del negozio", + statsTransfer: "Trasferimenti", + statsAiExchange: "AI", + statsPUBs: 'PUBs', + statsPost: "Post", + statsOasisID: "Oasis ID", + statsSize: "Totale (dimensione)", + statsBlockchainSize: "Blockchain (dimensione)", + statsBlobsSize: "Blob (dimensione)", + statsActivity7d: "Attività (ultimi 7 giorni)", + statsActivity7dTotal: "Totale 7 giorni", + statsActivity30dTotal: "Totale 30 giorni", + statsKarmaScore: "Punteggio KARMA", + statsPublic: "Pubblico", + statsPrivate: "Privato", + day: "Giorno", + messages: "Messaggi", + statsProject: "Progetti", + statsProjectsTitle: "Progetti", + statsProjectsTotal: "Progetti totali", + statsProjectsActive: "Attivo", + statsProjectsCompleted: "Completato", + statsProjectsPaused: "In pausa", + statsProjectsCancelled: "Annullato", + statsProjectsGoalTotal: "Obiettivo totale", + statsProjectsPledgedTotal: "Contribuito totale", + statsProjectsSuccessRate: "Tasso di successo", + statsProjectsAvgProgress: "Progresso medio", + statsProjectsMedianProgress: "Progresso mediano", + statsProjectsActiveFundingAvg: "Finanziamento attivo medio", + statsJobsTitle: "Lavori", + statsJobsTotal: "Lavori totali", + statsJobsOpen: "Aperti", + statsJobsClosed: "Chiusi", + statsJobsOpenVacants: "Posti vacanti aperti", + statsJobsSubscribersTotal: "Iscritti totali", + statsJobsAvgSalary: "Stipendio medio", + statsJobsMedianSalary: "Stipendio mediano", + statsMarketTitle: "Mercato", + statsMarketTotal: "Articoli totali", + statsMarketForSale: "In vendita", + statsMarketReserved: "Riservato", + statsMarketClosed: "Chiuso", + statsMarketSold: "Venduto", + statsMarketRevenue: "Entrate", + statsMarketAvgSoldPrice: "Prezzo medio venduto", + statsUsersTitle: "Abitanti", + user: "Abitante", + statsTombstoneTitle: "Tombstone", + statsNetworkTombstones: "Tombstone della rete", + statsTombstoneRatio: "Rapporto tombstone (%)", + statsParliamentCandidature: "Candidature parlamento", + statsParliamentTerm: "Mandati parlamento", + statsParliamentProposal: "Proposte parlamento", + statsParliamentRevocation: "Revoche parlamento", + statsParliamentLaw: "Leggi parlamento", + statsCourtsCase: "Casi tribunale", + statsCourtsEvidence: "Prove tribunale", + statsCourtsAnswer: "Risposte tribunale", + statsCourtsVerdict: "Verdetti tribunale", + statsCourtsSettlement: "Accordi tribunale", + statsCourtsSettlementProposal: "Proposte di accordo", + statsCourtsSettlementAccepted: "Accordi accettati", + statsCourtsNomination: "Nomine giudici", + statsCourtsNominationVote: "Voti nomina", + ai: "AI", + aiTitle: "AI", + aiDescription: "Un'Intelligenza Artificiale Collettiva (IAC) chiamata '42' che impara dalla tua rete.", + aiUserQuestion: "Domanda", + aiResponseTitle: "Rispondi", + aiSubmitButton: "Invia!", + aiSettingsDescription: "Imposta il tuo prompt (max 128 caratteri) per il modello AI.", + aiPrompt: "Fornisci una risposta informativa e precisa.", + aiConfiguration: "Imposta prompt", + aiPromptUsed: "Prompt", + aiClearHistory: "Cancella cronologia chat", + aiSharePrompt: "Aggiungere questa risposta all'addestramento collettivo?", + aiShareYes: "Sì", + aiShareNo: "No", + aiSharedLabel: "Aggiunto all'addestramento", + aiRejectedLabel: "Non aggiunto all'addestramento", + aiServerError: "L'AI non ha potuto rispondere. Riprova.", + aiInputPlaceholder: "Cos'è Oasis?", + typeAiExchange: "AI", + aiApproveTrain: "Aggiungi all'addestramento collettivo", + aiRejectTrain: "Non addestrare", + aiTrainPending: "In attesa di approvazione", + aiTrainApproved: "Approvato per l'addestramento", + aiTrainRejected: "Rifiutato per l'addestramento", + aiSnippetsUsed: "Snippet utilizzati", + aiSnippetsLearned: "Snippet appresi", + statsAITraining: "Addestramento AI", + aiApproveCustomTrain: "Addestra usando questa risposta personalizzata", + aiCustomAnswerPlaceholder: "Scrivi la tua risposta personalizzata…", + statsAIExchanges: "Scambi modello", + marketMineSectionTitle: "I tuoi articoli", + marketCreateSectionTitle: "Crea articolo", + marketUpdateSectionTitle: "Aggiorna", + marketAllSectionTitle: "Mercato", + marketRecentSectionTitle: "Mercato recente", + marketTitle: "Mercato", + marketDescription: "Un mercato per scambiare beni o servizi nella tua rete.", + marketFilterAll: "TUTTI", + marketFilterMine: "MIEI", + marketFilterAuctions: "ASTE", + marketFilterItems: "SCAMBIO", + marketFilterNew: "NUOVO", + marketFilterUsed: "USATO", + marketFilterBroken: "ROTTO", + marketFilterForSale: "IN VENDITA", + marketFilterSold: "VENDUTO", + marketFilterDiscarded: "SCARTATO", + marketFilterRecent: "RECENTI", + marketFilterMyBids: "OFFERTE", + marketCreateButton: "Crea articolo", + marketItemType: "Tipo", + marketItemTitle: "Titolo", + marketItemAvailable: "Scadenza", + marketItemDescription: "Descrizione", + marketItemDescriptionPlaceholder: "Descrivi l'articolo che stai vendendo", + marketItemStatus: "Stato", + marketShopLabel: "Negozio", + marketItemCondition: "Condizione", + marketItemPrice: "Prezzo", + marketItemTags: "Tag", + marketItemTagsPlaceholder: "Inserisci tag separati da virgole", + marketItemDeadline: "Scadenza", + marketItemIncludesShipping: "Spedizione inclusa?", + marketItemHighestBid: "Offerta più alta", + marketItemHighestBidder: "Miglior offerente", + marketItemStock: "Disponibilità", + marketOutOfStock: "Esaurito", + marketItemBidTime: "Ora dell'offerta", + marketActionsUpdate: "Aggiorna", + marketUpdateButton: "Aggiorna", + marketActionsDelete: "Elimina", + marketActionsSold: "Segna come venduto", + marketActionsChangeStatus: "CAMBIA STATO", + marketActionsBuy: "COMPRA!", + marketAuctionBids: "Offerte attuali", + marketPlaceBidButton: "Fai un'offerta", + marketItemSeller: "Venditore", + marketNoItems: "Nessun articolo disponibile ancora.", + marketYourBid: "La tua offerta", + marketCreateFormImageLabel: "Carica media (max: 50MB)", + marketSearchLabel: "Cerca", + marketSearchPlaceholder: "Cerca titolo o tag", + marketMinPriceLabel: "Prezzo minimo", + marketMaxPriceLabel: "Prezzo massimo", + marketSortLabel: "Ordina per", + marketSortRecent: "Più recenti", + marketSortPrice: "Prezzo", + marketSortDeadline: "Scadenza", + marketSearchButton: "Cerca", + marketAuctionEndsIn: "Termina", + marketAuctionEnded: "Terminata", + marketMyBidBadge: "Hai offerto", + marketNoItemsMatch: "Nessun articolo corrisponde alla ricerca.", + jobsTitle: "Lavori", + jobsCandidatesTitle: "Candidati suggeriti", + jobsCandidatesDescription: "Abitanti le cui competenze corrispondono a questo lavoro. Inviate un messaggio privato per invitarli.", + jobsCandidatesPmBody: "Ciao, penso che il tuo profilo corrisponda alla mia offerta di lavoro", + jobsDescription: "Scopri e gestisci lavori nella tua rete.", + jobsFilterRecent: "RECENTI", + jobsFilterMine: "MIEI", + jobsFilterAll: "TUTTI", + jobsFilterRemote: "REMOTO", + jobsFilterOpen: "APERTI", + jobsFilterClosed: "CHIUSI", + jobsCV: "CV", + jobsCreateJob: "Crea lavoro", + jobsRecentTitle: "Lavori recenti", + jobsMineTitle: "I tuoi lavori", + jobsAllTitle: "Lavori", + jobsRemoteTitle: "Lavori da remoto", + jobsOpenTitle: "Lavori aperti", + jobsClosedTitle: "Lavori chiusi", + jobsCVTitle: "CV", + jobsFilterPresencial: "IN SEDE", + jobsFilterFreelancer: "FREELANCER", + jobsFilterEmployee: "DIPENDENTE", + jobsPresencialTitle: "Lavori in sede", + jobsFreelancerTitle: "Lavori freelance", + jobsEmployeeTitle: "Lavori dipendente", + jobTitle: "Titolo", + jobLocation: "Posizione", + jobSalary: "Stipendio (ECO/1h)", + jobVacants: "Posti vacanti", + jobDescription: "Descrizione", + jobRequirements: "Requisiti", + jobLanguages: "Lingue", + jobStatus: "Stato", + jobStatusOPEN: "APERTO", + jobStatusCLOSED: "CHIUSO", + jobSetClosed: "Segna come CHIUSO", + jobSubscribeButton: "Candidati!", + jobUnsubscribeButton: "Ritira candidatura!", + jobTitlePlaceholder: "Inserisci titolo del lavoro", + jobDescriptionPlaceholder: "Descrivi il lavoro", + jobRequirementsPlaceholder: "Inserisci requisiti", + jobLanguagesPlaceholder: "Inglese, Spagnolo, Francese, Basco...", + jobTasksPlaceholder: "Elenca i compiti", + jobLocationPresencial: "In sede", + jobLocationRemote: "Da remoto", + jobVacantsPlaceholder: "Numero di posizioni", + jobSalaryPlaceholder: "Stipendio in ECO per 1 ora dedicata", + jobImage: "Carica media (max: 50MB)", + jobTasks: "Compiti", + jobType: "Tipo lavoro", + jobTime: "Orario lavoro", + jobSubscribers: "Candidati", + noSubscribers: "Nessun candidato", + jobsFilterTop: "TOP", + jobsTopTitle: "Lavori con stipendio più alto", + createJobButton: "Pubblica lavoro", + viewDetailsButton: "Vedi dettagli", + noJobsFound: "Nessuna offerta di lavoro trovata.", + jobAuthor: "Di", + jobTypeFreelance: "Freelance", + jobTypeSalary: "Dipendente", + jobTypeExchange: "Scambio di ore", + jobTypeEXCHANGE: "SCAMBIO DI ORE", + jobsFilterExchange: "SCAMBIO", + jobsExchangeTitle: "Lavori in scambio di ore", + jobsHoursOffered: "Ore offerte", + jobsHoursRequested: "Ore richieste in cambio", + jobsExchangeSkill: "Competenza richiesta in cambio", + jobsHoursOfferedPlaceholder: "es. 4", + jobsHoursRequestedPlaceholder: "es. 4", + jobsExchangeSkillPlaceholder: "es. falegnameria, design", + jobExchangeHint: "Per i lavori in scambio di ore, compila i campi seguenti invece dello stipendio.", + jobTimePartial: "Part-time", + jobTimeComplete: "Full-time", + jobsDeleteButton: "ELIMINA", + jobsUpdateButton: "AGGIORNA", + jobsFilterApplied: "CANDIDATURE", + jobsAppliedTitle: "Le mie candidature", + jobsAppliedBadge: "Candidato", + jobsFilterFavs: "Preferiti", + jobsFavsTitle: "Preferiti", + jobsFilterNeeds: "Serve aiuto", + jobsNeedsTitle: "Serve aiuto", + jobsSearchLabel: "Cerca", + jobsSearchPlaceholder: "Cerca titolo, tag, descrizione...", + jobsMinSalaryLabel: "Stipendio minimo", + jobsMaxSalaryLabel: "Stipendio massimo", + jobsSortLabel: "Ordina per", + jobsSortRecent: "Più recenti", + jobsSortSalary: "Stipendio più alto", + jobsSortSubscribers: "Più candidati", + jobsSearchButton: "Cerca", + jobsFavoriteButton: "Preferito", + jobsUnfavoriteButton: "Rimuovi preferito", + jobsMessageAuthorButton: "PM", + jobsApplicants: "Candidati", + jobsUpdatedAt: "Aggiornato", + jobSetOpen: "Segna come aperto", + jobNewBadge: "NUOVO", + jobsTagsLabel: "Tag", + jobsTagsPlaceholder: "tag1, tag2, tag3", + noJobsMatch: "Nessun lavoro corrisponde alla ricerca.", + projectsTitle: "Progetti", + projectsDescription: "Crea, finanzia e segui progetti comunitari nella tua rete.", + projectCreateProject: "Crea progetto", + projectCreateButton: "Crea progetto", + projectUpdateButton: "AGGIORNA", + projectDeleteButton: "ELIMINA", + projectNoProjectsFound: "Nessun progetto trovato.", + projectFilterAll: "TUTTI", + projectFilterMine: "MIEI", + projectFilterActive: "ATTIVI", + projectFilterPaused: "IN PAUSA", + projectFilterCompleted: "COMPLETATI", + projectFilterFollowing: "SEGUITI", + projectFilterRecent: "RECENTI", + projectFilterTop: "TOP", + projectAllTitle: "Progetti", + projectMineTitle: "I tuoi progetti", + projectActiveTitle: "Progetti attivi", + projectPausedTitle: "Progetti in pausa", + projectCompletedTitle: "Progetti completati", + projectFollowingTitle: "Progetti seguiti", + projectRecentTitle: "Progetti recenti", + projectTopTitle: "Più finanziati", + projectTitlePlaceholder: "Nome del progetto", + projectImage: "Carica media (max: 50MB)", + projectDescription: "Descrizione", + projectDescriptionPlaceholder: "Racconta la storia e gli obiettivi…", + projectGoal: "Obiettivo (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Scadenza", + projectProgress: "Progresso iniziale (%)", + projectStatus: "Stato", + projectFunding: "Finanziamento", + projectPledged: "Contribuito", + projectSetStatus: "Imposta stato", + projectSetProgress: "Aggiorna progresso", + projectFollowButton: "SEGUI", + projectUnfollowButton: "SMETTI DI SEGUIRE", + projectStatusACTIVE: "ATTIVO", + projectStatusPAUSED: "IN PAUSA", + projectStatusCOMPLETED: "COMPLETATO", + projectStatusCANCELLED: "ANNULLATO", + projectPledgeTitle: "Supporta questo progetto", + projectPledgePlaceholder: "Importo in ECO", + projectBounties: "Taglie", + projectBountiesInputLabel: "Taglie (una per riga: Titolo|Importo [ECO]|Descrizione)", + projectBountiesPlaceholder: "Correggi bug UI|100|Link al problema\nScrivi documentazione|250|Descrivi esempi d'uso", + projectNoBounties: "Nessuna taglia trovata.", + projectTitle: "Titolo", + projectAddBountyTitle: "Nuova taglia", + projectBountyTitle: "Titolo taglia", + projectBountyAmount: "Importo (ECO)", + projectBountyDescription: "Descrizione", + projectMilestoneSelect: "Seleziona traguardo", + projectBountyCreateButton: "Crea taglia", + projectBountyStatus: "Stato taglia", + projectBountyOpen: "aperta", + projectBountyClaimed: "rivendicata", + projectBountyDone: "completata", + projectBountyClaimedBy: "rivendicata da", + projectBountyClaimButton: "rivendica", + projectBountyCompleteButton: "Segna come completata", + projectMilestones: "Traguardi", + projectAddMilestoneTitle: "Nuovo traguardo", + projectMilestoneTitle: "Titolo traguardo", + projectMilestoneTargetPercent: "Percentuale (%)", + projectMilestoneDueDate: "Data", + projectMilestoneCreateButton: "Crea traguardo", + projectMilestoneStatus: "Stato traguardo", + projectMilestoneOpen: "aperto", + projectMilestoneDone: "completato", + projectMilestoneDue: "scadenza", + projectNoMilestones: "Nessun traguardo trovato.", + projectMilestoneMarkDone: "Segna come completato", + projectMilestoneTitlePlaceholder: "Inserisci titolo traguardo", + projectMilestoneDescriptionPlaceholder: "Inserisci descrizione per questo traguardo", + projectMilestoneDescription: "Descrizione traguardo", + projectBudgetGoal: "Budget (obiettivo)", + projectBudgetAssigned: "Assegnato alle taglie", + projectBudgetRemaining: "Rimanente", + projectBudgetOver: "⚠ Budget superato: l'assegnato supera l'obiettivo", + projectFollowers: "Sostenitori", + projectFollowersTitle: "Sostenitori", + projectFollowersNone: "Nessun sostenitore ancora.", + projectMore: "altri", + projectYouFollowHint: "Stai seguendo questo progetto", + projectBackers: "Finanziatori", + projectBackersTitle: "Finanziatori", + projectBackersTotal: "Finanziatori totali", + projectBackersTotalPledged: "Totale contribuito", + projectBackersYourPledge: "Il tuo contributo", + projectBackersNone: "Nessun contributo ancora.", + projectNoRemainingBudget: "Nessun budget rimanente.", + projectFilterBackers: "FINANZIATORI", + projectFilterApplied: "CANDIDATURE", + projectAppliedTitle: "CANDIDATURE", + projectBackersLeaderboardTitle: "Migliori finanziatori", + projectNoBackersFound: "Nessun finanziatore trovato.", + projectBackerAmount: "Totale contribuito", + projectBackerPledges: "Contributi", + projectBackerProjects: "Progetti", + projectPledgeAmount: "Importo", + projectSelectMilestoneOrBounty: "Seleziona traguardo o taglia", + projectPledgeButton: "Contribuisci", + footerLicense: "GPLv3", + footerPackage: "Pacchetto", + footerVersion: "Versione", + modulesModuleName: "Nome", + modulesModuleDescription: "Descrizione", + modulesModuleStatus: "Stato", + modulesTotalModulesLabel: "Moduli caricati", + modulesEnabledModulesLabel: "Attivato", + modulesDisabledModulesLabel: "Disattivato", + modulesPopularLabel: "In evidenza", + modulesPopularDescription: "Modulo per ricevere i post più popolari, più visti o più commentati.", + modulesTopicsLabel: "Argomenti", + modulesTopicsDescription: "Modulo per ricevere categorie di discussione basate su interessi comuni.", + modulesSummariesLabel: "Riassunti", + modulesSummariesDescription: "Modulo per ricevere riassunti di discussioni o post lunghi.", + modulesLatestLabel: "Più recenti", + modulesLatestDescription: "Modulo per ricevere i post e le discussioni più recenti.", + modulesThreadsLabel: "Discussioni", + modulesThreadsDescription: "Modulo per ricevere conversazioni raggruppate per argomento o domanda.", + modulesMultiverseLabel: "Multiverso", + modulesMultiverseDescription: "Modulo per ricevere contenuti da altri peer federati.", + modulesInvitesLabel: "Inviti", + modulesInvitesDescription: "Modulo per gestire e applicare codici invito.", + modulesWalletLabel: "Portafoglio", + modulesWalletDescription: "Modulo per gestire i tuoi asset digitali (ECOin).", + modulesLegacyLabel: "Chiavi", + modulesLegacyDescription: "Modulo per gestire il tuo segreto (chiave privata) in modo rapido e sicuro.", + modulesCipherLabel: "Crittografia", + modulesCipherDescription: "Modulo per crittografare e decifrare il tuo testo simmetricamente (usando una password condivisa).", + modulesBookmarksLabel: "Segnalibri", + modulesBookmarksDescription: "Modulo per scoprire e gestire i segnalibri.", + modulesVideosLabel: "Video", + modulesVideosDescription: "Modulo per scoprire e gestire i video.", + modulesDocsLabel: "Documenti", + modulesDocsDescription: "Modulo per scoprire e gestire i documenti.", + modulesAudiosLabel: "Audio", + modulesAudiosDescription: "Modulo per scoprire e gestire gli audio.", + modulesTagsLabel: "Tag", + modulesTagsDescription: "Modulo per scoprire ed esplorare i pattern tassonomici (tag).", + modulesImagesLabel: "Immagini", + modulesImagesDescription: "Modulo per scoprire e gestire le immagini.", + modulesTrendingLabel: "Tendenze", + modulesTrendingDescription: "Modulo per esplorare i contenuti più popolari.", + modulesEventsLabel: "Eventi", + modulesEventsDescription: "Modulo per scoprire e gestire gli eventi.", + modulesTasksLabel: "Compiti", + modulesTasksDescription: "Modulo per scoprire e gestire i compiti.", + modulesMarketLabel: "Mercato", + modulesMarketDescription: "Modulo per scambiare beni o servizi.", + modulesShopsLabel: "Negozi", + modulesShopsDescription: "Modulo per gestire e scoprire negozi.", + modulesTribesLabel: "Tribù", + modulesTribesDescription: "Modulo per esplorare o creare tribù (gruppi).", + modulesVotationsLabel: "Votazioni", + modulesVotationsDescription: "Modulo per scoprire e gestire le votazioni.", + modulesReportsLabel: "Rapporti", + modulesReportsDescription: "Modulo per gestire e monitorare segnalazioni relative a problemi, bug, abusi e avvisi sui contenuti.", + modulesOpinionsLabel: "Opinioni", + modulesOpinionsDescription: "Modulo per scoprire e votare opinioni.", + modulesTransfersLabel: "Trasferimenti", + modulesTransfersDescription: "Modulo per scoprire e gestire i contratti intelligenti (trasferimenti).", + modulesFediverseLabel: "Fediverse", + modulesFediverseDescription: "Gestisci i tuoi altri account del fediverso, inclusi l'invio e la ricezione di contenuti.", + modulesFeedLabel: "Feed", + modulesFeedDescription: "Modulo per scoprire e condividere testi brevi (feed).", + modulesParliamentLabel: "Parlamento", + modulesParliamentDescription: "Modulo per eleggere governi e votare leggi.", + modulesCourtsLabel: "Tribunali", + modulesCourtsDescription: "Modulo per risolvere conflitti ed emettere verdetti.", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Modulo per disegnare su una griglia collaborativa.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Modulo per generare e condividere il «suono» della tua blockchain.", + modulesAgendaLabel: "Agenda", + modulesAgendaDescription: "Modulo per gestire tutti gli elementi assegnati a te.", + modulesAILabel: "AI", + modulesAIDescription: "Modulo per parlare con un LLM chiamato '42'.", + modulesForumLabel: "Forum", + modulesForumDescription: "Modulo per scoprire e gestire i forum.", + modulesJobsLabel: "Lavori", + modulesJobsDescription: "Modulo per scoprire e gestire i lavori.", + modulesProjectsLabel: "Progetti", + modulesProjectsDescription: "Modulo per esplorare, finanziare collettivamente e gestire i progetti.", + modulesBankingLabel: "Banca", + modulesBankingDescription: "Modulo per determinare il valore reale di ECOIN e distribuire una UBI usando la tesoreria comune.", + modulesFavoritesLabel: "Preferiti", + modulesFavoritesDescription: "Modulo per gestire i tuoi contenuti preferiti.", + fileTooLargeTitle: "File troppo grande", + fileTooLargeMessage: "Il file supera la dimensione massima consentita (50 MB). Seleziona un file più piccolo.", + goBack: "Torna indietro", + errorPageTitle: "Si è verificato un errore", + visibilityLabel: "Visibilità", + visibilityPublic: "Pubblica", + visibilityHidden: "Nascosta", + visibilityMakePublic: "Rendi pubblica", + visibilityMakeHidden: "Rendi nascosta", + invitesInhabitantsTitle: "Abitanti", + invitesInhabitantsFollow: "Segui", + aiNavPlaceholder: "Dove vuoi andare?", + aiNavDisabled: "La navigazione con IA è disattivata.", + aiNavResultsTitle: "Suggerimenti di navigazione IA", + aiNavQueryLabel: "Richiesta", + aiNavResultsDescription: "Scegli il percorso più adatto a ciò che stai cercando.", + aiNavResultPath: "Percorso", + aiNavResultDescription: "Cosa puoi fare", + aiNavResultMatch: "Corrispondenza", + aiNavResultsEmpty: "Nessun percorso corrispondente. Prova /search.", + aiNavSearchInstead: "Cerca invece", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Modulo per query in linguaggio naturale sui contenuti della rete.", + directConnect: "Connessione diretta", + directConnectDescription: "Connettiti direttamente a un peer inserendo indirizzo IP, porta e chiave pubblica.", + peerHost: "IP", + peerPort: "Porta (predefinita: 8008)", + peerPublicKey: "Chiave pubblica (@...ed25519)", + connectAndFollow: "Connetti", + deviceSourceLabel: "Dispositivo", + modulesPresetTitle: "Configurazioni Comuni", + modulesPreset_minimal: "Minimo", + modulesPreset_basic: "Base", + modulesPreset_social: "Sociale", + modulesPreset_economy: "Economia", + modulesPreset_full: "Completo", + statsCarbonFootprintTitle: "Impronta di carbonio", + statsCarbonFootprintNetwork: "Impronta di carbonio della rete", + statsCarbonFootprintYours: "La tua impronta di carbonio", + statsCarbonTombstone: "Impronta del tombstoning", + feedSuccessMsg: "Feed pubblicato con successo!", + dominantOpinionLabel: "Opinione dominante", + uploadMedia: "Carica media (max: 50MB)", + + courtsRespondentInvalid: "Deve essere un ID SSB valido (@...ed25519)", + feedDetailTitle: "Feed", + feedOpenDiscussion: "Apri discussione", + feedPostComment: "Pubblica commento", + noComments: "Nessun commento ancora", + mapsLabel: "Mappe", + mapTitle: "Mappe", + mapDescription: "Esplora e gestisci mappe offline nella tua rete.", + mapMineSectionTitle: "Le Tue Mappe", + mapCreateSectionTitle: "Crea Mappa", + mapUpdateSectionTitle: "Aggiorna Mappa", + mapAllSectionTitle: "Mappe", + mapRecentSectionTitle: "Mappe Recenti", + mapFavoritesSectionTitle: "Preferiti", + mapFilterAll: "TUTTI", + mapFilterMine: "MIEI", + mapFilterRecent: "RECENTI", + mapFilterFavorites: "PREFERITI", + mapUploadButton: "Crea Mappa", + mapCreateButton: "Crea Mappa", + mapUpdateButton: "Aggiorna", + mapDeleteButton: "Elimina", + mapAddFavoriteButton: "Aggiungi ai preferiti", + mapRemoveFavoriteButton: "Rimuovi dai preferiti", + mapLatLabel: "Latitudine", + mapLatPlaceholder: "es. 41.9028", + mapLngLabel: "Longitudine", + mapLngPlaceholder: "es. 12.4964", + mapDescriptionLabel: "Descrizione", + mapDescriptionPlaceholder: "Descrivi la mappa o la posizione...", + mapTypeLabel: "Tipo di mappa", + mapTypeSingle: "SINGOLO (solo posizione iniziale)", + mapTypeOpen: "APERTO (chiunque può aggiungere marcatori)", + mapTypeClosed: "CHIUSO (solo il creatore aggiunge marcatori)", + mapTagsLabel: "Tag", + mapTagsPlaceholder: "Inserisci tag separati da virgole", + mapUrlLabel: "URL della mappa", + mapPickCoordLabel: "Oppure seleziona una posizione sulla griglia:", + mapMarkersLabel: "marcatori", + mapMarkersTitle: "Marcatori", + mapMarkerDefault: "Marcatore", + mapMarkerLatLabel: "Latitudine marcatore", + mapMarkerLngLabel: "Longitudine marcatore", + mapMarkerLabelField: "Etichetta marcatore", + mapMarkerLabelPlaceholder: "Descrivi questo marcatore...", + markerImageLabel: "Immagine del Marcatore", + mapAddMarkerTitle: "Aggiungi Marcatore", + mapAddMarkerButton: "Aggiungi Marcatore", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Applica Zoom", + mapSearchPlaceholder: "Cerca descrizione, tag, autore...", + mapSearchButton: "Cerca", + mapUpdatedAt: "Aggiornato", + mapNoMatch: "Nessuna mappa corrisponde alla tua ricerca.", + noMaps: "Nessuna mappa disponibile.", + mapLocationTitle: "Posizione", + mapVisitLabel: "Visita mappa", + typeMap: "MAPPE", + typeMapMarker: "MARCATORE MAPPA", + modulesMapLabel: "Mappe", + modulesMapDescription: "Modulo per gestire e condividere mappe offline.", + padsTitle: "Pad", + padTitle: "Pad", + modulesPadsLabel: "Pad", + modulesPadsDescription: "Modulo per gestire editor di testo collaborativi.", + padFilterAll: "TUTTI", + padFilterMine: "MIO", + padFilterRecent: "RECENTE", + padFilterOpen: "APERTO", + padFilterClosed: "CHIUSO", + padCreate: "Crea Pad", + padUpdate: "Aggiorna Pad", + padDelete: "Elimina Pad", + padTitleLabel: "Titolo", + padTitlePlaceholder: "Inserisci il titolo del pad...", + padStatusLabel: "Stato", + padStatusOpen: "APERTO", + padStatusInviteOnly: "SOLO SU INVITO", + padStatusClosed: "CHIUSO", + padDeadlineLabel: "Scadenza", + padTagsLabel: "Tag", + padTagsPlaceholder: "tag1, tag2, ...", + padMembersLabel: "Membri", + padVisitPad: "Visita Pad", + padShareUrl: "Condividi URL", + padCreated: "Creato", + padAuthor: "Autore", + padGenerateCode: "Genera Codice", + padInviteCodeLabel: "Codice Invito", + padInviteCodePlaceholder: "Inserisci il codice invito...", + padValidateInvite: "Valida", + padStartEditing: "INIZIA A MODIFICARE!", + padEditorPlaceholder: "Inizia a scrivere...", + padSubmitEntry: "Invia", + padNoEntries: "Nessuna voce ancora.", + padAllSectionTitle: "Tutti i Pad", + padMineSectionTitle: "I Miei Pad", + padsDescription: "Gestisci editor di testo collaborativi cifrati nella tua rete.", + padRecentSectionTitle: "Pad Recenti", + padOpenSectionTitle: "Pad Aperti", + padClosedSectionTitle: "Pad Chiusi", + padCreateSectionTitle: "Crea Nuovo Pad", + padUpdateSectionTitle: "Aggiorna Pad", + padInviteGenerated: "Codice Invito Generato", + typePad: "PADS", + padNew: "NUOVO", + padAddFavorite: "Aggiungi ai Preferiti", + padRemoveFavorite: "Rimuovi dai Preferiti", + padClose: "Chiudi Pad", + padBackToEditor: "Torna all'editor", + padSearchPlaceholder: "Cerca pad...", + + modulesChatsLabel: "Chat", + modulesChatsDescription: "Modulo per scoprire e gestire chat cifrate.", + typeChat: "CHATS", + typeChatMessage: "MESSAGGIO CHAT", + chatLabel: "CHAT", + chatMessageLabel: "MESSAGGI CHAT", + chatsTitle: "Chat", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "Preferiti", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "Descrizione", + chatCategory: "Categoria", + chatStatus: "STATO", + chatFilterAll: "TUTTI", + chatFilterMine: "MIO", + chatFilterRecent: "RECENTE", + chatFilterFavorites: "PREFERITI", + chatFilterOpen: "APERTO", + chatFilterClosed: "CHIUSO", + chatCreate: "Crea Chat", + chatUpdate: "Aggiorna Chat", + chatDelete: "Elimina Chat", + chatClose: "Chiudi Chat", + chatVisitChat: "VISITA CHAT", + chatUntitled: "Chat senza titolo", + chatNoItems: "Nessuna chat trovata.", + chatParticipants: "Partecipanti", + chatStartChatting: "INIZIA A CHATTARE!", + chatGenerateCode: "Genera Codice", + chatShareUrl: "Condividi URL", + chatCreatedAt: "CREATO", + chatSearchPlaceholder: "Cerca chat...", + chatStatusOpen: "APERTO", + chatStatusInviteOnly: "SOLO SU INVITO", + chatStatusClosed: "CHIUSO", + chatSendMessage: "Invia", + chatMessagePlaceholder: "Scrivi il tuo messaggio...", + chatNoMessages: "Ancora nessun messaggio.", + chatLeave: "Abbandona Chat", + chatInviteCodeLabel: "Inserisci codice invito", + chatJoinByInvite: "Unisciti", + chatTitlePlaceholder: "Titolo chat", + chatDescriptionPlaceholder: "Descrizione chat", + chatTagsPlaceholder: "tag1, tag2, tag3", + chatImageLabel: "Seleziona un file immagine (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Codice Invito", + chatAuthor: "Autore", + chatCreated: "Creato", + chatAddFavorite: "Aggiungi ai Preferiti", + chatRemoveFavorite: "Rimuovi dai Preferiti", + chatPM: "MP", + chatStatusLabel: "Stato", + chatCategoryLabel: "Categoria", + chatParticipantsLabel: "Partecipanti", + gamesTitle: "Giochi", + gamesDescription: "Scopri e gioca ad alcuni giochi.", + gamesFilterAll: "TUTTI", + gamesPlayButton: "GIOCA!", + gamesBackToGames: "Torna ai Giochi", + modulesGamesLabel: "Giochi", + modulesGamesDescription: "Modulo per scoprire e giocare ad alcuni giochi.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Modulo per esplorare la rete come una mappa interattiva dei nodi.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Collega i PUB agli abitanti tramite validatori, negozi e accumulatori. Sopravvivi alla minaccia CBDC!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Infiltra la griglia, raccogli dati riservati, evita i droni di sicurezza e fuggi. Quanti livelli riesci a superare?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "Fermate l'invasione aliena! Abbattete le ondate di invasori.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Rompi tutti i mattoni con la tua racchetta e la pallina. Una sfida arcade classica.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Ping-pong classico contro un'IA. Il primo a 5 punti vince.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "Corsa contro il tempo! Evita il traffico e raggiungi il traguardo prima che scada.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Pilota la tua navicella in un campo di asteroidi. Sparali prima che ti colpiscano.", + gamesRockPaperScissorsTitle: "Carta Forbici Sasso", + gamesRockPaperScissorsDesc: "Carta, forbici, sasso contro una IA. Il migliore dei tre round vince.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Tris classico contro l'IA. Allinea tre simboli per vincere.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Lancia una moneta e scommetti su testa o croce. Quanta fortuna hai?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "Calendari", + calendarTitle: "Calendario", + modulesCalendarsLabel: "Calendari", + modulesCalendarsDescription: "Modulo per scoprire e gestire i calendari.", + typeCalendar: "CALENDARI", + calendarFilterAll: "TUTTI", + calendarFilterMine: "I MIEI", + calendarFilterOpen: "APERTO", + calendarFilterClosed: "CHIUSO", + calendarFilterRecent: "RECENTI", + calendarFilterFavorites: "PREFERITI", + calendarCreate: "Crea calendario", + calendarUpdate: "Aggiorna", + calendarDelete: "Elimina", + calendarTitleLabel: "Titolo", + calendarTitlePlaceholder: "Titolo del calendario...", + calendarStatusLabel: "Stato", + calendarStatusOpen: "APERTO", + calendarStatusClosed: "CHIUSO", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Tag", + calendarTagsPlaceholder: "tag1, tag2...", + calendarParticipantsLabel: "Partecipanti", + calendarParticipantsCount: "Partecipanti", + calendarVisitCalendar: "Visita il calendario", + calendarCreated: "Creato", + calendarAuthor: "Autore", + calendarJoin: "Partecipa", + calendarGenerateInvite: "Genera invito", + calendarInviteCodePlaceholder: "Inserisci il codice di invito...", + calendarValidateInvite: "Convalida codice", + calendarJoined: "Iscritto", + calendarAddDate: "Aggiungi data", + calendarAddNote: "Aggiungi nota", + calendarDateLabel: "Data", + calendarDatePlaceholder: "Descrivi questa data...", + calendarNoteLabel: "Nota", + calendarNotePlaceholder: "Aggiungi una nota...", + calendarFirstDateLabel: "Data", + calendarFirstNoteLabel: "Note", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Descrizione", + calendarNoDates: "Nessuna data aggiunta.", + calendarNoNotes: "Nessuna nota.", + calendarsNoItems: "Nessun calendario trovato.", + calendarsDescription: "Scopri e gestisci i calendari nella tua rete.", + calendarMonthPrev: "← Precedente", + calendarMonthNext: "Successivo →", + calendarMonthLabel: "Date", + calendarsShareUrl: "URL di condivisione", + calendarAllSectionTitle: "Calendari", + calendarRecentSectionTitle: "Calendari Recenti", + calendarFavoritesSectionTitle: "Preferiti", + calendarMineSectionTitle: "I Tuoi Calendari", + calendarOpenSectionTitle: "Calendari aperti", + calendarClosedSectionTitle: "Calendari chiusi", + calendarCreateSectionTitle: "Crea nuovo calendario", + calendarUpdateSectionTitle: "Aggiorna calendario", + calendarAddFavorite: "Aggiungi ai preferiti", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Rimuovi dai preferiti", + calendarSearchPlaceholder: "Cerca calendari...", + calendarAddEntry: "Aggiungi Voce", + calendarLeave: "Lascia Calendario", + statsCalendar: "Calendari", + statsCalendarDate: "Date calendario", + statsCalendarNote: "Note calendario", + chatAccessDenied: "Non hai accesso a questa chat. Chiedi un invito per accedere al contenuto.", + padAccessDenied: "Non hai accesso a questo pad. Chiedi un invito per accedere al contenuto.", + contentAccessDenied: "Non hai accesso a questo contenuto.", + blockAccessRestricted: "Accesso limitato", + tribeContentAccessDenied: "Accesso Negato", + tribeContentAccessDeniedMsg: "Questo contenuto appartiene a una tribù. Devi essere membro per accedervi.", + tribeViewTribes: "Visualizza Tribù", + torrentsTitle: "Torrent", + torrentsDescription: "Esplora e gestisci i torrent nella tua rete.", + torrentAllSectionTitle: "Torrent", + torrentMineSectionTitle: "I Tuoi Torrent", + torrentRecentSectionTitle: "Torrent Recenti", + torrentTopSectionTitle: "Migliori Torrent", + torrentFavoritesSectionTitle: "Preferiti", + torrentFilterAll: "TUTTI", + torrentFilterMine: "MIEI", + torrentFilterRecent: "RECENTI", + torrentFilterTop: "MIGLIORI", + torrentFilterFavorites: "PREFERITI", + torrentCreateSectionTitle: "Carica Torrent", + torrentUpdateSectionTitle: "Aggiorna Torrent", + torrentCreateButton: "Carica Torrent", + torrentUpdateButton: "Aggiorna", + torrentDeleteButton: "Elimina", + torrentAddFavoriteButton: "Aggiungi ai Preferiti", + torrentRemoveFavoriteButton: "Rimuovi dai Preferiti", + torrentFileLabel: "Seleziona file torrent (.torrent)", + torrentTitleLabel: "Titolo", + torrentTitlePlaceholder: "Titolo", + torrentDescriptionLabel: "Descrizione", + torrentDescriptionPlaceholder: "Descrizione", + torrentTagsLabel: "Tag", + torrentTagsPlaceholder: "Inserisci tag separati da virgole", + torrentSizeLabel: "Dimensione", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "Nessun file torrent", + noTorrents: "Nessun torrent trovato.", + torrentSearchPlaceholder: "Cerca titolo, tag, autore...", + torrentSearchButton: "Cerca", + torrentSortRecent: "Più recenti", + torrentSortOldest: "Più vecchi", + torrentSortTop: "Più votati", + torrentNoMatch: "Nessun torrent corrispondente.", + torrentMessageAuthorButton: "Invia Messaggio all'Autore", + torrentUpdatedAt: "Aggiornato", + statsTorrent: "Torrent", + typeTorrent: "TORRENT", + modulesTorrentsLabel: "Torrent", + modulesTorrentsDescription: "Modulo per scoprire e gestire i torrent.", + favoritesFilterTorrents: "TORRENT", + favoritesFilterMarket: "MERCATO", + favoritesFilterShopProducts: "ARTICOLI NEGOZIO", + tribeSectionTorrents: "TORRENT", + tribeCreateTorrent: "Carica Torrent", + tribeMediaTypeTorrent: "Torrent", + settingsWishTitle: "Desiderio", + settingsWishDesc: "Configura il desiderio del tuo Avatar.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Messaggi Privati", + settingsPmVisibilityDesc: "Configura il livello di esposizione ai messaggi privati.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "L'invio è un guardrail UX. La ricezione è un filtro di attenzione.", + pmBlockedNonMutual: "Puoi inviare MP solo ad abitanti in supporto reciproco.", + inhabitantsPendingFollowsTitle: "Richieste di supporto in attesa", + inhabitantsPendingAccept: "Accetta", + inhabitantsPendingReject: "Rifiuta", + bxEncrypted: "CRITTOGRAFATO", + bxEncryptedHexLabel: "Testo cifrato (anteprima)", + tribeSectionGovernance: "GOVERNANCE", + tribeSubStatusPublic: "PUBBLICA", + tribeSubStatusPrivate: "PRIVATA", + tribeSubInheritedPrivate: "PRIVATA (ereditata dalla tribù principale)", + tribePadInviteRequired: "Non hai accesso al pad. Richiedi un invito.", + tribeChatInviteRequired: "Non hai accesso alla chat. Richiedi un invito.", + tribeGovernanceDesc: "Governance interna di questa tribù.", + tribeGovernanceNoGov: "Nessun governo attivo", + tribeGovernanceNoGovDesc: "Questa tribù non ha ancora eletto un governo.", + tribeGovCardTitle: "Governo Attuale", + tribeGovCycleSince: "INIZIO CICLO", + tribeGovCycleEnd: "FINE CICLO", + tribeGovTimeRemaining: "TEMPO RIMANENTE", + tribeGovPopulation: "POPOLAZIONE", + tribeGovMethod: "METODO", + tribeGovVotesReceived: "VOTI RICEVUTI", + tribeGovLeader: "LEADER", + tribeGovFilterGovernment: "GOVERNO", + tribeGovFilterCandidatures: "CANDIDATURE", + tribeGovFilterLaws: "LEGGI", + tribeGovCandidatureId: "Candidatura", + tribeGovCandidatureMethod: "Metodo", + tribeGovCandidatureProposeBtn: "Pubblica candidatura", + tribeGovRuleTitle: "Titolo della regola", + tribeGovRuleBody: "Corpo della regola", + tribeGovProposals: "PROPOSTE", + tribeGovRevocations: "REVOCHE", + tribeGovHistorical: "STORICO", + tribeGovRules: "REGOLE", + tribeGovernanceAlreadyPublished: "Questa tribù ha già una candidatura aperta.", + tribeGovernanceProposeInternal: "Proponi candidatura interna", + tribeGovernanceInternalCandidatures: "Candidature interne", + tribeGovernanceNoCandidatures: "Nessuna candidatura aperta.", + tribeGovernanceAddRule: "Aggiungi regola", + tribeGovernanceNoRules: "Nessuna regola.", + tribeGovernanceNoLeaders: "Nessun leader eletto.", + tribeGovernanceComingSoon: "Presto nel modulo governance.", + tribeGovNoProposals: "Ancora nessuna proposta", + tribeGovNoLaws: "Ancora nessuna legge", + tribeGovNoRevocations: "Ancora nessuna revoca", + tribeGovNoHistorical: "Ancora nessun record", + logsTitle: "Diario", + logsDescription: "Registra la tua esperienza nella rete.", + logsReadMore: "Leggi di più", + logsViewTitle: "Diario", + logsColumnType: "Tipo", + logsView: "Vedi", + logsManualPrompt: "Scrivi il tuo diario", + logsUpdateButton: "Aggiorna", + logsEditTitle: "Modifica voce", + logsCreateDescription: "Registra la tua esperienza...", + logsCreateTitle: "Crea voce", + logsWriteButton: "Scrivi", + logsTextPlaceholder: "Descrivi le tue esperienze...", + logsTextField: "Testo", + logsLabelPlaceholder: "Etichetta...", + logsLabelField: "Scrivi il tuo diario (etichetta)", + logsAiDisabledWarn: "Attiva il modulo IA in /modules per usare le voci scritte dall'IA.", + logsAiContextValue: "Giorno di blockchain", + logsAiContext: "Contesto", + logsAiModStatus: "AImod", + logsModeApply: "Applica!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Manuale", + logsModeAI: "IA", + logsColumnDelete: "Elimina", + logsColumnEdit: "Modifica", + logsColumnLog: "Diario", + logsColumnDate: "Data", + logsDelete: "Elimina", + logsEdit: "Modifica", + logsFilterAlways: "SEMPRE", + logsFilterYear: "ULTIMO ANNO", + logsFilterMonth: "ULTIMO MESE", + logsFilterWeek: "ULTIMA SETTIMANA", + logsFilterToday: "OGGI", + logsFilterRecent: "RECENTI", + logsFilterAll: "TUTTI", + logsCreate: "Crea voce", + logsExport: "Esporta diario", + logsExportOne: "Esporta", + logsViewDetails: "Vedi dettagli", + logsGenerateButton: "Genera testo", + logsSearchText: "Cerca nei diari...", + logsSearchAnyType: "Qualsiasi tipo", + logsSearchButton: "Cerca", + logsEmpty: "Nessuna voce ancora.", + modulesLogsLabel: "Diario", + modulesLogsDescription: "Modulo per registrare (tramite assistente IA) le tue esperienze.", + statsLogsTitle: "Diario", + statsLogsEntries: "Voci", + typeLog: "DIARIO", + blockchainCycle: "Ciclo", + + larpTitle: "L.A.R.P.", + larpDescription: "Uno strato di gioco di ruolo dal vivo per la sperimentazione collaborativa.", + larpAllHouses: "Case:", + larpFilterRuling: "GOVERNA", + larpFilterHouses: "CASE", + larpFilterRules: "FAQ", + larpRulesTitle: "FAQ del L.A.R.P.", + larpRulesEntryTitle: "Casa iniziale", + larpRulesEntryText: "Ogni abitante inizia in ACADEMIA per impostazione predefinita. Da ACADEMIA, scegli una casa dal pannello \"Unisciti a una casa\": questo apre il suo test d'ingresso. Rispondi alle 10 domande e invia. Se superi la soglia (7/10), sei ammesso automaticamente in quella casa; altrimenti vieni rifiutato e rimani in ACADEMIA. In entrambi i casi il sistema registra il tuo OasisID e il ciclo del tentativo e impedisce un nuovo tentativo fino allo scadere del periodo di attesa di 30 cicli.", + larpRulesLeaveText: "I membri di qualsiasi casa possono tornare ad ACADEMIA in qualsiasi momento dalla pagina della propria casa; ciò ripristina la loro appartenenza, ma non annulla il periodo di attesa del test.", + larpRulesGovernanceTitle: "Muro di governo", + larpRulesGovernanceText: "Durante il suo ciclo, la casa governante porta l'insegna \"Governa\" ed è l'unica il cui Muro è visibile pubblicamente sotto la scheda GOVERNA.", + larpRulesSignTitle: "Emblema L.A.R.P.", + larpRulesSignText: "Gli abitanti possono attivarlo (in /profile/edit > Sensori) per mostrare l'immagine della loro casa attuale come sigillo sul profilo, sulla scheda autore e abitante. Il sigillo rimanda alla pagina della casa.", + larpPostsTitle: "Muro", + larpPostsEmpty: "Ancora nessuna pubblicazione.", + larpPostLabel: "Nuova pubblicazione", + larpPostPlaceholder: "Cosa ha da dire questa casa?", + larpPostPublic: "Pubblico (visibile a chiunque, altrimenti solo ai membri)", + larpPostSubmit: "Pubblica", + larpPostMembersOnly: "Solo membri", + larpCycleLabel: "Ciclo:", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + typeGameScore: "GAME SCORES", + bankTaxesLookupNote: "Inserisci un ID di blocco per cercarlo nel tuo feed locale.", + bankTaxesUserNoteChipsClick: "Clicca su qualsiasi chip per ispezionare il suo blocco nel blockexplorer.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "Codice d'invito", + larpRulesInviteEntryText: "I membri delle case possono generare codici di invito che danno accesso diretto alla casa.", + larpRulesOneHouseText: "Puoi appartenere al massimo a una casa in ogni momento. Se non appartieni a nessuna, sei in ACADEMIA. Ogni pagina di casa (non ACADEMIA) mostra ai suoi membri un pulsante \"Lascia la Casa\" che reimposta l'appartenenza ad ACADEMIA. Uscire NON annulla il periodo di attesa del test.", + larpRulesOneHouseTitle: "Una casa alla volta", + larpRulesTestEntry: "Test WILL", + larpRulesTestEntryText: "Ogni opzione pondera una o più case; all'invio, il sistema somma i punteggi e ti ammette automaticamente nella casa con il punteggio più alto.", + larpWillTestHint: "Una volta completato, ti verrà assegnata la casa che meglio corrisponde alle tue risposte. Le tue risposte individuali vengono elaborate localmente e mai memorizzate — solo l'assegnazione di casa risultante viene pubblicata nel tuo feed.", + larpWillTestTitle: "Test WILL", + settingsLanDesc: "Annuncia periodicamente questo peer ad altre istanze di Oasis sulla stessa rete locale. Disabilita per interrompere le trasmissioni UDP.", + settingsLanEnable: "Abilita trasmissione LAN", + settingsLanTitle: "Trasmissione LAN", + settingsReplicationTitle: "Replicazione", + settingsReplicationDesc: "Configura il numero di salti che il tuo peer segue dal tuo feed durante la replica dei contenuti.", + settingsReplicationHopsLabel: "Salti", + aiApproveTagsLabel: "Tag (separati da virgole)", + aiApproveTagsPlaceholder: "es. oasis, governance, ecologia", + aiApproveRatingLabel: "Valutazione", + aiExchangeLang: "Lingua", + aiExchangeTags: "Tag", + aiExchangeRating: "Valutazione", + aiExchangeHelpful: "Utile", + aiExchangeMarkHelpful: "+1 utile", + larpCyclesUntilRuling: "Prossimo ciclo di governo", + larpVisitTribe: "Visita la tribù", + larpGoverning: "Governa:", + larpMyHouse: "La mia Casa:", + larpMembersCount: "Membri", + larpMembersTitle: "Membri", + larpNoMembers: "Ancora nessun membro.", + larpRolesLabel: "Ruoli", + larpFunctionLabel: "Funzione", + larpMonthLabel: "Ciclo di governo", + larpLeaveToAcademia: "Torna ad ACADEMIA", + larpAcademiaJoinTitle: "Unisciti a una casa", + larpAcademiaJoinHint: "Sostieni il test psicologico di profilo per essere assegnato alla casa che meglio si adatta a te, oppure usa un codice di invito condiviso da un membro di una casa.", + larpTakeTestButton: "Sostieni il test di profilo", + larpInviteRedeem: "Unisciti alla Casa", + larpInviteCreate: "Genera codice di invito", + larpInvitePlaceholder: "Codice di invito", + larpInviteBannerTitle: "Nuovo codice di invito", + larpInviteBannerHint: "Condividi questo codice con qualcuno in ACADEMIA. Scade tra 30 cicli o dopo un singolo uso.", + larpTestAssignedHouse: "Casa assegnata", + larpTestRankingTitle: "Punteggio per casa", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "Codice di invito di casa", + invitesHouseJoinButton: "Unisciti alla Casa", + ecoTaxLabel: "ECO Tax", + ecoinTaxLabel: "ECOin Tax", + bankTaxes: "Tasse", + bankOverviewYourTaxes: "Le tue tasse", + bankTaxesNetworkTitle: "Tasse della rete", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesUserTitle: "Le tue tasse", + bankTaxesUserAmount: "La tua ECO Tax (prezzo da restituire)", + bankTaxesArchTaxUserAmount: "La tua ARCH Tax (prezzo da restituire)", + bankTaxesArchTaxFirstBlock: "Età del tuo primo blocco", + bankTaxesArchTaxRate: "Aliquota", + bankTaxesTotalBlocks: "Blocchi totali", + bankTaxesTotalBytes: "Byte totali", + bankTaxesTotalCarbon: "Carbonio totale", + bankTaxesTotalEcoin: "ECO Tax totale (a vita)", + bankTaxesEcoTaxLifetime: "ECO Tax (a vita)", + bankTaxesAnnualEcoin: "ECO Tax (annuale)", + bankTaxesMonthlyEcoin: "ECO Tax (mensile)", + bankTaxesArchTaxLifetime: "ARCH Tax (a vita)", + bankTaxesArchTaxAnnual: "ARCH Tax (annuale)", + bankTaxesArchTaxMonthly: "ARCH Tax (mensile)", + bankTaxesTotalLifetime: "Totale (a vita)", + bankTaxesTotalAnnual: "Totale (annuale)", + bankTaxesTotalMonthly: "Totale (mensile)", + bankTaxesRate: "Aliquota", + bankTaxesSpan: "Periodo di campionamento", + bankTaxesLookupTitle: "Ispeziona un blocco", + bankTaxesLookupPlaceholder: "ID del blocco (es. %abc...=.sha256)", + bankTaxesLookupButton: "Ispeziona", + bankTaxesLookupNotFound: "Nessun blocco trovato nel tuo feed locale con questo ID.", + bankTaxesLookupAuthor: "Autore", + bankTaxesLookupType: "Tipo", + bankTaxesLookupSize: "Dimensione", + bankTaxesLookupCarbon: "Impronta di carbonio", + bankTaxesLookupEcoin: "ECO Tax", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annuale)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (mensile)", + bankTaxesTypesLabel: "Seleziona quali tasse vuoi pagare", + bankTaxesTypesApply: "Imposta le mie tasse", + bankRulesPoolTitle: "Pool mensile", + bankRulesShareTitle: "Quota per utente", + bankRulesKarmaTitle: "Karma (punteggio di partecipazione)", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesChipTitle: "Banda di colore del chip ECO Tax", + bankRulesEpochKind: "Granularità dell'epoch", + bankRulesAlpha: "Alpha (quota massima del saldo del pub per epoch)", + bankRulesReserveMin: "Riserva minima (mantenuta nel saldo del pub)", + bankRulesCapPerEpoch: "Tetto per epoch (assoluto)", + bankRulesGraceDays: "Periodo di grazia del claim", + bankRulesWMin: "Peso minimo (w_min)", + bankRulesWMax: "Peso massimo (w_max)", + bankRulesFloor: "Pavimento per utente (lordo)", + bankRulesCapUser: "Tetto per utente per epoch", + bankRulesEcoTaxRate: "Aliquota", + bankRulesArchTaxRate: "Aliquota", + bankRulesCarbonFactor: "Fattore di carbonio", + statsEcoTaxTitle: "ECO Tax", + statsTombstoneEmpty: "Ancora nessun tombstone nella rete.", + blockchainBlockNotAvailable: "Questo blocco non è disponibile nel tuo feed locale. Potrebbe appartenere a un peer da cui non stai ancora replicando.", + blockchainBackToBlockexplorer: "Torna al blockexplorer", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeDetailDescription: "Decodifica il payload incorporato e la mappa di composizione originale della blockchain.", + audioTranscodeStegoTitle: "Incorporato nella forma d'onda", + audioTranscodeStegoOasisId: "Da", + audioTranscodeStegoTimestamp: "Generato il", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoEmpty: "(nessuno)", + audioTranscodeStegoUnknown: "—", + audioTranscodeStegoNotFound: "Nessun payload steganografico è stato decodificato da questo audio.", + audioTranscodeCompositionEmpty: "Questo audio non include una composizione blockchain salvata.", + audioBackToAudio: "Torna all'audio", + audioBackToBcs: "Torna a BCS", + audioAuthorLabel: "Autore", + melodyCompositionTitle: "Mappa di composizione blockchain", + melodyFilterMine: "MIO", + melodyByLabel: "Da", + melodyAllEmpty: "Ancora nessuna composizione BCS da altri abitanti.", + melodyNoteUnavailable: "il blocco non è nel tuo feed locale", + melodyUploadBcsTitle: "Pubblica", + melodyUploadBcsButton: "Pubblica", + melodyUploadStegoLabel: "Messaggio nascosto (steganografia)", + melodyUploadStegoMaxLabel: "max 280 caratteri", + melodyUploadStegoPlaceholder: "Facoltativo.", + melodyUploadBcsNameNote: "L'audio sarà pubblicato col nome auto-generato", + larpLastAttemptTitle: "Il tuo ultimo tentativo", + larpLastAttemptHouse: "Casa", + larpLastAttemptResult: "Risultato", + larpLastAttemptWhen: "Quando", + larpLastAttemptCooldown: "Prossimo tentativo in", + larpTestTitle: "Test d'ingresso", + larpTestIntro: "Rispondi alle 10 domande. Servono almeno 7 risposte corrette per essere ammessi. Puoi tentare un test ogni 30 cicli, indipendentemente dall'esito.", + larpTestSubmit: "Unisciti alla casa", + larpTestCooldownActive: "Prossimo test disponibile tra", + larpTestCooldownDays: "cicli", + larpTestResultTitle: "Risultato del test", + larpTestPassed: "Test superato — benvenuto!", + larpTestFailed: "Test non superato", + larpTestScore: "Punteggio", + larpTestNextAttempt: "Puoi tentare un altro test tra 30 cicli.", + larpGoToHouse: "Vai alla tua casa", + larpBackToAcademia: "Torna ad ACADEMIA", + larpBackToHouses: "◀ Case", + larpBadgeYou: "Tu", + larpBadgeRuling: "Governa", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Modulo per lo strato di gioco di ruolo dal vivo di Oasis (le 9 Case).", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "Di fronte a un problema complesso, cosa fai per primo?", + larpProfileQ1O1: "Parlare con altri per cercare il consenso", + larpProfileQ1O2: "Costruire un prototipo da testare", + larpProfileQ1O3: "Studiare la letteratura", + larpProfileQ1O4: "Sabotare il sistema che lo provoca", + larpProfileQ2: "Cosa dà senso al tuo lavoro quotidiano?", + larpProfileQ2O1: "Difendere le persone che amo", + larpProfileQ2O2: "Creare qualcosa di tangibile", + larpProfileQ2O3: "Guarire o coltivare la vita", + larpProfileQ2O4: "Lavorare con parole e idee", + larpProfileQ2O5: "Far ridere gli altri", + larpProfileQ3: "Quando nasce un conflitto nel tuo gruppo, tu…", + larpProfileQ3O1: "Guidi il dialogo", + larpProfileQ3O2: "Prendi posizione e resti fermo", + larpProfileQ3O3: "Fai una battuta per smorzare", + larpProfileQ3O4: "Metti in dubbio che il conflitto sia reale", + larpProfileQ3O5: "Cerchi le cause ecologiche profonde", + larpProfileQ4: "Il tuo progetto preferito a lungo termine sarebbe…", + larpProfileQ4O1: "Costruire una città", + larpProfileQ4O2: "Restaurare una foresta", + larpProfileQ4O3: "Scrivere una costituzione", + larpProfileQ4O4: "Organizzare un festival", + larpProfileQ4O5: "Allestire una rete di difesa", + larpProfileQ4O6: "Progettare una nuova macchina", + larpProfileQ4O7: "Curare un archivio", + larpProfileQ4O8: "Smantellare un ordine ingiusto", + larpProfileQ5: "Cosa rende una buona leader?", + larpProfileQ5O1: "Chi ascolta e fa da mediatore", + larpProfileQ5O2: "Chi sa combattere e proteggere", + larpProfileQ5O3: "Chi conosce la storia", + larpProfileQ5O4: "Chi ti fa sorridere", + larpProfileQ6: "Il tuo rapporto con le regole:", + larpProfileQ6O1: "Le regole nascono dal dialogo e dalla legge", + larpProfileQ6O2: "Le regole vanno seguite rigorosamente", + larpProfileQ6O3: "Le regole vanno spesso infrante", + larpProfileQ6O4: "Le regole devono servire la vita", + larpProfileQ6O5: "Le regole costruiscono infrastrutture solide", + larpProfileQ7: "Come gestisci l'informazione?", + larpProfileQ7O1: "La curo e la conservo", + larpProfileQ7O2: "La condivido tramite storie", + larpProfileQ7O3: "Ne metto in dubbio le origini", + larpProfileQ7O4: "Ne estraggo il pezzo utile", + larpProfileQ7O5: "La uso per guarire", + larpProfileQ8: "La tua idea di successo è…", + larpProfileQ8O1: "Una macchina funzionante", + larpProfileQ8O2: "Una comunità pacifica", + larpProfileQ8O3: "Un festival vivace", + larpProfileQ8O4: "Una famiglia al sicuro", + larpProfileQ8O5: "Un archivio intatto", + larpProfileQ8O6: "Un dogma incrinato", + larpProfileQ8O7: "Un raccolto fiorente", + larpProfileQ8O8: "Un edificio finito", + larpProfileQ9: "Al risveglio, vuoi…", + larpProfileQ9O1: "Allenare il corpo", + larpProfileQ9O2: "Leggere o scrivere", + larpProfileQ9O3: "Coltivare o cucinare", + larpProfileQ9O4: "Armeggiare con qualcosa", + larpProfileQ9O5: "Mettere in dubbio l'autorità", + larpProfileQ9O6: "Pianificare un progetto", + larpProfileQ9O7: "Parlare con le amiche", + larpProfileQ9O8: "Fare arte", + larpProfileQ10: "La tua debolezza potrebbe essere:", + larpProfileQ10O1: "Parlare troppo", + larpProfileQ10O2: "Essere troppo pragmatica", + larpProfileQ10O3: "Essere troppo idealista", + larpProfileQ10O4: "Essere troppo dirompente", + larpProfileQ10O5: "Essere troppo rigida", + larpProfileQ10O6: "Essere troppo spensierata", + larpProfileQ10O7: "Essere troppo cauta", + larpProfileQ10O8: "Essere troppo ambiziosa", + uxModeTitle: "UX", + uxModeDescription: "Seleziona la modalità di navigazione UX per la tua interfaccia.", + uxModeMenus: "Blocchi", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_pt.js b/src/client/assets/translations/oasis_pt.js new file mode 100644 index 0000000..2b2f5e5 --- /dev/null +++ b/src/client/assets/translations/oasis_pt.js @@ -0,0 +1,3873 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + pt: { + languageName: "Português", + shopOrderStatusPaid: "Pagamento recebido", + shopOrderStatusReceived: "Recebido", + shopOrderMarkPaid: "Marcar pagamento recebido", + shopOrderConfirmReceived: "Confirmar receção", + shopMyOrdersTitle: "As minhas encomendas", + shopPurchasesButton: "Compras", + shopMyOrdersDescription: "As tuas encomendas de compra.", + shopMyOrdersEmpty: "Ainda não tens compras.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Vendedor", + shopOrderPaymentConcept: "Pagamento", + shopOrderInvoice: "Fatura", + shopOrderInvoiceConcept: "Fatura", + shopOrderStatusPending: "Pendente", + shopOrderStatusAccepted: "Aceite", + shopOrderStatusRejected: "Rejeitado", + shopOrderStatusShipped: "Enviado", + shopOrderStatusDelivered: "Entregue", + shopOrderAccept: "Aceitar", + shopOrderReject: "Rejeitar", + shopOrderMarkShipped: "Marcar enviado", + shopOrderMarkDelivered: "Marcar entregue", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Contrato", + shopOrderContractConcept: "Encomenda de loja", + shopOrdersPending: "Encomendas pendentes", + all: "Todos", + mine: "Meus", + recent: "Recentes", + anonymous: "Anónimo", + unnamed: "Anónimo", + authorLabel: "AUTOR", + delete: "Eliminar", + save: "Guardar", + vote: "Votar", + url: "URL", + price: "Preço", + priority: "Prioridade", + severity: "Gravidade", + organizer: "Organizador", + edited: "editado", + isPublic: "Público", + searchIsPublicLabel: "Público", + privacyPrivate: "PRIVADO", + privacyPublic: "PÚBLICO", + notFound: "Não encontrado", + no_results: "Sem dados", + searchButton: "Pesquisar", + mapZoomLabel: "Zoom", + modulesTitle: "Módulos", + viewJson: "Ver JSON", + matchScore: "Pontuação de correspondência", + preferencesLabel: "Preferências", + inhabitantProfileTitle: "Perfil do habitante", + contentWarningLabel: "Aviso de conteúdo", + invalidPost: "Conteúdo inválido", + invalidPostHint: "Esta mensagem tem texto vazio/inválido.", + spreadBy: "Por", + spreadChron: "Difusão", + spreaded: "Difundido", + spreadMore: "mais", + spreadContentUnavailable: "Conteúdo ainda não disponível (replicação pendente)", + filteredByTag: "Filtrado por etiqueta", + filteredByTagTitle: "Filtrado por etiqueta", + feedPublishedSuccess: "Feed publicado com sucesso!", + tribeFeedSent: "Mensagem enviada com sucesso!", + tribeContentEncrypted: "Conteúdo encriptado", + tribeTorrentsEmpty: "Ainda sem torrents.", + torrentDescription: "Descrição", + torrentDownload: "Baixar", + chatInviteMode: "Convidar", + padInviteMode: "Convidar", + noInviteMode: "Sem modo de convite", + noDeadline: "Sem prazo", + noStatus: "Sem estado", + noSeverity: "Sem gravidade", + calendarDeleteDate: "Eliminar data", + calendarIntervalUntil: "Até", + bookmarkCategory: "Categoria", + bookmarkLastVisit: "Última visita", + profileClearnetBookmarksLabel: "Marcadores", + forumFilterHot: "Populares", + invitesPort: "Porta", + currentlyUnrecheable: "ERRO!", + peerHostValidation: "IPv4 válido (ex. 192.168.1.100) ou nome de host (ex. pub.example.com)", + peerPortValidation: "Porta 1-65535", + peerKeyValidation: "Chave pública SSB ed25519 (@<44 caracteres base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Cancelamento da tua oferta de emprego", + inboxProjectFollowedTitle: "Novo seguidor do teu projeto", + inboxProjectUnfollowedTitle: "Deixou de seguir o teu projeto", + pmHasFollowedYourProject: "seguiu o teu projeto", + pmHasUnfollowedYourProject: "deixou de seguir o teu projeto", + pmHasUnsubscribedFromYourJobOffer: "cancelou a inscrição na tua oferta de emprego", + marketDeleteButton: "Eliminar", + marketNoBids: "Ainda sem licitações", + marketAuctionBidAmount: "Valor da licitação", + marketAuctionBidTime: "Hora da licitação", + marketAuctionUser: "Licitante", + parliamentActorInPower: "TRIBO NO PODER", + parliamentWinningCandidature: "Candidatura vencedora", + projectBounty: "Recompensa", + projectFollowing: "A SEGUIR", + projectBackerAuthor: "Apoiador", + projectBackerDate: "Data", + projectConfirmTransferButton: "Confirmar transferência", + projectPendingTransfersTitle: "Transferências pendentes", + transfersUpdateSectionTitle: "Atualizar transferência", + taskUnassignedFrom: "Não atribuído de", + taskDescriptionPlaceholder: "Descrição da tarefa", + exportPasswordLabel: "Senha de exportação", + importPasswordPlaceholder: "Introduz a palavra-passe", + searchCasesPlaceholder: "Pesquisar casos…", + shopBuyDeliveryAddressPlaceholder: "Endereço de entrega", + shopBuyNotesPlaceholder: "Notas", + bankTaxesUserNoteBold: "reduzir diretamente o imposto ECO", + bankTaxesUserNoteIntro: "O teu imposto ECO é deduzido do excedente que a rede gera acima do teu RBU; o valor base do RBU é fixado como mínimo inamovível. O imposto nunca é deduzido do montante fixo que corresponde a cada habitante como RBU. Os fundos deduzidos alimentam o algoritmo de redistribuição de riqueza e são canalizados de volta a outros habitantes através das suas reivindicações de RBU. Esses outros habitantes que recebem mais RBU para os seus projetos provavelmente terão alguma tarefa dedicada a ajudar-te a reduzir o teu imposto ECO. Ou podem dedicar-se a ", + bankTaxesUserNoteOutro: ", e a sua contribuição contínua pode ser exigida pelo consenso da rede.", + courtsViewDetails: "Ver", + courtsViewDetailsShort: "Detalhes", + courtsThDetails: "Detalhes", + courtsDetailsAnswersTitle: "Respostas", + courtsDetailsEvidenceTitle: "Provas", + courtsDetailsSettlementsTitle: "Acordos", + courtsDetailsVerdictTitle: "Veredicto", + courtsDetailsNotPublic: "Não público", + courtsEvidenceSideAccuser: "Provas do acusador", + courtsEvidenceSideRespondent: "Provas da defesa", + courtsEvidenceSideUnknown: "Desconhecido", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Apoiar o caso", + courtsSettlementAcceptBtn: "Aceitar acordo", + courtsSupportCount: "Apoios", + courtsVerdictVoteTitle: "Votar no veredicto", + courtsVerdictVoteLabel: "Voto do veredicto", + courtsVerdictVoteAccept: "Aceitar", + courtsVerdictVoteReject: "Rejeitar", + courtsVerdictVoteSubmit: "Enviar voto", + reportsSetStatus: "Definir estado", + reportsStatusClosed: "FECHADO", + statsAvgPerInhabitant: "Média por habitante", + statsCarbonMaxAnnual: "Estimativa máx. anual", + statsCarbonNetwork: "Total da rede", + statsCarbonOfEstMax: "da capacidade máx. estimada", + statsCarbonOfNetwork: "do total da rede", + statsCarbonUser: "A tua pegada", + statsContentCountColumn: "Quantidade", + statsContentTypeColumn: "Tipo", + statsMsgsPerDay: "Mensagens/dia (total)", + statsMyShare: "A tua parte da rede", + statsNetworkSpan: "Abrangência da rede", + statsTombstoneRatioLabel: "Proporção de eliminados", + statsTopTagsTitle: "Principais etiquetas", + statsTopTypesTitle: "Principais tipos de conteúdo", + statsTotalMsgs: "Total de mensagens", + feedViewDetails: "Ver detalhes", + eventFileLabel: "Anexar imagem", + taskFileLabel: "Anexar imagem", + courtsRespondentRequired: "O ID do réu é obrigatório", + extended: "Multiverso", + extendedDescription: [ + "Quando apoias alguém, podes descarregar publicações dos habitantes que apoiam, e estas aparecem aqui, ordenadas por data.", + ], + popular: "Destaques", + popularDescription: [ + "Publicações de habitantes na tua rede, ", + strong("ordenadas por difusão"), + ". Seleciona o período de tempo para obter uma lista.", + ], + week: "SEMANA", + month: "MÊS", + year: "ANO", + latest: "Mais recentes", + latestDescription: [ + strong("Publicações"), + " de ti e dos habitantes que apoias, ordenadas por data.", + ], + topics: "Tópicos", + topicsDescription: [ + strong("Tópicos"), + " de ti e dos habitantes que apoias, ordenadas por data.", + ], + summaries: "Resumos", + summariesDescription: [ + strong("Tópicos com comentários"), + " de ti e dos habitantes que apoias, ordenadas por data.", + ], + threads: "Conversas", + threadsDescription: [ + strong("Publicações com comentários"), + " dos habitantes que apoias (incluindo do multiverso), ordenadas por data.", + ], + profile: "Avatar", + inhabitants: "Habitantes", + peersReplicatedFeeds: "Feeds replicados", + graphos: "Graphos", + graphosDescription: "Mapa interativo da rede ao seu redor.", + graphosViewingGraphOf: "A ver", + graphosBackToMine: "← Minha rede", + graphosShowing: "Mostrando", + graphosYou: "Você", + graphosTotalNodes: "Total de nós", + manualMode: "Modo manual", + mentions: "Menções", + mentionsDescription: [ + strong("Publicações que te @mencionam"), + ", ordenadas por data.", + ], + nextPage: "Seguinte", + previousPage: "Anterior", + noMentions: "Ainda não recebeste @menções.", + privateReminders: "LEMBRETES", + inboxFiltersLabel: "Filtros:", + inboxToggleToMutuals: "Mudar para mútuos", + inboxToggleToWhole: "Mudar para todos", + privateFrom: "De", + privateTo: "Para", + privateDate: "Data", + pmReply: "Responder", + pmReplies: "respostas", + pmNew: "novas", + pmMarkRead: "Marcar como lido", + inReplyTo: "EM RESPOSTA A", + pmPreview: "Pré-visualizar", + pmPreviewTitle: "Pré-visualização da mensagem", + peers: "Pares", + searchDescription: "Descrição", + imageSearch: "Pesquisa de imagens", + searchFromLabel: "De", + searchToLabel: "Até", + searchMinOpinionsLabel: "Mín opiniões", + searchInhabitantLabel: "Habitante (Oasis ID)", + searchQueryLabel: "Pesquisa", + searchPerPageLabel: "Resultados por página", + settings: "Definições", + continueReading: "Continuar a ler", + moreComments: "mais comentários", + readThread: "ler o resto da conversa", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Desenha pixéis na grelha e colabor-ARTe com outros na tua rede.', + melodyTitle: 'Melody', + melodyDescription: 'Toca a melodia da tua blockchain — cada bloco torna-se uma nota.', + melodyEmpty: 'Ainda sem notas — a tua blockchain ainda não produziu nenhum bloco.', + melodyCompositionHelp: 'Cada bloco da tua blockchain torna-se uma nota musical segundo o seu tipo e tamanho.', + melodyStatsTitle: 'Distribuição por tipo', + melodyInhabitantLabel: 'Habitante', + melodyTotalBlocks: 'Notas', + melodyType: 'Tipo', + melodyCount: 'Blocos', + melodyFilterAll: 'TODAS', + melodyFilterShare: 'Carregar melodia', + melodyShareTitle: 'Partilha a tua melodia', + melodyShareHelp: 'Publica um instantâneo da tua melodia atual para que outros a oiçam e remixem.', + melodyShareTitleLabel: 'Título (opcional)', + melodyShareTitlePlaceholder: 'Um fantasma de blockchain', + melodyShareSubmit: 'Publicar melodia', + melodyNoShared: 'Ainda sem melodias de blockchain.', + melodyRegenerate: 'Regenerar', + melodyDownload: "Descarregar BCS", + profileActivityTitle: 'Atividade', + profileActivityMore: 'Ver toda a atividade', + profileContentSectionTitle: 'Conteúdo do Avatar', + profileContentHelp: 'Escolhe quais módulos serão exibidos no teu avatar.', + profileSensorsHelp: 'Métricas opcionais exibidas no teu avatar.', + profileClearnetHelp: 'Módulos acessíveis a partir de fora do Oasis.', + profileHubAll: 'Tudo', + profileHubTitle: 'Conteúdo Público', + footerPulseTitle: 'Pulso', + footerPulsePeers: 'Pares', + footerPulseInbox: 'Caixa', + footerPulseSync: 'Última sinc.', + footerPulseEcoValue: 'ECO/h', + footerPulseLastActivity: 'Última atividade', + footerLicenseLabel: 'Licença', + profileSwitchToOasis: 'Voltar para Oasis', + profileSwitchToClearnet: 'Mergulha na Clearnet', + profileVisibilityFediverse: "Fediverse", + profileSwitchToFediverse: "Mergulhar no fediverso", + coordLabel: 'Coordenada (ex.: A3)', + coordPlaceholder: 'Introduz coordenada', + contributorsTitle: "Contribuidores", + pixeliaBy: "por", + colorLabel: 'Escolhe uma cor', + paintButton: 'Pintar!', + invalidCoordinate: 'Coordenada incorreta', + goToMuralButton: "Ver mural", + totalPixels: 'Total de pixéis', + modules: "Módulos", + modulesViewTitle: "Módulos", + modulesViewDescription: "Configura o teu ambiente ativando ou desativando módulos.", + inbox: "Caixa de entrada", + multiverse: "Multiverso", + fediverse: "Fediverse", + fediverseDescription: "Faça a gestão das suas outras contas do fediverso, incluindo enviar e receber conteúdo.", + fediverseStatus: "Estado", + fediverseDisconnected: "Fediverso desligado. Verifique %LINK% ou o estado da ligação.", + fediverseSettingsTitle: "Fediverse", + fediverseInstanceLabel: "Endereço", + fediverseTokenLabel: "Token de acesso", + fediverseTokenHelp: "Defina o seu token de acesso. Será usado para gerir a sua conta de Mastodon a partir do Oasis.", + fediverseConnect: "Ligar", + fediverseConnectedAs: "Conectado como", + fediverseDisconnect: "Desconectar", + fediverseEmpty: "Quando ligar outras contas do fediverso nas %LINK%, poderá geri-las a partir daqui.", + fediverseEmptyLink: "suas definições", + fediverseComposePlaceholder: "No que está pensando?", + fediverseReplyPlaceholder: "Escreva a sua resposta…", + fediverseAttach: "Anexar mídia", + fediversePublish: "Publicar", + fediverseReply: "Responder", + fediverseBoosted: "impulsionou", + fediverseNoPosts: "A sua timeline está vazia.", + fediverseThread: "Tópico", + fediverseTimeline: "Cronologias", + fediverseManageTimeline: "Gerir cronologia", + fediverseFollowers: "Seguidores", + fediverseFollowing: "A seguir", + fediversePosts: "Publicações", + fediverseJoined: "Aderiu", + fediverseOverview: "Resumo", + fediverseRefresh: "Atualizar", + fediverseWrite: "Escrever", + fediversePreview: "Pré-visualizar", + fediverseEdit: "Editar", + fediverseOpenFeed: "Ver feed", + fediverseManage: "Gerir", + fediverseStatusNotConnected: "Não conectado", + fediverseError: "Não foi possível contactar o Mastodon.", + fediverseErrInstance: "URL de instância inválido.", + fediverseErrAuth: "Token inválido ou expirado.", + fediverseErrConnect: "Não foi possível conectar à instância.", + fediverseErrToken: "Token obrigatório.", + fediverseErrPublic: "Indisponível no modo público.", + fediverseErrFetch: "Não foi possível carregar a timeline.", + fediverseErrPost: "Não foi possível publicar.", + fediverseErrMedia: "Não foi possível enviar o ficheiro.", + fediverseErrEmpty: "Escreva algo ou anexe um ficheiro.", + popularLabel: "Destaques", + topicsLabel: "Tópicos", + latestLabel: "Mais recentes", + summariesLabel: "Resumos", + threadsLabel: "Conversas", + multiverseLabel: "Multiverso", + inboxLabel: "Caixa de entrada", + invitesLabel: "Convites", + walletLabel: "Carteira", + legacyLabel: "Chaves", + cipherLabel: "Encriptação", + bookmarksLabel: "Marcadores", + videosLabel: "Vídeos", + torrentsLabel: "Torrents", + docsLabel: "Documentos", + audiosLabel: "Áudios", + tagsLabel: "Tags", + imagesLabel: "Imagens", + inhabitantsLabel: "Habitantes", + trendingLabel: "Tendências", + eventsLabel: "Eventos", + tasksLabel: "Tarefas", + apply: "Aplicar", + menuPersonal: "Pessoal", + menuContent: "Conteúdo", + menuBlogs: "Blogues", + menuGovernance: "Governação", + menuOffice: "Escritório", + menuMultiverse: "Multiverso", + menuNetwork: "Rede", + menuCreative: "Criativo", + menuEconomy: "Economia", + menuMedia: "Multimédia", + menuTools: "Ferramentas", + comment: "Comentário", + subtopic: "Subtópico", + json: "JSON", + createdBy: "por", + unfollow: "Deixar de apoiar", + follow: "Apoiar", + block: "Bloquear", + unblock: "Desbloquear", + newerPosts: "Publicações mais recentes", + olderPosts: "Publicações mais antigas", + feedRangeEmpty: "O intervalo indicado está vazio para este feed. Tenta ver o ", + seeFullFeed: "feed completo", + feedEmpty: "A rede Oasis nunca viu publicações desta conta.", + beginningOfFeed: "Este é o início do feed", + noNewerPosts: "Ainda não foram recebidas publicações mais recentes.", + relationshipNotFollowing: "Não te apoia", + relationshipTheyFollow: "Apoia-te", + relationshipMutuals: "Apoio mútuo", + relationshipFollowing: "Estás a apoiar", + relationshipYou: "Tu", + relationshipBlocking: "Estás a bloquear", + relationshipBlockedBy: "Estás bloqueado", + relationshipMutualBlock: "Bloqueio mútuo", + relationshipNone: "Não estás a apoiar", + relationshipConflict: "Conflito", + relationshipBlockingPost: "Publicação bloqueada", + viewLikes: "Ver difusões", + spreadedDescription: "Lista de publicações difundidas pelo habitante.", + totalspreads: "Total de difusões", + attachFiles: "Anexar ficheiros", + preview: "Pré-visualizar", + publish: "Publicar", + contentWarningPlaceholder: "Adiciona um assunto à publicação (opcional)", + privateWarningPlaceholder: "Adiciona habitantes para enviar uma publicação privada (opcional)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "LEMBRA-TE: Devido à tecnologia blockchain, uma vez publicado, não pode ser editado nem eliminado.", + ], + commentWarning: [ + "LEMBRA-TE: Devido à tecnologia blockchain, uma vez publicado, não pode ser editado nem eliminado.", + ], + commentPublic: "público", + commentPrivate: "privado", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "LEMBRA-TE: Devido à tecnologia blockchain, uma vez publicado, não pode ser editado nem eliminado.", + ], + replyLabel: ({ markdownUrl }) => [ + "LEMBRA-TE: Devido à tecnologia blockchain, uma vez publicado, não pode ser editado nem eliminado.", + ], + publishCustomInfo: ({ href }) => [ + "Se tens experiência, também podes ", + a({ href }, "escrever uma publicação avançada"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "Se não tens experiência, deves ", + a({ href }, "escrever uma publicação"), + ".", + ], + publishCustom: "Escrever publicação avançada", + subtopicLabel: "Criar um subtópico desta publicação", + messagePreview: "Pré-visualização da publicação", + mentionsMatching: "Menções correspondentes", + mentionsName: "Nome", + mentionsRelationship: "Relacionamento", + updateit: "OBTER ATUALIZAÇÕES!", + updateBannerText: "Uma nova versão do Oasis está disponível.", + updateBannerAction: "Atualizar agora →", + info: "Info", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "atrás", + sendTime: "há ", + theme: "Tema", + legacy: "Chaves", + legacyTitle: "Chaves", + legacyDescription: "Gere o teu segredo (chave privada) de forma rápida e segura.", + legacyExportButton: "Exportar", + legacyImportButton: "Importar", + ssbLogStream: "Blokchain", + ssbLogStreamDescription: "Configura o limite de mensagens para os fluxos da Blockchain.", + saveSettings: "Guardar definições", + exportTitle: "Exportar dados", + exportDescription: "Define uma senha (mínimo 32 caracteres) para encriptar a tua chave", + exportDataTitle: "Cópia de segurança", + exportDataDescription: "Descarrega os teus dados (chave secreta excluída!)", + exportDataButton: "Descarregar base de dados", + pubWallet: "Carteira PUB", + pubWalletDescription: "Define o URL da carteira PUB. Será usado para transações PUB (incluindo o UBI).", + pubWalletConfiguration: "Guardar configuração", + importTitle: "Importar dados", + importDescription: "Importa o teu segredo encriptado (chave privada) para ativar o teu avatar", + importAttach: "Anexar ficheiro encriptado (.enc)", + passwordLengthInfo: "A senha deve ter pelo menos 32 caracteres.", + passwordImport: "Escreve a tua senha para decifrar os dados que serão guardados no teu diretório pessoal (nome: secret)", + exportPasswordPlaceholder: "Usa minúsculas, maiúsculas, números e símbolos", + fileInfo: "A tua chave secreta encriptada será guardada no teu diretório pessoal (nome: oasis.enc)", + themeIntro: + "Escolhe um tema.", + setTheme: "Definir tema", + language: "Idioma", + languageDescription: + "Se preferires usar outro idioma, seleciona-o aqui.", + setLanguage: "Definir idioma", + peerConnections: "Pares", + peerConnectionsIntro: "Gere todas as tuas ligações com outros pares.", + peerConnectionsTitle: "Ligações", + online: "Online", + offline: "Offline", + discovered: 'Descobertos', + unknown: 'Desconhecido', + lanBroadcastLabel: "Difusão LAN", + lanBroadcastActive: "Ativa (multicast UDP na LAN)", + lanBroadcastDisabled: "Desativada (modo pub)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Recente", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Apoiados", + recommended: "Recomendados", + blocked: "Bloqueados", + noConnections: "Nenhum par conectado.", + noDiscovered: "Nenhum par descoberto.", + noUnknownPeers: "Sem pares desconhecidos no grafo de amizades.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Atualizar", + peerPruneIdle: "Remover inativos", + peerExport: "Exportar", + peerImport: "Importar", + peerImportTitle: "Importar lista de peers", + peerImportPlaceholder: "Cola um endereço multiserver por linha, ou carrega um ficheiro .txt…", + noSupportedConnections: "Nenhum par apoiado.", + noBlockedConnections: "Nenhum par bloqueado.", + noRecommendedConnections: "Nenhum par recomendado.", + connectionActionIntro: + "", + startNetworking: "Iniciar rede", + stopNetworking: "Parar rede", + restartNetworking: "Reiniciar rede", + sync: "Sincronizar rede", + indexes: "Índices", + indexesDescription: + "Reconstruir os teus índices é seguro e pode corrigir alguns tipos de erros.", + homePageTitle: "Início", + homePageDescription: "Seleciona qual módulo queres como página inicial.", + saveHomePage: "Definir início", + invites: "Convites", + invitesTitle: "Convites", + invitesInvites: "Convites", + invitesDescription: "Gere e aplica códigos de convite na tua rede.", + invitesTribesTitle: "Tribos", + invitesTribeInviteCodePlaceholder: "Introduz código de convite da tribo", + invitesTribeJoinButton: "Aderir à tribo", + invitesChatsTitle: "Chats", + invitesChatInviteCodePlaceholder: "Introduz código de convite do chat", + invitesChatJoinButton: "Entrar no chat", + invitesPadsTitle: "Pads", + invitesPadInviteCodePlaceholder: "Introduz código de convite do pad", + invitesPadJoinButton: "Entrar no pad", + invitesCalendarsTitle: "Calendários", + invitesCalendarInviteCodePlaceholder: "Introduz código de convite do calendário", + invitesCalendarJoinButton: "Entrar no calendário", + invitesEventsTitle: "Eventos", + invitesEventInviteCodePlaceholder: "Introduz código de convite do evento", + invitesEventJoinButton: "Entrar no evento", + invitesForumsTitle: "Fóruns", + invitesForumInviteCodePlaceholder: "Introduz código de convite do fórum", + invitesForumJoinButton: "Entrar no fórum", + invitesMapsTitle: "Mapas", + invitesMapInviteCodePlaceholder: "Introduz código de convite do mapa", + invitesMapJoinButton: "Entrar no mapa", + invitesShopsTitle: "Lojas", + invitesShopInviteCodePlaceholder: "Insira o código de convite da loja", + invitesShopJoinButton: "Entrar na loja", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "Introduz código de convite PUB", + invitesAcceptInvite: "Aderir ao PUB", + invitesAlreadyFederated: "Você já está federado com este pub.", + invitesFederationsTitle: "Federações", + invitesAcceptedInvites: "Federadas", + invitesNoInvites: "Ainda sem convites aceites.", + invitesUnfollow: "Deixar de seguir", + invitesFollow: "Seguir", + invitesUnfollowedInvites: "Não federadas", + invitesNoFederatedPubs: "Sem pubs federados.", + invitesNoUnfollowed: "Sem pubs não federados.", + invitesUnreachablePubs: "Inalcançáveis", + invitesNoUnreachablePubs: "Sem pubs inalcançáveis.", + invitesPubsRefresh: "Atualizar", + invitesPubsClearUnreachable: "Remover inalcançáveis", + invitesPubsExport: "Exportar", + invitesPubsImport: "Importar", + invitesPubsImportTitle: "Importar pubs", + invitesPubsImportPlaceholder: "Cola um endereço multiserver ou código de convite por linha, ou carrega um ficheiro .txt…", + currentlyUnreachable: "ERRO!", + errorDetails: "Detalhes do erro", + genericError: "Ocorreu um erro.", + panicMode: "Modo pânico!", + encryptData: "Define uma senha (mínimo 32 caracteres) para encriptar a tua blockchain", + decryptData: "Introduz a senha para decifrar a tua blockchain", + panicModeDescription: "Encriptar/Decifrar ou ELIMINAR a tua blockchain", + removeDataDescription: "AVISO: Este processo não pode ser revertido.", + encryptPanicButton: "Encriptar blockchain", + decryptPanicButton: "Decifrar blockchain", + removePanicButton: "ELIMINAR TODOS OS TEUS DADOS!", + searchTitle: "Pesquisa", + searchDescriptionLabel: "Pesquisa conteúdo na tua rede.", + searchLanguagesLabel: "Idiomas", + searchSkillsLabel: "Competências", + searchPlaceholder:"Pesquisar conteúdo...", + searchDateLabel:"Data", + searchLocationLabel:"Localização", + searchPriceLabel:"Preço", + searchUrlLabel:"URL", + searchCategoryLabel:"Categoria", + searchStartLabel:"Hora de início", + searchEndLabel:"Hora de fim", + searchPriorityLabel:"Prioridade", + searchStatusLabel:"Estado", + statusLabel:"Estado", + totalVotesLabel:"Total de votos", + noResultsFound:"Nenhum resultado encontrado.", + votesOption:"Opções de voto", + voteYesLabel:"Sim", + voteNoLabel:"Não", + allTypesLabel: "ILIMITADO", + ABSTENTIONLabel:"ABSTENÇÃO", + YESLabel:"SIM", + NOLabel:"NÃO", + FOLLOW_MAJORITYLabel: "SEGUIR MAIORIA", + CONFUSEDLabel: "CONFUSO", + NOT_INTERESTEDLabel: "SEM INTERESSE", + StatusLabel:"Estado", + votesOptionYesLabel:"Sim", + votesOptionNoLabel:"Não", + votesOptionAbstentionLabel:"Abstenção", + votesQuestionLabel:"Pergunta", + votesCreatedByLabel:"Criado por", + voteStatusOpen:"ABERTO", + voteStatusClosed:"FECHADO", + imageSearchLabel: "Introduz palavras para pesquisar imagens etiquetadas com elas.", + commentDescription: ({ parentUrl }) => [ + " comentou em ", + a({ href: parentUrl }, " conversa"), + ], + commentTitle: ({ authorName }) => [`Comentário na publicação de @${authorName}`], + subtopicDescription: ({ parentUrl }) => [ + " criou um subtópico de ", + a({ href: parentUrl }, " uma publicação"), + ], + subtopicTitle: ({ authorName }) => [`Subtópico na publicação de @${authorName}`], + mysteryDescription: "publicou uma publicação misteriosa", + oasisDescription: "Rede do Projeto OASIS", + searchSubmit: "Pesquisar...", + postLabel: "PUBLICAÇÕES", + aboutLabel: "HABITANTES", + feedLabel: "FEEDS", + votesLabel: "VOTAÇÕES", + reportLabel: "DENÚNCIAS", + imageLabel: "IMAGENS", + videoLabel: "VÍDEOS", + audioLabel: "ÁUDIOS", + documentLabel: "DOCUMENTOS", + torrentLabel: "TORRENTS", + pdfFallbackLabel: "Documento PDF", + eventLabel: "EVENTOS", + taskLabel: "TAREFAS", + transferLabel: "TRANSFERÊNCIAS", + curriculumLabel: "CURRÍCULO", + bookmarkLabel: "MARCADORES", + tribeLabel: "TRIBOS", + marketLabel: "MERCADO", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "CARTEIRAS", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Enviar", + subjectLabel: "Assunto", + editProfile: "Editar avatar", + profileQrAlt: "Código QR do perfil", + profileQrHint: "Lê para copiar este Oasis ID.", + oasisIdLabel: "OasisID", + spreadLabel: "Difundir", + spreadHint: "Difunde isto aos teus apoiantes (replica pelo teu feed).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Bem-vindo ao Oasis — uma rede social livre, peer-to-peer, federada e cifrada, com uma arquitetura distribuída apenas por convite.\n\nAlguns locais interessantes por onde começar:\n\n- /profile — Edita o teu avatar, nome, descrição e quais módulos aparecem no teu lado público.\n- /invites — Adiciona um pub para te federares com o resto da rede.\n- /peers — Vê quem está ligado, faz nova varredura da LAN, exporta ou importa listas de peers.\n- /inhabitants — Descobre e visita os avatares de outras pessoas.\n- /tribes — Cria ou junta-te a grupos privados com cifragem ponta-a-ponta.\n- /inbox — Lê e envia mensagens privadas.\n- /activity — Vê a atividade recente, tua e da tua rede.\n- /publish — Escreve um post ou partilha uma imagem, áudio, vídeo ou documento.\n\nAlguns lugares interessantes para conectar:\n\n- /fediverse — Faça a gestão das suas outras contas do fediverso, incluindo enviar e receber conteúdo.\n\nEsta mensagem foi enviada de forma privada de ti para ti mesmo, por isso não foi necessária qualquer ligação para a receber.", + profileVisibilityTitle: "Visibilidade do perfil público", + profileVisibilityHint: "Escolhe que campos os outros habitantes veem no teu perfil. Por defeito apenas Karma é mostrado.", + profileSensorsSectionTitle: "Sensores", + profileVisibilityActivity: "Nível de atividade", + profileVisibilityDevice: "Dispositivo", + profileDeviceLockedHint: "Este sensor está sempre ativo: permite que outros vejam de que dispositivo te conectas e não pode ser desativado.", + profileVisibilityKarma: "Pontuação KARMA", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "Carteira ECOIN", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "Chave GPG", + profileVisibilityClearnet: "Publicar o meu perfil", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Lojas", + profileClearnetJobsLabel: "Empregos", + profileClearnetEventsLabel: "Eventos", + profileClearnetProjectsLabel: "Projetos", + profileClearnetPostsLabel: "Blogs", + profileClearnetAudiosLabel: "Áudios", + profileClearnetVideosLabel: "Vídeos", + profileClearnetImagesLabel: "Imagens", + profileClearnetDocumentsLabel: "Documentos", + profileClearnetTorrentsLabel: "Torrents", + editProfileDescription: + "", + profileName: "Nome", + profileImage: "Imagem Avatar", + profileGpgKey: "Chave Pública GPG (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Baixar", + profileGpgRemove: "Remover", + profileDescription: "Descrição", + hashtagDescription: + "Publicações de habitantes na tua rede que referenciam esta #hashtag, ordenadas por data.", + rebuildName: "Reconstruir base de dados", + wallet: "Carteira", + walletAddress: "Endereço", + walletAmount: "Montante", + walletAddressLine: ({ address }) => `Endereço: ${address}`, + walletAmountLine: ({ amount }) => `Montante: ${amount} ECO`, + walletBack: "Voltar", + walletBalanceTitle: "Saldo", + walletWalletSendTitle: "Enviar", + walletReceiveTitle: "Receber", + walletHistoryTitle: "Histórico", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Conf.", + walletConfirm: "Confirmar", + walletDescription: "Gere os teus ativos digitais, incluindo enviar e receber ECOin, ver o teu saldo e aceder ao teu histórico de transações.", + walletDate: "Data", + walletFee: "Taxa (quanto maior a taxa, mais rápido a tua transação será processada)", + walletFeeLine: ({ fee }) => `Taxa: ECO ${fee}`, + walletHistory: "Histórico", + walletReceive: "Receber", + walletReset: "Repor", + walletSend: "Enviar", + walletStatus: "Estado", + walletDisconnected: [ + "ECOin ", + strong("carteira desconectada"), + ". Verifica ", + a({ href: '/settings#wallet' }, "as tuas definições"), + " ou o estado da ligação.", + ], + walletSentToLine: ({ destination, amount }) => `Enviado ECO ${amount} para ${destination}`, + walletSettingsTitle: "Carteira", + walletSettingsDescription: "Integra o Oasis com a tua carteira ECOin.", + walletSettingsDocLink: "Guia de instalação ECOin", + walletStatusMessages: { + invalid_amount: "Montante inválido", + invalid_dest: "Endereço de destino inválido", + invalid_fee: "Taxa inválida", + validation_errors: "Erros de validação", + send_tx_success: "Transação bem-sucedida", + }, + walletTitle: "Carteira", + walletTotalCostLine: ({ totalCost }) => `Custo total: ECO ${totalCost}`, + walletTransactionId: "ID da transação", + walletTxId: "ID Tx", + walletType: "Tipo", + walletUser: "Nome de utilizador", + walletPass: "Senha", + walletConfiguration: "Definir carteira", + cipher: "Encriptação", + cipherTitle: "Encriptação", + cipherDescription: "Encripta e decifra o teu texto simetricamente (usando uma senha partilhada).", + randomPassword: "Senha aleatória", + cipherEncryptTitle: "Encriptar texto", + cipherEncryptDescription: "Introduz o texto a encriptar", + cipherTextLabel: "Texto a encriptar", + cipherTextPlaceholder: "Introduz o texto a encriptar...", + cipherPasswordLabel: "Define uma senha (mínimo 32 caracteres) para encriptar o teu texto", + cipherPasswordDecryptLabel: "Define uma senha (mínimo 32 caracteres) para decifrar o seu texto", + cipherPasswordPlaceholder: "Introduz uma senha...", + cipherEncryptButton: "Encriptar", + cipherDecryptTitle: "Decifrar texto", + cipherDecryptDescription: "Introduz o texto a decifrar", + cipherEncryptedMessageLabel: "Texto encriptado", + cipherDecryptedMessageLabel: "Texto decifrado", + cipherPasswordUsedLabel: "Senha usada para encriptar (guarda-a!)", + cipherEncryptedTextPlaceholder: "Introduz o texto encriptado...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "Introduz o vetor de inicialização...", + cipherDecryptButton: "Decifrar", + password: "Senha", + text: "Texto", + encryptedText: "Texto encriptado", + iv: "Vetor de inicialização (IV)", + encryptTitle: "Encriptar o teu texto", + encryptDescription: "Introduz o texto que queres encriptar e fornece uma senha.", + encryptButton: "Encriptar", + decryptTitle: "Decifrar o teu texto", + decryptDescription: "Introduz o texto encriptado e fornece a mesma senha usada para encriptar.", + decryptButton: "Decifrar", + passwordLengthError: "A senha deve ter pelo menos 32 caracteres.", + missingFieldsError: "Texto, senha ou IV não fornecidos.", + encryptionError: "Erro ao encriptar o texto.", + decryptionError: "Erro ao decifrar o texto.", + bookmarkTitle: "Marcadores", + bookmarkDescription: "Descobre e gere marcadores na tua rede.", + bookmarkAllSectionTitle: "Marcadores", + bookmarkMineSectionTitle: "Os teus marcadores", + bookmarkRecentSectionTitle: "Marcadores recentes", + bookmarkTopSectionTitle: "Marcadores mais votados", + bookmarkFavoritesSectionTitle: "Favoritos", + bookmarkCreateSectionTitle: "Criar marcador", + bookmarkUpdateSectionTitle: "Atualizar marcador", + bookmarkFilterAll: "TODOS", + bookmarkFilterMine: "MEUS", + bookmarkFilterTop: "TOP", + bookmarkFilterFavorites: "FAVORITOS", + bookmarkFilterRecent: "RECENTES", + bookmarkCreateButton: "Criar marcador", + bookmarkUpdateButton: "Atualizar", + bookmarkDeleteButton: "Eliminar", + bookmarkAddFavoriteButton: "Adicionar favorito", + bookmarkRemoveFavoriteButton: "Remover favorito", + bookmarkUrlLabel: "Link", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "Descrição", + bookmarkDescriptionPlaceholder: "Opcional", + bookmarkTagsLabel: "Tags", + bookmarkTagsPlaceholder: "Introduz tags separadas por vírgulas", + bookmarkCategoryLabel: "Categoria", + bookmarkCategoryPlaceholder: "Opcional", + bookmarkLastVisitLabel: "Última visita", + bookmarkSearchPlaceholder: "Pesquisar URL, tags, categoria, autor...", + bookmarkSortRecent: "Mais recentes", + bookmarkSortOldest: "Mais antigos", + bookmarkSortTop: "Mais votados", + bookmarkSearchButton: "Pesquisa", + bookmarkUpdatedAt: "Atualizado", + bookmarkNoMatch: "Nenhum marcador corresponde à tua pesquisa.", + noBookmarks: "Sem marcadores disponíveis.", + noUrl: "Sem link", + noCategory: "Sem categoria", + noLastVisit: "Sem última visita", + videoTitle: "Vídeos", + videoDescription: "Explora e gere conteúdo de vídeo na tua rede.", + videoPluginTitle: "Título", + videoPluginDescription: "Descrição", + videoMineSectionTitle: "Os teus vídeos", + videoCreateSectionTitle: "Carregar vídeo", + videoUpdateSectionTitle: "Atualizar vídeo", + videoAllSectionTitle: "Vídeos", + videoRecentSectionTitle: "Vídeos recentes", + videoTopSectionTitle: "Vídeos mais votados", + videoFavoritesSectionTitle: "Favoritos", + videoFilterAll: "TODOS", + videoFilterMine: "MEUS", + videoFilterRecent: "RECENTES", + videoFilterTop: "TOP", + videoFilterFavorites: "FAVORITOS", + videoCreateButton: "Carregar vídeo", + videoUpdateButton: "Atualizar", + videoDeleteButton: "Eliminar", + videoAddFavoriteButton: "Adicionar favorito", + videoRemoveFavoriteButton: "Remover favorito", + videoFileLabel: "Seleciona um ficheiro de vídeo (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Tags", + videoTagsPlaceholder: "Introduz tags separadas por vírgulas", + videoTitleLabel: "Título", + videoTitlePlaceholder: "Opcional", + videoDescriptionLabel: "Descrição", + videoDescriptionPlaceholder: "Opcional", + videoNoFile: "Nenhum ficheiro de vídeo fornecido", + noVideos: "Sem vídeos disponíveis.", + videoSearchPlaceholder: "Pesquisar título, tags, autor...", + videoSortRecent: "Mais recentes", + videoSortOldest: "Mais antigos", + videoSortTop: "Mais votados", + videoSearchButton: "Pesquisa", + videoMessageAuthorButton: "MP", + videoUpdatedAt: "Atualizado", + videoNoMatch: "Nenhum vídeo corresponde à tua pesquisa.", + documentTitle: "Documentos", + documentDescription: "Descobre e gere documentos na tua rede.", + documentAllSectionTitle: "Documentos", + documentMineSectionTitle: "Os teus documentos", + documentRecentSectionTitle: "Documentos recentes", + documentTopSectionTitle: "Documentos mais votados", + documentFavoritesSectionTitle: "Favoritos", + documentCreateSectionTitle: "Carregar documento", + documentUpdateSectionTitle: "Editar documento", + documentFilterAll: "TODOS", + documentFilterMine: "MEUS", + documentFilterRecent: "RECENTES", + documentFilterTop: "TOP", + documentFilterFavorites: "FAVORITOS", + documentCreateButton: "Carregar documento", + documentUpdateButton: "Atualizar", + documentDeleteButton: "Eliminar", + documentAddFavoriteButton: "Adicionar favorito", + documentRemoveFavoriteButton: "Remover dos favoritos", + documentMessageAuthorButton: "MP", + documentFileLabel: "Carregar documento (.pdf)", + documentTagsLabel: "Tags", + documentTagsPlaceholder: "Introduz tags separadas por vírgulas", + documentTitleLabel: "Título", + documentTitlePlaceholder: "Opcional", + documentDescriptionLabel: "Descrição", + documentDescriptionPlaceholder: "Opcional", + documentNoFile: "Sem ficheiro.", + noDocuments: "Sem documentos disponíveis.", + documentSearchPlaceholder: "Pesquisar título, tags, descrição, autor...", + documentSortRecent: "Mais recentes", + documentSortOldest: "Mais antigos", + documentSortTop: "Mais votados", + documentSearchButton: "Pesquisa", + documentNoMatch: "Nenhum documento corresponde à tua pesquisa.", + documentUpdatedAt: "Atualizado", + audioTitle: "Áudios", + audioDescription: "Explora e gere conteúdo de áudio na tua rede.", + audioPluginTitle: "Título", + audioPluginDescription: "Descrição", + audioMineSectionTitle: "Os teus áudios", + audioCreateSectionTitle: "Carregar áudio", + audioUpdateSectionTitle: "Atualizar áudio", + audioAllSectionTitle: "Áudios", + audioRecentSectionTitle: "Áudios recentes", + audioTopSectionTitle: "Áudios mais votados", + audioFilterAll: "TODOS", + audioFilterMine: "MEUS", + audioFilterRecent: "RECENTES", + audioFilterTop: "TOP", + audioFilterBlockchain: "BLOCKCHAIN", + audioCreateButton: "Carregar áudio", + audioUpdateButton: "Atualizar", + audioDeleteButton: "Eliminar", + audioAddFavoriteButton: "Adicionar favorito", + audioRemoveFavoriteButton: "Remover favorito", + audioFileLabel: "Seleciona um ficheiro de áudio (.mp3, .wav, .ogg)", + audioTagsLabel: "Tags", + audioTagsPlaceholder: "Introduz tags separadas por vírgulas", + audioTitleLabel: "Título", + audioTitlePlaceholder: "Opcional", + audioDescriptionLabel: "Descrição", + audioDescriptionPlaceholder: "Opcional", + audioNoFile: "Nenhum ficheiro de áudio fornecido", + noAudios: "Sem áudios disponíveis.", + audioSearchPlaceholder: "Pesquisar título, tags, autor...", + audioSortRecent: "Mais recentes", + audioSortOldest: "Mais antigos", + audioSortTop: "Mais votados", + audioSearchButton: "Pesquisa", + audioMessageAuthorButton: "MP", + audioUpdatedAt: "Atualizado", + audioNoMatch: "Nenhum áudio corresponde à tua pesquisa.", + audioFavoritesSectionTitle: "Favoritos", + audioFilterFavorites: "FAVORITOS", + favoritesTitle: "Favoritos", + favoritesDescription: "Todos os teus conteúdos favoritos num só lugar.", + favoritesFilterAll: "TODOS", + favoritesFilterRecent: "RECENTES", + favoritesFilterAudios: "ÁUDIOS", + favoritesFilterBookmarks: "MARCADORES", + favoritesFilterDocuments: "DOCUMENTOS", + favoritesFilterImages: "IMAGENS", + favoritesFilterMaps: "MAPAS", + favoritesFilterPads: "PADS", + favoritesFilterChats: "CHATS", + favoritesFilterCalendars: "CALENDÁRIOS", + favoritesFilterVideos: "VÍDEOS", + favoritesRemoveButton: "Remover dos favoritos", + favoritesNoItems: "Ainda sem favoritos.", + yourContacts: "Os teus contactos", + allInhabitants: "Habitantes", + allCVs: "Todos os CVs", + discoverPeople: "Descobre habitantes na tua rede.", + allInhabitantsButton: "TODOS", + contactsButton: "APOIOS", + CVsButton: "CVs", + matchSkills: "Combinar competências", + matchSkillsButton: "COMBINAR COMPETÊNCIAS", + suggestedButton: "SUGERIDOS", + searchInhabitantsPlaceholder: "FILTRAR habitantes POR NOME …", + filterLocation: "FILTRAR habitantes POR LOCALIZAÇÃO …", + filterLanguage: "FILTRAR habitantes POR IDIOMA …", + filterSkills: "FILTRAR habitantes POR COMPETÊNCIAS …", + applyFilters: "Aplicar filtros", + locationLabel: "Localização", + languagesLabel: "Idiomas", + skillsLabel: "Competências", + commonSkills: "Competências comuns", + mutualFollowers: "Seguidores mútuos", + suggestedFollowsYou: "Segue-te", + latestInteractions: "Interações recentes", + viewAvatar: "Ver Avatar", + viewCV: "Ver CV", + suggestedSectionTitle: "Sugeridos", + topkarmaSectionTitle: "Melhor Karma", + topecoSectionTitle: "Melhor Eco", + topactivitySectionTitle: "Top Atividade", + blockedSectionTitle: "Bloqueados", + gallerySectionTitle: "GALERIA", + blockedButton: "BLOQUEADOS", + blockedLabel: "Habitante bloqueado", + inhabitantviewDetails: "Ver detalhes", + keepReading: "Continuar lendo...", + oasisId: "ID", + noInhabitantsFound: "Ainda sem habitantes encontrados.", + inhabitantActivityLevel: "Nível de atividade", + lifespanLabel: "Vigência", + deviceLabel: "Dispositivo", + parliamentTitle: "Parlamento", + parliamentDescription: "Explora formas de governo e leis de gestão coletiva.", + parliamentFilterGovernment: "GOVERNO", + parliamentFilterCandidatures: "CANDIDATURAS", + parliamentFilterProposals: "PROPOSTAS", + parliamentFilterLaws: "LEIS", + parliamentFilterHistorical: "HISTÓRICO", + parliamentFilterLeaders: "LÍDERES", + parliamentFilterRules: "REGRAS", + parliamentGovernmentCard: "Governo atual", + parliamentActorInPowerInhabitant: 'HABITANTE NO PODER', + parliamentActorInPowerTribe: 'TRIBO NO PODER', + parliamentHistoricalGovernmentsTitle: 'GOVERNOS', + parliamentHistoricalElectionsTitle: 'CICLOS ELEITORAIS', + parliamentHistoricalLawsTitle: 'HISTÓRICO', + parliamentHistoricalLeadersTitle: 'LÍDERES', + parliamentThCycles: 'CICLOS', + parliamentThTimesInPower: 'MANDATOS', + parliamentThTotalCandidatures: 'CANDIDATURAS', + parliamentThProposed: 'LEIS PROPOSTAS', + parliamentThApproved: 'LEIS APROVADAS', + parliamentThDeclined: 'LEIS REJEITADAS', + parliamentThDiscarded: 'LEIS DESCARTADAS', + parliamentMembers: "MEMBROS", + parliamentLegSince: "INÍCIO DO CICLO", + parliamentLegEnd: "FIM DO CICLO", + parliamentPoliciesProposal: "PROPOSTAS DE LEIS", + parliamentPoliciesApproved: "LEIS APROVADAS", + parliamentPoliciesDeclined: "LEIS REJEITADAS", + parliamentPoliciesDiscarded: "LEIS DESCARTADAS", + parliamentEfficiency: "% EFICIÊNCIA", + parliamentFilterRevocations: 'REVOGAÇÕES', + parliamentRevocationFormTitle: 'Revogar lei', + parliamentRevocationLaw: 'Lei', + parliamentRevocationTitle: 'Título', + parliamentRevocationReasons: 'Motivos', + parliamentRevocationPublish: 'Publicar revogação', + parliamentCurrentRevocationsTitle: 'Revogações atuais', + parliamentFutureRevocationsTitle: 'Revogações futuras', + parliamentPoliciesRevocated: 'LEIS REVOGADAS', + parliamentPoliciesTitle: 'HISTÓRICO', + parliamentLawsTitle: 'LEIS APROVADAS', + parliamentRulesRevocations: 'Qualquer lei aprovada pode ser revogada usando o método de governo em vigor.', + parliamentNoStableGov: "Ainda sem governo escolhido.", + parliamentNoGovernments: "Ainda sem governos.", + parliamentCandidatureFormTitle: "Propor candidatura", + parliamentCandidatureIdPh: "Oasis ID (@...) ou nome da tribo", + parliamentCandidatureSlogan: "Slogan (máx 140 caracteres)", + parliamentCandidatureSloganPh: "Um lema curto", + parliamentCandidatureMethod: "Método", + parliamentCandidatureProposeBtn: "Publicar candidatura", + parliamentThDate: "Data da proposta", + parliamentThSlogan: "Slogan", + parliamentThSince: "Perfil desde", + parliamentThVotes: "Votos recebidos", + parliamentThVoteAction: "Votar", + parliamentTypeUser: "Habitante", + parliamentTypeTribe: "Tribo", + parliamentVoteBtn: "Votar", + parliamentProposalFormTitle: "Propor lei", + parliamentProposalDescription: "Descrição (≤1000)", + parliamentProposalPublish: "Publicar proposta", + parliamentFinalize: "Finalizar", + parliamentDeadline: "Prazo", + parliamentNoProposals: "Ainda sem propostas de lei.", + parliamentNoLaws: "Ainda sem leis aprovadas.", + parliamentMethodDEMOCRACY: "Democracia", + parliamentMethodMAJORITY: "Maioria (80%)", + parliamentMethodMINORITY: "Minoria (20%)", + parliamentMethodDICTATORSHIP: "Ditadura", + parliamentMethodKARMATOCRACY: "Karmatocracia", + parliamentThId: "ID", + parliamentThProposalDate: "Data da proposta", + parliamentThMethod: "Method", + parliamentThKarma: "Karma", + parliamentThSupports: "Apoios", + parliamentThVote: "Votar", + parliamentCurrentProposalsTitle: "Propostas atuais", + parliamentVotesSlashTotal: "Votos/Total", + parliamentVotesNeeded: "Votos necessários", + parliamentFutureLawsTitle: "Leis futuras", + parliamentNoFutureLaws: "Ainda sem leis futuras.", + parliamentVoteAction: "Votar", + parliamentLeadersTitle: "Líderes", + parliamentThLeader: "AVATAR", + parliamentPopulation: "População", + parliamentThType: "Tipo", + parliamentThInPower: "No poder", + parliamentThPresented: "CANDIDATURAS", + parliamentNoLeaders: "Ainda sem líderes.", + parliamentCandidaturesListTitle: "Lista de candidaturas", + parliamentTimeRemaining: "Tempo restante", + parliamentNoLeader: "Ainda sem candidatura vencedora.", + parliamentElectionsStatusTitle: "Próximo governo", + parliamentProposalDeadlineLabel: "Prazo", + parliamentProposalTimeLeft: "Tempo restante", + parliamentProposalOnTrack: "A caminho da aprovação", + parliamentProposalOffTrack: "Apoio insuficiente", + parliamentRulesTitle: "Como funciona o Parlamento", + parliamentRulesIntro: "As eleições resolvem-se a cada 2 meses; as candidaturas são contínuas e reiniciam quando um governo é escolhido.", + parliamentRulesCandidates: "Qualquer habitante pode propor-se a si, a outro habitante ou a qualquer tribo. Cada habitante pode propor até 3 candidaturas por ciclo; duplicados no mesmo ciclo são rejeitados.", + parliamentRulesElection: "O vencedor é a candidatura com mais votos no momento da resolução.", + parliamentRulesTies: "Desempate: maior karma do habitante; se empatado, perfil mais antigo; se ainda empatado, proposta mais antiga; depois ordem lexicográfica por ID.", + parliamentRulesFallback: "Se ninguém votar, a última candidatura proposta vence. Se não houver candidaturas, uma tribo aleatória é selecionada.", + parliamentRulesTerm: "O separador Governo mostra o governo atual e estatísticas.", + parliamentRulesMethods: "Formas: Anarquia (maioria simples), Democracia (50%+1), Maioria (80%), Minoria (20%), Karmatocracia (propostas com maior karma), Ditadura (aprovação instantânea).", + parliamentRulesAnarchy: "A Anarquia é o modo predefinido: se nenhuma candidatura for eleita na resolução, a Anarquia é proclamada. Sob Anarquia, qualquer habitante pode propor leis.", + parliamentRulesProposals: "Se és o habitante governante ou membro da tribo governante, podes publicar propostas de lei. Métodos não-ditatoriais criam uma votação pública.", + parliamentRulesLimit: "Cada habitante pode publicar no máximo 3 propostas de lei por ciclo.", + parliamentRulesLaws: "Quando uma proposta atinge o limiar, torna-se Lei e aparece no separador Leis com a data de promulgação.", + parliamentRulesHistorical: "No separador Histórico podes ver cada ciclo de governo que ocorreu e dados sobre a sua gestão.", + parliamentRulesLeaders: "No separador Líderes podes ver um ranking de habitantes/tribos que governaram (ou se candidataram), ordenados por eficiência.", + parliamentProposalVoteStatusLabel: "Estado da votação", + parliamentProposalOnTrackYes: "Limiar atingido", + parliamentProposalOnTrackNo: "Abaixo do limiar", + courtsTitle: "Tribunais", + courtsDescription: "Explora formas de resolução de conflitos e gestão coletiva de justiça.", + courtsFilterCases: "CASOS", + courtsFilterMyCases: "MEUS", + courtsFilterJudges: "JUÍZES", + courtsFilterHistory: "HISTÓRICO", + courtsFilterRules: "REGRAS", + courtsCaseFormTitle: "Abrir caso", + courtsCaseRespondent: "Acusado / Demandado", + courtsCaseRespondentPh: "Oasis ID (@...) ou nome da tribo", + courtsCaseMediatorsAccuser: "Mediadores (acusação)", + courtsCaseMethod: "Método de resolução", + courtsCaseDescription: "Descrição (máx 1000 caracteres)", + courtsCaseEvidenceTitle: "Provas do caso", + courtsCaseEvidenceHelp: "Anexa imagens, áudios, documentos (PDF) ou vídeos que sustentem o teu caso.", + courtsCaseSubmit: "Apresentar caso", + courtsNominateJudge: "Nomear juiz", + courtsJudgeId: "Juiz", + courtsJudgeIdPh: "Oasis ID (@...) ou nome do habitante", + courtsNominateBtn: "Nomear", + courtsAddEvidence: "Adicionar prova", + courtsEvidenceText: "Texto", + courtsEvidenceLink: "Link", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "Anexar", + courtsAnswerText: "Resposta resumida", + courtsAnswerSubmit: "Enviar resposta", + courtsVerdictTitle: "Emitir veredicto", + courtsVerdictOrdersPh: "Ações, prazos, medidas restaurativas ...", + courtsIssueVerdict: "Emitir veredicto", + courtsMediationPropose: "Propor acordo", + courtsSettlementProposeBtn: "Propor", + courtsNominationsTitle: "Nomeações judiciais", + courtsThJudge: "Juiz", + courtsThDate: "Data", + courtsThVote: "Voto", + courtsNoNominations: "Ainda sem nomeações.", + courtsCaseMediatorsRespondentTitle: "Adicionar mediadores de defesa", + courtsCaseMediatorsRespondent: "Mediadores (defesa)", + courtsMediatorsAccuserLabel: "Mediadores (acusação)", + courtsMediatorsRespondentLabel: "Mediadores (defesa)", + courtsMediatorsSubmit: "Guardar mediadores", + courtsNoMyCases: "Ainda sem conflitos.", + courtsNoHistory: "Ainda sem julgamentos registados.", + courtsMethodDICTATOR: "Ditador", + courtsMethodPOPULAR: "Popular", + courtsMethodKARMATOCRACY: "Karmatocracia", + courtsRulesTitle: "Como funcionam os Tribunais", + courtsRulesIntro: "Os Tribunais são um processo comunitário para resolver conflitos e promover justiça restaurativa. Diálogo, provas claras e medidas proporcionais são priorizados.", + courtsRulesLifecycle: "Processo: 1) Abrir caso 2) Selecionar método 3) Apresentar provas 4) Audiência e deliberação 5) Veredicto e medida 6) Cumprimento e encerramento 7) Recurso (se aplicável).", + courtsRulesRoles: "Acusador: abre o caso. Defesa: pessoa ou tribo acusada. Método: mecanismo escolhido pela comunidade para facilitar, avaliar provas e emitir veredicto. Testemunha: fornece depoimento ou provas. Mediadores: pessoas neutras convidadas pelo acusador e/ou defesa, com acesso a todos os detalhes, que ajudam a desescalar o conflito e co-criar acordos.", + courtsRulesEvidence: "Descrição até 1000 caracteres. Anexa imagens, áudio, vídeo e documentos PDF relevantes e legais. Não partilhes dados privados sensíveis sem consentimento.", + courtsRulesDeliberation: "As audiências podem ser públicas ou privadas. Os juízes garantem respeito, pedem esclarecimentos e podem descartar material irrelevante ou ilegal.", + courtsRulesVerdict: "A restauração é priorizada: pedidos de desculpa, acordos de mediação, moderação de conteúdo, restrições temporárias ou outras medidas proporcionais. A fundamentação deve ser registada.", + courtsRulesAppeals: "Recurso: permitido quando há novas provas ou erro processual claro. Deve ser apresentado em 7 dias, salvo indicação contrária.", + courtsRulesPrivacy: "Respeita a privacidade e segurança. Doxing, ódio ou ameaças são removidos. Os juízes podem editar ou selar partes do registo para proteger pessoas em risco.", + courtsRulesMisconduct: "Assédio, manipulação ou provas fabricadas podem levar a resolução negativa imediata.", + courtsRulesGlossary: "Caso: registo de um conflito. Provas: materiais que sustentam alegações. Veredicto: decisão com medida. Recurso: pedido de revisão do veredicto.", + courtsFilterActions: "AÇÕES", + courtsNoActions: "Sem ações pendentes para o teu papel.", + courtsCaseTitlePlaceholder: "Breve descrição do conflito", + courtsCaseSeverity: "Gravidade", + courtsCaseSeverityNone: "Sem etiqueta de gravidade", + courtsCaseSeverityLOW: "Baixa", + courtsCaseSeverityMEDIUM: "Média", + courtsCaseSeverityHIGH: "Alta", + courtsCaseSeverityCRITICAL: "Crítica", + courtsCaseSubject: "Tópico", + courtsCaseSubjectNone: "Sem etiqueta de tópico", + courtsCaseSubjectBEHAVIOUR: "Comportamento", + courtsCaseSubjectCONTENT: "Conteúdo", + courtsCaseSubjectGOVERNANCE: "Governação / regras", + courtsCaseSubjectFINANCIAL: "Financeiro / recursos", + courtsCaseSubjectOTHER: "Outro", + courtsHiddenRespondent: "Oculto (visível apenas para os envolvidos).", + courtsThRole: "Papel", + courtsRoleAccuser: "Acusador", + courtsRoleDefence: "Defesa", + courtsRoleMediator: "Mediador", + courtsRoleJudge: "Juiz", + courtsRoleDictator: "Ditador", + courtsAssignJudgeTitle: "Escolher juiz", + courtsAssignJudgeBtn: "Escolher juiz", + trendingTitle: "Tendências", + exploreTrending: "Explora o conteúdo mais popular na tua rede.", + bookmarkButton: "MARCADORES", + transferButton: "TRANSFERÊNCIAS", + eventButton: "EVENTOS", + taskButton: "TAREFAS", + votesButton: "VOTAÇÕES", + reportButton: "RELATÓRIOS", + feedButton: "FEED", + marketButton: "MERCADO", + imageButton: "IMAGENS", + audioButton: "ÁUDIOS", + videoButton: "VÍDEOS", + documentButton: "DOCUMENTOS", + torrentButton: "TORRENTS", + noTrendingFound: "Nenhum conteúdo em tendência encontrado.", + noContentMessage: "Ainda sem conteúdo em tendência disponível.", + trendingDescription: "Descrição", + trendingDate: "Data", + trendingLocation: "Localização", + trendingPrice: "Preço", + trendingUrl: "URL", + trendingCategory: "Categoria", + trendingStart: "Início", + trendingEnd: "Fim", + trendingPriority: "Prioridade", + trendingStatus: "Estado", + trendingFrom: "De", + trendingTo: "Para", + trendingConcept: "Conceito", + trendingAmount: "Montante", + trendingDeadline: "Prazo", + trendingItemStatus: "Estado do item", + trendingTotalVotes: "Total de votos", + trendingTotalOpinions: "Total de opiniões", + trendingNoContentMessage: "Ainda sem conteúdo em tendência disponível.", + trendingAuthor: "Por", + trendingCreatedAtLabel: "Criado em", + trendingTotalCount: "Contagem total", + tasksTitle: "Tarefas", + tasksDescription: "Descobre e gere tarefas na tua rede.", + taskTitleLabel: "Título", + taskDescriptionLabel: "Descrição", + taskStartTimeLabel: "Hora de início", + taskEndTimeLabel: "Hora de fim", + taskPriorityLabel: "Prioridade", + taskPrioritySelect: "Selecionar prioridade", + taskPriorityUrgent: "Urgente", + taskPriorityHigh: "Alta", + taskPriorityMedium: "Média", + taskPriorityLow: "Baixa", + taskLocationLabel: "Localização", + taskTagsLabel: "Tags", + taskVisibilityLabel: "Visibilidade", + taskPublic: "público", + taskPrivate: "privado", + taskCreatedAt: "Criado em", + taskBy: "Por", + taskStatus: "Estado", + taskStatusOpen: "Abrir", + taskStatusInProgress: "Em progresso", + taskStatusClosed: "Fechado", + taskAssignedTo: "Atribuído a", + taskAssignees: "Atribuídos", + taskAssignButton: "Atribuir a mim", + taskUnassignButton: "Remover", + taskCreateButton: "Criar tarefa", + taskUpdateButton: "Atualizar", + taskDeleteButton: "Eliminar", + taskFilterAll: "TODOS", + taskFilterMine: "MEUS", + taskFilterOpen: "ABERTO", + taskFilterInProgress: "EM PROGRESSO", + taskFilterClosed: "FECHADO", + taskFilterAssigned: "ATRIBUÍDO", + taskFilterArchived: "ARQUIVADO", + taskFilterUrgent: "URGENTE", + taskFilterHigh: "ALTA", + taskFilterMedium: "MÉDIA", + taskFilterLow: "BAIXA", + taskAllSectionTitle: "Tarefas", + taskMineSectionTitle: "As tuas tarefas", + taskCreateSectionTitle: "Criar tarefa", + taskUpdateSectionTitle: "Atualizar", + taskOpenTitle: "Tarefas abertas", + taskInProgressTitle: "Tarefas em progresso", + taskClosedTitle: "Tarefas fechadas", + taskAssignedTitle: "Tarefas atribuídas", + taskArchivedTitle: "Tarefas arquivadas", + taskPublicTitle: "Tarefas públicas", + taskPrivateTitle: "Tarefas privadas", + notasks: "Sem tarefas disponíveis.", + noLocation: "Sem localização especificada", + taskSetStatus: "Definir estado", + eventTitle: "Eventos", + eventDateLabel: "Data", + eventsTitle: "Eventos", + eventsDescription: "Descobre e gere eventos na tua rede.", + eventDescription: "Descrição", + eventPrice: "Preço", + eventStatus: "Estado", + eventOrganizer: "Organizador", + eventAllSectionTitle: "Eventos", + eventMineSectionTitle: "Os teus eventos", + eventArchivedTitle: "Eventos arquivados", + eventCreateSectionTitle: "Criar evento", + eventUpdateSectionTitle: "Atualizar evento", + eventDeleteButton: "Eliminar", + eventCreateButton: "Criar evento", + eventAddToCalendar: "Adicionar ao calendário", + eventVisitCalendar: "Ver calendário", + viewTask: "Ver tarefa", + viewReport: "Ver relatório", + viewTransfer: "Ver transferência", + viewItem: "Ver item", + viewShop: "Ver loja", + viewProject: "Ver projeto", + viewVotation: "Ver votação", + voteCastTitle: "Emitir Voto", + voteResults: "Resultados", + eventTitleLabel: "Título", + eventDescriptionLabel: "Descrição", + eventDescriptionPlaceholder: "Introduz a descrição do evento...", + eventUpdateButton: "Atualizar", + eventAttendeesLabel: "Participantes", + eventAttendeesPlaceholder: "Introduz participantes, separados por vírgulas...", + eventTagsLabel: "Tags", + eventTagsPlaceholder: "Introduz tags do evento, separadas por vírgulas...", + eventTags: "Tags", + eventPriceLabel: "Preço", + eventUrlLabel: "URL", + eventAttendees: "Participantes", + noAttendees: "Ainda sem participantes", + eventCreatedAt: "Criado em", + eventLocation: "Localização", + eventLocationLabel: "Localização", + eventNoLocation: "Sem localização especificada", + eventNoURL: "Sem URL especificado", + eventBy: "Por", + noevents: "Sem eventos disponíveis.", + eventDate: "Data", + eventDateFormat: "DD:MM:AAAA HH:mm", + eventAttendButton: "Participar no evento", + eventUnattendButton: "Sair do evento", + eventCreatedBy: "Criado por", + eventAttendeesCount: "Número de participantes", + eventCreatedByYou: "Criaste este evento", + eventFilterAll: "TODOS", + eventFilterMine: "MEUS", + eventFilterToday: "HOJE", + eventFilterWeek: "ESTA SEMANA", + eventFilterMonth: "ESTE MÊS", + eventFilterYear: "ESTE ANO", + eventFilterArchived: "ARQUIVADO", + eventTodayTitle: "Eventos de hoje", + eventThisWeekTitle: "Eventos desta semana", + eventThisMonthTitle: "Eventos deste mês", + eventThisYearTitle: "Eventos deste ano", + eventPrivacyLabel: "Visibilidade", + eventPublic: "Público", + eventPrivate: "Privado", + eventPublicTitle: "Eventos públicos", + eventNoPrice: "Evento gratuito", + eventNoImage: "Sem imagem carregada", + eventAttendConfirmation: "You are now attending this event", + eventUnattendConfirmation: "You are no longer attending this event", + eventAttended: "Presente", + eventUnattended: "Ausente", + eventStatusOpen: "Abrir", + eventStatusClosed: "Fechado", + tagsTitle: "Tags", + tagsDescription: "Descobre e explora padrões taxonómicos na tua rede.", + tagsAllSectionTitle: "Tags", + tagsTopSectionTitle: "Tags populares", + tagsCloudSectionTitle: "Nuvem de tags", + tagsFilterAll: "TODOS", + tagsFilterTop: "TOP", + tagsFilterCloud: "NUVEM", + tagsNoItems: "Sem tags disponíveis.", + tagsTableHeaderTag: "TAG/LINK:", + tagsTableHeaderCount: "CONTADOR:", + transfersTitle: "Transferências", + transfersDescription: "Descobre e gere transferências na tua rede.", + transfersFrom: "De", + transfersTo: "Para", + transfersFilterAll: "TODOS", + transfersFilterMine: "MEUS", + transfersFilterUBI: "RBU", + transfersFilterMarket: "MERCADO", + transfersFilterTop: "TOP", + transfersFilterPending: "PENDENTE", + transfersFilterUnconfirmed: "NÃO CONFIRMADO", + transfersFilterClosed: "FECHADO", + transfersFilterDiscarded: "DESCARTADO", + transfersFilterEconomic: "ECONÓMICA", + transfersFilterTime: "TEMPO", + transfersFilterTrust: "CONFIANÇA", + transfersCategory: "Categoria", + transfersCategoryEconomic: "Económica", + transfersCategoryTime: "Tempo", + transfersCategoryTrust: "Confiança", + transfersCategoryApply: "Aplicar", + transfersUnitEco: "ECO", + transfersUnitHours: "h", + transfersUnitTrust: "confiança", + transfersEconomicSectionTitle: "Transferências económicas", + transfersTimeSectionTitle: "Transferências de tempo", + transfersTrustSectionTitle: "Transferências de confiança", + transfersCreateButton: "Criar transferência", + transfersUpdateButton: "Atualizar", + transfersDeleteButton: "Eliminar", + transfersToUser: "Oasis ID", + transfersToUserValidation: "Oasis ID válido, ex: @…=.ed25519", + transfersConcept: "Conceito", + transfersAmount: "Montante", + transfersDeadline: "Prazo", + transfersTags: "Tags", + transfersStatus: "Estado", + transfersStatusUnconfirmed: "NÃO CONFIRMADO", + transfersStatusClosed: "FECHADO", + transfersStatusDiscarded: "DESCARTADO", + transfersCreatedAt: "Criado em", + transfersConfirmations: "CONFIRMAÇÕES", + transfersContainingBlock: "Bloco contendor", + transfersExportContract: "Criar contrato", + transfersConfirmButton: "Confirmar transferência", + transfersNoItems: "Sem transferências encontradas.", + transfersMineSectionTitle: "As tuas transferências", + transfersUBISectionTitle: "Transferências RBU", + transfersMarketSectionTitle: "Transferências de mercado", + transfersTopSectionTitle: "Transferências mais votadas", + transfersPendingSectionTitle: "Transferências pendentes", + transfersUnconfirmedSectionTitle: "Transferências não confirmadas", + transfersClosedSectionTitle: "Transferências fechadas", + transfersDiscardedSectionTitle: "Transferências descartadas", + transfersCreateSectionTitle: "Criar transferência", + transfersAllSectionTitle: "Transferências", + transfersFilterFavs: "Favoritos", + transfersFavsSectionTitle: "Transferências favoritas", + transfersSearchLabel: "Pesquisa", + transfersSearchPlaceholder: "Pesquisar conceito, tags, utilizadores...", + transfersMinAmountLabel: "Montante mínimo", + transfersMaxAmountLabel: "Montante máximo", + transfersSortLabel: "Ordenar por", + transfersSortRecent: "Mais recentes", + transfersSortAmount: "Maior montante", + transfersSortDeadline: "Prazo mais próximo", + transfersSearchButton: "Pesquisa", + transfersFavoriteButton: "Favorito", + transfersUnfavoriteButton: "Remover favorito", + transfersMessageUserButton: "Mensagem", + transfersExpiringSoonBadge: "A EXPIRAR", + transfersExpiredBadge: "EXPIRADO", + transfersUpdatedAt: "Atualizado", + transfersNoMatch: "Nenhuma transferência corresponde à tua pesquisa.", + votationsTitle: "Votações", + votationsDescription: "Descobre e gere votações na tua rede.", + voteMineSectionTitle: "As tuas votações", + voteCreateSectionTitle: "Criar votação", + voteUpdateSectionTitle: "Atualizar", + voteOpenTitle: "Votações abertas", + voteClosedTitle: "Votações fechadas", + voteAllSectionTitle: "Votações", + voteCreateButton: "Criar votação", + voteUpdateButton: "Atualizar", + voteDeleteButton: "Eliminar", + voteOptionYes: "YES", + voteOptionNo: "NO", + voteOptionAbstention: "ABSTENTION", + voteConfused: "CONFUSO", + voteFollowMajority: "SEGUIR MAIORIA", + voteNotInterested: "SEM INTERESSE", + voteFilterAll: "TODOS", + voteFilterMine: "MEUS", + voteFilterOpen: "ABERTO", + voteFilterClosed: "FECHADO", + voteQuestionLabel: "Pergunta", + voteDeadlineLabel: "Prazo", + voteOptionsLabel: "O teu voto", + voteBreakdown: "Discriminação", + voteFinalResult: "RESULTADO", + voteNoQuorum: "SEM QUÓRUM", + voteTagsLabel: "Tags", + voteDeadline: "Prazo", + voteStatus: "Estado", + voteTags: "Tags", + voteOpinions: "Opiniões", + novotes: "Sem propostas de votação disponíveis.", + voteBy: "Por", + voteCreatedAt: "Criado em", + voteNoQuestion: "Sem questão fornecida", + voteUnknownCreator: "Criador desconhecido", + voteUnknownDate: "Data desconhecida", + errorVoteNotFound: "Votação não encontrada", + errorAlreadyVoted: "Já deste a tua opinião.", + errorVoteClosedCannotEdit: "Não podes editar uma votação fechada", + errorVoteDeadlinePassed: "O prazo desta votação já passou", + errorRetrievingVote: "Erro ao obter votação", + errorCreatingVote: "Erro ao criar votação", + errorVoteAlreadyVoted: "Não é possível editar após a opinião ter sido emitida", + errorDeletingOldVote: "Erro ao eliminar opinião anterior", + errorCreatingUpdatedVote: "Erro ao criar opinião atualizada", + errorCreatingTombstone: "Erro ao criar tombstone", + voteDetailSectionTitle: 'Detalhes da votação', + voteCommentsLabel: 'Comentários', + voteCommentsForumButton: 'Abrir discussão', + voteCommentsSectionTitle: 'Abrir discussão', + voteNoCommentsYet: 'Ainda sem comentários. Sê o primeiro a responder.', + voteNewCommentPlaceholder: 'Escreve o teu comentário aqui…', + voteNewCommentButton: 'Publicar comentário', + voteNewCommentLabel: 'Adicionar comentário', + cvTitle: "Currículo", + cvLabel: "Curriculum Vitae (CV)", + cvEditSectionTitle: "Editar CV", + cvCreateSectionTitle: "Criar CV", + cvDescription: "Gere e partilha as tuas competências e informações profissionais.", + cvNameLabel: "Nome completo", + cvDescriptionLabel: "Resumo", + cvPhotoLabel: "Foto", + cvPersonalExperiencesLabel: "Experiências pessoais", + cvPersonalSkillsLabel: "Competências pessoais (separadas por vírgulas)", + cvOasisExperiencesLabel: "Experiências de contribuição Oasis", + cvOasisSkillsLabel: "Competências de contribuição Oasis (separadas por vírgulas)", + cvEducationExperiencesLabel: "Experiências educativas", + cvEducationalSkillsLabel: "Competências educativas (separadas por vírgulas)", + cvProfessionalExperiencesLabel: "Experiências profissionais", + cvProfessionalSkillsLabel: "Competências profissionais (separadas por vírgulas)", + cvLanguagesLabel: "Idiomas", + cvLocationLabel: "Localização", + cvStatusLabel: "Estado", + cvPreferencesLabel: "Preferências", + cvOasisContributorLabel: "Contribuidor Oasis", + cvPersonal: "Pessoal", + cvOasis: "Contribuidor Oasis (opcional)", + cvOasisContributorView: "Contribuição Oasis", + cvEducational: "Educativo (opcional)", + cvEducationalView: "Educativo", + cvProfessional: "Profissional (opcional)", + cvProfessionalView: "Profissional", + cvAvailability: "Disponibilidade (opcional)", + cvAvailabilityView: "Disponibilidade", + cvUpdateButton: "Atualizar", + cvCreateButton: "Criar CV", + cvContactLabel: "Contacto", + cvCreatedAt: "Criado em", + cvUpdatedAt: "Atualizado em", + cvEditButton: "Atualizar", + cvDeleteButton: "Eliminar", + cvNoCV: "Sem CV encontrado.", + blogSubject: "Assunto", + blogMessage: "Corpo limitado a 8096 caracteres.", + blogImage: "Carregar mídia (máx: 50MB)", + blogPublish: "Pré-visualizar", + noPopularMessages: "Ainda sem mensagens populares publicadas", + forumTitle: "Fóruns", + forumCategoryLabel: "Categoria", + forumTitleLabel: "Título", + forumTitlePlaceholder: "Título do fórum...", + forumCreateButton: "Criar fórum", + forumCreateSectionTitle: "Criar fórum", + forumDescription: "Conversa abertamente com outros habitantes na tua rede.", + forumFilterAll: "TODOS", + forumFilterMine: "MEUS", + forumFilterRecent: "RECENTES", + forumFilterTop: "TOP", + forumMineSectionTitle: "Os teus fóruns", + forumRecentSectionTitle: "Fóruns recentes", + forumAllSectionTitle: "Fóruns", + forumDeleteButton: "Eliminar", + forumParticipants: "participantes", + forumMessages: "mensagens", + forumLastMessage: "Última mensagem", + forumMessageLabel: "Mensagem", + forumMessagePlaceholder: "Escreve a tua mensagem...", + forumSendButton: "Enviar", + forumVisitForum: "Visitar fórum", + noForums: "Sem fóruns encontrados.", + forumVisitButton: "Visitar fórum", + forumCatGENERAL: "Geral", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "Política", + forumCatTECH: "Tecnologia", + forumCatSCIENCE: "Ciência", + forumCatMUSIC: "Música", + forumCatART: "Arte", + forumCatGAMING: "Jogos", + forumCatBOOKS: "Livros", + forumCatFILMS: "Filmes", + forumCatPHILOSOPHY: "Filosofia", + forumCatSOCIETY: "Sociedade", + forumCatPRIVACY: "Privacidade", + forumCatCYBERWARFARE: "Guerra cibernética", + forumCatSURVIVALISM: "Sobrevivencialismo", + imageTitle: "Imagens", + imageDescription: "Explora e gere conteúdo de imagens na tua rede.", + imagePluginTitle: "Título", + imagePluginDescription: "Descrição", + imageMineSectionTitle: "As tuas imagens", + imageCreateSectionTitle: "Carregar imagem", + imageUpdateSectionTitle: "Atualizar imagem", + imageAllSectionTitle: "Imagens", + imageRecentSectionTitle: "Imagens recentes", + imageTopSectionTitle: "Imagens mais votadas", + imageFavoritesSectionTitle: "Favoritos", + imageGallerySectionTitle: "Galeria", + imageMemeSectionTitle: "Memes", + imageFilterAll: "TODOS", + imageFilterMine: "MEUS", + imageFilterRecent: "RECENTES", + imageFilterTop: "TOP", + imageFilterFavorites: "FAVORITOS", + imageFilterGallery: "GALERIA", + imageFilterMeme: "MEMES", + imageCreateButton: "Carregar imagem", + imageUpdateButton: "Atualizar", + imageDeleteButton: "Eliminar", + imageAddFavoriteButton: "Adicionar favorito", + imageRemoveFavoriteButton: "Remover favorito", + imageFileLabel: "Seleciona um ficheiro de imagem (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Tags", + imageTagsPlaceholder: "Introduz tags separadas por vírgulas", + imageTitleLabel: "Título", + imageTitlePlaceholder: "Opcional", + imageDescriptionLabel: "Descrição", + imageDescriptionPlaceholder: "Opcional", + imageMemeLabel: "MEME?", + imageNoFile: "Nenhum ficheiro de imagem fornecido", + noImages: "Sem imagens disponíveis.", + imageSearchPlaceholder: "Pesquisar título, tags, descrição, autor...", + imageSortRecent: "Mais recentes", + imageSortOldest: "Mais antigos", + imageSortTop: "Mais votados", + imageSearchButton: "Pesquisa", + imageMessageAuthorButton: "Mensagem", + imageUpdatedAt: "Atualizado", + imageNoMatch: "Nenhuma imagem corresponde à tua pesquisa.", + feedTitle: "Feed", + createFeedTitle: "Criar Feed", + createFeedButton: "Enviar Feed!", + feedPlaceholder: "O que se passa? (máx 280 caracteres)", + TODAYButton: "HOJE", + CREATEButton: "Criar Feed", + totalOpinions: "Total de opiniões", + moreVoted: "Mais votado", + noFeedsFound: "Sem feeds encontrados.", + createdAtLabel: "Criado em", + FeedshareYourOpinions: "Descobre e partilha textos curtos na tua rede.", + refeedButton: "Republicar", + alreadyRefeeded: "Já fizeste refeed disto.", + activityTitle: "Atividade", + yourActivity: "A tua atividade", + globalActivity: "Atividade global", + activityList: "Atividade", + activityDesc: "Vê a atividade mais recente na tua rede.", + allButton: "TODOS", + mineButton: "MEUS", + noActions: "Sem atividade disponível.", + performed: "→", + from: "De", + to: "Para", + amount: "Montante", + concept: "Conceito", + description: "Descrição", + meme: "Meme", + activityContact: "Contacto", + activityBy: "Nome", + activityPixelia: "Novo pixel adicionado", + viewImage: "Ver imagem", + playAudio: "Reproduzir áudio", + playVideo: "Reproduzir vídeo", + typeRecent: "RECENTES", + errorActivity: "Erro ao obter atividade", + typePost: "BLOGS", + recentButton: "RECENTES", + typeTribe: "TRIBOS", + typeLarp: "L.A.R.P.", + typeInhabitants: "HABITANTES", + activityProfileUpdated: "atualizou seu perfil", + typeAbout: "HABITANTES", + typeCurriculum: "CVS", + typeImage: "IMAGENS", + typeBookmark: "MARCADORES", + typeDocument: "DOCUMENTOS", + typeVotes: "VOTAÇÕES", + typeAudio: "ÁUDIOS", + typeMarket: "MERCADO", + typeJob: "EMPREGOS", + typeProject: "PROJETOS", + typeVideo: "VÍDEOS", + typeVote: "DIFUSÃO", + typeEvent: "EVENTOS", + typeTransfer: "TRANSFERÊNCIAS", + typeTask: "TAREFAS", + typePixelia: "PIXELIA", + typeForum: "FÓRUNS", + typeReport: "RELATÓRIOS", + typeFeed: "FEEDS", + typeContact: "CONTACTO", + typePub: "PUB", + typeTombstone: "TOMBSTONE", + typeBanking: "BANCA", + typeBankWallet: "BANCA/CARTEIRA", + typeBankClaim: "BANCA/UBI", + typeKarmaScore: "KARMA", + typeParliament: "PARLIAMENT", + typeSpread: "DIFUSÕES", + typeParliamentCandidature: "Parliament · Candidature", + typeParliamentTerm: "Parliament · Term", + typeParliamentProposal:"Parliament · Proposal", + typeParliamentRevocation:"Parlamento · Revogação", + typeParliamentLaw: "Parliament · New Law", + typeCourts: "COURTS", + typeCourtsCase: "Tribunais · Caso", + typeCourtsEvidence: "Tribunais · Prova", + typeCourtsAnswer: "Tribunais · Resposta", + typeCourtsVerdict: "Tribunais · Veredicto", + typeCourtsSettlement: "Tribunais · Acordo", + typeCourtsSettlementProposal: "Tribunais · Proposta de acordo", + typeCourtsSettlementAccepted: "Tribunais · Acordo aceite", + typeCourtsNomination: "Tribunais · Nomeação", + typeCourtsNominationVote: "Tribunais · Voto de nomeação", + activitySupport: "Nova aliança formada", + activityJoin: "Novo PUB aderido", + question: "Pergunta", + deadline: "Prazo", + status: "Estado", + votes: "Votos", + totalVotes: "Total Votes", + voteTotalVotes: "Total Votes", + name: "Nome", + skills: "Competências", + tags: "Tags", + title: "Título", + date: "Data", + category: "Categoria", + attendees: "Participantes", + activitySpread: "->", + visitLink: "Visitar link", + viewDocument: "Ver documento", + location: "Localização", + contentWarning: "Assunto", + personName: "Nome do habitante", + bankWalletConnected: "Carteira ECOin", + bankUbiReceived: "UBI recebido", + bankTx: "Tx", + bankEpochShort: "Época", + bankingUserEngagementScore: "Pontuação KARMA", + viewDetails: "Ver detalhes", + link: "Link", + activityProjectFollow: "%OASIS% está agora a %ACTION% este projeto %PROJECT%", + activityProjectUnfollow: "%OASIS% está agora a %ACTION% este projeto %PROJECT%", + activityProjectPledged: "%OASIS% %ACTION% %AMOUNT% ao projeto %PROJECT%", + following: "A SEGUIR", + unfollowing: "A DEIXAR DE SEGUIR", + pledged: "COMPROMETIDO", + parliamentCandidatureId: "Candidature", + parliamentGovMethod: "Method", + parliamentVotesReceived: "Votes received", + parliamentMethodANARCHY: "Anarchy", + parliamentMethodVOTE: "Votação comunitária", + parliamentMethodRANKED: "Escolha por ranking", + parliamentMethodPLURALITY: "Pluralidade", + parliamentMethodCOUNCIL: "Conselho", + parliamentMethodJURY: "Júri", + parliamentAnarchy: "ANARQUIA", + parliamentElectionsStart: "Elections start", + parliamentElectionsEnd: "Elections end", + parliamentCurrentLeader: "Winning candidature", + parliamentProposalTitle: "Título", + parliamentOpenVote: "Open vote", + parliamentStatus: "Estado", + parliamentLawQuestion: "Question", + parliamentLawMethod: "Method", + parliamentLawProposer: "Proposer", + parliamentLawEnacted: "Enacted at", + parliamentLawVotes: "Votações", + createdAt: "Created at", + courtsCaseTitle: "Case", + courtsMethod: "Method", + courtsMethodJUDGE: "Judge", + courtsMethodJUDGES: "Painel de juízes", + courtsMethodSINGLE_JUDGE: "Juiz único", + courtsMethodJURY: "Júri", + courtsMethodCOUNCIL: "Conselho", + courtsMethodCOMMUNITY: "Comunidade", + courtsMethodARBITRATION: "Arbitragem", + courtsMethodVOTE: "Votação comunitária", + courtsAccuser: "Accuser", + courtsRespondent: "Respondent", + courtsThStatus: "Estado", + courtsThAnswerBy: "Answer by", + courtsThEvidenceBy: "Evidence by", + courtsThDecisionBy: "Decision by", + courtsVotesNeeded: "Votes needed", + courtsVotesSlashTotal: "YES/TOTAL", + courtsOpenVote: "Open vote", + courtsAnswerTitle: "Answer", + courtsStanceADMIT: "Admit", + courtsStanceDENY: "Deny", + courtsStancePARTIAL: "Partial", + courtsStanceCOUNTERCLAIM: "Reconvenção", + courtsStanceNEUTRAL: "Neutro", + courtsVerdictResult: "Result", + courtsVerdictOrders: "Orders", + courtsSettlementText: "Settlement", + courtsSettlementAccepted: "Aceite", + courtsSettlementPending: "Pendente", + courtsJudge: "Juiz", + courtsThSupports: "Supports", + courtsFilterOpenCase: 'Open Case', + courtsEvidenceFileLabel: 'Ficheiro de prova (imagem, áudio, vídeo ou PDF)', + courtsCaseMediators: 'Mediadores', + courtsCaseMediatorsPh: 'Mediator Oasis IDs, separated by comma', + courtsMediatorsLabel: 'Mediadores', + courtsThCase: 'Case', + courtsThCreatedAt: 'Start date', + courtsThActions: 'Actions', + courtsPublicPrefLabel: 'Visibility after resolution', + courtsPublicPrefYes: 'I agree this case can be fully public', + courtsPublicPrefNo: 'I prefer to keep the details private', + courtsPublicPrefSubmit: 'Save visibility preference', + courtsMethodMEDIATION: 'Mediation', + courtsNoCases: 'No cases.', + reportsTitle: "Relatórios", + reportsDescription: "Gere e acompanha relatórios relacionados com problemas, bugs, abusos e avisos de conteúdo na tua rede.", + reportsFilterAll: "TODOS", + reportsFilterMine: "MEUS", + reportsFilterFeatures: "FUNCIONALIDADES", + reportsFilterBugs: "BUGS", + reportsFilterAbuse: "ABUSO", + reportsFilterContent: "CONTEÚDO", + reportsFilterConfirmed: "CONFIRMADO", + reportsFilterResolved: "RESOLVIDO", + reportsFilterOpen: "ABERTO", + reportsFilterUnderReview: "EM ANÁLISE", + reportsFilterInvalid: "INVÁLIDO", + reportsCreateButton: "Criar relatório", + reportsTitleLabel: "Título", + reportsDescriptionLabel: "Descrição", + reportsDescriptionPlaceholder: "Por favor, fornece uma descrição detalhada.", + reportsCategory: "Categoria", + reportsCategoryLabel: "Categoria", + reportsCategoryFeatures: "Funcionalidades", + reportsCategoryBugs: "Bugs", + reportsCategoryAbuse: "Abuso", + reportsCategoryContent: "Problemas de conteúdo", + reportsUpdateButton: "Atualizar", + reportsDeleteButton: "Eliminar", + reportsDateLabel: "Data", + reportsUploadFile: "Carregar mídia (máx: 50MB)", + reportsMineSectionTitle: "Os teus relatórios", + reportsFeaturesSectionTitle: "Pedidos de funcionalidades", + reportsBugsSectionTitle: "Bugs", + reportsAbuseSectionTitle: "Relatórios de abuso", + reportsContentSectionTitle: "Problemas de conteúdo", + reportsAllSectionTitle: "Relatórios", + reportsNoItems: "Sem relatórios disponíveis.", + reportsValidationTitle: "Por favor, introduz um título válido.", + reportsValidationDescription: "A descrição não pode estar vazia.", + reportsValidationCategory: "Por favor, seleciona uma categoria.", + reportsCreatedAt: "Criado em", + reportsCreatedBy: "By", + reportsSeverity: "Gravidade", + reportsSeverityLow: "Baixa", + reportsSeverityMedium: "Média", + reportsSeverityHigh: "Alta", + reportsSeverityCritical: "Crítica", + reportsStatus: "Estado", + reportsStatusOpen: "Abrir", + reportsStatusUnderReview: "Em análise", + reportsStatusResolved: "Resolvido", + reportsStatusInvalid: "Inválido", + reportsUpdateStatusButton: "Atualizar estado", + reportsAnonymityOption: "Enviar anonimamente", + reportsAnonymousAuthor: "Anónimo", + reportsConfirmButton: "CONFIRMAR RELATÓRIO!", + reportsConfirmations: "Confirmações", + reportsConfirmedSectionTitle: "Relatórios confirmados", + reportsCreateTaskButton: "CRIAR TAREFA", + reportsOpenSectionTitle: "Relatórios abertos", + reportsUnderReviewSectionTitle: "Relatórios em análise", + reportsResolvedSectionTitle: "Relatórios resolvidos", + reportsInvalidSectionTitle: "Relatórios inválidos", + reportsTemplateSectionTitle: 'Modelo de relatório', + reportsBugTemplateTitle: 'Detalhes de reprodução (Bugs)', + reportsFeatureTemplateTitle: 'Detalhes (Funcionalidades)', + reportsAbuseTemplateTitle: 'Detalhes (Abuso)', + reportsContentTemplateTitle: 'Detalhes (Problemas de conteúdo)', + reportsStepsToReproduceLabel: 'Passos para reproduzir', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: 'Resultado esperado', + reportsExpectedBehaviorPlaceholder: 'O que deveria acontecer?', + reportsActualBehaviorLabel: 'Resultado real', + reportsActualBehaviorPlaceholder: 'O que realmente acontece?', + reportsEnvironmentLabel: 'Ambiente', + reportsEnvironmentPlaceholder: 'Versão, dispositivo, SO, navegador, definições, registos, etc.', + reportsReproduceRateLabel: 'Taxa de reprodução', + reportsReproduceRateAlways: 'Sempre', + reportsReproduceRateOften: 'Frequentemente', + reportsReproduceRateSometimes: 'Às vezes', + reportsReproduceRateRarely: 'Raramente', + reportsReproduceRateUnable: 'Impossível reproduzir', + reportsReproduceRateUnknown: 'Não especificado', + reportsProblemStatementLabel: 'Problema / necessidade', + reportsProblemStatementPlaceholder: 'Que problema esta funcionalidade resolve?', + reportsUserStoryLabel: 'História do habitante', + reportsUserStoryPlaceholder: 'Como , quero , para que .', + reportsAcceptanceCriteriaLabel: 'Critérios de aceitação', + reportsAcceptanceCriteriaPlaceholder: '- Dado...\n- Quando...\n- Então...', + reportsWhatHappenedLabel: 'O que aconteceu?', + reportsWhatHappenedPlaceholder: 'Descreve o incidente com contexto e datas aproximadas.', + reportsReportedUserLabel: 'Habitante / entidade denunciada', + reportsReportedUserPlaceholder: '@habitante, feed, ID, link, etc.', + reportsEvidenceLinksLabel: 'Provas / links', + reportsEvidenceLinksPlaceholder: 'Cola links, IDs de mensagens, capturas de ecrã (se aplicável), etc.', + reportsContentLocationLabel: 'Onde está o conteúdo', + reportsContentLocationPlaceholder: 'Link, ID, canal, conversa, autor, etc.', + reportsWhyInappropriateLabel: "Porquê inapropriado", + reportsWhyInappropriatePlaceholder: 'Explica o motivo e o impacto.', + reportsRequestedActionLabel: 'Ação solicitada', + reportsRequestedActionPlaceholder: 'Remover, ocultar, etiquetar, avisar, etc.', + tribesTitle: "Tribos", + tribeAllSectionTitle: "Tribos", + tribeMineSectionTitle: "As tuas tribos", + tribeCreateSectionTitle: "Criar tribo", + tribeUpdateSectionTitle: "Atualizar tribo", + tribeGallerySectionTitle: "Galeria de tribos", + tribeRecentSectionTitle: "Tribos recentes", + tribeTopSectionTitle: "Tribos populares", + tribeviewTribeButton: "Visitar tribo", + tribeviewSubTribeButton: "Visitar sub-tribo", + tribeRootLabel: "RAIZ", + tribeDescription: "Explora ou cria tribos na tua rede.", + tribeFilterAll: "TODOS", + tribeFilterMine: "MEUS", + tribeFilterMembership: "MEMBROS", + tribeFilterRecent: "RECENTES", + tribeFilterTop: "TOP", + tribeFilterSubtribes: "SUB-TRIBOS", + tribeFilterGallery: "GALERIA", + tribeMainTribeLabel: "TRIBO PRINCIPAL", + tribeCreateButton: "Criar tribo", + tribeUpdateButton: "Atualizar", + tribeDeleteButton: "Eliminar", + tribeImageLabel: "Carregar mídia (máx: 50MB)", + tribeTitleLabel: "Título", + searchTribesPlaceholder: "FILTRAR tribos POR NOME …", + tribeTitlePlaceholder: "Nome da tribo", + tribeDescriptionLabel: "Descrição", + tribeDescriptionPlaceholder: "Descreve esta tribo", + tribeLocationLabel: "Localização", + tribeLocationPlaceholder: "Onde está localizada esta tribo?", + tribeTagsLabel: "Tags", + tribeTagsPlaceholder: "Introduz tags separadas por vírgulas", + tribeInviteMode: "Modo de convite", + tribeModeLabel: "MODE", + tribeIsAnonymousLabel: "ESTADO", + tribeMembersCount: "Members", + tribeInviteCodePlaceholder: "Introduz código de convite", + tribeJoinByCodeButton: "Aderir com código", + tribeJoinButton: "ADERIR", + tribeEnterInvite: "INSERIR CONVITE", + tribeLeaveButton: "SAIR", + tribeYes: "SIM", + tribeNo: "NÃO", + tribePublic: "PÚBLICO", + tribePrivate: "PRIVADO", + tribeGenerateInvite: "CONVITE ÚNICO", + tribeOpenInvitation: "CONVITE ABERTO", + tribeJoinOpen: "ENTRAR NA TRIBO", + tribeRemoveInvitation: "REMOVER CONVITE", + tribeCreatedAt: "Criado em", + tribeAuthor: "Por", + tribeAuthorLabel: "AUTOR", + tribeStrict: "Restrito", + tribeOpen: "Abrir", + tribeFeedFilterRECENT: "RECENTES", + tribeFeedFilterMINE: "MEUS", + tribeFeedFilterALL: "TODOS", + tribeFeedFilterTOP: "TOP", + tribeFeedRefeeds: "Republicações", + tribeFeedRefeed: "Republicar", + tribeFeedMessagePlaceholder: "Escreve um feed…", + tribeFeedSend: "Enviar", + tribeFeedEmpty: "Ainda sem mensagens de feed disponíveis.", + noTribes: "Ainda nenhuma tribo encontrada.", + tribeNotFound: "Tribo não encontrada!", + createTribeTitle: "Criar tribo", + updateTribeTitle: "Atualizar tribo", + tribeSectionOverview: "Visão geral", + tribeSectionInhabitants: "Habitantes", + tribeSectionVotations: "VOTAÇÕES", + tribeSectionEvents: "EVENTOS", + tribeSectionReports: "Relatórios", + tribeSectionTasks: "TAREFAS", + tribeSectionFeed: "FEED", + tribeSectionForum: "FÓRUM", + tribeSectionMarket: "Mercado", + tribeSectionJobs: "Empregos", + tribeSectionProjects: "Projetos", + tribeSectionMedia: "Média", + tribeSectionImages: "IMAGENS", + tribeSectionAudios: "ÁUDIOS", + tribeSectionVideos: "VÍDEOS", + tribeSectionDocuments: "DOCUMENTOS", + tribeSectionBookmarks: "MARCADORES", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "Ainda sem habitantes nesta tribo.", + tribeEventCreate: "Criar evento", + tribeEventsEmpty: "Ainda sem eventos.", + tribeEventTitle: "Título", + tribeEventDescription: "Descrição", + tribeEventDate: "Data", + tribeEventLocation: "Localização", + tribeEventAttend: "PARTICIPAR", + tribeEventUnattend: "SAIR", + tribeEventAttendees: "Participantes", + tribeTaskCreate: "Criar tarefa", + tribeTasksEmpty: "Ainda sem tarefas.", + tribeTaskTitle: "Título", + tribeTaskDescription: "Descrição", + tribeTaskPriority: "Prioridade", + tribeTaskDeadline: "Prazo", + tribeTaskAssignees: "Atribuídos", + tribeTaskStatusInProgress: "EM PROGRESSO", + tribeTaskStatusClosed: "FECHAR", + tribeTaskAssign: "ATRIBUIR", + tribeTaskUnassign: "REMOVER", + tribeReportCreate: "Criar relatório", + tribeReportsEmpty: "Ainda sem relatórios.", + tribeReportTitle: "Título", + tribeReportDescription: "Descrição", + tribeReportCategory: "Categoria", + tribeVotationCreate: "Criar votação", + tribeVotationsEmpty: "Ainda sem votações.", + tribeVotationTitle: "Título", + tribeVotationDescription: "Descrição", + tribeVotationOptions: "Opções", + tribeVotationDeadline: "Prazo", + tribeVotationVote: "VOTAR", + tribeVotationResults: "Votos", + tribeVotationClose: "FECHAR VOTAÇÃO", + tribeVotationOptionPlaceholder: "Opção", + tribeForumCreate: "Criar Forum", + tribeForumEmpty: "Ainda sem tópicos.", + tribeForumTitle: "Título", + tribeForumText: "Mensagem", + tribeForumCategory: "Categoria", + tribeForumReply: "Responder", + tribeForumReplies: "Respostas", + tribeMarketCreate: "Criar anúncio", + tribeMarketEmpty: "Ainda sem anúncios.", + tribeMarketTitle: "Título", + tribeMarketDescription: "Descrição", + tribeMarketPrice: "Preço", + tribeMarketImage: "Imagem", + tribeMarketCategory: "Categoria", + tribeJobCreate: "Criar emprego", + tribeJobsEmpty: "Ainda sem empregos.", + tribeJobTitle: "Título", + tribeJobDescription: "Descrição", + tribeJobLocation: "Localização", + tribeJobSalary: "Salário", + tribeJobDeadline: "Prazo", + tribeProjectCreate: "Criar projeto", + tribeProjectsEmpty: "Ainda sem projetos.", + tribeProjectTitle: "Título", + tribeProjectDescription: "Descrição", + tribeProjectGoal: "Objetivo", + tribeProjectFunded: "Financiado", + tribeProjectDeadline: "Prazo", + tribeMediaUpload: "Carregar média", + readDocument: "Ler Documento", + tribeCreateImage: "Criar Imagem", + tribeCreateAudio: "Criar Áudio", + tribeCreateVideo: "Criar Vídeo", + tribeCreateDocument: "Criar Documento", + tribeCreateBookmark: "Criar Marcador", + tribeMediaEmpty: "Ainda sem média.", + tribeMediaTitle: "Título", + tribeMediaDescription: "Descrição", + tribeMediaType: "Tipo", + tribeMediaTypeImage: "Imagem", + tribeMediaTypeVideo: "Vídeo", + tribeMediaTypeAudio: "Áudio", + tribeMediaTypeDocument: "Documento", + tribeMediaTypeBookmark: "Favorito", + tribeContentDelete: "ELIMINAR", + tribeInviteCodeText: "Código de convite: ", + oasisVersionLabel: "Versão do Oasis", + tribeInviteCodeHint: "Partilha este código com quem queres convidar. Pode entrar via /invites → Tribes.", + tribeGroupTribe: "Tribo", + tribeGroupOffice: "Escritório", + tribeGroupNetwork: "Rede", + tribeGroupEconomy: "Economia", + tribeGroupMedia: "Média", + tribeStatusOpen: "ABERTO", + tribeStatusClosed: "FECHADO", + tribeStatusInProgress: "EM PROGRESSO", + tribePriorityLow: "BAIXA", + tribePriorityMedium: "MÉDIA", + tribePriorityHigh: "ALTA", + tribePriorityCritical: "CRÍTICA", + tribeTaskFilterAll: "TODOS", + tribeMediaFilterAll: "TODOS", + tribeReportCatBug: "BUG", + tribeReportCatAbuse: "ABUSO", + tribeReportCatContent: "CONTEÚDO", + tribeReportCatOther: "OUTRO", + tribeForumCatGeneral: "GERAL", + tribeForumCatProposal: "PROPOSTA", + tribeForumCatQuestion: "PERGUNTA", + tribeForumCatAnnouncement: "ANÚNCIO", + tribeMarketCatGoods: "BENS", + tribeMarketCatServices: "SERVIÇOS", + tribeMarketCatFood: "ALIMENTAÇÃO", + tribeMarketCatOther: "OUTRO", + tribeStatusLabel: "Estado", + tribeSubTribes: "SUB-TRIBOS", + tribeSubTribesCreate: "Criar Sub-Tribo", + tribeSubTribesStrictDenied: "O modo estrito da tribo não permite criar novas sub-tribos. Por favor, entre em contato com o administrador.", + tribeSubTribesEmpty: "Ainda sem sub-tribos criadas.", + tribeActivityJoined: "ADERIU", + tribeActivityLeft: "SAIU", + tribeActivityFeed: "FEED", + tribeActivityRefeed: "REPUBLICAÇÃO", + tribeGroupAnalytics: "Análises", + tribeGroupCreative: "Criativo", + tribeSectionActivity: "ATIVIDADE", + tribeSectionTrending: "TENDÊNCIAS", + tribeSectionOpinions: "OPINIÕES", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "TAGS", + tribeSectionSearch: "PESQUISA", + tribeActivityEmpty: "Ainda sem atividade.", + tribeActivityCreated: "criou", + tribeActivityPosted: "publicou", + tribeActivityReplied: "respondeu", + tribeTrendingEmpty: "Ainda sem conteúdo em tendência.", + tribeTrendingPeriodDay: "Hoje", + tribeTrendingPeriodWeek: "Esta semana", + tribeTrendingPeriodAll: "Todo o tempo", + tribeTrendingEngagement: "envolvimento", + tribeOpinionsEmpty: "Ainda sem opiniões.", + tribeOpinionsCast: "Votar", + tribeOpinionsRankings: "Classificações", + tribeOpinionsAlreadyVoted: "Já votado", + tribeTopCategory: "Mais votado", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Tela colaborativa de pixel art.", + tribePixeliaPaint: "Pintar", + tribePixeliaContributors: "contribuidores", + tribePixeliaTotalPixels: "pixeis pintados", + tribeTagsEmpty: "Nenhum tag encontrado.", + tribeTagsCloud: "Nuvem de tags", + tribeTagsContentWith: "Conteúdo com tag", + tribeSearchPlaceholder: "Pesquisar conteúdo da tribo...", + tribeSearchEmpty: "Nenhum resultado encontrado.", + tribeSearchResults: "Resultados", + tribeSearchMinChars: "Introduz pelo menos 2 caracteres para pesquisar.", + agendaTitle: "Agenda", + agendaDescription: "Aqui podes encontrar todos os teus itens atribuídos.", + agendaFilterAll: "TODOS", + agendaFilterToday: "HOJE", + agendaFilterUpcoming: "PRÓXIMOS", + agendaFilterOverdue: "ATRASADOS", + agendaFilterOpen: "ABERTO", + agendaFilterClosed: "FECHADO", + agendaFilterTasks: "TAREFAS", + agendaFilterMarket: "MERCADO", + agendaFilterTribes: "TRIBOS", + agendaFilterEvents: "EVENTOS", + agendaFilterReports: "RELATÓRIOS", + agendaFilterTransfers: "TRANSFERÊNCIAS", + agendaFilterJobs: "EMPREGOS", + agendaFilterProjects: "PROJETOS", + agendaFilterCalendars: "CALENDÁRIOS", + agendaNoItems: "Sem atribuições encontradas.", + agendaAuthor: "Por", + agendaDiscardButton: "Descartar", + agendaRestoreButton: "Restaurar", + agendaCreatedAt: "Criado em", + agendaTitleLabel: "Título", + agendaMembersCount: "Membros", + agendaDescriptionLabel: "Descrição", + agendaStatus: "Estado", + agendaVisibility: "Visibilidade", + agendaEventDate: "Data do evento", + agendaEventLocation: "Localização", + agendaEventPrice: "Preço", + agendaEventUrl: "URL", + agendaTaskStart: "Hora de início", + agendaLocationLabel: "Localização", + agendaYes: "SIM", + agendaNo: "NÃO", + agendaInviteModeLabel: "Estado", + agendaAnonymousLabel: "Anónimo", + agendaMembersLabel: "Membros", + agendareportCategory: "Categoria", + agendareportSeverity: "Gravidade", + agendareportStatus: "Estado", + agendareportDescription: "Descrição", + agendaTaskEnd: "Hora de fim", + agendaTaskPriority: "Prioridade", + agendaTransferFrom: "De", + agendaTransferTo: "Para", + agendaTransferConcept: "Concept", + agendaTransferAmount: "Montante", + agendaTransferDeadline: "Deadline", + opinionsTitle: "Opiniões", + shareYourOpinions: "Descobre e vota em opiniões na tua rede.", + author: "By", + voteNow: "Votar agora", + alreadyVoted: "You have already opined.", + noOpinionsFound: "Sem opiniões encontradas.", + RECENTButton: "RECENT", + TOPButton: "TOP", + interestingButton: "INTERESSANTE", + necessaryButton: "NECESSÁRIO", + funnyButton: "ENGRAÇADO", + disgustingButton: "REPUGNANTE", + sensibleButton: "SENSÍVEL", + propagandaButton: "PROPAGANDA", + adultOnlyButton: "PARA ADULTOS", + boringButton: "ABORRECIDO", + confusingButton: "CONFUSO", + usefulButton: "ÚTIL", + informativeButton: "INFORMATIVO", + wellResearchedButton: "BEM PESQUISADO", + accurateButton: "PRECISO", + needsSourcesButton: "PRECISA FONTES", + wrongButton: "ERRADO", + lowQualityButton: "BAIXA QUALIDADE", + creativeButton: "CRIATIVO", + insightfulButton: "PERSPICAZ", + actionableButton: "ACIONÁVEL", + inspiringButton: "INSPIRING", + loveButton: "AMOR", + clearButton: "CLARO", + upliftingButton: "EDIFICANTE", + unnecessaryButton: "DESNECESSÁRIO", + rejectedButton: "REJEITADO", + misleadingButton: "ENGANOSO", + offTopicButton: "FORA DO TÓPICO", + duplicateButton: "DUPLICADO", + clickbaitButton: "CLICKBAIT", + spamButton: "SPAM", + trollButton: "TROLL", + nsfwButton: "NSFW", + violentButton: "VIOLENTO", + toxicButton: "TÓXICO", + harassmentButton: "ASSÉDIO", + hateButton: "ÓDIO", + scamButton: "FRAUDE", + triggeringButton: "PERTURBANTE", + opinionsCreatedAt: "Criado em", + opinionsTotalCount: "Total de opiniões", + voteInteresting: "Interessante", + voteNecessary: "Necessário", + voteUseful: "Útil", + voteInformative: "Informativo", + voteWellResearched: "Bem pesquisado", + voteNeedsSources: "Precisa fontes", + voteWrong: "Errado", + voteLowQuality: "Baixa qualidade", + voteLove: "Amor", + voteClear: "Limpar", + voteMisleading: "Enganoso", + voteOffTopic: "Fora do tópico", + voteDuplicate: "Duplicado", + voteClickbait: "Clickbait", + votePropaganda: "Propaganda", + voteFunny: "Engraçado", + voteInspiring: "Inspirador", + voteUplifting: "Edificante", + voteUnnecessary: "Desnecessário", + voteRejected: "Rejeitado", + voteConfusing: "Confuso", + voteTroll: "Troll", + voteNsfw: "NSFW", + voteViolent: "Violento", + voteToxic: "Tóxico", + voteHarassment: "Assédio", + voteHate: "Ódio", + voteScam: "Fraude", + voteTriggering: "Perturbante", + voteInsightful: "Perspicaz", + voteAccurate: "Preciso", + voteActionable: "Acionável", + voteCreative: "Criativo", + voteSpam: "Spam", + voteAdultOnly: "Para adultos", + publishBlog: "Publicar blog", + privateMessage: "MP", + pmSendTitle: "Mensagens privadas", + pmSend: "Enviar!", + pmDescription: "Usa este formulário para enviar uma mensagem encriptada a outros habitantes.", + pmRecipients: "Destinatários", + pmRecipientsHint: "Introduz Oasis IDs separados por vírgulas", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Assunto", + pmSubjectHint: "Introduz o assunto da mensagem", + pmText: "Mensagem", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Decifrar", + pmCrypterBadKey: "A chave partilhada está incorreta!", + pmCrypterTooLong: "A mensagem é demasiado longa para ser cifrada com o Crypter. Encurta-a e tenta de novo.", + pmCrypterCipherLabel: "Mensagem recifrada", + pmCrypterCharsLabel: "caracteres", + pmInvalidRecipients: "Oasis ID inválido (deveria ser como @…=.ed25519).", + pmEncryptionLabel: "Cifragem", + pmFile: "Anexo", + private: "Privado", + privateDescription: "Your encrypted messages.", + privateInbox: "CAIXA DE ENTRADA", + privateSent: "ENVIADAS", + privateDelete: "Eliminar", + pmCreateButton: "Escrever MP", + noPrivateMessages: "No private messages.", + pmFromLabel: "De:", + pmToLabel: "Para:", + pmInvalidMessage: "Mensagem inválida", + pmNoSubject: "(sem assunto)", + pmSubjectLabel: "Assunto:", + pmBodyLabel: "Corpo", + pmBotJobs: "42-EmpregoBOT", + pmBotProjects: "42-ProjetosBOT", + pmBotMarket: "42-MercadoBOT", + inboxJobSubscribedTitle: "Nova subscrição à tua oferta de emprego", + pmInhabitantWithId: "Habitante com Oasis ID:", + pmHasSubscribedToYourJobOffer: "subscreveu a tua oferta de emprego", + inboxProjectCreatedTitle: "Novo projeto criado", + pmHasCreatedAProject: "criou um projeto", + inboxMarketItemSoldTitle: "Item vendido", + pmYourItem: "O teu item", + pmHasBeenSoldTo: "foi vendido a", + pmFor: "por", + inboxProjectPledgedTitle: "Novo compromisso com o teu projeto", + pmHasPledged: "comprometeu", + pmToYourProject: "ao teu projeto", + blockchain: 'BlockExplorer', + blockchainTitle: 'BlockExplorer', + blockchainDescription: 'Explore and visualize the blocks in the blockchain.', + blockchainNoBlocks: 'No blocks found in the blockchain.', + blockchainStatsTitle: 'Estatísticas', + blockchainStatsTypeBreakdown: 'Distribuição por tipo', + blockchainStatsTopAuthors: 'Autores principais', + blockchainStatsCount: 'Blocos', + blockchainBlockID: 'Block ID', + blockchainBlockAuthor: 'Author', + blockchainBlockCarbon: 'Pegada de carbono', + blockchainBlockType: 'Type', + blockchainBlockTimestamp: 'Timestamp', + blockchainBlockContent: 'Block', + blockchainBlockURL: 'URL:', + blockchainContent: 'Block', + blockchainContentPreview: 'Preview of the block content', + blockchainLatestDatagram: 'Último Datagrama', + blockchainDatagram: 'Datagrama', + blockchainDetails: 'View block details', + blockchainViewBlockexplorer: "Ver no blockexplorer", + blockchainBlockInfo: 'Block Information', + blockchainBlockDetails: 'Details of the selected block', + blockchainBack: 'Back to Blockexplorer', + blockchainContentDeleted: "Este conteúdo foi eliminado (tombstone)", + visitContent: "Visitar conteúdo", + banking: 'Banking', + bankingTitle: 'Banking', + bankingDescription: 'Explore the current value of ECOin and the corresponding UBI allocation, distributed per epoch based on participation and trust.', + bankOverview: 'Overview', + bankEpochs: 'Epochs', + bankRules: 'Rules', + pending: 'Pending', + closed: 'Closed', + bankBack: 'Back to Banking', + bankViewTx: 'View Tx', + bankClaimNow: 'Claim now', + bankClaimUBI: 'Reivindicar RBU!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: 'Nenhuma alocação RBU pendente para esta época.', + bankEpoch: 'Epoch', + bankPool: 'Pool (this epoch)', + bankWeightsSum: 'Sum of weights', + bankAllocations: 'Allocations', + bankNoAllocations: 'No allocations found.', + bankNoEpochs: 'No epochs found.', + bankEpochAllocations: 'Epoch allocations', + bankAllocId: 'Allocation ID', + bankAllocDate: 'Date', + bankAllocConcept: 'Concept', + bankAllocFrom: 'From', + bankAllocTo: 'To', + bankAllocAmount: 'Amount', + bankAllocStatus: 'Status', + bankEpochId: 'Epoch ID', + bankRuleHash: 'Rules Snapshot Hash', + bankViewEpoch: 'View Epoch', + bankUserBalance: 'Your Balance', + ecoWalletNotConfigured: 'ECOin Wallet not configured', + editWallet: 'Edit wallet', + addWallet: 'Add wallet', + bankAddresses: 'Addresses', + bankNoAddresses: 'No addresses found.', + bankUser: 'Oasis ID', + bankAddress: 'Address', + bankAddAddressTitle: 'Add ECOIN address', + bankAddAddressUser: 'Oasis ID', + bankAddAddressAddress: 'ECOIN Address', + bankAddAddressSave: 'Save', + bankAddressAdded: 'Address added', + bankAddressUpdated: 'Address updated', + bankAddressExists: 'Address already exists', + bankAddressInvalid: 'Invalid address', + bankAddressDeleted: 'Address deleted', + bankAddressNotFound: 'Address not found', + bankAddressForbidden: 'Só podes configurar o teu próprio endereço de cobrança', + bankAddressTotal: 'Total Addresses', + bankAddressSearch: 'Search @inhabitant or address', + bankAddressActions: 'Actions', + bankAddressDelete: 'Delete', + bankAddressSource: 'Source', + bankAddressDeleteConfirm: 'Delete this address?', + search: 'Search!', + bankLocal: 'Local', + bankFromOasis: 'Oasis', + bankMyAddress: 'Your address', + bankRemoveMyAddress: 'Remove my address', + bankNotRemovableOasis: 'Addresses cannot be removed locally', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "SEM FUNDOS!", + bankUbiAvailableOk: "DISPONÍVEL!", + bankUbiAvailability: "Disponibilidade UBI", + bankAlreadyClaimedThisMonth: "Já reclamado este mês", + bankUbiThisMonth: "RBU (este mês)", + bankUbiLastClaimed: "RBU (última reclamada)", + bankUbiNeverClaimed: "Nunca reclamado", + bankUbiTotalClaimed: "RBU (total reclamado)", + bankUbiPub: "PUB", + bankUbiInhabitant: "HABITANTE", + bankUbiClaimedAmount: "RECLAMADO (ECO)", + typeBankUbiResult: "BANCÁRIO - RBU", + bankNoPubConfigured: "Nenhum PUB configurado. Define o teu ID PUB nas Definições.", + shopsTitle: "Lojas", + shopDescription: "Descubra e gerencie lojas na rede.", + shopTitle: "Loja", + shopFilterAll: "TODAS", + shopFilterMine: "MINHAS", + shopFilterRecent: "RECENTES", + shopFilterTop: "TOP", + shopFilterProducts: "PRODUTOS", + shopFilterPrices: "PREÇOS", + shopFilterFavorites: "FAVORITAS", + shopUpload: "Criar Loja", + shopAllSectionTitle: "Todas as Lojas", + shopMineSectionTitle: "Minhas Lojas", + shopRecentSectionTitle: "Lojas Recentes", + shopTopSectionTitle: "Top Lojas", + shopProductsSectionTitle: "Top Produtos", + shopPricesSectionTitle: "Produtos por Preço", + shopFavoritesSectionTitle: "Favoritas", + shopCreateSectionTitle: "Criar Loja", + shopUpdateSectionTitle: "Atualizar Loja", + shopCreate: "Criar", + shopUpdate: "Atualizar", + shopDelete: "Excluir", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Publicar na Clearnet", + shopClearnetUnpublish: "Remover da Clearnet", + shopClearnetUrlLabel: "URL Clearnet", + shopClearnetWarning: "Publicar na Clearnet torna esta loja indexável por motores de busca e rastreadores externos.", + shopAddFavorite: "Adicionar Favorito", + shopRemoveFavorite: "Remover Favorito", + shopOpen: "ABERTA", + shopClosed: "FECHADA", + shopOpenShop: "Abrir Loja", + shopCloseShop: "Fechar Loja", + shopMakePrivate: "TORNAR PRIVADA (E2E)", + shopMakePublic: "TORNAR PÚBLICA", + shopProducts: "Produtos", + shopProductAdd: "Adicionar Produto", + shopProductTitle: "Produto", + shopProductUpdate: "Atualizar Produto", + shopProductPrice: "Preço (ECO)", + shopProductStock: "Estoque", + shopProductUntitled: "Produto sem título", + shopUntitled: "Loja sem título", + shopNoItems: "Nenhuma loja encontrada.", + shopNoProducts: "Nenhum produto ainda.", + shopOutOfStock: "Esgotado", + shopBuy: "Comprar", + shopBuyEncryptedNote: "Os teus dados de entrega são enviados cifrados, visíveis apenas para o dono da loja.", + shopBuyDeliveryAddress: "Endereço de entrega", + shopBuyContact: "Contacto", + shopBuyContactPlaceholder: "email, telefone, etc.", + shopBuyNotes: "Notas", + shopBuyDeliveryRequired: "O endereço de entrega é obrigatório.", + shopOrdersTitle: "Encomendas", + shopOrdersDescription: "Encomendas recebidas por esta loja.", + shopOrdersEmpty: "Ainda sem encomendas.", + shopOrderProduct: "Produto", + shopOrderPrice: "Preço", + shopOrderBuyer: "Comprador", + shopBackToShop: "Voltar à Loja", + shopShareUrl: "URL de partilha", + shopVisitShop: "VISITAR LOJA", + marketVisitItem: "VER ARTIGO", + shopStatus: "ESTADO", + shopCreatedAt: "CRIADO", + shopSearchPlaceholder: "Pesquisar lojas...", + shopUrl: "URL", + shopLocation: "Localização", + shopTags: "Tags", + shopVisibility: "Visibilidade", + shopImage: "Imagem", + shopShortDescription: "Descricao Curta", + shopShortDescriptionPlaceholder: "Descricao breve para os cartoes (max 160 caracteres)", + shopTitlePlaceholder: "Nome da sua loja", + shopDescriptionPlaceholder: "Descricao detalhada da sua loja", + shopUrlPlaceholder: "https://url-da-sua-loja.com", + shopLocationPlaceholder: "Cidade, Pais", + shopTagsPlaceholder: "tag1, tag2, tag3", + mapTitlePlaceholder: "Titulo do mapa", + shopProductFeatured: "Produto em Destaque", + shopSendToMarket: "Send to Market", + typeShop: "LOJAS", + typeShopProduct: "PRODUTO DE LOJA", + bankExchange: 'Exchange', + bankExchangeCurrentValue: 'ECOin Value (1h)', + bankTotalSupply: 'ECOin Total Supply', + bankEcoinHours: "Equivalência ECOin em tempo", + bankHoursOfWork: 'hours', + bankUnitMs: "ms", + bankUnitSeconds: "segundos", + bankUnitMinutes: "minutos", + bankUnitDays: "dias", + bankExchangeNoData: 'No data available', + bankExchangeIndex: 'ECOin Value (1h)', + bankInflation: 'ECOin Inflation (anual)', + bankInflationMonthly: 'ECOin Inflation (mensual)', + bankExchangeChartTitle: 'Progressão do valor de ECOin ao longo do tempo', + bankExchangeChartValue: 'Valor (ECO/h)', + bankExchangeChartSupply: 'Oferta', + bankExchangeChartInflation: 'Inflação %', + bankExchangeChartEmpty: 'Ainda não há amostras suficientes — volte mais tarde', + bankCurrentSupply: 'ECOin Current Supply', + bankingSyncStatus: 'ECOin Status', + bankingSyncStatusSynced: 'Synced', + bankingSyncStatusOutdated: 'Outdated', + statsTitle: 'Statistics', + statistics: "Estatísticas", + statsInhabitant: "Estatísticas do habitante", + statsDescription: "Descobre estatísticas sobre a tua rede.", + ALLButton: "ALL", + MINEButton: "MINE", + TOMBSTONEButton: "TOMBSTONES", + statsYou: "Tu", + statsUserId: "Oasis ID", + statsCreatedAt: "Criado em", + statsYourContent: "Conteúdo", + statsYourOpinions: "Opiniões", + statsYourTombstone: "Tombstones", + statsNetwork: "Rede", + statsTotalInhabitants: "Habitantes", + statsDiscoveredTribes: "Tribos (públicas)", + statsPrivateDiscoveredTribes: "Tribos (privadas)", + statsNetworkContent: "Conteúdo", + statsYourMarket: "Mercado", + statsYourJob: "Empregos", + statsYourProject: "Projetos", + statsYourTransfer: "Transferências", + statsYourForum: "Fóruns", + statsNetworkOpinions: "Opiniões", + statsDiscoveredMarket: "Mercado", + statsDiscoveredJob: "Empregos", + statsDiscoveredProject: "Projetos", + statsBankingTitle: "Banca", + statsEcoWalletLabel: "Carteira ECOIN", + statsEcoWalletNotConfigured: "Não configurada!", + statsTotalEcoAddresses: "Total de endereços", + statsDiscoveredTransfer: "Transferências", + statsDiscoveredForum: "Fóruns", + statsNetworkTombstone: "Tombstones", + statsBookmark: "Marcadores", + statsEvent: "Eventos", + statsTask: "Tarefas", + statsVotes: "Votos", + statsMarket: "Mercado", + statsForum: "Fóruns", + statsJob: "Empregos", + statsReport: "Relatórios", + statsFeed: "Feeds", + statsTribe: "Tribos", + statsImage: "Imagens", + statsAudio: "Áudios", + statsVideo: "Vídeos", + statsDocument: "Documentos", + statsMap: "Mapas", + statsShop: "Lojas", + statsShopProduct: "Produtos de loja", + statsTransfer: "Transferências", + statsAiExchange: "IA", + statsPUBs: 'PUBs', + statsPost: "Publicações", + statsOasisID: "Oasis ID", + statsSize: "Total (tamanho)", + statsBlockchainSize: "Blockchain (tamanho)", + statsBlobsSize: "Blobs (tamanho)", + statsActivity7d: "Atividade (últimos 7 dias)", + statsActivity7dTotal: "Total 7 dias", + statsActivity30dTotal: "Total 30 dias", + statsKarmaScore: "Pontuação KARMA", + statsPublic: "Público", + statsPrivate: "Privado", + day: "Day", + messages: "Mensagens", + statsProject: "Projetos", + statsProjectsTitle: "Projetos", + statsProjectsTotal: "Total de projetos", + statsProjectsActive: "Ativo", + statsProjectsCompleted: "Concluído", + statsProjectsPaused: "Pausado", + statsProjectsCancelled: "Cancelado", + statsProjectsGoalTotal: "Objetivo total", + statsProjectsPledgedTotal: "Total comprometido", + statsProjectsSuccessRate: "Taxa de sucesso", + statsProjectsAvgProgress: "Progresso médio", + statsProjectsMedianProgress: "Progresso mediano", + statsProjectsActiveFundingAvg: "Financiamento ativo médio", + statsJobsTitle: "Empregos", + statsJobsTotal: "Total de empregos", + statsJobsOpen: "Abrir", + statsJobsClosed: "Fechado", + statsJobsOpenVacants: "Vagas abertas", + statsJobsSubscribersTotal: "Total de subscritores", + statsJobsAvgSalary: "Salário médio", + statsJobsMedianSalary: "Salário mediano", + statsMarketTitle: "Mercado", + statsMarketTotal: "Total de itens", + statsMarketForSale: "À venda", + statsMarketReserved: "Reservado", + statsMarketClosed: "Fechado", + statsMarketSold: "Vendido", + statsMarketRevenue: "Receita", + statsMarketAvgSoldPrice: "Preço médio de venda", + statsUsersTitle: "Habitantes", + user: "Habitante", + statsTombstoneTitle: "Tombstones", + statsNetworkTombstones: "Tombstones da rede", + statsTombstoneRatio: "Rácio de tombstones (%)", + statsParliamentCandidature: "Candidaturas parlamentares", + statsParliamentTerm: "Mandatos parlamentares", + statsParliamentProposal: "Propostas parlamentares", + statsParliamentRevocation: "Revogações parlamentares", + statsParliamentLaw: "Leis parlamentares", + statsCourtsCase: "Casos judiciais", + statsCourtsEvidence: "Provas judiciais", + statsCourtsAnswer: "Respostas judiciais", + statsCourtsVerdict: "Veredictos judiciais", + statsCourtsSettlement: "Acordos judiciais", + statsCourtsSettlementProposal: "Propostas de acordo", + statsCourtsSettlementAccepted: "Acordos aceites", + statsCourtsNomination: "Nomeações de juízes", + statsCourtsNominationVote: "Votos de nomeação", + ai: "IA", + aiTitle: "IA", + aiDescription: "Uma Inteligência Artificial Coletiva (IAC) chamada '42' que aprende com a tua rede.", + aiUserQuestion: "Pergunta", + aiResponseTitle: "Responder", + aiSubmitButton: "Enviar!", + aiSettingsDescription: "Define o teu prompt (máx 128 caracteres) para o modelo de IA.", + aiPrompt: "Fornece uma resposta informativa e precisa.", + aiConfiguration: "Definir prompt", + aiPromptUsed: "Prompt", + aiClearHistory: "Limpar histórico de conversa", + aiSharePrompt: "Adicionar esta resposta ao treino coletivo?", + aiShareYes: "Sim", + aiShareNo: "Não", + aiSharedLabel: "Adicionado ao treino", + aiRejectedLabel: "Não adicionado ao treino", + aiServerError: "A IA não conseguiu responder. Por favor, tenta novamente.", + aiInputPlaceholder: "What is Oasis?", + typeAiExchange: "IA", + aiApproveTrain: "Adicionar ao treino coletivo", + aiRejectTrain: "Não treinar", + aiTrainPending: "Aprovação pendente", + aiTrainApproved: "Aprovado para treino", + aiTrainRejected: "Rejeitado para treino", + aiSnippetsUsed: "Excertos usados", + aiSnippetsLearned: "Snippets learned", + statsAITraining: "AI training", + aiApproveCustomTrain: "Treinar com esta resposta personalizada", + aiCustomAnswerPlaceholder: "Escreve a tua resposta personalizada…", + statsAIExchanges: "Model Exchanges", + marketMineSectionTitle: "Os teus itens", + marketCreateSectionTitle: "Criar item", + marketUpdateSectionTitle: "Atualizar", + marketAllSectionTitle: "Mercado", + marketRecentSectionTitle: "Mercado recente", + marketTitle: "Mercado", + marketDescription: "Um mercado para trocar bens ou serviços na tua rede.", + marketFilterAll: "TODOS", + marketFilterMine: "MEUS", + marketFilterAuctions: "LEILÕES", + marketFilterItems: "TROCA", + marketFilterNew: "NOVO", + marketFilterUsed: "USADO", + marketFilterBroken: "AVARIADO", + marketFilterForSale: "À VENDA", + marketFilterSold: "VENDIDO", + marketFilterDiscarded: "DESCARTADO", + marketFilterRecent: "RECENTES", + marketFilterMyBids: "LICITAÇÕES", + marketCreateButton: "Criar item", + marketItemType: "Tipo", + marketItemTitle: "Título", + marketItemAvailable: "Prazo", + marketItemDescription: "Descrição", + marketItemDescriptionPlaceholder: "Descreve o item que estás a vender", + marketItemStatus: "Estado", + marketShopLabel: "Loja", + marketItemCondition: "Condição", + marketItemPrice: "Preço", + marketItemTags: "Tags", + marketItemTagsPlaceholder: "Introduz tags separadas por vírgulas", + marketItemDeadline: "Prazo", + marketItemIncludesShipping: "Inclui envio?", + marketItemHighestBid: "Maior licitação", + marketItemHighestBidder: "Maior licitante", + marketItemStock: "Disponibilidade", + marketOutOfStock: "Esgotado", + marketItemBidTime: "Hora da licitação", + marketActionsUpdate: "Atualizar", + marketUpdateButton: "Atualizar", + marketActionsDelete: "Eliminar", + marketActionsSold: "Marcar como vendido", + marketActionsChangeStatus: "ALTERAR ESTADO", + marketActionsBuy: "COMPRAR!", + marketAuctionBids: "Licitações atuais", + marketPlaceBidButton: "Fazer licitação", + marketItemSeller: "Vendedor", + marketNoItems: "Ainda sem itens disponíveis.", + marketYourBid: "A tua licitação", + marketCreateFormImageLabel: "Carregar mídia (máx: 50MB)", + marketSearchLabel: "Pesquisa", + marketSearchPlaceholder: "Pesquisar título ou tags", + marketMinPriceLabel: "Preço mínimo", + marketMaxPriceLabel: "Preço máximo", + marketSortLabel: "Ordenar por", + marketSortRecent: "Mais recentes", + marketSortPrice: "Preço", + marketSortDeadline: "Prazo", + marketSearchButton: "Pesquisa", + marketAuctionEndsIn: "Termina", + marketAuctionEnded: "Terminado", + marketMyBidBadge: "Licitaste", + marketNoItemsMatch: "Nenhum item corresponde à tua pesquisa.", + jobsTitle: "Empregos", + jobsCandidatesTitle: "Candidatos sugeridos", + jobsCandidatesDescription: "Habitantes cujas competências combinam com este emprego. Envie uma mensagem privada para convidá-los.", + jobsCandidatesPmBody: "Olá, acho que o teu perfil combina com a minha oferta de trabalho", + jobsDescription: "Descobre e gere empregos na tua rede.", + jobsFilterRecent: "RECENTES", + jobsFilterMine: "MEUS", + jobsFilterAll: "TODOS", + jobsFilterRemote: "REMOTO", + jobsFilterOpen: "ABERTO", + jobsFilterClosed: "FECHADO", + jobsCV: "CVs", + jobsCreateJob: "Criar emprego", + jobsRecentTitle: "Empregos recentes", + jobsMineTitle: "Os teus empregos", + jobsAllTitle: "Empregos", + jobsRemoteTitle: "Empregos remotos", + jobsOpenTitle: "Empregos abertos", + jobsClosedTitle: "Empregos fechados", + jobsCVTitle: "CVs", + jobsFilterPresencial: "PRESENCIAL", + jobsFilterFreelancer: "FREELANCER", + jobsFilterEmployee: "EMPREGADO", + jobsPresencialTitle: "Empregos presenciais", + jobsFreelancerTitle: "Empregos freelance", + jobsEmployeeTitle: "Empregos por conta de outrem", + jobTitle: "Título", + jobLocation: "Localização", + jobSalary: "Salário (ECO/1h)", + jobVacants: "Vagas", + jobDescription: "Descrição", + jobRequirements: "Requisitos", + jobLanguages: "Idiomas", + jobStatus: "Estado", + jobStatusOPEN: "ABERTO", + jobStatusCLOSED: "FECHADO", + jobSetClosed: "Definir como FECHADO", + jobSubscribeButton: "Aderir a esta oferta!", + jobUnsubscribeButton: "Sair desta oferta!", + jobTitlePlaceholder: "Introduz o título do emprego", + jobDescriptionPlaceholder: "Descreve o emprego", + jobRequirementsPlaceholder: "Introduz os requisitos", + jobLanguagesPlaceholder: "Inglês, Espanhol, Francês, Basco...", + jobTasksPlaceholder: "Lista de tarefas", + jobLocationPresencial: "Presencial", + jobLocationRemote: "Remoto", + jobVacantsPlaceholder: "Número de posições", + jobSalaryPlaceholder: "Salário em ECO por 1 hora dedicada", + jobImage: "Carregar mídia (máx: 50MB)", + jobTasks: "Tarefas", + jobType: "Tipo de emprego", + jobTime: "Horário", + jobSubscribers: "Subscritores", + noSubscribers: "Sem subscritores", + jobsFilterTop: "TOP", + jobsTopTitle: "Empregos com melhor salário", + createJobButton: "Publicar emprego", + viewDetailsButton: "Ver detalhes", + noJobsFound: "Sem ofertas de emprego encontradas.", + jobAuthor: "Por", + jobTypeFreelance: "Freelancer", + jobTypeSalary: "Empregado", + jobTypeExchange: "Troca de horas", + jobTypeEXCHANGE: "TROCA DE HORAS", + jobsFilterExchange: "TROCA", + jobsExchangeTitle: "Empregos por troca de horas", + jobsHoursOffered: "Horas oferecidas", + jobsHoursRequested: "Horas pedidas em troca", + jobsExchangeSkill: "Habilidade pedida em troca", + jobsHoursOfferedPlaceholder: "p. ex. 4", + jobsHoursRequestedPlaceholder: "p. ex. 4", + jobsExchangeSkillPlaceholder: "p. ex. carpintaria, design", + jobExchangeHint: "Para empregos de troca de horas, preencha os campos abaixo em vez do salário.", + jobTimePartial: "Part-time", + jobTimeComplete: "Full-time", + jobsDeleteButton: "ELIMINAR", + jobsUpdateButton: "ATUALIZAR", + jobsFilterApplied: "CANDIDATADO", + jobsAppliedTitle: "As minhas candidaturas", + jobsAppliedBadge: "Candidatado", + jobsFilterFavs: "Favoritos", + jobsFavsTitle: "Favoritos", + jobsFilterNeeds: "Precisa ajuda", + jobsNeedsTitle: "Precisa ajuda", + jobsSearchLabel: "Pesquisa", + jobsSearchPlaceholder: "Pesquisar título, tags, descrição...", + jobsMinSalaryLabel: "Salário mínimo", + jobsMaxSalaryLabel: "Salário máximo", + jobsSortLabel: "Ordenar por", + jobsSortRecent: "Mais recentes", + jobsSortSalary: "Maior salário", + jobsSortSubscribers: "Mais candidatos", + jobsSearchButton: "Pesquisa", + jobsFavoriteButton: "Favorito", + jobsUnfavoriteButton: "Remover favorito", + jobsMessageAuthorButton: "MP", + jobsApplicants: "Candidatos", + jobsUpdatedAt: "Atualizado", + jobSetOpen: "Set open", + jobNewBadge: "NOVO", + jobsTagsLabel: "Tags", + jobsTagsPlaceholder: "tag1, tag2, tag3", + noJobsMatch: "Nenhum emprego corresponde à tua pesquisa.", + projectsTitle: "Projetos", + projectsDescription: "Cria, financia e acompanha projetos comunitários na tua rede.", + projectCreateProject: "Criar projeto", + projectCreateButton: "Criar projeto", + projectUpdateButton: "ATUALIZAR", + projectDeleteButton: "ELIMINAR", + projectNoProjectsFound: "Sem projetos encontrados.", + projectFilterAll: "TODOS", + projectFilterMine: "MEUS", + projectFilterActive: "ATIVO", + projectFilterPaused: "PAUSADO", + projectFilterCompleted: "CONCLUÍDO", + projectFilterFollowing: "A SEGUIR", + projectFilterRecent: "RECENTES", + projectFilterTop: "TOP", + projectAllTitle: "Projetos", + projectMineTitle: "Os teus projetos", + projectActiveTitle: "Projetos ativos", + projectPausedTitle: "Projetos pausados", + projectCompletedTitle: "Projetos concluídos", + projectFollowingTitle: "Projetos seguidos", + projectRecentTitle: "Projetos recentes", + projectTopTitle: "Mais financiados", + projectTitlePlaceholder: "Nome do projeto", + projectImage: "Carregar mídia (máx: 50MB)", + projectDescription: "Descrição", + projectDescriptionPlaceholder: "Conta a história e os objetivos…", + projectGoal: "Objetivo (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Prazo", + projectProgress: "Progresso inicial (%)", + projectStatus: "Estado", + projectFunding: "Financiamento", + projectPledged: "Comprometido", + projectSetStatus: "Definir estado", + projectSetProgress: "Atualizar progresso", + projectFollowButton: "SEGUIR", + projectUnfollowButton: "DEIXAR DE SEGUIR", + projectStatusACTIVE: "ATIVO", + projectStatusPAUSED: "PAUSADO", + projectStatusCOMPLETED: "CONCLUÍDO", + projectStatusCANCELLED: "CANCELADO", + projectPledgeTitle: "Apoiar este projeto", + projectPledgePlaceholder: "Montante em ECO", + projectBounties: "Recompensas", + projectBountiesInputLabel: "Recompensas (uma por linha: Título|Montante [ECO]|Descrição)", + projectBountiesPlaceholder: "Corrigir bug UI|100|Link para o problema\nEscrever docs|250|Exemplos de utilização", + projectNoBounties: "Sem recompensas encontradas.", + projectTitle: "Título", + projectAddBountyTitle: "Nova recompensa", + projectBountyTitle: "Título da recompensa", + projectBountyAmount: "Montante (ECO)", + projectBountyDescription: "Descrição", + projectMilestoneSelect: "Selecionar marco", + projectBountyCreateButton: "Criar recompensa", + projectBountyStatus: "Estado da recompensa", + projectBountyOpen: "aberto", + projectBountyClaimed: "reclamado", + projectBountyDone: "concluído", + projectBountyClaimedBy: "reclamado por", + projectBountyClaimButton: "reclamar", + projectBountyCompleteButton: "Marcar como concluído", + projectMilestones: "Marcos", + projectAddMilestoneTitle: "Novo marco", + projectMilestoneTitle: "Título do marco", + projectMilestoneTargetPercent: "Percentagem (%)", + projectMilestoneDueDate: "Data", + projectMilestoneCreateButton: "Criar marco", + projectMilestoneStatus: "Estado do marco", + projectMilestoneOpen: "aberto", + projectMilestoneDone: "concluído", + projectMilestoneDue: "prazo", + projectNoMilestones: "Sem marcos encontrados.", + projectMilestoneMarkDone: "Mark as Done", + projectMilestoneTitlePlaceholder: "Introduz o título do marco", + projectMilestoneDescriptionPlaceholder: "Introduz a descrição para este marco", + projectMilestoneDescription: "Descrição do marco", + projectBudgetGoal: "Orçamento (objetivo)", + projectBudgetAssigned: "Atribuído a recompensas", + projectBudgetRemaining: "Restante", + projectBudgetOver: "Acima do orçamento: atribuído excede o objetivo", + projectFollowers: "Seguidores", + projectFollowersTitle: "Seguidores", + projectFollowersNone: "Ainda sem seguidores.", + projectMore: "mais", + projectYouFollowHint: "Segues este projeto", + projectBackers: "Apoiantes", + projectBackersTitle: "Apoiantes", + projectBackersTotal: "Total de apoiantes", + projectBackersTotalPledged: "Total comprometido", + projectBackersYourPledge: "O teu compromisso", + projectBackersNone: "Ainda sem compromissos.", + projectNoRemainingBudget: "Sem orçamento restante.", + projectFilterBackers: "APOIANTES", + projectFilterApplied: "CANDIDATADO", + projectAppliedTitle: "CANDIDATADO", + projectBackersLeaderboardTitle: "Top apoiantes", + projectNoBackersFound: "Sem apoiantes encontrados.", + projectBackerAmount: "Total contribuído", + projectBackerPledges: "Compromissos", + projectBackerProjects: "Projetos", + projectPledgeAmount: "Montante", + projectSelectMilestoneOrBounty: "Selecionar marco ou recompensa", + projectPledgeButton: "Comprometer", + footerLicense: "GPLv3", + footerPackage: "Pacote", + footerVersion: "Versão", + modulesModuleName: "Nome", + modulesModuleDescription: "Descrição", + modulesModuleStatus: "Estado", + modulesTotalModulesLabel: "Módulos carregados", + modulesEnabledModulesLabel: "Ativado", + modulesDisabledModulesLabel: "Desativado", + modulesPopularLabel: "Destaques", + modulesPopularDescription: "Módulo para receber publicações em tendência, mais vistas ou mais comentadas.", + modulesTopicsLabel: "Tópicos", + modulesTopicsDescription: "Módulo para receber categorias de discussão baseadas em interesses comuns.", + modulesSummariesLabel: "Resumos", + modulesSummariesDescription: "Módulo para receber resumos de discussões ou publicações longas.", + modulesLatestLabel: "Mais recentes", + modulesLatestDescription: "Módulo para receber as publicações e discussões mais recentes.", + modulesThreadsLabel: "Conversas", + modulesThreadsDescription: "Módulo para receber conversas agrupadas por tópico ou questão.", + modulesMultiverseLabel: "Multiverso", + modulesMultiverseDescription: "Módulo para receber conteúdo de outros pares federados.", + modulesInvitesLabel: "Convites", + modulesInvitesDescription: "Módulo para gerir e aplicar códigos de convite.", + modulesWalletLabel: "Carteira", + modulesWalletDescription: "Módulo para gerir os teus ativos digitais (ECOin).", + modulesLegacyLabel: "Chaves", + modulesLegacyDescription: "Módulo para gerir o teu segredo (chave privada) de forma rápida e segura.", + modulesCipherLabel: "Encriptação", + modulesCipherDescription: "Módulo para encriptar e decifrar o teu texto simetricamente (com senha partilhada).", + modulesBookmarksLabel: "Marcadores", + modulesBookmarksDescription: "Módulo para descobrir e gerir marcadores.", + modulesVideosLabel: "Vídeos", + modulesVideosDescription: "Módulo para descobrir e gerir vídeos.", + modulesDocsLabel: "Documentos", + modulesDocsDescription: "Módulo para descobrir e gerir documentos.", + modulesAudiosLabel: "Áudios", + modulesAudiosDescription: "Módulo para descobrir e gerir áudios.", + modulesTagsLabel: "Tags", + modulesTagsDescription: "Módulo para descobrir e explorar padrões taxonómicos (tags).", + modulesImagesLabel: "Imagens", + modulesImagesDescription: "Módulo para descobrir e gerir imagens.", + modulesTrendingLabel: "Tendências", + modulesTrendingDescription: "Módulo para explorar o conteúdo mais popular.", + modulesEventsLabel: "Eventos", + modulesEventsDescription: "Módulo para descobrir e gerir eventos.", + modulesTasksLabel: "Tarefas", + modulesTasksDescription: "Módulo para descobrir e gerir tarefas.", + modulesMarketLabel: "Mercado", + modulesMarketDescription: "Módulo para trocar bens ou serviços.", + modulesShopsLabel: "Lojas", + modulesShopsDescription: "Módulo para gerir e descobrir lojas.", + modulesTribesLabel: "Tribos", + modulesTribesDescription: "Módulo para explorar ou criar tribos (grupos).", + modulesVotationsLabel: "Votações", + modulesVotationsDescription: "Módulo para descobrir e gerir votações.", + modulesReportsLabel: "Relatórios", + modulesReportsDescription: "Módulo para gerir e acompanhar relatórios de problemas, bugs, abusos e avisos de conteúdo.", + modulesOpinionsLabel: "Opiniões", + modulesOpinionsDescription: "Módulo para descobrir e votar em opiniões.", + modulesTransfersLabel: "Transferências", + modulesTransfersDescription: "Módulo para descobrir e gerir contratos inteligentes (transferências).", + modulesFediverseLabel: "Fediverse", + modulesFediverseDescription: "Faça a gestão das suas outras contas do fediverso, incluindo enviar e receber conteúdo.", + modulesFeedLabel: "Feed", + modulesFeedDescription: "Módulo para descobrir e partilhar textos curtos (feeds).", + modulesParliamentLabel: "Parlamento", + modulesParliamentDescription: "Módulo para eleger governos e votar em leis.", + modulesCourtsLabel: "Tribunais", + modulesCourtsDescription: "Módulo para resolver conflitos e emitir veredictos.", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Módulo para desenhar numa grelha colaborativa.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Módulo para gerar e partilhar o \"som\" da tua blockchain.", + modulesAgendaLabel: "Agenda", + modulesAgendaDescription: "Módulo para gerir todos os teus itens atribuídos.", + modulesAILabel: "AI", + modulesAIDescription: "Módulo para conversar com um LLM chamado '42'.", + modulesForumLabel: "Fóruns", + modulesForumDescription: "Módulo para descobrir e gerir fóruns.", + modulesJobsLabel: "Empregos", + modulesJobsDescription: "Módulo para descobrir e gerir empregos.", + modulesProjectsLabel: "Projetos", + modulesProjectsDescription: "Módulo para explorar, financiar coletivamente e gerir projetos.", + modulesBankingLabel: "Banca", + modulesBankingDescription: "Módulo para determinar o valor real do ECOIN e distribuir um UBI usando o tesouro comum.", + modulesFavoritesLabel: "Favoritos", + modulesFavoritesDescription: "Módulo para gerir o teu conteúdo favorito.", + fileTooLargeTitle: "Ficheiro demasiado grande", + fileTooLargeMessage: "O ficheiro excede o tamanho máximo permitido (50 MB). Por favor, seleciona um ficheiro mais pequeno.", + goBack: "Voltar", + errorPageTitle: "Algo correu mal", + visibilityLabel: "Visibilidade", + visibilityPublic: "Pública", + visibilityHidden: "Oculta", + visibilityMakePublic: "Tornar pública", + visibilityMakeHidden: "Ocultar", + invitesInhabitantsTitle: "Habitantes", + invitesInhabitantsFollow: "Seguir", + aiNavPlaceholder: "Para onde queres ir?", + aiNavDisabled: "A navegação por IA está desativada.", + aiNavResultsTitle: "Sugestões de navegação IA", + aiNavQueryLabel: "Consulta", + aiNavResultsDescription: "Escolhe a rota que melhor corresponde ao que procuras.", + aiNavResultPath: "Rota", + aiNavResultDescription: "O que podes fazer", + aiNavResultMatch: "Correspondência", + aiNavResultsEmpty: "Sem rotas correspondentes. Tenta /search.", + aiNavSearchInstead: "Pesquisar em vez disso", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Módulo para consultas em linguagem natural sobre o conteúdo da rede.", + directConnect: "Ligação direta", + directConnectDescription: "Conecta-te diretamente a um par inserindo o endereço IP, porta e chave pública.", + peerHost: "IP", + peerPort: "Porta (predefinida: 8008)", + peerPublicKey: "Chave pública (@...ed25519)", + connectAndFollow: "Conectar", + deviceSourceLabel: "Dispositivo", + modulesPresetTitle: "Configurações Comuns", + modulesPreset_minimal: "Mínimo", + modulesPreset_basic: "Básico", + modulesPreset_social: "Social", + modulesPreset_economy: "Economia", + modulesPreset_full: "Completo", + statsCarbonFootprintTitle: "Pegada de carbono", + statsCarbonFootprintNetwork: "Pegada de carbono da rede", + statsCarbonFootprintYours: "A tua pegada de carbono", + statsCarbonTombstone: "Pegada do tombstoning", + feedSuccessMsg: "Feed publicado com sucesso!", + dominantOpinionLabel: "Opinião dominante", + uploadMedia: "Carregar mídia (máx: 50MB)", + + courtsRespondentInvalid: "Demandado inválido", + feedDetailTitle: "Detalhe do feed", + feedOpenDiscussion: "Abrir discussão", + feedPostComment: "Publicar comentário", + noComments: "Ainda sem comentários.", + mapsLabel: "Mapas", + mapTitle: "Mapas", + mapDescription: "Explore e gerencie mapas offline na sua rede.", + mapMineSectionTitle: "Seus Mapas", + mapCreateSectionTitle: "Criar Mapa", + mapUpdateSectionTitle: "Atualizar Mapa", + mapAllSectionTitle: "Mapas", + mapRecentSectionTitle: "Mapas Recentes", + mapFavoritesSectionTitle: "Favoritos", + mapFilterAll: "TODOS", + mapFilterMine: "MEUS", + mapFilterRecent: "RECENTES", + mapFilterFavorites: "FAVORITOS", + mapUploadButton: "Criar Mapa", + mapCreateButton: "Criar Mapa", + mapUpdateButton: "Atualizar", + mapDeleteButton: "Excluir", + mapAddFavoriteButton: "Adicionar favorito", + mapRemoveFavoriteButton: "Remover favorito", + mapLatLabel: "Latitude", + mapLatPlaceholder: "ex. 38.7223", + mapLngLabel: "Longitude", + mapLngPlaceholder: "ex. -9.1393", + mapDescriptionLabel: "Descrição", + mapDescriptionPlaceholder: "Descreva o mapa ou localização...", + mapTypeLabel: "Tipo de mapa", + mapTypeSingle: "ÚNICO (apenas localização inicial)", + mapTypeOpen: "ABERTO (qualquer um pode adicionar marcadores)", + mapTypeClosed: "FECHADO (apenas o criador adiciona marcadores)", + mapTagsLabel: "Tags", + mapTagsPlaceholder: "Insira tags separadas por vírgulas", + mapUrlLabel: "URL do mapa", + mapPickCoordLabel: "Ou selecione uma localização na grade:", + mapMarkersLabel: "marcadores", + mapMarkersTitle: "Marcadores", + mapMarkerDefault: "Marcador", + mapMarkerLatLabel: "Latitude do marcador", + mapMarkerLngLabel: "Longitude do marcador", + mapMarkerLabelField: "Rótulo do marcador", + mapMarkerLabelPlaceholder: "Descreva este marcador...", + markerImageLabel: "Imagem do Marcador", + mapAddMarkerTitle: "Adicionar Marcador", + mapAddMarkerButton: "Adicionar Marcador", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Aplicar Zoom", + mapSearchPlaceholder: "Buscar descrição, tags, autor...", + mapSearchButton: "Buscar", + mapUpdatedAt: "Atualizado", + mapNoMatch: "Nenhum mapa corresponde à sua busca.", + noMaps: "Nenhum mapa disponível.", + mapLocationTitle: "Localização", + mapVisitLabel: "Visitar mapa", + typeMap: "MAPAS", + typeMapMarker: "MARCADOR DE MAPA", + modulesMapLabel: "Mapas", + modulesMapDescription: "Módulo para gerenciar e compartilhar mapas offline.", + padsTitle: "Pads", + padTitle: "Pad", + modulesPadsLabel: "Pads", + modulesPadsDescription: "Módulo para gerir editores de texto colaborativos.", + padFilterAll: "TODOS", + padFilterMine: "MEU", + padFilterRecent: "RECENTE", + padFilterOpen: "ABERTO", + padFilterClosed: "FECHADO", + padCreate: "Criar Pad", + padUpdate: "Atualizar Pad", + padDelete: "Eliminar Pad", + padTitleLabel: "Título", + padTitlePlaceholder: "Escreva o título do pad...", + padStatusLabel: "Estado", + padStatusOpen: "ABERTO", + padStatusInviteOnly: "APENAS POR CONVITE", + padStatusClosed: "FECHADO", + padDeadlineLabel: "Prazo", + padTagsLabel: "Tags", + padTagsPlaceholder: "tag1, tag2, ...", + padMembersLabel: "Membros", + padVisitPad: "Visitar Pad", + padShareUrl: "Partilhar URL", + padCreated: "Criado", + padAuthor: "Autor", + padGenerateCode: "Gerar Código", + padInviteCodeLabel: "Código de Convite", + padInviteCodePlaceholder: "Introduza o código de convite...", + padValidateInvite: "Validar", + padStartEditing: "COMEÇAR A EDITAR!", + padEditorPlaceholder: "Comece a escrever...", + padSubmitEntry: "Enviar", + padNoEntries: "Sem entradas ainda.", + padAllSectionTitle: "Todos os Pads", + padMineSectionTitle: "Os Meus Pads", + padsDescription: "Gere editores de texto colaborativos cifrados na tua rede.", + padRecentSectionTitle: "Pads Recentes", + padOpenSectionTitle: "Pads Abertos", + padClosedSectionTitle: "Pads Fechados", + padCreateSectionTitle: "Criar Novo Pad", + padUpdateSectionTitle: "Atualizar Pad", + padInviteGenerated: "Código de Convite Gerado", + typePad: "PADS", + padNew: "NOVO", + padAddFavorite: "Adicionar aos Favoritos", + padRemoveFavorite: "Remover dos Favoritos", + padClose: "Fechar Pad", + padBackToEditor: "Voltar ao editor", + padSearchPlaceholder: "Pesquisar pads...", + + modulesChatsLabel: "Chats", + modulesChatsDescription: "Módulo para descobrir e gerir chats cifrados.", + typeChat: "CHATS", + typeChatMessage: "MENSAGEM CHAT", + chatLabel: "CHATS", + chatMessageLabel: "MENSAGENS CHAT", + chatsTitle: "Chats", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "Favoritos", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "Descrição", + chatCategory: "Categoria", + chatStatus: "ESTADO", + chatFilterAll: "TODOS", + chatFilterMine: "MEU", + chatFilterRecent: "RECENTE", + chatFilterFavorites: "FAVORITOS", + chatFilterOpen: "ABERTO", + chatFilterClosed: "FECHADO", + chatCreate: "Criar Chat", + chatUpdate: "Atualizar Chat", + chatDelete: "Eliminar Chat", + chatClose: "Fechar Chat", + chatVisitChat: "VER CHAT", + chatUntitled: "Chat sem título", + chatNoItems: "Nenhum chat encontrado.", + chatParticipants: "Participantes", + chatStartChatting: "COMEÇAR A CONVERSAR!", + chatGenerateCode: "Gerar Código", + chatShareUrl: "Partilhar URL", + chatCreatedAt: "CRIADO", + chatSearchPlaceholder: "Pesquisar chats...", + chatStatusOpen: "ABERTO", + chatStatusInviteOnly: "SÓ POR CONVITE", + chatStatusClosed: "FECHADO", + chatSendMessage: "Enviar", + chatMessagePlaceholder: "Escreva a sua mensagem...", + chatNoMessages: "Sem mensagens ainda.", + chatLeave: "Sair do Chat", + chatInviteCodeLabel: "Introduza o código de convite", + chatJoinByInvite: "Aderir", + chatTitlePlaceholder: "Título do chat", + chatDescriptionPlaceholder: "Descrição do chat", + chatTagsPlaceholder: "tag1, tag2, tag3", + chatImageLabel: "Seleciona um ficheiro de imagem (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Código de Convite", + chatAuthor: "Autor", + chatCreated: "Criado", + chatAddFavorite: "Adicionar aos Favoritos", + chatRemoveFavorite: "Remover dos Favoritos", + chatPM: "MP", + chatStatusLabel: "Estado", + chatCategoryLabel: "Categoria", + chatParticipantsLabel: "Participantes", + gamesTitle: "Jogos", + gamesDescription: "Descubra e jogue alguns jogos.", + gamesFilterAll: "TODOS", + gamesPlayButton: "JOGAR!", + gamesBackToGames: "Voltar aos Jogos", + modulesGamesLabel: "Jogos", + modulesGamesDescription: "Módulo para descobrir e jogar alguns jogos.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Módulo para explorar a rede como um mapa interativo de nós.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Liga PUBs a habitantes através de validadores, lojas e acumuladores. Sobrevive à ameaça CBDC!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Infiltre a grade, colete dados confidenciais, evite os drones de segurança e escape. Quantos níveis consegue passar?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "Pare a invasão alienígena! Destrua ondas de invasores.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Quebre todos os tijolos com sua raquete e bola. Um clássico desafio arcade.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Ping-pong clássico contra uma IA. O primeiro a 5 pontos ganha.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "Corrida contra o tempo! Desvie do tráfego e chegue à meta antes do tempo acabar.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Pilote sua nave por um campo de asteroides mortais. Destrua-os antes que te atinjam.", + gamesRockPaperScissorsTitle: "Pedra Papel Tesoura", + gamesRockPaperScissorsDesc: "Pedra, papel ou tesoura contra uma IA. Melhor de três rodadas vence.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Jogo da Velha clássico contra a IA. Alinhe três para ganhar.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Lance uma moeda e aposte em cara ou coroa. Qual é a sua sorte?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "Calendários", + calendarTitle: "Calendário", + modulesCalendarsLabel: "Calendários", + modulesCalendarsDescription: "Módulo para descobrir e gerir calendários.", + typeCalendar: "CALENDÁRIOS", + calendarFilterAll: "TODOS", + calendarFilterMine: "OS MEUS", + calendarFilterOpen: "ABERTO", + calendarFilterClosed: "FECHADO", + calendarFilterRecent: "RECENTES", + calendarFilterFavorites: "FAVORITOS", + calendarCreate: "Criar calendário", + calendarUpdate: "Atualizar", + calendarDelete: "Eliminar", + calendarTitleLabel: "Título", + calendarTitlePlaceholder: "Título do calendário...", + calendarStatusLabel: "Estado", + calendarStatusOpen: "ABERTO", + calendarStatusClosed: "FECHADO", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Etiquetas", + calendarTagsPlaceholder: "etiqueta1, etiqueta2...", + calendarParticipantsLabel: "Participantes", + calendarParticipantsCount: "Participantes", + calendarVisitCalendar: "Visitar calendário", + calendarCreated: "Criado", + calendarAuthor: "Autor", + calendarJoin: "Participar", + calendarGenerateInvite: "Gerar convite", + calendarInviteCodePlaceholder: "Digite o código de convite...", + calendarValidateInvite: "Validar código", + calendarJoined: "Inscrito", + calendarAddDate: "Adicionar data", + calendarAddNote: "Adicionar nota", + calendarDateLabel: "Data", + calendarDatePlaceholder: "Descrever esta data...", + calendarNoteLabel: "Nota", + calendarNotePlaceholder: "Adicionar uma nota...", + calendarFirstDateLabel: "Data", + calendarFirstNoteLabel: "Notas", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Descrição", + calendarNoDates: "Nenhuma data adicionada.", + calendarNoNotes: "Nenhuma nota.", + calendarsNoItems: "Nenhum calendário encontrado.", + calendarsDescription: "Descubra e gerencie calendários na sua rede.", + calendarMonthPrev: "← Anterior", + calendarMonthNext: "Seguinte →", + calendarMonthLabel: "Datas", + calendarsShareUrl: "URL de partilha", + calendarAllSectionTitle: "Calendários", + calendarRecentSectionTitle: "Calendários Recentes", + calendarFavoritesSectionTitle: "Favoritos", + calendarMineSectionTitle: "Seus Calendários", + calendarOpenSectionTitle: "Calendários abertos", + calendarClosedSectionTitle: "Calendários fechados", + calendarCreateSectionTitle: "Criar novo calendário", + calendarUpdateSectionTitle: "Atualizar calendário", + calendarAddFavorite: "Adicionar aos favoritos", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Remover dos favoritos", + calendarSearchPlaceholder: "Pesquisar calendários...", + calendarAddEntry: "Adicionar Entrada", + calendarLeave: "Sair do Calendário", + statsCalendar: "Calendários", + statsCalendarDate: "Datas de calendário", + statsCalendarNote: "Notas de calendário", + chatAccessDenied: "Você não tem acesso a este chat. Solicite um convite para acessar o conteúdo.", + padAccessDenied: "Você não tem acesso a este pad. Solicite um convite para acessar o conteúdo.", + contentAccessDenied: "Você não tem acesso a este conteúdo.", + blockAccessRestricted: "Acesso restrito", + tribeContentAccessDenied: "Acesso Negado", + tribeContentAccessDeniedMsg: "Este conteúdo pertence a uma tribo. Você deve ser membro para acessá-lo.", + tribeViewTribes: "Ver Tribos", + torrentsTitle: "Torrents", + torrentsDescription: "Explore e gerencie torrents na sua rede.", + torrentAllSectionTitle: "Torrents", + torrentMineSectionTitle: "Seus Torrents", + torrentRecentSectionTitle: "Torrents Recentes", + torrentTopSectionTitle: "Melhores Torrents", + torrentFavoritesSectionTitle: "Favoritos", + torrentFilterAll: "TODOS", + torrentFilterMine: "MEUS", + torrentFilterRecent: "RECENTES", + torrentFilterTop: "MELHORES", + torrentFilterFavorites: "FAVORITOS", + torrentCreateSectionTitle: "Enviar Torrent", + torrentUpdateSectionTitle: "Atualizar Torrent", + torrentCreateButton: "Enviar Torrent", + torrentUpdateButton: "Atualizar", + torrentDeleteButton: "Excluir", + torrentAddFavoriteButton: "Adicionar aos Favoritos", + torrentRemoveFavoriteButton: "Remover dos Favoritos", + torrentFileLabel: "Selecionar arquivo torrent (.torrent)", + torrentTitleLabel: "Título", + torrentTitlePlaceholder: "Título", + torrentDescriptionLabel: "Descrição", + torrentDescriptionPlaceholder: "Descrição", + torrentTagsLabel: "Tags", + torrentTagsPlaceholder: "Insira tags separadas por vírgulas", + torrentSizeLabel: "Tamanho", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "Nenhum arquivo torrent", + noTorrents: "Nenhum torrent encontrado.", + torrentSearchPlaceholder: "Pesquisar título, tags, autor...", + torrentSearchButton: "Pesquisar", + torrentSortRecent: "Mais recentes", + torrentSortOldest: "Mais antigos", + torrentSortTop: "Mais votados", + torrentNoMatch: "Nenhum torrent correspondente.", + torrentMessageAuthorButton: "Enviar Mensagem ao Autor", + torrentUpdatedAt: "Atualizado", + statsTorrent: "Torrents", + typeTorrent: "TORRENTS", + modulesTorrentsLabel: "Torrents", + modulesTorrentsDescription: "Módulo para descobrir e gerenciar torrents.", + favoritesFilterTorrents: "TORRENTS", + favoritesFilterMarket: "MERCADO", + favoritesFilterShopProducts: "ITENS DA LOJA", + tribeSectionTorrents: "TORRENTS", + tribeCreateTorrent: "Enviar Torrent", + tribeMediaTypeTorrent: "Torrent", + settingsWishTitle: "Desejo", + settingsWishDesc: "Configure o desejo do seu Avatar.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Mensagens Privadas", + settingsPmVisibilityDesc: "Configure seu nível de exposição a mensagens privadas.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "O envio é uma barreira de UX. O recebimento é um filtro de atenção.", + pmBlockedNonMutual: "Só pode enviar MPs a habitantes em apoio mútuo.", + inhabitantsPendingFollowsTitle: "Pedidos de apoio pendentes", + inhabitantsPendingAccept: "Aceitar", + inhabitantsPendingReject: "Rejeitar", + bxEncrypted: "CRIPTOGRAFADO", + bxEncryptedHexLabel: "Texto cifrado (pré-visualização)", + tribeSectionGovernance: "GOVERNANÇA", + tribeSubStatusPublic: "PÚBLICA", + tribeSubStatusPrivate: "PRIVADA", + tribeSubInheritedPrivate: "PRIVADA (herdada da tribo principal)", + tribePadInviteRequired: "Sem acesso ao pad. Peça um convite.", + tribeChatInviteRequired: "Sem acesso ao chat. Peça um convite.", + tribeGovernanceDesc: "Governança interna desta tribo.", + tribeGovernanceNoGov: "Sem governo ativo", + tribeGovernanceNoGovDesc: "Esta tribo ainda não elegeu governo.", + tribeGovCardTitle: "Governo Atual", + tribeGovCycleSince: "INÍCIO DO CICLO", + tribeGovCycleEnd: "FIM DO CICLO", + tribeGovTimeRemaining: "TEMPO RESTANTE", + tribeGovPopulation: "POPULAÇÃO", + tribeGovMethod: "MÉTODO", + tribeGovVotesReceived: "VOTOS RECEBIDOS", + tribeGovLeader: "LÍDER", + tribeGovFilterGovernment: "GOVERNO", + tribeGovFilterCandidatures: "CANDIDATURAS", + tribeGovFilterLaws: "LEIS", + tribeGovCandidatureId: "Candidatura", + tribeGovCandidatureMethod: "Método", + tribeGovCandidatureProposeBtn: "Publicar candidatura", + tribeGovRuleTitle: "Título da regra", + tribeGovRuleBody: "Corpo da regra", + tribeGovProposals: "PROPOSTAS", + tribeGovRevocations: "REVOGAÇÕES", + tribeGovHistorical: "HISTÓRICO", + tribeGovRules: "REGRAS", + tribeGovernanceAlreadyPublished: "Esta tribo já tem candidatura aberta.", + tribeGovernanceProposeInternal: "Propor candidatura interna", + tribeGovernanceInternalCandidatures: "Candidaturas internas", + tribeGovernanceNoCandidatures: "Sem candidaturas abertas.", + tribeGovernanceAddRule: "Adicionar regra", + tribeGovernanceNoRules: "Sem regras.", + tribeGovernanceNoLeaders: "Sem líderes eleitos.", + tribeGovernanceComingSoon: "Em breve no módulo de governança.", + tribeGovNoProposals: "Ainda nenhuma proposta", + tribeGovNoLaws: "Ainda nenhuma lei", + tribeGovNoRevocations: "Ainda nenhuma revogação", + tribeGovNoHistorical: "Ainda nenhum registo", + logsTitle: "Diário", + logsDescription: "Regista a sua experiência na rede.", + logsReadMore: "Ler mais", + logsViewTitle: "Diário", + logsColumnType: "Tipo", + logsView: "Ver", + logsManualPrompt: "Escreva o seu diário", + logsUpdateButton: "Atualizar", + logsEditTitle: "Editar entrada", + logsCreateDescription: "Regista a sua experiência...", + logsCreateTitle: "Criar entrada", + logsWriteButton: "Escrever", + logsTextPlaceholder: "Descreva as suas experiências...", + logsTextField: "Texto", + logsLabelPlaceholder: "Rótulo...", + logsLabelField: "Escreva o seu diário (rótulo)", + logsAiDisabledWarn: "Ative o módulo de IA em /modules para usar entradas escritas pela IA.", + logsAiContextValue: "Dia de blockchain", + logsAiContext: "Contexto", + logsAiModStatus: "AImod", + logsModeApply: "Aplicar!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Manual", + logsModeAI: "IA", + logsColumnDelete: "Eliminar", + logsColumnEdit: "Editar", + logsColumnLog: "Diário", + logsColumnDate: "Data", + logsDelete: "Eliminar", + logsEdit: "Editar", + logsFilterAlways: "SEMPRE", + logsFilterYear: "ÚLTIMO ANO", + logsFilterMonth: "ÚLTIMO MÊS", + logsFilterWeek: "ÚLTIMA SEMANA", + logsFilterToday: "HOJE", + logsFilterRecent: "RECENTES", + logsFilterAll: "TODOS", + logsCreate: "Criar entrada", + logsExport: "Exportar diário", + logsExportOne: "Exportar", + logsViewDetails: "Ver detalhes", + logsGenerateButton: "Gerar texto", + logsSearchText: "Procurar nos diários...", + logsSearchAnyType: "Qualquer tipo", + logsSearchButton: "Procurar", + logsEmpty: "Sem entradas.", + modulesLogsLabel: "Diário", + modulesLogsDescription: "Módulo para registar (via assistente de IA) as suas experiências.", + statsLogsTitle: "Diário", + statsLogsEntries: "Entradas", + typeLog: "DIÁRIO", + blockchainCycle: "Ciclo", + + larpTitle: "L.A.R.P.", + larpDescription: "Uma camada de jogo de interpretação ao vivo para experimentação colaborativa.", + larpAllHouses: "Casas:", + larpFilterRuling: "GOVERNA", + larpFilterHouses: "CASAS", + larpFilterRules: "FAQ", + larpRulesTitle: "FAQ do L.A.R.P.", + larpRulesEntryTitle: "Casa inicial", + larpRulesEntryText: "Cada habitante começa em ACADEMIA por defeito. A partir de ACADEMIA, escolhe uma casa no painel \"Entrar numa casa\": isto abre o seu teste de entrada. Responde às 10 perguntas e submete. Se passares o limiar (7/10), és automaticamente admitido nessa casa; caso contrário, és rejeitado e permaneces em ACADEMIA. Em qualquer caso o sistema regista o teu OasisID e o ciclo da tentativa e impede nova tentativa até decorrer o período de espera de 30 ciclos.", + larpRulesLeaveText: "Os membros de qualquer casa podem regressar a ACADEMIA a qualquer momento a partir da página da sua casa; isso repõe a sua participação, mas não anula o período de espera do teste.", + larpRulesGovernanceTitle: "Mural de governança", + larpRulesGovernanceText: "Durante o seu ciclo, a casa governante exibe a insígnia \"Governa\" e é a única cujo Mural é exibido publicamente no separador GOVERNA.", + larpRulesSignTitle: "Emblema L.A.R.P.", + larpRulesSignText: "Os habitantes podem ativá-lo (em /profile/edit > Sensores) para mostrar a imagem da sua casa atual como um selo no perfil, no cartão de autor e de habitante. O selo liga à página da casa.", + larpPostsTitle: "Mural", + larpPostsEmpty: "Ainda sem publicações.", + larpPostLabel: "Nova publicação", + larpPostPlaceholder: "O que tem esta casa a dizer?", + larpPostPublic: "Público (visível para qualquer pessoa, senão só para membros)", + larpPostSubmit: "Publicar", + larpPostMembersOnly: "Apenas membros", + larpCycleLabel: "Ciclo:", + audioTranscodeCompositionEmpty: "This audio does not include a stored blockchain composition.", + audioTranscodeDetailDescription: "Decode the embedded payload and the original blockchain composition map.", + audioTranscodeStegoNotFound: "No steganographic payload could be decoded from this audio.", + bankRulesAlpha: "Alpha (max share of pub balance per epoch)", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesArchTaxRate: "Rate", + bankRulesCapPerEpoch: "Cap per epoch (absolute)", + bankRulesCapUser: "Cap per user per epoch", + bankRulesCarbonFactor: "Carbon factor", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesEcoTaxRate: "Rate", + bankRulesEpochKind: "Epoch granularity", + bankRulesFloor: "Floor per user (gross)", + bankRulesGraceDays: "Claim grace period", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesReserveMin: "Reserve minimum (kept in pub balance)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankRulesWMax: "Maximum weight (w_max)", + bankRulesWMin: "Minimum weight (w_min)", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + typeGameScore: "GAME SCORES", + bankTaxesLookupNote: "Introduz um ID de bloco para o procurar no teu feed local.", + bankTaxesUserNoteChipsClick: "Clica em qualquer chip para inspecionar o seu bloco no blockexplorer.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "Código de convite", + larpRulesInviteEntryText: "Os membros das casas podem gerar códigos de convite que dão acesso direto à casa.", + larpRulesOneHouseText: "Só podes pertencer a uma casa de cada vez. Se não pertenceres a nenhuma, estás em ACADEMIA. Cada página de casa (não ACADEMIA) mostra um botão \"Sair da Casa\" aos seus membros que reinicia a pertença a ACADEMIA. Sair NÃO cancela o tempo de espera do teste.", + larpRulesOneHouseTitle: "Uma casa de cada vez", + larpRulesTestEntry: "Teste WILL", + larpRulesTestEntryText: "Cada opção pondera uma ou mais casas; ao submeter, o sistema soma os pontos e admite-te automaticamente na casa com a maior pontuação.", + larpWillTestHint: "Uma vez concluído, ser-te-á atribuída a casa que melhor corresponde às tuas respostas. As tuas respostas individuais são processadas localmente e nunca armazenadas — apenas a atribuição de casa resultante é publicada no teu feed.", + larpWillTestTitle: "Teste WILL", + settingsLanDesc: "Anuncia periodicamente este peer a outras instâncias do Oasis na mesma rede local. Desativar para parar as difusões UDP.", + settingsLanEnable: "Ativar difusão LAN", + settingsLanTitle: "Difusão LAN", + settingsReplicationTitle: "Replicação", + settingsReplicationDesc: "Configura o número de saltos que o teu peer segue a partir do teu próprio feed ao replicar conteúdo.", + settingsReplicationHopsLabel: "Saltos", + aiApproveTagsLabel: "Etiquetas (separadas por vírgulas)", + aiApproveTagsPlaceholder: "p.ex. oasis, governança, ecologia", + aiApproveRatingLabel: "Avaliação", + aiExchangeLang: "Idioma", + aiExchangeTags: "Etiquetas", + aiExchangeRating: "Avaliação", + aiExchangeHelpful: "Útil", + aiExchangeMarkHelpful: "+1 útil", + larpCyclesUntilRuling: "Próximo ciclo de governança", + larpVisitTribe: "Visitar tribo", + larpGoverning: "Governa:", + larpMyHouse: "A minha Casa:", + larpMembersCount: "Membros", + larpMembersTitle: "Membros", + larpNoMembers: "Ainda sem membros.", + larpRolesLabel: "Funções", + larpFunctionLabel: "Função", + larpMonthLabel: "Ciclo de governança", + larpLeaveToAcademia: "Voltar a ACADEMIA", + larpAcademiaJoinTitle: "Entrar numa casa", + larpAcademiaJoinHint: "Faz o teste psicológico de perfil para que te seja atribuída a casa que melhor encaixa contigo, ou usa um código de convite partilhado por um membro de uma casa.", + larpTakeTestButton: "Fazer o teste de perfil", + larpInviteRedeem: "Entrar na Casa", + larpInviteCreate: "Gerar código de convite", + larpInvitePlaceholder: "Código de convite", + larpInviteBannerTitle: "Novo código de convite", + larpInviteBannerHint: "Partilha este código com alguém em ACADEMIA. Expira em 30 ciclos ou após um único uso.", + larpTestAssignedHouse: "Casa atribuída", + larpTestRankingTitle: "Pontuação por casa", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "Código de convite de casa", + invitesHouseJoinButton: "Entrar na Casa", + ecoTaxLabel: "ECO Tax", + ecoinTaxLabel: "ECOin Tax", + bankTaxes: "Impostos", + bankOverviewYourTaxes: "Os teus impostos", + bankTaxesNetworkTitle: "Impostos da rede", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesUserTitle: "Os teus impostos", + bankTaxesUserAmount: "A tua ECO Tax (preço a devolver)", + bankTaxesArchTaxUserAmount: "A tua ARCH Tax (preço a devolver)", + bankTaxesArchTaxFirstBlock: "Idade do teu primeiro bloco", + bankTaxesArchTaxRate: "Taxa", + bankTaxesTotalBlocks: "Blocos totais", + bankTaxesTotalBytes: "Bytes totais", + bankTaxesTotalCarbon: "Carbono total", + bankTaxesTotalEcoin: "ECO Tax total (vida)", + bankTaxesEcoTaxLifetime: "ECO Tax (vida)", + bankTaxesAnnualEcoin: "ECO Tax (anual)", + bankTaxesMonthlyEcoin: "ECO Tax (mensal)", + bankTaxesArchTaxLifetime: "ARCH Tax (vida)", + bankTaxesArchTaxAnnual: "ARCH Tax (anual)", + bankTaxesArchTaxMonthly: "ARCH Tax (mensal)", + bankTaxesTotalLifetime: "Total (vida)", + bankTaxesTotalAnnual: "Total (anual)", + bankTaxesTotalMonthly: "Total (mensal)", + bankTaxesRate: "Taxa", + bankTaxesSpan: "Período de amostragem", + bankTaxesLookupTitle: "Inspecionar um bloco", + bankTaxesLookupPlaceholder: "ID do bloco (ex. %abc...=.sha256)", + bankTaxesLookupButton: "Inspecionar", + bankTaxesLookupNotFound: "Nenhum bloco encontrado no teu feed local com esse ID.", + bankTaxesLookupAuthor: "Autor", + bankTaxesLookupType: "Tipo", + bankTaxesLookupSize: "Tamanho", + bankTaxesLookupCarbon: "Pegada de carbono", + bankTaxesLookupEcoin: "ECO Tax", + bankExchangeEcoTaxAnnual: "ECOin Taxes (anual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (mensal)", + bankTaxesTypesLabel: "Seleciona que impostos queres pagar", + bankTaxesTypesApply: "Definir os meus impostos", + bankRulesPoolTitle: "Pool mensal", + bankRulesShareTitle: "Parcela por utilizador", + bankRulesKarmaTitle: "Karma (pontuação de envolvimento)", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesChipTitle: "Faixa de cor do chip ECO Tax", + statsEcoTaxTitle: "ECO Tax", + statsTombstoneEmpty: "Ainda sem tombstones na rede.", + blockchainBlockNotAvailable: "Este bloco não está disponível no teu feed local. Pode pertencer a um peer do qual ainda não replicas.", + blockchainBackToBlockexplorer: "Voltar ao blockexplorer", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDetailTitle: "Transcodificar", + audioTranscodeStegoOasisId: "Por", + audioTranscodeStegoTimestamp: "Gerado em", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoEmpty: "(nenhum)", + audioTranscodeStegoUnknown: "—", + audioBackToAudio: "Voltar ao áudio", + audioBackToBcs: "Voltar ao BCS", + audioAuthorLabel: "Autor", + melodyCompositionTitle: "Mapa de composição da blockchain", + melodyFilterMine: "MEU", + melodyByLabel: "Por", + melodyAllEmpty: "Ainda sem composições BCS de outros habitantes.", + melodyNoteUnavailable: "o bloco não está no teu feed local", + melodyUploadBcsTitle: "Publicar", + melodyUploadBcsButton: "Publicar", + melodyUploadStegoLabel: "Mensagem oculta (esteganografia)", + melodyUploadStegoMaxLabel: "máx. 280 caracteres", + melodyUploadStegoPlaceholder: "Opcional.", + melodyUploadBcsNameNote: "O áudio será publicado com o nome auto-gerado", + larpLastAttemptTitle: "A tua última tentativa", + larpLastAttemptHouse: "Casa", + larpLastAttemptResult: "Resultado", + larpLastAttemptWhen: "Quando", + larpLastAttemptCooldown: "Próxima tentativa em", + larpTestTitle: "Teste de entrada", + larpTestIntro: "Responde às 10 perguntas. Precisas de pelo menos 7 respostas corretas para seres admitido. Podes tentar um teste a cada 30 ciclos, independentemente do resultado.", + larpTestSubmit: "Juntar-se à casa", + larpTestCooldownActive: "Próximo teste disponível em", + larpTestCooldownDays: "ciclos", + larpTestResultTitle: "Resultado do teste", + larpTestPassed: "Teste passado — bem-vindo!", + larpTestFailed: "Teste não passado", + larpTestScore: "Pontuação", + larpTestNextAttempt: "Podes tentar outro teste em 30 ciclos.", + larpGoToHouse: "Ir para a tua casa", + larpBackToAcademia: "Voltar a ACADEMIA", + larpBackToHouses: "◀ Casas", + larpBadgeYou: "Tu", + larpBadgeRuling: "Governa", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Módulo para a camada de jogo de interpretação ao vivo do Oasis (as 9 Casas).", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + audioTranscodeStegoTitle: "Embedded in the waveform", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "Diante de um problema complexo, o que fazes primeiro?", + larpProfileQ1O1: "Falar com outras pessoas para procurar consenso", + larpProfileQ1O2: "Construir um protótipo para testar", + larpProfileQ1O3: "Investigar a literatura", + larpProfileQ1O4: "Sabotar o sistema que o provoca", + larpProfileQ2: "O que dá sentido ao teu trabalho diário?", + larpProfileQ2O1: "Defender as pessoas que amo", + larpProfileQ2O2: "Criar algo tangível", + larpProfileQ2O3: "Curar ou cuidar da vida", + larpProfileQ2O4: "Trabalhar com palavras e ideias", + larpProfileQ2O5: "Fazer os outros rir", + larpProfileQ3: "Quando surge um conflito no teu grupo, tu…", + larpProfileQ3O1: "Lideras o diálogo", + larpProfileQ3O2: "Tomas partido e manténs firme", + larpProfileQ3O3: "Soltas uma piada para aliviar", + larpProfileQ3O4: "Questionas se o conflito é real", + larpProfileQ3O5: "Procuras as causas ecológicas de raiz", + larpProfileQ4: "O teu projeto favorito a longo prazo seria…", + larpProfileQ4O1: "Construir uma cidade", + larpProfileQ4O2: "Restaurar uma floresta", + larpProfileQ4O3: "Redigir uma constituição", + larpProfileQ4O4: "Organizar um festival", + larpProfileQ4O5: "Montar uma rede de defesa", + larpProfileQ4O6: "Desenhar uma máquina nova", + larpProfileQ4O7: "Curar um arquivo", + larpProfileQ4O8: "Desmontar uma ordem injusta", + larpProfileQ5: "O que faz uma boa líder?", + larpProfileQ5O1: "Alguém que ouve e medeia", + larpProfileQ5O2: "Alguém capaz de lutar e proteger", + larpProfileQ5O3: "Alguém que conhece a história", + larpProfileQ5O4: "Alguém que te faz sorrir", + larpProfileQ6: "A tua relação com as regras:", + larpProfileQ6O1: "As regras surgem do diálogo e da lei", + larpProfileQ6O2: "As regras devem ser cumpridas estritamente", + larpProfileQ6O3: "As regras devem ser quebradas com frequência", + larpProfileQ6O4: "As regras devem servir a vida", + larpProfileQ6O5: "As regras erguem infraestruturas sólidas", + larpProfileQ7: "Como lidas com a informação?", + larpProfileQ7O1: "Curo-a e preservo-a", + larpProfileQ7O2: "Partilho-a através de histórias", + larpProfileQ7O3: "Questiono as suas origens", + larpProfileQ7O4: "Extraio o útil", + larpProfileQ7O5: "Uso-a para curar", + larpProfileQ8: "A tua ideia de sucesso é…", + larpProfileQ8O1: "Uma máquina que funciona", + larpProfileQ8O2: "Uma comunidade em paz", + larpProfileQ8O3: "Um festival animado", + larpProfileQ8O4: "Uma família segura", + larpProfileQ8O5: "Um arquivo intacto", + larpProfileQ8O6: "Um dogma fissurado", + larpProfileQ8O7: "Uma colheita próspera", + larpProfileQ8O8: "Um edifício terminado", + larpProfileQ9: "Ao acordar, queres…", + larpProfileQ9O1: "Treinar o corpo", + larpProfileQ9O2: "Ler ou escrever", + larpProfileQ9O3: "Cultivar ou cozinhar", + larpProfileQ9O4: "Mexer com alguma coisa", + larpProfileQ9O5: "Questionar a autoridade", + larpProfileQ9O6: "Planificar um projeto", + larpProfileQ9O7: "Falar com as amigas", + larpProfileQ9O8: "Fazer arte", + larpProfileQ10: "A tua fraqueza pode ser:", + larpProfileQ10O1: "Falar demais", + larpProfileQ10O2: "Ser demasiado pragmática", + larpProfileQ10O3: "Ser demasiado idealista", + larpProfileQ10O4: "Ser demasiado disruptiva", + larpProfileQ10O5: "Ser demasiado rígida", + larpProfileQ10O6: "Ser demasiado despreocupada", + larpProfileQ10O7: "Ser demasiado cautelosa", + larpProfileQ10O8: "Ser demasiado ambiciosa", + uxModeTitle: "UX", + uxModeDescription: "Seleciona o modo de navegação UX para a tua interface.", + uxModeMenus: "Blocos", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_ru.js b/src/client/assets/translations/oasis_ru.js new file mode 100644 index 0000000..58f2059 --- /dev/null +++ b/src/client/assets/translations/oasis_ru.js @@ -0,0 +1,3872 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + ru: { + languageName: "Русский", + shopOrderStatusPaid: "Оплата получена", + shopOrderStatusReceived: "Получено", + shopOrderMarkPaid: "Отметить оплату полученной", + shopOrderConfirmReceived: "Подтвердить получение", + shopMyOrdersTitle: "Мои заказы", + shopPurchasesButton: "Покупки", + shopMyOrdersDescription: "Ваши заказы.", + shopMyOrdersEmpty: "У вас пока нет покупок.", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "Продавец", + shopOrderPaymentConcept: "Оплата", + shopOrderInvoice: "Счёт", + shopOrderInvoiceConcept: "Счёт", + shopOrderStatusPending: "Ожидает", + shopOrderStatusAccepted: "Принят", + shopOrderStatusRejected: "Отклонён", + shopOrderStatusShipped: "Отправлен", + shopOrderStatusDelivered: "Доставлен", + shopOrderAccept: "Принять", + shopOrderReject: "Отклонить", + shopOrderMarkShipped: "Отметить отправленным", + shopOrderMarkDelivered: "Отметить доставленным", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "Смарт-контракт", + shopOrderContractConcept: "Заказ магазина", + shopOrdersPending: "Ожидающие заказы", + all: "Все", + mine: "Мои", + recent: "Недавние", + anonymous: "Аноним", + unnamed: "Аноним", + authorLabel: "АВТОР", + delete: "Удалить", + save: "Сохранить", + vote: "Голосовать", + url: "URL", + price: "Цена", + priority: "Приоритет", + severity: "Серьёзность", + organizer: "Организатор", + edited: "изменено", + isPublic: "Публичный", + searchIsPublicLabel: "Публичный", + privacyPrivate: "ПРИВАТНЫЙ", + privacyPublic: "ПУБЛИЧНЫЙ", + notFound: "Не найдено", + no_results: "Нет данных", + searchButton: "Поиск", + mapZoomLabel: "Масштаб", + modulesTitle: "Модули", + viewJson: "Просмотр JSON", + matchScore: "Совпадение", + preferencesLabel: "Предпочтения", + inhabitantProfileTitle: "Профиль жителя", + contentWarningLabel: "Предупреждение о контенте", + invalidPost: "Недопустимое содержимое", + invalidPostHint: "В этом сообщении пустой/недопустимый текст.", + spreadBy: "От", + spreadChron: "Распространение", + spreaded: "Распространено", + spreadMore: "ещё", + spreadContentUnavailable: "Контент пока недоступен (ожидается репликация)", + filteredByTag: "Отфильтровано по тегу", + filteredByTagTitle: "Отфильтровано по тегу", + feedPublishedSuccess: "Лента успешно опубликована!", + tribeFeedSent: "Сообщение успешно отправлено!", + tribeContentEncrypted: "Зашифрованное содержимое", + tribeTorrentsEmpty: "Торрентов пока нет.", + torrentDescription: "Описание", + torrentDownload: "Скачать", + chatInviteMode: "Пригласить", + padInviteMode: "Пригласить", + noInviteMode: "Без режима приглашения", + noDeadline: "Без срока", + noStatus: "Без статуса", + noSeverity: "Без серьёзности", + calendarDeleteDate: "Удалить дату", + calendarIntervalUntil: "До", + bookmarkCategory: "Категория", + bookmarkLastVisit: "Последний визит", + profileClearnetBookmarksLabel: "Закладки", + forumFilterHot: "Популярные", + invitesPort: "Порт", + currentlyUnrecheable: "ОШИБКА!", + peerHostValidation: "Корректный IPv4 (напр. 192.168.1.100) или имя хоста (напр. pub.example.com)", + peerPortValidation: "Порт 1-65535", + peerKeyValidation: "Открытый ключ SSB ed25519 (@<44 символа base64>=.ed25519)", + inboxJobUnsubscribedTitle: "Отписка от вашей вакансии", + inboxProjectFollowedTitle: "Новый подписчик вашего проекта", + inboxProjectUnfollowedTitle: "Отписался от вашего проекта", + pmHasFollowedYourProject: "подписался на ваш проект", + pmHasUnfollowedYourProject: "отписался от вашего проекта", + pmHasUnsubscribedFromYourJobOffer: "отписался от вашей вакансии", + marketDeleteButton: "Удалить", + marketNoBids: "Ставок пока нет", + marketAuctionBidAmount: "Сумма ставки", + marketAuctionBidTime: "Время ставки", + marketAuctionUser: "Участник", + parliamentActorInPower: "ПРАВЯЩЕЕ ПЛЕМЯ", + parliamentWinningCandidature: "Победившая кандидатура", + projectBounty: "Награда", + projectFollowing: "ОТСЛЕЖИВАЕТСЯ", + projectBackerAuthor: "Спонсор", + projectBackerDate: "Дата", + projectConfirmTransferButton: "Подтвердить перевод", + projectPendingTransfersTitle: "Ожидающие переводы", + transfersUpdateSectionTitle: "Обновить перевод", + taskUnassignedFrom: "Снято назначение с", + taskDescriptionPlaceholder: "Описание задачи", + exportPasswordLabel: "Пароль экспорта", + importPasswordPlaceholder: "Введите пароль", + searchCasesPlaceholder: "Поиск дел…", + shopBuyDeliveryAddressPlaceholder: "Адрес доставки", + shopBuyNotesPlaceholder: "Заметки", + bankTaxesUserNoteBold: "прямое снижение ECO-налога", + bankTaxesUserNoteIntro: "Ваш ECO-налог вычитается из излишка, который сеть генерирует сверх вашего БОД; базовое значение БОД установлено как неизменный минимум. Налог никогда не вычитается из фиксированной суммы БОД каждого жителя. Удержанные средства питают алгоритм перераспределения и возвращаются другим жителям через их заявки на БОД. Те жители, которые получают больше БОД на свои проекты, вероятно, будут иметь задачу, помогающую снизить ваш ECO-налог. Или они могут посвятить себя ", + bankTaxesUserNoteOutro: ", и их постоянный вклад может потребоваться по консенсусу сети.", + courtsViewDetails: "Просмотр", + courtsViewDetailsShort: "Подробности", + courtsThDetails: "Подробности", + courtsDetailsAnswersTitle: "Ответы", + courtsDetailsEvidenceTitle: "Доказательства", + courtsDetailsSettlementsTitle: "Соглашения", + courtsDetailsVerdictTitle: "Вердикт", + courtsDetailsNotPublic: "Не публично", + courtsEvidenceSideAccuser: "Доказательства обвинителя", + courtsEvidenceSideRespondent: "Доказательства защиты", + courtsEvidenceSideUnknown: "Неизвестно", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "Поддержать дело", + courtsSettlementAcceptBtn: "Принять соглашение", + courtsSupportCount: "Поддержавшие", + courtsVerdictVoteTitle: "Голосование по вердикту", + courtsVerdictVoteLabel: "Голос по вердикту", + courtsVerdictVoteAccept: "Принять", + courtsVerdictVoteReject: "Отклонить", + courtsVerdictVoteSubmit: "Отправить голос", + reportsSetStatus: "Установить статус", + reportsStatusClosed: "ЗАКРЫТО", + statsAvgPerInhabitant: "Среднее на жителя", + statsCarbonMaxAnnual: "Годовая макс. оценка", + statsCarbonNetwork: "Всего по сети", + statsCarbonOfEstMax: "от оценочной макс. ёмкости", + statsCarbonOfNetwork: "от общего по сети", + statsCarbonUser: "Ваш след", + statsContentCountColumn: "Количество", + statsContentTypeColumn: "Тип", + statsMsgsPerDay: "Сообщений/день (за всё время)", + statsMyShare: "Ваша доля в сети", + statsNetworkSpan: "Охват сети", + statsTombstoneRatioLabel: "Доля удалённых", + statsTopTagsTitle: "Топ тегов", + statsTopTypesTitle: "Топ типов контента", + statsTotalMsgs: "Всего сообщений", + extended: "Мультивселенная", + extendedDescription: [ + "Когда вы поддерживаете кого-то, вы можете загружать записи жителей, которых они поддерживают, и эти записи отображаются здесь в обратном хронологическом порядке.", + ], + popular: "Избранное", + popularDescription: [ + "Записи жителей вашей сети, ", + strong("отсортированные по распространениям"), + ". Выберите период времени для получения списка.", + ], + day: "ДЕНЬ", + week: "НЕДЕЛЯ", + month: "МЕСЯЦ", + year: "ГОД", + latest: "Последние", + latestDescription: [ + strong("Записи"), + " от вас и жителей, которых вы поддерживаете, в обратном хронологическом порядке.", + ], + topics: "Темы", + topicsDescription: [ + strong("Темы"), + " от вас и жителей, которых вы поддерживаете, в обратном хронологическом порядке.", + ], + summaries: "Сводки", + summariesDescription: [ + strong("Темы с комментариями"), + " от вас и жителей, которых вы поддерживаете, в обратном хронологическом порядке.", + ], + threads: "Обсуждения", + threadsDescription: [ + strong("Записи с комментариями"), + " от жителей, которых вы поддерживаете (включая мультивселенную), в обратном хронологическом порядке.", + ], + profile: "Аватар", + inhabitants: "Жители", + peersReplicatedFeeds: "Реплицированные ленты", + graphos: "Графос", + graphosDescription: "Интерактивная карта сети вокруг вас.", + graphosViewingGraphOf: "Просмотр", + graphosBackToMine: "← Моя сеть", + graphosShowing: "Показано", + graphosYou: "Вы", + graphosTotalNodes: "Всего узлов", + manualMode: "Ручной режим", + mentions: "Упоминания", + mentionsDescription: [ + strong("Записи, в которых @упоминают вас"), + ", в обратном хронологическом порядке.", + ], + nextPage: "Далее", + previousPage: "Назад", + noMentions: "Вы ещё не получали @упоминаний.", + private: "Входящие", + privateInbox: "ВХОДЯЩИЕ", + privateReminders: "НАПОМИНАНИЯ", + inboxFiltersLabel: "Фильтры:", + inboxToggleToMutuals: "Переключить на взаимных", + inboxToggleToWhole: "Переключить на всех", + privateSent: "ОТПРАВЛЕННЫЕ", + privateFrom: "От", + privateTo: "Кому", + privateDate: "Дата", + privateDelete: "Удалить", + pmCreateButton: "Написать ЛС", + pmReply: "Ответить", + pmReplies: "ответы", + pmNew: "новое", + pmMarkRead: "Отметить как прочитанное", + inReplyTo: "В ОТВЕТ НА", + pmPreview: "Предпросмотр", + pmPreviewTitle: "Предпросмотр сообщения", + noPrivateMessages: "Вы ещё не получали личных сообщений.", + peers: "Узлы", + privateDescription: ["Личные сообщения ",strong("зашифрованы вашим открытым ключом")," и могут иметь максимум 7 получателей."], + search: "Поиск", + searchDescription: "Описание", + imageSearch: "Поиск изображений", + searchFromLabel: "С", + searchToLabel: "До", + searchMinOpinionsLabel: "Мин. мнений", + searchInhabitantLabel: "Житель (Oasis ID)", + searchQueryLabel: "Запрос", + searchPerPageLabel: "Результатов на странице", + settings: "Настройки", + continueReading: "Продолжить чтение", + moreComments: "ещё комментарий", + readThread: "прочитать остальное обсуждение", + pixeliaTitle: 'Pixelia', + pixeliaDescription: 'Рисуйте пиксели на сетке и сотрудничайте с другими в вашей сети.', + melodyTitle: 'Melody', + melodyDescription: 'Воспроизведите мелодию вашего блокчейна — каждый блок становится нотой.', + melodyEmpty: 'Ещё нет нот — ваш блокчейн пока не создал ни одного блока.', + melodyCompositionTitle: 'Композиция', + melodyCompositionHelp: 'Каждый блок вашего блокчейна становится музыкальной нотой по типу и размеру.', + melodyStatsTitle: 'Разбивка по типам', + melodyInhabitantLabel: 'Житель', + melodyTotalBlocks: 'Ноты', + melodyType: 'Тип', + melodyCount: 'Блоки', + melodyFilterMine: 'МОЁ', + melodyFilterAll: 'ВСЕ', + melodyFilterShare: 'Загрузить мелодию', + melodyShareTitle: 'Поделитесь своей мелодией', + melodyShareHelp: 'Опубликуйте снимок вашей текущей мелодии, чтобы другие могли её услышать и переработать.', + melodyShareTitleLabel: 'Название (необязательно)', + melodyShareTitlePlaceholder: 'Призрак блокчейна', + melodyShareSubmit: 'Опубликовать мелодию', + melodyNoShared: 'Ещё нет мелодий блокчейна.', + melodyRegenerate: 'Перегенерировать', + melodyDownload: "Скачать BCS", + profileActivityTitle: 'Активность', + profileActivityMore: 'Вся активность', + profileContentSectionTitle: 'Содержимое аватара', + profileContentHelp: 'Выбери, какие модули будут отображаться в твоём аватаре.', + profileSensorsHelp: 'Дополнительные метрики, отображаемые в аватаре.', + profileClearnetHelp: 'Модули, доступные извне Oasis.', + profileHubAll: 'Все', + profileHubTitle: 'Публичное содержимое', + footerPulseTitle: 'Пульс', + footerPulsePeers: 'Узлы', + footerPulseInbox: 'Входящие', + footerPulseSync: 'Посл. синх.', + footerPulseEcoValue: 'ECO/ч', + footerPulseLastActivity: 'Последняя активность', + footerLicenseLabel: 'Лицензия', + profileSwitchToOasis: 'Вернуться в Oasis', + profileSwitchToClearnet: 'Погрузиться в Clearnet', + profileVisibilityFediverse: "Федиверс", + profileSwitchToFediverse: "Погрузиться в федиверс", + coordLabel: 'Координата (напр., A3)', + coordPlaceholder: 'Введите координату', + contributorsTitle: "Участники", + pixeliaBy: "автор", + colorLabel: 'Выберите цвет', + paintButton: 'Рисовать!', + invalidCoordinate: 'Неверная координата', + goToMuralButton: "Смотреть фреску", + totalPixels: 'Всего пикселей', + modules: "Модули", + modulesViewTitle: "Модули", + modulesViewDescription: "Настройте свою среду, включая или отключая модули.", + inbox: "Входящие", + multiverse: "Мультивселенная", + fediverse: "Федиверс", + fediverseDescription: "Управляйте своими другими аккаунтами федиверса, включая отправку и получение контента.", + fediverseStatus: "Статус", + fediverseDisconnected: "Федиверс отключён. Проверьте %LINK% или состояние подключения.", + fediverseSettingsTitle: "Федиверс", + fediverseInstanceLabel: "Адрес", + fediverseTokenLabel: "Токен доступа", + fediverseTokenHelp: "Укажите ваш токен доступа. Он будет использоваться для управления вашим аккаунтом Mastodon из Oasis.", + fediverseConnect: "Подключить", + fediverseConnectedAs: "Подключено как", + fediverseDisconnect: "Отключить", + fediverseEmpty: "Когда вы подключите другие аккаунты федиверса из %LINK%, вы сможете управлять ими отсюда.", + fediverseEmptyLink: "настроек", + fediverseComposePlaceholder: "О чём вы думаете?", + fediverseReplyPlaceholder: "Напишите ответ…", + fediverseAttach: "Прикрепить медиа", + fediversePublish: "Опубликовать", + fediverseReply: "Ответить", + fediverseBoosted: "продвинул(а)", + fediverseNoPosts: "Ваша лента пуста.", + fediverseThread: "Обсуждение", + fediverseTimeline: "Ленты", + fediverseManageTimeline: "Управлять лентой", + fediverseFollowers: "Подписчики", + fediverseFollowing: "Подписки", + fediversePosts: "Посты", + fediverseJoined: "Регистрация", + fediverseOverview: "Обзор", + fediverseRefresh: "Обновить", + fediverseWrite: "Написать", + fediversePreview: "Предпросмотр", + fediverseEdit: "Изменить", + fediverseOpenFeed: "Открыть ленту", + fediverseManage: "Управление", + fediverseStatusNotConnected: "Не подключено", + fediverseError: "Не удалось связаться с Mastodon.", + fediverseErrInstance: "Неверный URL сервера.", + fediverseErrAuth: "Неверный или просроченный токен.", + fediverseErrConnect: "Не удалось подключиться к серверу.", + fediverseErrToken: "Требуется токен.", + fediverseErrPublic: "Недоступно в публичном режиме.", + fediverseErrFetch: "Не удалось загрузить ленту.", + fediverseErrPost: "Не удалось опубликовать.", + fediverseErrMedia: "Не удалось загрузить файл.", + fediverseErrEmpty: "Напишите что-нибудь или прикрепите файл.", + popularLabel: "Избранное", + topicsLabel: "Темы", + latestLabel: "Последние", + summariesLabel: "Сводки", + threadsLabel: "Обсуждения", + multiverseLabel: "Мультивселенная", + inboxLabel: "Входящие", + invitesLabel: "Приглашения", + walletLabel: "Кошелёк", + legacyLabel: "Ключи", + cipherLabel: "Шифратор", + bookmarksLabel: "Закладки", + videosLabel: "Видео", + torrentsLabel: "Торренты", + docsLabel: "Документы", + audiosLabel: "Аудио", + tagsLabel: "Теги", + imagesLabel: "Изображения", + inhabitantsLabel: "Жители", + trendingLabel: "В тренде", + eventsLabel: "События", + tasksLabel: "Задачи", + apply: "Применить", + menuPersonal: "Личное", + menuContent: "Контент", + menuBlogs: "Блоги", + menuGovernance: "Управление", + menuOffice: "Офис", + menuMultiverse: "Мультивселенная", + menuNetwork: "Сеть", + menuCreative: "Творчество", + menuEconomy: "Экономика", + menuMedia: "Медиа", + menuTools: "Инструменты", + comment: "Комментарий", + subtopic: "Подтема", + json: "JSON", + createdBy: "автор", + unfollow: "Отменить поддержку", + follow: "Поддержать", + block: "Заблокировать", + unblock: "Разблокировать", + newerPosts: "Более новые записи", + olderPosts: "Более старые записи", + feedRangeEmpty: "Данный диапазон пуст для этой ленты. Попробуйте просмотреть ", + seeFullFeed: "полную ленту", + feedEmpty: "Сеть Oasis никогда не видела записей от этого аккаунта.", + beginningOfFeed: "Это начало ленты", + noNewerPosts: "Более новых записей пока не получено.", + relationshipNotFollowing: "Вас не поддерживают", + relationshipTheyFollow: "Поддерживает вас", + relationshipMutuals: "Взаимная поддержка", + relationshipFollowing: "Вы поддерживаете", + relationshipYou: "Вы", + relationshipBlocking: "Вы блокируете", + relationshipBlockedBy: "Вы заблокированы", + relationshipMutualBlock: "Взаимная блокировка", + relationshipNone: "Вы не поддерживаете", + relationshipConflict: "Конфликт", + relationshipBlockingPost: "Заблокированная запись", + viewLikes: "Посмотреть распространения", + spreadedDescription: "Список записей, распространённых жителем.", + totalspreads: "Всего распространений", + attachFiles: "Прикрепить файлы", + preview: "Предпросмотр", + publish: "Написать", + contentWarningPlaceholder: "Добавьте тему к записи (необязательно)", + privateWarningPlaceholder: "Добавьте жителей для отправки приватной записи (необязательно)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "ПОМНИТЕ: Благодаря технологии блокчейн, после публикации запись невозможно отредактировать или удалить.", + ], + commentWarning: [ + "ПОМНИТЕ: Благодаря технологии блокчейн, после публикации запись невозможно отредактировать или удалить.", + ], + commentPublic: "публичный", + commentPrivate: "приватный", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "ПОМНИТЕ: Благодаря технологии блокчейн, после публикации запись невозможно отредактировать или удалить.", + ], + replyLabel: ({ markdownUrl }) => [ + "ПОМНИТЕ: Благодаря технологии блокчейн, после публикации запись невозможно отредактировать или удалить.", + ], + publishCustomInfo: ({ href }) => [ + "Если у вас есть опыт, вы также можете ", + a({ href }, "написать расширенную запись"), + ".", + ], + publishBasicInfo: ({ href }) => [ + "Если у вас нет опыта, вам следует ", + a({ href }, "написать запись"), + ".", + ], + publishCustom: "Написать расширенную запись", + subtopicLabel: "Создать подтему для этой записи", + messagePreview: "Предпросмотр записи", + mentionsMatching: "Совпадающие упоминания", + mentionsName: "Имя", + mentionsRelationship: "Отношение", + updateit: "ОБНОВИТЬ!", + updateBannerText: "Доступна новая версия Oasis.", + updateBannerAction: "Обновить сейчас →", + info: "Информация", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "назад", + sendTime: "примерно ", + theme: "Тема", + legacy: "Ключи", + legacyTitle: "Ключи", + legacyDescription: "Быстро и безопасно управляйте своим секретом (приватным ключом).", + legacyExportButton: "Экспорт", + legacyImportButton: "Импорт", + ssbLogStream: "Блокчейн", + ssbLogStreamDescription: "Настройте лимит сообщений для потоков блокчейна.", + saveSettings: "Сохранить настройки", + exportTitle: "Экспорт данных", + exportDescription: "Установите пароль (минимум 32 символа) для шифрования вашего ключа", + exportDataTitle: "Резервная копия", + exportDataDescription: "Скачайте ваши данные (без секретного ключа!)", + exportDataButton: "Скачать базу данных", + pubWallet: "PUB Кошелёк", + pubWalletDescription: "Установите URL кошелька PUB. Он будет использоваться для транзакций PUB (включая UBI).", + pubWalletConfiguration: "Сохранить конфигурацию", + importTitle: "Импорт данных", + importDescription: "Импортируйте ваш зашифрованный секрет (приватный ключ) для активации аватара", + importAttach: "Прикрепить зашифрованный файл (.enc)", + passwordLengthInfo: "Пароль должен содержать минимум 32 символа.", + passwordImport: "Введите пароль для расшифровки данных, которые будут сохранены в домашней директории системы (имя файла: secret)", + exportPasswordPlaceholder: "Используйте строчные, заглавные буквы, цифры и символы", + fileInfo: "Ваш зашифрованный секретный ключ будет сохранён в домашней директории системы (имя файла: oasis.enc)", + themeIntro: + "Выберите тему.", + setTheme: "Установить тему", + language: "Язык", + languageDescription: + "Если вы хотите использовать другой язык, выберите его здесь.", + setLanguage: "Установить язык", + peerConnections: "Узлы", + peerConnectionsIntro: "Управляйте всеми подключениями к другим узлам.", + peerConnectionsTitle: "Подключения", + online: "В сети", + offline: "Не в сети", + discovered: 'Обнаружен', + unknown: 'Неизвестно', + lanBroadcastLabel: "LAN-вещание", + lanBroadcastActive: "Активно (UDP-multicast в LAN)", + lanBroadcastDisabled: "Отключено (режим pub)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "Недавний", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "Поддерживается", + recommended: "Рекомендовано", + blocked: "Заблокирован", + noConnections: "Нет подключённых узлов.", + noDiscovered: "Нет обнаруженных узлов.", + noUnknownPeers: "Нет неизвестных пиров в графе друзей.", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "Обновить", + peerPruneIdle: "Удалить неактивные", + peerExport: "Экспорт", + peerImport: "Импорт", + peerImportTitle: "Импорт списка пиров", + peerImportPlaceholder: "Вставьте по одному multiserver-адресу на строку или загрузите .txt файл…", + noSupportedConnections: "Нет поддерживаемых узлов.", + noBlockedConnections: "Нет заблокированных узлов.", + noRecommendedConnections: "Нет рекомендованных узлов.", + connectionActionIntro: + "", + startNetworking: "Запустить сеть", + stopNetworking: "Остановить сеть", + restartNetworking: "Перезапустить сеть", + sync: "Синхронизировать сеть", + indexes: "Индексы", + indexesDescription: + "Перестройка индексов безопасна и может исправить некоторые типы ошибок.", + homePageTitle: "Главная", + homePageDescription: "Выберите модуль, который будет вашей главной страницей.", + saveHomePage: "Установить главную", + invites: "Приглашения", + invitesTitle: "Приглашения", + invitesInvites: "Приглашения", + invitesDescription: "Управляйте и применяйте коды приглашений в вашей сети.", + invitesTribesTitle: "Племена", + invitesTribeInviteCodePlaceholder: "Введите код приглашения племени", + invitesTribeJoinButton: "Вступить в племя", + invitesChatsTitle: "Чаты", + invitesChatInviteCodePlaceholder: "Введите код приглашения чата", + invitesChatJoinButton: "Войти в чат", + invitesPadsTitle: "Пады", + invitesPadInviteCodePlaceholder: "Введите код приглашения пада", + invitesPadJoinButton: "Войти в пад", + invitesCalendarsTitle: "Календари", + invitesCalendarInviteCodePlaceholder: "Введите код приглашения календаря", + invitesCalendarJoinButton: "Войти в календарь", + invitesEventsTitle: "События", + invitesEventInviteCodePlaceholder: "Введите код приглашения события", + invitesEventJoinButton: "Войти в событие", + invitesForumsTitle: "Форумы", + invitesForumInviteCodePlaceholder: "Введите код приглашения форума", + invitesForumJoinButton: "Войти на форум", + invitesMapsTitle: "Карты", + invitesMapInviteCodePlaceholder: "Введите код приглашения карты", + invitesMapJoinButton: "Войти на карту", + invitesShopsTitle: "Магазины", + invitesShopInviteCodePlaceholder: "Введите код приглашения магазина", + invitesShopJoinButton: "Войти в магазин", + invitesPubsTitle: "PUB", + invitesPubInviteCodePlaceholder: "Введите код приглашения PUB", + invitesAcceptInvite: "Присоединиться к PUB", + invitesAlreadyFederated: "Вы уже федерированы с этим пабом.", + invitesFederationsTitle: "Федерации", + invitesAcceptedInvites: "Федеративные", + invitesNoInvites: "Приглашения ещё не приняты.", + invitesUnfollow: "Отписаться", + invitesFollow: "Подписаться", + invitesUnfollowedInvites: "Нефедеративные", + invitesNoFederatedPubs: "Нет федеративных pub-ов.", + invitesNoUnfollowed: "Нет нефедеративных pub-ов.", + invitesUnreachablePubs: "Недоступные", + invitesNoUnreachablePubs: "Нет недоступных pub-ов.", + invitesPubsRefresh: "Обновить", + invitesPubsClearUnreachable: "Удалить недоступные", + invitesPubsExport: "Экспорт", + invitesPubsImport: "Импорт", + invitesPubsImportTitle: "Импорт pub-ов", + invitesPubsImportPlaceholder: "Вставьте один multiserver-адрес или код приглашения на строку, или загрузите .txt файл…", + currentlyUnreachable: "ОШИБКА!", + errorDetails: "Подробности ошибки", + genericError: "Произошла ошибка.", + panicMode: "Режим паники!", + encryptData: "Установите пароль (минимум 32 символа) для шифрования вашего блокчейна", + decryptData: "Введите пароль для расшифровки вашего блокчейна", + panicModeDescription: "Зашифровать/Расшифровать или УДАЛИТЬ ваш блокчейн", + removeDataDescription: "ВНИМАНИЕ: Этот процесс нельзя отменить.", + encryptPanicButton: "Зашифровать блокчейн", + decryptPanicButton: "Расшифровать блокчейн", + removePanicButton: "УДАЛИТЬ ВСЕ ДАННЫЕ!", + searchTitle: "Поиск", + searchDescriptionLabel: "Поиск контента в вашей сети.", + searchLanguagesLabel: "Языки", + searchSkillsLabel: "Навыки", + searchPlaceholder:"Искать контент...", + searchDateLabel:"Дата", + searchLocationLabel:"Местоположение", + searchPriceLabel:"Цена", + searchUrlLabel:"URL", + searchCategoryLabel:"Категория", + searchStartLabel:"Время начала", + searchEndLabel:"Время окончания", + searchPriorityLabel:"Приоритет", + searchStatusLabel:"Статус", + statusLabel:"Статус", + totalVotesLabel:"Всего голосов", + noResultsFound:"Результатов не найдено.", + votesOption:"Варианты голосования", + voteYesLabel:"За", + voteNoLabel:"Против", + allTypesLabel: "БЕЗ ОГРАНИЧЕНИЙ", + ABSTENTIONLabel:"ВОЗДЕРЖАНИЕ", + YESLabel:"ЗА", + NOLabel:"ПРОТИВ", + FOLLOW_MAJORITYLabel: "СЛЕДОВАТЬ БОЛЬШИНСТВУ", + CONFUSEDLabel: "ЗАМЕШАТЕЛЬСТВО", + NOT_INTERESTEDLabel: "НЕ ИНТЕРЕСНО", + StatusLabel:"Статус", + votesOptionYesLabel:"За", + votesOptionNoLabel:"Против", + votesOptionAbstentionLabel:"Воздержание", + votesQuestionLabel:"Вопрос", + votesCreatedByLabel:"Создано", + voteStatusOpen:"ОТКРЫТО", + voteStatusClosed:"ЗАКРЫТО", + imageSearchLabel: "Введите слова для поиска изображений с соответствующими метками.", + commentDescription: ({ parentUrl }) => [ + " прокомментировал ", + a({ href: parentUrl }, " обсуждение"), + ], + commentTitle: ({ authorName }) => [`Комментарий к записи @${authorName}`], + subtopicDescription: ({ parentUrl }) => [ + " создал подтему из ", + a({ href: parentUrl }, " записи"), + ], + subtopicTitle: ({ authorName }) => [`Подтема записи @${authorName}`], + mysteryDescription: "опубликовал загадочную запись", + oasisDescription: "Сеть проекта OASIS", + searchSubmit: "Искать...", + postLabel: "ЗАПИСИ", + aboutLabel: "ЖИТЕЛИ", + feedLabel: "ЛЕНТЫ", + votesLabel: "ГОЛОСОВАНИЯ", + reportLabel: "ОТЧЁТЫ", + imageLabel: "ИЗОБРАЖЕНИЯ", + videoLabel: "ВИДЕО", + audioLabel: "АУДИО", + documentLabel: "ДОКУМЕНТЫ", + torrentLabel: "ТОРРЕНТЫ", + pdfFallbackLabel: "PDF документ", + eventLabel: "СОБЫТИЯ", + taskLabel: "ЗАДАЧИ", + transferLabel: "ПЕРЕВОДЫ", + curriculumLabel: "РЕЗЮМЕ", + bookmarkLabel: "ЗАКЛАДКИ", + tribeLabel: "ПЛЕМЕНА", + marketLabel: "РЫНОК", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "КОШЕЛЬКИ", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "Отправить", + subjectLabel: "Тема", + editProfile: "Редактировать аватар", + profileQrAlt: "QR-код профиля", + profileQrHint: "Отсканируйте, чтобы скопировать этот Oasis ID.", + oasisIdLabel: "OasisID", + spreadLabel: "Распространить", + spreadHint: "Распространить вашим сторонникам (через ваш feed).", + welcomePmSubject: "Hello World!", + welcomePmBody: "Добро пожаловать в Oasis — свободная, одноранговая, федеративная и зашифрованная социальная сеть с распределённой архитектурой только по приглашениям.\n\nНесколько интересных мест, с которых можно начать:\n\n- /profile — Отредактируй аватар, имя, описание и какие модули видны на твоей публичной стороне.\n- /invites — Добавь pub, чтобы федерироваться с остальной сетью.\n- /peers — Посмотри, кто на связи, пересканируй LAN, экспортируй или импортируй списки пиров.\n- /inhabitants — Открывай и заходи к аватарам других людей.\n- /tribes — Создавай или вступай в приватные группы со сквозным шифрованием.\n- /inbox — Читай и отправляй приватные сообщения.\n- /activity — Смотри свежую активность — свою и твоей сети.\n- /publish — Напиши пост или поделись изображением, аудио, видео или документом.\n\nНесколько интересных мест для подключения:\n\n- /fediverse — Управляйте своими другими аккаунтами федиверса, включая отправку и получение контента.\n\nЭто сообщение было отправлено приватно от тебя самому себе, поэтому для его получения не требовалось соединение.", + profileVisibilityTitle: "Видимость публичного профиля", + profileVisibilityHint: "Выберите поля, видимые другим жителям. По умолчанию показана только Карма.", + profileSensorsSectionTitle: "Сенсоры", + profileVisibilityActivity: "Уровень активности", + profileVisibilityDevice: "Устройство", + profileDeviceLockedHint: "Этот датчик всегда включён: он показывает другим, с какого устройства вы подключаетесь, и не может быть отключён.", + profileVisibilityKarma: "Карма", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "Кошелёк ECOIN", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "Ключ GPG", + profileVisibilityClearnet: "Опубликовать мой профиль", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "Магазины", + profileClearnetJobsLabel: "Вакансии", + profileClearnetEventsLabel: "События", + profileClearnetProjectsLabel: "Проекты", + profileClearnetPostsLabel: "Блоги", + profileClearnetAudiosLabel: "Аудио", + profileClearnetVideosLabel: "Видео", + profileClearnetImagesLabel: "Изображения", + profileClearnetDocumentsLabel: "Документы", + profileClearnetTorrentsLabel: "Торренты", + editProfileDescription: + "", + profileName: "Имя", + profileImage: "Изображение аватара", + profileGpgKey: "Открытый ключ GPG (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "Скачать", + profileGpgRemove: "Удалить", + profileDescription: "Описание", + hashtagDescription: + "Записи жителей вашей сети, ссылающиеся на этот #хештег, в обратном хронологическом порядке.", + rebuildName: "Перестроить базу данных", + wallet: "Кошелёк", + walletAddress: "Адрес", + walletAmount: "Сумма", + walletAddressLine: ({ address }) => `Адрес: ${address}`, + walletAmountLine: ({ amount }) => `Сумма: ${amount} ECO`, + walletBack: "Назад", + walletBalanceTitle: "Баланс", + walletWalletSendTitle: "Отправить", + walletReceiveTitle: "Получить", + walletHistoryTitle: "История", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "Подтв.", + walletConfirm: "Подтвердить", + walletDescription: "Управляйте цифровыми активами: отправка и получение ECOin, просмотр баланса и история транзакций.", + walletDate: "Дата", + walletFee: "Комиссия (чем выше комиссия, тем быстрее обработка транзакции)", + walletFeeLine: ({ fee }) => `Комиссия: ECO ${fee}`, + walletHistory: "История", + walletReceive: "Получить", + walletReset: "Сбросить", + walletSend: "Отправить", + walletStatus: "Статус", + walletDisconnected: [ + "ECOin ", + strong("кошелёк отключён"), + ". Проверьте ", + a({ href: '/settings#wallet' }, "ваши настройки"), + " или состояние подключения.", + ], + walletSentToLine: ({ destination, amount }) => `Отправлено ECO ${amount} на ${destination}`, + walletSettingsTitle: "Кошелёк", + walletSettingsDescription: "Интегрируйте Oasis с вашим кошельком ECOin.", + walletSettingsDocLink: "Руководство по установке ECOin", + walletStatusMessages: { + invalid_amount: "Неверная сумма", + invalid_dest: "Неверный адрес назначения", + invalid_fee: "Неверная комиссия", + validation_errors: "Ошибки валидации", + send_tx_success: "Транзакция успешна", + }, + walletTitle: "Кошелёк", + walletTotalCostLine: ({ totalCost }) => `Общая стоимость: ECO ${totalCost}`, + walletTransactionId: "ID транзакции", + walletTxId: "ID транзакции", + walletType: "Тип", + walletUser: "Имя пользователя", + walletPass: "Пароль", + walletConfiguration: "Настроить кошелёк", + cipher: "Шифратор", + cipherTitle: "Шифратор", + cipherDescription: "Симметричное шифрование и расшифровка текста (с использованием общего пароля).", + randomPassword: "Случайный пароль", + cipherEncryptTitle: "Зашифровать текст", + cipherEncryptDescription: "Введите текст для шифрования", + cipherTextLabel: "Текст для шифрования", + cipherTextPlaceholder: "Введите текст для шифрования...", + cipherPasswordLabel: "Установите пароль (минимум 32 символа) для шифрования текста", + cipherPasswordDecryptLabel: "Установите пароль (минимум 32 символа) для расшифровки текста", + cipherPasswordPlaceholder: "Введите пароль...", + cipherEncryptButton: "Зашифровать", + cipherDecryptTitle: "Расшифровать текст", + cipherDecryptDescription: "Введите текст для расшифровки", + cipherEncryptedMessageLabel: "Зашифрованный текст", + cipherDecryptedMessageLabel: "Расшифрованный текст", + cipherPasswordUsedLabel: "Пароль, использованный для шифрования (сохраните его!)", + cipherEncryptedTextPlaceholder: "Введите зашифрованный текст...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "Введите вектор инициализации...", + cipherDecryptButton: "Расшифровать", + password: "Пароль", + text: "Текст", + encryptedText: "Зашифрованный текст", + iv: "Вектор инициализации (IV)", + encryptTitle: "Зашифровать текст", + encryptDescription: "Введите текст для шифрования и укажите пароль.", + encryptButton: "Зашифровать", + decryptTitle: "Расшифровать текст", + decryptDescription: "Введите зашифрованный текст и укажите тот же пароль, что использовался при шифровании.", + decryptButton: "Расшифровать", + passwordLengthError: "Пароль должен содержать минимум 32 символа.", + missingFieldsError: "Текст, пароль или IV не указаны.", + encryptionError: "Ошибка шифрования текста.", + decryptionError: "Ошибка расшифровки текста.", + bookmarkTitle: "Закладки", + bookmarkDescription: "Находите и управляйте закладками в вашей сети.", + bookmarkAllSectionTitle: "Закладки", + bookmarkMineSectionTitle: "Ваши закладки", + bookmarkRecentSectionTitle: "Недавние закладки", + bookmarkTopSectionTitle: "Лучшие закладки", + bookmarkFavoritesSectionTitle: "Избранное", + bookmarkCreateSectionTitle: "Создать закладку", + bookmarkUpdateSectionTitle: "Обновить закладку", + bookmarkFilterAll: "ВСЕ", + bookmarkFilterMine: "МОИ", + bookmarkFilterTop: "ЛУЧШИЕ", + bookmarkFilterFavorites: "ИЗБРАННОЕ", + bookmarkFilterRecent: "НЕДАВНИЕ", + bookmarkCreateButton: "Создать закладку", + bookmarkUpdateButton: "Обновить", + bookmarkDeleteButton: "Удалить", + bookmarkAddFavoriteButton: "В избранное", + bookmarkRemoveFavoriteButton: "Из избранного", + bookmarkUrlLabel: "Ссылка", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "Описание", + bookmarkDescriptionPlaceholder: "Необязательно", + bookmarkTagsLabel: "Теги", + bookmarkTagsPlaceholder: "Введите теги через запятую", + bookmarkCategoryLabel: "Категория", + bookmarkCategoryPlaceholder: "Необязательно", + bookmarkLastVisitLabel: "Последний визит", + bookmarkSearchPlaceholder: "Искать URL, теги, категорию, автора...", + bookmarkSortRecent: "Новейшие", + bookmarkSortOldest: "Старейшие", + bookmarkSortTop: "Больше голосов", + bookmarkSearchButton: "Искать", + bookmarkUpdatedAt: "Обновлено", + bookmarkNoMatch: "Закладки не найдены.", + noBookmarks: "Нет доступных закладок.", + noUrl: "Нет ссылки", + noCategory: "Нет категории", + noLastVisit: "Нет записи о посещении", + videoTitle: "Видео", + videoDescription: "Просматривайте и управляйте видеоконтентом в вашей сети.", + videoPluginTitle: "Название", + videoPluginDescription: "Описание", + videoMineSectionTitle: "Ваши видео", + videoCreateSectionTitle: "Загрузить видео", + videoUpdateSectionTitle: "Обновить видео", + videoAllSectionTitle: "Видео", + videoRecentSectionTitle: "Недавние видео", + videoTopSectionTitle: "Лучшие видео", + videoFavoritesSectionTitle: "Избранное", + videoFilterAll: "ВСЕ", + videoFilterMine: "МОИ", + videoFilterRecent: "НЕДАВНИЕ", + videoFilterTop: "ЛУЧШИЕ", + videoFilterFavorites: "ИЗБРАННОЕ", + videoCreateButton: "Загрузить видео", + videoUpdateButton: "Обновить", + videoDeleteButton: "Удалить", + videoAddFavoriteButton: "В избранное", + videoRemoveFavoriteButton: "Из избранного", + videoFileLabel: "Выберите видеофайл (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "Теги", + videoTagsPlaceholder: "Введите теги через запятую", + videoTitleLabel: "Название", + videoTitlePlaceholder: "Необязательно", + videoDescriptionLabel: "Описание", + videoDescriptionPlaceholder: "Необязательно", + videoNoFile: "Видеофайл не указан", + noVideos: "Нет доступных видео.", + videoSearchPlaceholder: "Искать название, теги, автора...", + videoSortRecent: "Новейшие", + videoSortOldest: "Старейшие", + videoSortTop: "Больше голосов", + videoSearchButton: "Искать", + videoMessageAuthorButton: "ЛС", + videoUpdatedAt: "Обновлено", + videoNoMatch: "Видео не найдены.", + documentTitle: "Документы", + documentDescription: "Находите и управляйте документами в вашей сети.", + documentAllSectionTitle: "Документы", + documentMineSectionTitle: "Ваши документы", + documentRecentSectionTitle: "Недавние документы", + documentTopSectionTitle: "Лучшие документы", + documentFavoritesSectionTitle: "Избранное", + documentCreateSectionTitle: "Загрузить документ", + documentUpdateSectionTitle: "Редактировать документ", + documentFilterAll: "ВСЕ", + documentFilterMine: "МОИ", + documentFilterRecent: "НЕДАВНИЕ", + documentFilterTop: "ЛУЧШИЕ", + documentFilterFavorites: "ИЗБРАННОЕ", + documentCreateButton: "Загрузить документ", + documentUpdateButton: "Обновить", + documentDeleteButton: "Удалить", + documentAddFavoriteButton: "В избранное", + documentRemoveFavoriteButton: "Из избранного", + documentMessageAuthorButton: "ЛС", + documentFileLabel: "Загрузить документ (.pdf)", + documentTagsLabel: "Теги", + documentTagsPlaceholder: "Введите теги через запятую", + documentTitleLabel: "Название", + documentTitlePlaceholder: "Необязательно", + documentDescriptionLabel: "Описание", + documentDescriptionPlaceholder: "Необязательно", + documentNoFile: "Нет файла.", + noDocuments: "Нет доступных документов.", + documentSearchPlaceholder: "Искать название, теги, описание, автора...", + documentSortRecent: "Новейшие", + documentSortOldest: "Старейшие", + documentSortTop: "Больше голосов", + documentSearchButton: "Искать", + documentNoMatch: "Документы не найдены.", + documentUpdatedAt: "Обновлено", + audioTitle: "Аудио", + audioDescription: "Просматривайте и управляйте аудиоконтентом в вашей сети.", + audioPluginTitle: "Название", + audioPluginDescription: "Описание", + audioMineSectionTitle: "Ваши аудио", + audioCreateSectionTitle: "Загрузить аудио", + audioUpdateSectionTitle: "Обновить аудио", + audioAllSectionTitle: "Аудио", + audioRecentSectionTitle: "Недавние аудио", + audioTopSectionTitle: "Лучшие аудио", + audioFilterAll: "ВСЕ", + audioFilterMine: "МОИ", + audioFilterRecent: "НЕДАВНИЕ", + audioFilterTop: "ЛУЧШИЕ", + audioFilterBlockchain: "БЛОКЧЕЙН", + audioCreateButton: "Загрузить аудио", + audioUpdateButton: "Обновить", + audioDeleteButton: "Удалить", + audioAddFavoriteButton: "В избранное", + audioRemoveFavoriteButton: "Из избранного", + audioFileLabel: "Выберите аудиофайл (.mp3, .wav, .ogg)", + audioTagsLabel: "Теги", + audioTagsPlaceholder: "Введите теги через запятую", + audioTitleLabel: "Название", + audioTitlePlaceholder: "Необязательно", + audioDescriptionLabel: "Описание", + audioDescriptionPlaceholder: "Необязательно", + audioNoFile: "Аудиофайл не указан", + noAudios: "Нет доступных аудио.", + audioSearchPlaceholder: "Искать название, теги, автора...", + audioSortRecent: "Новейшие", + audioSortOldest: "Старейшие", + audioSortTop: "Больше голосов", + audioSearchButton: "Искать", + audioMessageAuthorButton: "ЛС", + audioUpdatedAt: "Обновлено", + audioNoMatch: "Аудио не найдены.", + audioFavoritesSectionTitle: "Избранное", + audioFilterFavorites: "ИЗБРАННОЕ", + favoritesTitle: "Избранное", + favoritesDescription: "Весь ваш избранный контент в одном месте.", + favoritesFilterAll: "ВСЕ", + favoritesFilterRecent: "НЕДАВНИЕ", + favoritesFilterAudios: "АУДИО", + favoritesFilterBookmarks: "ЗАКЛАДКИ", + favoritesFilterDocuments: "ДОКУМЕНТЫ", + favoritesFilterImages: "ИЗОБРАЖЕНИЯ", + favoritesFilterMaps: "КАРТЫ", + favoritesFilterPads: "БЛОКНОТЫ", + favoritesFilterChats: "ЧАТЫ", + favoritesFilterCalendars: "КАЛЕНДАРИ", + favoritesFilterVideos: "ВИДЕО", + favoritesRemoveButton: "Убрать из избранного", + favoritesNoItems: "Пока нет избранного.", + yourContacts: "Ваши контакты", + allInhabitants: "Жители", + allCVs: "Все резюме", + discoverPeople: "Находите жителей в вашей сети.", + allInhabitantsButton: "ВСЕ", + contactsButton: "ПОДДЕРЖКА", + CVsButton: "Резюме", + matchSkills: "Совпадение навыков", + matchSkillsButton: "СОВПАДЕНИЕ НАВЫКОВ", + suggestedButton: "РЕКОМЕНДОВАННЫЕ", + searchInhabitantsPlaceholder: "ФИЛЬТР жителей ПО ИМЕНИ...", + filterLocation: "ФИЛЬТР жителей ПО МЕСТОПОЛОЖЕНИЮ...", + filterLanguage: "ФИЛЬТР жителей ПО ЯЗЫКУ...", + filterSkills: "ФИЛЬТР жителей ПО НАВЫКАМ...", + applyFilters: "Применить фильтры", + locationLabel: "Местоположение", + languagesLabel: "Языки", + skillsLabel: "Навыки", + commonSkills: "Общие навыки", + mutualFollowers: "Общие подписчики", + suggestedFollowsYou: "Подписан на тебя", + latestInteractions: "Последние взаимодействия", + viewAvatar: "Смотреть аватар", + viewCV: "Смотреть резюме", + suggestedSectionTitle: "Рекомендованные", + topkarmaSectionTitle: "Лучшая Карма", + topecoSectionTitle: "Лучшие Эко", + topactivitySectionTitle: "Наибольшая активность", + blockedSectionTitle: "Заблокированные", + gallerySectionTitle: "ГАЛЕРЕЯ", + blockedButton: "ЗАБЛОКИРОВАННЫЕ", + blockedLabel: "Заблокированный пользователь", + inhabitantviewDetails: "Подробнее", + keepReading: "Продолжить чтение...", + oasisId: "ID", + noInhabitantsFound: "Жители пока не найдены.", + inhabitantActivityLevel: "Уровень активности", + lifespanLabel: "Срок жизни", + parliamentTitle: "Парламент", + parliamentDescription: "Изучайте формы правления и коллективные законы управления.", + parliamentFilterGovernment: "ПРАВИТЕЛЬСТВО", + parliamentFilterCandidatures: "КАНДИДАТУРЫ", + parliamentFilterProposals: "ЗАКОНОПРОЕКТЫ", + parliamentFilterLaws: "ЗАКОНЫ", + parliamentFilterHistorical: "ИСТОРИЯ", + parliamentFilterLeaders: "ЛИДЕРЫ", + parliamentFilterRules: "ПРАВИЛА", + parliamentGovernmentCard: "Текущее правительство", + parliamentActorInPowerInhabitant: 'ПРАВЯЩИЙ ЖИТЕЛЬ', + parliamentActorInPowerTribe: 'ПРАВЯЩЕЕ ПЛЕМЯ', + parliamentHistoricalGovernmentsTitle: 'ПРАВИТЕЛЬСТВА', + parliamentHistoricalElectionsTitle: 'ИЗБИРАТЕЛЬНЫЕ ЦИКЛЫ', + parliamentHistoricalLawsTitle: 'ИСТОРИЯ', + parliamentHistoricalLeadersTitle: 'ЛИДЕРЫ', + parliamentThCycles: 'ЦИКЛЫ', + parliamentThTimesInPower: 'ПРАВЛЕНИЕ', + parliamentThTotalCandidatures: 'КАНДИДАТУРЫ', + parliamentThProposed: 'ПРЕДЛОЖЕНО ЗАКОНОВ', + parliamentThApproved: 'ПРИНЯТО ЗАКОНОВ', + parliamentThDeclined: 'ОТКЛОНЕНО ЗАКОНОВ', + parliamentThDiscarded: 'ОТМЕНЕНО ЗАКОНОВ', + parliamentMembers: "УЧАСТНИКИ", + parliamentLegSince: "ЦИКЛ С", + parliamentLegEnd: "ЦИКЛ ДО", + parliamentPoliciesProposal: "ЗАКОНОПРОЕКТЫ", + parliamentPoliciesApproved: "ПРИНЯТЫЕ ЗАКОНЫ", + parliamentPoliciesDeclined: "ОТКЛОНЁННЫЕ ЗАКОНЫ", + parliamentPoliciesDiscarded: "ОТМЕНЁННЫЕ ЗАКОНЫ", + parliamentEfficiency: "% ЭФФЕКТИВНОСТЬ", + parliamentFilterRevocations: 'ОТЗЫВЫ', + parliamentRevocationFormTitle: 'Отозвать закон', + parliamentRevocationLaw: 'Закон', + parliamentRevocationTitle: 'Название', + parliamentRevocationReasons: 'Причины', + parliamentRevocationPublish: 'Опубликовать отзыв', + parliamentCurrentRevocationsTitle: 'Текущие отзывы', + parliamentFutureRevocationsTitle: 'Будущие отзывы', + parliamentPoliciesRevocated: 'ОТОЗВАННЫЕ ЗАКОНЫ', + parliamentPoliciesTitle: 'ИСТОРИЯ', + parliamentLawsTitle: 'ПРИНЯТЫЕ ЗАКОНЫ', + parliamentRulesRevocations: 'Любой принятый закон может быть отозван с использованием текущего метода правления.', + parliamentNoStableGov: "Правительство ещё не выбрано.", + parliamentNoGovernments: "Правительств пока нет.", + parliamentCandidatureFormTitle: "Предложить кандидатуру", + parliamentCandidatureIdPh: "Oasis ID (@...) или название племени", + parliamentCandidatureSlogan: "Слоган (макс. 140 символов)", + parliamentCandidatureSloganPh: "Краткий девиз", + parliamentCandidatureMethod: "Метод", + parliamentCandidatureProposeBtn: "Опубликовать кандидатуру", + parliamentThType: "Тип", + parliamentThId: "ID", + parliamentThDate: "Дата предложения", + parliamentThSlogan: "Слоган", + parliamentThMethod: "Метод", + parliamentThKarma: "Карма", + parliamentThSince: "Профиль с", + parliamentThVotes: "Получено голосов", + parliamentThVoteAction: "Голосовать", + parliamentTypeUser: "Житель", + parliamentTypeTribe: "Племя", + parliamentVoteBtn: "Голосовать", + parliamentProposalFormTitle: "Предложить закон", + parliamentProposalDescription: "Описание (до 1000 символов)", + parliamentProposalPublish: "Опубликовать законопроект", + parliamentFinalize: "Завершить", + parliamentDeadline: "Крайний срок", + parliamentNoProposals: "Законопроектов пока нет.", + parliamentNoLaws: "Принятых законов пока нет.", + parliamentMethodDEMOCRACY: "Демократия", + parliamentMethodMAJORITY: "Большинство (80%)", + parliamentMethodMINORITY: "Меньшинство (20%)", + parliamentMethodDICTATORSHIP: "Диктатура", + parliamentMethodKARMATOCRACY: "Карматократия", + parliamentThProposalDate: "Дата предложения", + parliamentThSupports: "Поддержка", + parliamentThVote: "Голос", + parliamentCurrentProposalsTitle: "Текущие законопроекты", + parliamentVotesSlashTotal: "Голоса/Всего", + parliamentVotesNeeded: "Необходимо голосов", + parliamentFutureLawsTitle: "Будущие законы", + parliamentNoFutureLaws: "Будущих законов пока нет.", + parliamentVoteAction: "Голосовать", + parliamentLeadersTitle: "Лидеры", + parliamentThLeader: "АВАТАР", + parliamentPopulation: "Население", + parliamentThInPower: "У власти", + parliamentThPresented: "КАНДИДАТУРЫ", + parliamentNoLeaders: "Лидеров пока нет.", + parliamentCandidaturesListTitle: "Список кандидатур", + parliamentTimeRemaining: "Оставшееся время", + parliamentNoLeader: "Лидирующей кандидатуры пока нет.", + parliamentElectionsStatusTitle: "Следующее правительство", + parliamentProposalDeadlineLabel: "Крайний срок", + parliamentProposalTimeLeft: "Осталось времени", + parliamentProposalOnTrack: "На пути к принятию", + parliamentProposalOffTrack: "Недостаточно поддержки", + parliamentRulesTitle: "Как работает Парламент", + parliamentRulesIntro: "Выборы проводятся каждые 2 месяца; кандидатуры подаются непрерывно и сбрасываются при выборе правительства.", + parliamentRulesCandidates: "Любой житель может выдвинуть себя, другого жителя или любое племя. Каждый житель может предложить до 3 кандидатур за цикл; дубликаты в одном цикле отклоняются.", + parliamentRulesElection: "Побеждает кандидатура, набравшая наибольшее количество голосов на момент подведения итогов.", + parliamentRulesTies: "Порядок разрешения ничьих: наивысшая karma жителя; при равенстве — самый старый профиль; при дальнейшем равенстве — самое раннее предложение; затем лексикографически по ID.", + parliamentRulesFallback: "Если никто не голосует, побеждает последняя предложенная кандидатура. Если кандидатур нет, выбирается случайное племя.", + parliamentRulesTerm: "На вкладке «Правительство» отображается текущее правительство и статистика.", + parliamentRulesMethods: "Формы: Анархия (простое большинство), Демократия (50%+1), Большинство (80%), Меньшинство (20%), Карматократия (законопроекты с наивысшей karma), Диктатура (мгновенное утверждение).", + parliamentRulesAnarchy: "Анархия — режим по умолчанию: если ни одна кандидатура не избрана на момент подведения итогов, провозглашается Анархия. При Анархии любой житель может предлагать законы.", + parliamentRulesProposals: "Если вы правящий житель или член правящего племени, вы можете публиковать законопроекты. Недиктаторские методы создают публичное голосование.", + parliamentRulesLimit: "Каждый житель может опубликовать не более 3 законопроектов за цикл.", + parliamentRulesLaws: "Когда законопроект набирает необходимый порог, он становится Законом и появляется на вкладке «Законы» с датой принятия.", + parliamentRulesHistorical: "На вкладке «История» вы можете увидеть каждый цикл правления и данные о его управлении.", + parliamentRulesLeaders: "На вкладке «Лидеры» вы можете увидеть рейтинг жителей/племён, которые правили (или выдвигались), отсортированный по эффективности.", + parliamentProposalVoteStatusLabel: "Статус голосования", + parliamentProposalOnTrackYes: "Порог достигнут", + parliamentProposalOnTrackNo: "Ниже порога", + courtsTitle: "Суды", + courtsDescription: "Изучайте формы разрешения конфликтов и коллективного управления правосудием.", + courtsFilterCases: "ДЕЛА", + courtsFilterMyCases: "МОИ", + courtsFilterJudges: "СУДЬИ", + courtsFilterHistory: "ИСТОРИЯ", + courtsFilterRules: "ПРАВИЛА", + courtsCaseFormTitle: "Открыть дело", + courtsCaseRespondent: "Обвиняемый / Ответчик", + courtsCaseRespondentPh: "Oasis ID (@...) или название племени", + courtsCaseMediatorsAccuser: "Посредники (обвинитель)", + courtsCaseMethod: "Метод разрешения", + courtsCaseDescription: "Описание (макс. 1000 символов)", + courtsCaseEvidenceTitle: "Доказательства по делу", + courtsCaseEvidenceHelp: "Прикрепите изображения, аудио, документы (PDF) или видео, подтверждающие вашу позицию.", + courtsCaseSubmit: "Подать дело", + courtsNominateJudge: "Выдвинуть судью", + courtsJudgeId: "Судья", + courtsJudgeIdPh: "Oasis ID (@...) или имя жителя", + courtsNominateBtn: "Выдвинуть", + courtsAddEvidence: "Добавить доказательство", + courtsEvidenceText: "Текст", + courtsEvidenceLink: "Ссылка", + courtsEvidenceLinkPh: "https://...", + courtsEvidenceSubmit: "Прикрепить", + courtsAnswerText: "Краткий ответ", + courtsAnswerSubmit: "Отправить ответ", + courtsVerdictTitle: "Вынести вердикт", + courtsVerdictOrdersPh: "Действия, сроки, восстановительные меры...", + courtsIssueVerdict: "Вынести вердикт", + courtsMediationPropose: "Предложить мировое соглашение", + courtsSettlementProposeBtn: "Предложить", + courtsNominationsTitle: "Судебные номинации", + courtsThJudge: "Судья", + courtsThDate: "Дата", + courtsThVote: "Голос", + courtsNoNominations: "Номинаций пока нет.", + courtsCaseMediatorsRespondentTitle: "Добавить посредников защиты", + courtsCaseMediatorsRespondent: "Посредники (защита)", + courtsMediatorsAccuserLabel: "Посредники (обвинитель)", + courtsMediatorsRespondentLabel: "Посредники (защита)", + courtsMediatorsSubmit: "Сохранить посредников", + courtsNoMyCases: "У вас пока нет конфликтов.", + courtsNoHistory: "Зафиксированных разбирательств пока нет.", + courtsMethodDICTATOR: "Диктатор", + courtsMethodPOPULAR: "Народный", + courtsMethodKARMATOCRACY: "Карматократия", + courtsRulesTitle: "Как работают Суды", + courtsRulesIntro: "Суды — это общественный процесс разрешения конфликтов и содействия восстановительному правосудию. Приоритет отдаётся диалогу, чётким доказательствам и соразмерным мерам.", + courtsRulesLifecycle: "Процесс: 1) Открытие дела 2) Выбор метода 3) Представление доказательств 4) Слушание и обсуждение 5) Вердикт и средства защиты 6) Исполнение и закрытие 7) Апелляция (при необходимости).", + courtsRulesRoles: "Обвинитель: открывает дело. Защита: обвиняемое лицо или племя. Метод: механизм, выбранный сообществом для содействия, оценки доказательств и вынесения вердикта. Свидетель: предоставляет показания или доказательства. Посредники: нейтральные лица, приглашённые обвинителем и/или защитой, имеющие доступ ко всем деталям, которые помогают деэскалировать конфликт и совместно выработать соглашения.", + courtsRulesEvidence: "Описание до 1000 символов. Прикрепляйте релевантные и законные изображения, аудио, видео и PDF-документы. Не передавайте конфиденциальные личные данные без согласия.", + courtsRulesDeliberation: "Слушания могут быть публичными или закрытыми. Судьи обеспечивают уважение, запрашивают уточнения и могут отклонить нерелевантные или незаконные материалы.", + courtsRulesVerdict: "Приоритет отдаётся восстановлению: извинения, соглашения о медиации, модерация контента, временные ограничения или другие соразмерные меры. Обоснование должно быть зафиксировано.", + courtsRulesAppeals: "Апелляция: допускается при наличии новых доказательств или явной процедурной ошибки. Должна быть подана в течение 7 дней, если не указано иное.", + courtsRulesPrivacy: "Уважайте конфиденциальность и безопасность. Доксинг, ненависть или угрозы удаляются. Судьи могут редактировать или засекречивать части записи для защиты людей в зоне риска.", + courtsRulesMisconduct: "Преследование, манипуляции или фабрикация доказательств могут привести к немедленному негативному решению.", + courtsRulesGlossary: "Дело: запись о конфликте. Доказательство: материалы, подтверждающие утверждения. Вердикт: решение с мерой пресечения. Апелляция: запрос на пересмотр вердикта.", + courtsFilterActions: "ДЕЙСТВИЯ", + courtsNoActions: "Нет ожидающих действий для вашей роли.", + courtsCaseTitlePlaceholder: "Краткое описание конфликта", + courtsCaseSeverity: "Серьёзность", + courtsCaseSeverityNone: "Без метки серьёзности", + courtsCaseSeverityLOW: "Низкая", + courtsCaseSeverityMEDIUM: "Средняя", + courtsCaseSeverityHIGH: "Высокая", + courtsCaseSeverityCRITICAL: "Критическая", + courtsCaseSubject: "Тема", + courtsCaseSubjectNone: "Без метки темы", + courtsCaseSubjectBEHAVIOUR: "Поведение", + courtsCaseSubjectCONTENT: "Контент", + courtsCaseSubjectGOVERNANCE: "Управление / правила", + courtsCaseSubjectFINANCIAL: "Финансы / ресурсы", + courtsCaseSubjectOTHER: "Другое", + courtsHiddenRespondent: "Скрыто (видно только участникам дела).", + courtsThRole: "Роль", + courtsRoleAccuser: "Обвинитель", + courtsRoleDefence: "Защита", + courtsRoleMediator: "Посредник", + courtsRoleJudge: "Судья", + courtsRoleDictator: "Диктатор", + courtsAssignJudgeTitle: "Выбрать судью", + courtsAssignJudgeBtn: "Выбрать судью", + trendingTitle: "В тренде", + exploreTrending: "Изучайте самый популярный контент в вашей сети.", + RECENTButton: "НЕДАВНИЕ", + bookmarkButton: "ЗАКЛАДКИ", + transferButton: "ПЕРЕВОДЫ", + eventButton: "СОБЫТИЯ", + taskButton: "ЗАДАЧИ", + votesButton: "ГОЛОСОВАНИЯ", + reportButton: "ОТЧЁТЫ", + feedButton: "ЛЕНТА", + marketButton: "РЫНОК", + imageButton: "ИЗОБРАЖЕНИЯ", + audioButton: "АУДИО", + videoButton: "ВИДЕО", + documentButton: "ДОКУМЕНТЫ", + torrentButton: "ТОРРЕНТЫ", + noTrendingFound: "Трендовый контент не найден.", + noContentMessage: "Трендового контента пока нет.", + trendingDescription: "Описание", + trendingDate: "Дата", + trendingLocation: "Местоположение", + trendingPrice: "Цена", + trendingUrl: "URL", + trendingCategory: "Категория", + trendingStart: "Начало", + trendingEnd: "Конец", + trendingPriority: "Приоритет", + trendingStatus: "Статус", + trendingFrom: "От", + trendingTo: "Кому", + trendingConcept: "Концепция", + trendingAmount: "Сумма", + trendingDeadline: "Крайний срок", + trendingItemStatus: "Статус элемента", + trendingTotalVotes: "Всего голосов", + trendingTotalOpinions: "Всего мнений", + trendingNoContentMessage: "Трендового контента пока нет.", + trendingAuthor: "Автор", + trendingCreatedAtLabel: "Создано", + trendingTotalCount: "Всего", + tasksTitle: "Задачи", + tasksDescription: "Находите и управляйте задачами в вашей сети.", + taskTitleLabel: "Название", + taskDescriptionLabel: "Описание", + taskStartTimeLabel: "Время начала", + taskEndTimeLabel: "Время окончания", + taskPriorityLabel: "Приоритет", + taskPrioritySelect: "Выберите приоритет", + taskPriorityUrgent: "Срочный", + taskPriorityHigh: "Высокий", + taskPriorityMedium: "Средний", + taskPriorityLow: "Низкий", + taskLocationLabel: "Местоположение", + taskTagsLabel: "Теги", + taskVisibilityLabel: "Видимость", + taskPublic: "публичная", + taskPrivate: "приватная", + taskCreatedAt: "Создано", + taskBy: "Автор", + taskStatus: "Статус", + taskStatusOpen: "Открыта", + taskStatusInProgress: "В работе", + taskStatusClosed: "Закрыта", + taskAssignedTo: "Назначено", + taskAssignees: "Исполнители", + taskAssignButton: "Назначить себе", + taskUnassignButton: "Снять назначение", + taskCreateButton: "Создать задачу", + taskUpdateButton: "Обновить", + taskDeleteButton: "Удалить", + taskFilterAll: "ВСЕ", + taskFilterMine: "МОИ", + taskFilterOpen: "ОТКРЫТЫЕ", + taskFilterInProgress: "В РАБОТЕ", + taskFilterClosed: "ЗАКРЫТЫЕ", + taskFilterAssigned: "НАЗНАЧЕННЫЕ", + taskFilterArchived: "АРХИВ", + taskFilterUrgent: "СРОЧНЫЕ", + taskFilterHigh: "ВЫСОКИЙ", + taskFilterMedium: "СРЕДНИЙ", + taskFilterLow: "НИЗКИЙ", + taskAllSectionTitle: "Задачи", + taskMineSectionTitle: "Ваши задачи", + taskCreateSectionTitle: "Создать задачу", + taskUpdateSectionTitle: "Обновить", + taskOpenTitle: "Открытые задачи", + taskInProgressTitle: "Задачи в работе", + taskClosedTitle: "Закрытые задачи", + taskAssignedTitle: "Назначенные задачи", + taskArchivedTitle: "Архивные задачи", + taskPublicTitle: "Публичные задачи", + taskPrivateTitle: "Приватные задачи", + notasks: "Нет доступных задач.", + noLocation: "Местоположение не указано", + taskSetStatus: "Установить статус", + eventTitle: "Событие", + eventDateLabel: "Дата", + eventsTitle: "События", + eventsDescription: "Находите и управляйте событиями в вашей сети.", + eventDescription: "Описание", + eventPrice: "Цена", + eventStatus: "Статус", + eventOrganizer: "Организатор", + eventAllSectionTitle: "События", + eventMineSectionTitle: "Ваши события", + eventArchivedTitle: "Архивные события", + eventCreateSectionTitle: "Создать событие", + eventUpdateSectionTitle: "Обновить событие", + eventDeleteButton: "Удалить", + eventCreateButton: "Создать событие", + eventAddToCalendar: "Добавить в календарь", + eventVisitCalendar: "Открыть календарь", + viewTask: "Открыть задачу", + viewReport: "Открыть отчёт", + viewTransfer: "Открыть перевод", + viewItem: "Открыть объект", + viewShop: "Открыть магазин", + viewProject: "Открыть проект", + viewVotation: "Открыть голосование", + voteCastTitle: "Проголосовать", + voteResults: "Результаты", + eventTitleLabel: "Название", + eventDescriptionLabel: "Описание", + eventDescriptionPlaceholder: "Введите описание события...", + eventUpdateButton: "Обновить", + eventAttendeesLabel: "Участники", + eventAttendeesPlaceholder: "Введите участников через запятую...", + eventTagsLabel: "Теги", + eventTagsPlaceholder: "Введите теги через запятую...", + eventTags: "Теги", + eventPriceLabel: "Цена", + eventUrlLabel: "URL", + eventAttendees: "Участники", + noAttendees: "Участников пока нет", + eventCreatedAt: "Создано", + eventLocation: "Местоположение", + eventLocationLabel: "Местоположение", + eventNoLocation: "Местоположение не указано", + eventNoURL: "URL не указан", + eventBy: "Автор", + noevents: "Нет доступных событий.", + eventDate: "Дата", + eventDateFormat: "ДД:ММ:ГГГГ ЧЧ:мм", + eventAttendButton: "Принять участие", + eventUnattendButton: "Отменить участие", + eventCreatedBy: "Создано", + eventAttendeesCount: "Количество участников", + eventCreatedByYou: "Вы создали это событие", + eventAttendConfirmation: "Вы теперь участвуете в этом событии", + eventUnattendConfirmation: "Вы больше не участвуете в этом событии", + eventFilterAll: "ВСЕ", + eventFilterMine: "МОИ", + eventFilterToday: "СЕГОДНЯ", + eventFilterWeek: "ЭТА НЕДЕЛЯ", + eventFilterMonth: "ЭТОТ МЕСЯЦ", + eventFilterYear: "ЭТОТ ГОД", + eventFilterArchived: "АРХИВ", + eventTodayTitle: "Сегодняшние события", + eventThisWeekTitle: "События этой недели", + eventThisMonthTitle: "События этого месяца", + eventThisYearTitle: "События этого года", + eventPrivacyLabel: "Видимость", + eventPublic: "Публичное", + eventPrivate: "Приватное", + eventPublicTitle: "Публичные события", + eventNoPrice: "Бесплатное событие", + eventNoImage: "Изображение не загружено", + eventAttended: "Участвует", + eventUnattended: "Не участвует", + eventStatusOpen: "Открыто", + eventStatusClosed: "Закрыто", + tagsTitle: "Теги", + tagsDescription: "Изучайте и исследуйте таксономические шаблоны в вашей сети.", + tagsAllSectionTitle: "Теги", + tagsTopSectionTitle: "Популярные теги", + tagsCloudSectionTitle: "Облако тегов", + tagsFilterAll: "ВСЕ", + tagsFilterTop: "ЛУЧШИЕ", + tagsFilterCloud: "ОБЛАКО", + tagsNoItems: "Нет доступных тегов.", + tagsTableHeaderTag: "ТЕГ/ССЫЛКА:", + tagsTableHeaderCount: "СЧЁТЧИК:", + transfersTitle: "Переводы", + transfersDescription: "Находите и управляйте переводами в вашей сети.", + transfersFrom: "От", + transfersTo: "Кому", + transfersFilterAll: "ВСЕ", + transfersFilterMine: "МОИ", + transfersFilterUBI: "UBI", + transfersFilterMarket: "РЫНОК", + transfersFilterTop: "ЛУЧШИЕ", + transfersFilterPending: "ОЖИДАЮЩИЕ", + transfersFilterUnconfirmed: "НЕПОДТВЕРЖДЁННЫЕ", + transfersFilterClosed: "ЗАКРЫТЫЕ", + transfersFilterDiscarded: "ОТМЕНЁННЫЕ", + transfersFilterEconomic: "ЭКОНОМИЧЕСКИЕ", + transfersFilterTime: "ВРЕМЯ", + transfersFilterTrust: "ДОВЕРИЕ", + transfersCategory: "Категория", + transfersCategoryEconomic: "Экономический", + transfersCategoryTime: "Время", + transfersCategoryTrust: "Доверие", + transfersCategoryApply: "Применить", + transfersUnitEco: "ECO", + transfersUnitHours: "ч", + transfersUnitTrust: "доверие", + transfersEconomicSectionTitle: "Экономические переводы", + transfersTimeSectionTitle: "Переводы времени", + transfersTrustSectionTitle: "Переводы доверия", + transfersCreateButton: "Создать перевод", + transfersUpdateButton: "Обновить", + transfersDeleteButton: "Удалить", + transfersToUser: "Oasis ID", + transfersToUserValidation: "Действительный Oasis ID, напр. @...=.ed25519", + transfersConcept: "Концепция", + transfersAmount: "Сумма", + transfersDeadline: "Крайний срок", + transfersTags: "Теги", + transfersStatus: "Статус", + transfersStatusUnconfirmed: "НЕПОДТВЕРЖДЁН", + transfersStatusClosed: "ЗАКРЫТ", + transfersStatusDiscarded: "ОТМЕНЁН", + transfersCreatedAt: "Создано", + transfersConfirmations: "ПОДТВЕРЖДЕНИЯ", + transfersContainingBlock: "Содержащий блок", + transfersExportContract: "Создать контракт", + transfersConfirmButton: "Подтвердить перевод", + transfersNoItems: "Переводы не найдены.", + transfersMineSectionTitle: "Ваши переводы", + transfersUBISectionTitle: "UBI переводы", + transfersMarketSectionTitle: "Рыночные переводы", + transfersTopSectionTitle: "Лучшие переводы", + transfersPendingSectionTitle: "Ожидающие переводы", + transfersUnconfirmedSectionTitle: "Неподтверждённые переводы", + transfersClosedSectionTitle: "Закрытые переводы", + transfersDiscardedSectionTitle: "Отменённые переводы", + transfersCreateSectionTitle: "Создать перевод", + transfersAllSectionTitle: "Переводы", + transfersFilterFavs: "Избранное", + transfersFavsSectionTitle: "Избранные переводы", + transfersSearchLabel: "Поиск", + transfersSearchPlaceholder: "Искать концепцию, теги, пользователей...", + transfersMinAmountLabel: "Мин. сумма", + transfersMaxAmountLabel: "Макс. сумма", + transfersSortLabel: "Сортировать по", + transfersSortRecent: "Новейшие", + transfersSortAmount: "Наибольшая сумма", + transfersSortDeadline: "Ближайший срок", + transfersSearchButton: "Искать", + transfersFavoriteButton: "В избранное", + transfersUnfavoriteButton: "Из избранного", + transfersMessageUserButton: "Написать", + transfersExpiringSoonBadge: "ИСТЕКАЕТ", + transfersExpiredBadge: "ИСТЁК", + transfersUpdatedAt: "Обновлено", + transfersNoMatch: "Переводы не найдены по вашему запросу.", + votationsTitle: "Голосования", + votationsDescription: "Находите и управляйте голосованиями в вашей сети.", + voteMineSectionTitle: "Ваши голосования", + voteCreateSectionTitle: "Создать голосование", + voteUpdateSectionTitle: "Обновить", + voteOpenTitle: "Открытые голосования", + voteClosedTitle: "Закрытые голосования", + voteAllSectionTitle: "Голосования", + voteCreateButton: "Создать голосование", + voteUpdateButton: "Обновить", + voteDeleteButton: "Удалить", + voteOptionYes: "ЗА", + voteOptionNo: "ПРОТИВ", + voteOptionAbstention: "ВОЗДЕРЖАНИЕ", + voteConfused: "ЗАМЕШАТЕЛЬСТВО", + voteFollowMajority: "СЛЕДОВАТЬ БОЛЬШИНСТВУ", + voteNotInterested: "НЕ ИНТЕРЕСНО", + voteFilterAll: "ВСЕ", + voteFilterMine: "МОИ", + voteFilterOpen: "ОТКРЫТЫЕ", + voteFilterClosed: "ЗАКРЫТЫЕ", + voteQuestionLabel: "Вопрос", + voteDeadlineLabel: "Крайний срок", + voteOptionsLabel: "Ваш голос", + voteBreakdown: "Разбивка", + voteFinalResult: "РЕЗУЛЬТАТ", + voteNoQuorum: "НЕТ КВОРУМА", + voteTagsLabel: "Теги", + voteDeadline: "Крайний срок", + voteStatus: "Статус", + voteTags: "Теги", + voteOpinions: "Мнения", + novotes: "Нет доступных голосований.", + voteBy: "Автор", + voteCreatedAt: "Создано", + voteNoQuestion: "Вопрос не указан", + voteUnknownCreator: "Неизвестный автор", + voteUnknownDate: "Неизвестная дата", + errorVoteNotFound: "Голосование не найдено", + errorAlreadyVoted: "Вы уже высказали мнение.", + errorVoteClosedCannotEdit: "Нельзя редактировать закрытое голосование", + errorVoteDeadlinePassed: "Срок этого голосования истёк", + errorRetrievingVote: "Ошибка получения голосования", + errorCreatingVote: "Ошибка создания голосования", + errorVoteAlreadyVoted: "Нельзя редактировать после голосования", + errorDeletingOldVote: "Ошибка удаления старого мнения", + errorCreatingUpdatedVote: "Ошибка создания обновлённого мнения", + errorCreatingTombstone: "Ошибка создания записи об удалении", + voteDetailSectionTitle: 'Детали голосования', + voteCommentsLabel: 'Комментарии', + voteCommentsForumButton: 'Открыть обсуждение', + voteCommentsSectionTitle: 'Открыть обсуждение', + voteNoCommentsYet: 'Комментариев пока нет. Будьте первым.', + voteNewCommentPlaceholder: 'Напишите ваш комментарий...', + voteNewCommentButton: 'Отправить комментарий', + voteNewCommentLabel: 'Добавить комментарий', + cvTitle: "Резюме", + cvLabel: "Curriculum Vitae (CV)", + cvEditSectionTitle: "Редактировать резюме", + cvCreateSectionTitle: "Создать резюме", + cvDescription: "Управляйте и делитесь профессиональными навыками и информацией.", + cvNameLabel: "Полное имя", + cvDescriptionLabel: "Описание", + cvPhotoLabel: "Фото", + cvPersonalExperiencesLabel: "Личный опыт", + cvPersonalSkillsLabel: "Личные навыки (через запятую)", + cvOasisExperiencesLabel: "Опыт вклада в Oasis", + cvOasisSkillsLabel: "Навыки вклада в Oasis (через запятую)", + cvEducationExperiencesLabel: "Образовательный опыт", + cvEducationalSkillsLabel: "Образовательные навыки (через запятую)", + cvProfessionalExperiencesLabel: "Профессиональный опыт", + cvProfessionalSkillsLabel: "Профессиональные навыки (через запятую)", + cvLanguagesLabel: "Языки", + cvLocationLabel: "Местоположение", + cvStatusLabel: "Статус", + cvPreferencesLabel: "Предпочтения", + cvOasisContributorLabel: "Участник Oasis", + cvPersonal: "Личное", + cvOasis: "Участник Oasis (необязательно)", + cvOasisContributorView: "Вклад в Oasis", + cvEducational: "Образование (необязательно)", + cvEducationalView: "Образование", + cvProfessional: "Профессиональное (необязательно)", + cvProfessionalView: "Профессиональное", + cvAvailability: "Доступность (необязательно)", + cvAvailabilityView: "Доступность", + cvUpdateButton: "Обновить", + cvCreateButton: "Создать резюме", + cvContactLabel: "Контакт", + cvCreatedAt: "Создано", + cvUpdatedAt: "Обновлено", + cvEditButton: "Обновить", + cvDeleteButton: "Удалить", + cvNoCV: "Резюме не найдено.", + blogSubject: "Тема", + blogMessage: "Текст ограничен 8096 символами.", + blogImage: "Загрузить медиа (макс: 50MB)", + blogPublish: "Предварительный просмотр", + noPopularMessages: "Популярных сообщений пока нет", + forumTitle: "Форумы", + forumCategoryLabel: "Категория", + forumTitleLabel: "Название", + forumTitlePlaceholder: "Название форума...", + forumCreateButton: "Создать форум", + forumCreateSectionTitle: "Создать форум", + forumDescription: "Общайтесь открыто с другими жителями вашей сети.", + forumFilterAll: "ВСЕ", + forumFilterMine: "МОИ", + forumFilterRecent: "НЕДАВНИЕ", + forumFilterTop: "ТОП", + forumMineSectionTitle: "Ваши форумы", + forumRecentSectionTitle: "Последние форумы", + forumAllSectionTitle: "Форумы", + forumDeleteButton: "Удалить", + forumParticipants: "участники", + forumMessages: "сообщения", + forumLastMessage: "Последнее сообщение", + forumMessageLabel: "Сообщение", + forumMessagePlaceholder: "Напишите сообщение...", + forumSendButton: "Отправить", + forumVisitForum: "Посетить форум", + noForums: "Форумы не найдены.", + forumVisitButton: "Посетить форум", + forumCatGENERAL: "Общее", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "Политика", + forumCatTECH: "Технологии", + forumCatSCIENCE: "Наука", + forumCatMUSIC: "Музыка", + forumCatART: "Искусство", + forumCatGAMING: "Игры", + forumCatBOOKS: "Книги", + forumCatFILMS: "Фильмы", + forumCatPHILOSOPHY: "Философия", + forumCatSOCIETY: "Общество", + forumCatPRIVACY: "Конфиденциальность", + forumCatCYBERWARFARE: "Кибервойна", + forumCatSURVIVALISM: "Выживание", + imageTitle: "Изображения", + imageDescription: "Исследуйте и управляйте изображениями в вашей сети.", + imagePluginTitle: "Название", + imagePluginDescription: "Описание", + imageMineSectionTitle: "Ваши изображения", + imageCreateSectionTitle: "Загрузить изображение", + imageUpdateSectionTitle: "Обновить изображение", + imageAllSectionTitle: "Изображения", + imageRecentSectionTitle: "Последние изображения", + imageTopSectionTitle: "Топ изображений", + imageFavoritesSectionTitle: "Избранное", + imageGallerySectionTitle: "Галерея", + imageMemeSectionTitle: "Мемы", + imageFilterAll: "ВСЕ", + imageFilterMine: "МОИ", + imageFilterRecent: "НЕДАВНИЕ", + imageFilterTop: "ТОП", + imageFilterFavorites: "ИЗБРАННОЕ", + imageFilterGallery: "ГАЛЕРЕЯ", + imageFilterMeme: "МЕМЫ", + imageCreateButton: "Загрузить изображение", + imageUpdateButton: "Обновить", + imageDeleteButton: "Удалить", + imageAddFavoriteButton: "Добавить в избранное", + imageRemoveFavoriteButton: "Удалить из избранного", + imageFileLabel: "Выбрать файл изображения (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "Теги", + imageTagsPlaceholder: "Введите теги через запятую", + imageTitleLabel: "Название", + imageTitlePlaceholder: "Необязательно", + imageDescriptionLabel: "Описание", + imageDescriptionPlaceholder: "Необязательно", + imageMemeLabel: "Отметить как МЕМ", + imageNoFile: "Файл изображения не предоставлен", + noImages: "Изображений нет.", + imageSearchPlaceholder: "Поиск по названию, тегам, описанию, автору...", + imageSortRecent: "Самые новые", + imageSortOldest: "Самые старые", + imageSortTop: "С наибольшим рейтингом", + imageSearchButton: "Поиск", + imageMessageAuthorButton: "Сообщение", + imageUpdatedAt: "Обновлено", + imageNoMatch: "Изображения не найдены.", + feedTitle: "Лента", + createFeedTitle: "Создать пост", + createFeedButton: "Отправить пост!", + feedPlaceholder: "Что происходит? (макс 280 символов)", + ALLButton: "Посты", + MINEButton: "Ваши посты", + TODAYButton: "СЕГОДНЯ", + TOPButton: "Топ постов", + CREATEButton: "Создать пост", + totalOpinions: "Всего мнений", + moreVoted: "Больше голосов", + alreadyVoted: "Вы уже высказали мнение.", + noFeedsFound: "Посты не найдены.", + author: "Автор", + createdAtLabel: "Создано", + FeedshareYourOpinions: "Открывайте и делитесь короткими текстами в вашей сети.", + refeedButton: "Переопубликовать", + alreadyRefeeded: "Вы уже переопубликовали это.", + activityTitle: "Активность", + yourActivity: "Ваша активность", + globalActivity: "Глобальная активность", + activityList: "Активность", + activityDesc: "Смотрите последнюю активность в вашей сети.", + allButton: "ВСЕ", + mineButton: "МОИ", + noActions: "Активности нет.", + performed: "→", + from: "От", + to: "Кому", + amount: "Сумма", + concept: "Концепция", + description: "Описание", + meme: "Мем", + activityContact: "Контакт", + activityBy: "Имя", + activityPixelia: "Добавлен новый пиксель", + viewImage: "Просмотр изображения", + playAudio: "Воспроизвести аудио", + playVideo: "Воспроизвести видео", + typeRecent: "НЕДАВНИЕ", + errorActivity: "Ошибка получения активности", + typePost: "БЛОГИ", + recentButton: "НЕДАВНИЕ", + typeTribe: "ПЛЕМЕНА", + typeLarp: "L.A.R.P.", + typeInhabitants: "ЖИТЕЛИ", + activityProfileUpdated: "обновил профиль", + typeAbout: "ЖИТЕЛИ", + typeCurriculum: "РЕЗЮМЕ", + typeImage: "ИЗОБРАЖЕНИЯ", + typeBookmark: "ЗАКЛАДКИ", + typeDocument: "ДОКУМЕНТЫ", + typeVotes: "ГОЛОСОВАНИЯ", + typeAudio: "АУДИО", + typeMarket: "РЫНОК", + typeJob: "ВАКАНСИИ", + typeProject: "ПРОЕКТЫ", + typeVideo: "ВИДЕО", + typeVote: "РАСПРОСТРАНИТЬ", + typeEvent: "СОБЫТИЯ", + typeTransfer: "ПЕРЕВОДЫ", + typeTask: "ЗАДАЧИ", + typePixelia: "ПИКСЕЛИ", + typeForum: "ФОРУМЫ", + typeReport: "ОТЧЁТЫ", + typeFeed: "ЛЕНТЫ", + typeContact: "КОНТАКТ", + typePub: "PUB", + typeTombstone: "НАДГРОБИЕ", + typeBanking: "БАНКИНГ", + typeBankWallet: "БАНКИНГ/КОШЕЛЁК", + typeBankClaim: "БАНКИНГ/UBI", + typeKarmaScore: "КАРМА", + typeParliament: "ПАРЛАМЕНТ", + typeSpread: "РАСПРОСТРАНЕНИЯ", + typeParliamentCandidature: "Парламент · Кандидатура", + typeParliamentTerm: "Парламент · Срок", + typeParliamentProposal: "Парламент · Предложение", + typeParliamentRevocation: "Парламент · Отзыв", + typeParliamentLaw: "Парламент · Новый закон", + typeCourts: "СУДЫ", + typeCourtsCase: "Суды · Дело", + typeCourtsEvidence: "Суды · Доказательство", + typeCourtsAnswer: "Суды · Ответ", + typeCourtsVerdict: "Суды · Приговор", + typeCourtsSettlement: "Суды · Мировое соглашение", + typeCourtsSettlementProposal: "Суды · Предложение мирового соглашения", + typeCourtsSettlementAccepted: "Суды · Мировое соглашение принято", + typeCourtsNomination: "Суды · Назначение", + typeCourtsNominationVote: "Суды · Голосование за назначение", + activitySupport: "Заключён новый альянс", + activityJoin: "Присоединился к новому PUB", + question: "Вопрос", + deadline: "Срок", + status: "Статус", + votes: "Голоса", + totalVotes: "Всего голосов", + voteTotalVotes: "Всего голосов", + name: "Имя", + skills: "Навыки", + tags: "Теги", + title: "Название", + date: "Дата", + category: "Категория", + attendees: "Участники", + activitySpread: "->", + visitLink: "Перейти по ссылке", + viewDocument: "Просмотр документа", + location: "Местоположение", + contentWarning: "Тема", + personName: "Имя жителя", + bankWalletConnected: "Кошелёк ECOin", + bankUbiReceived: "Получен UBI", + bankTx: "Транзакция", + bankEpochShort: "Эпоха", + bankingUserEngagementScore: "Оценка КАРМА", + viewDetails: "Просмотр деталей", + link: "Ссылка", + activityProjectFollow: "%OASIS% теперь %ACTION% этот проект %PROJECT%", + activityProjectUnfollow: "%OASIS% теперь %ACTION% этот проект %PROJECT%", + activityProjectPledged: "%OASIS% внёс %ACTION% %AMOUNT% в проект %PROJECT%", + following: "СЛЕЖУ", + unfollowing: "ОТПИСАН", + pledged: "ПОДДЕРЖАЛ", + parliamentCandidatureId: "Кандидатура", + parliamentGovMethod: "Метод", + parliamentVotesReceived: "Получено голосов", + parliamentMethodANARCHY: "Анархия", + parliamentMethodVOTE: "Голосование сообщества", + parliamentMethodRANKED: "Ранговое голосование", + parliamentMethodPLURALITY: "Плюрализм", + parliamentMethodCOUNCIL: "Совет", + parliamentMethodJURY: "Жюри", + parliamentAnarchy: "АНАРХИЯ", + parliamentElectionsStart: "Начало выборов", + parliamentElectionsEnd: "Конец выборов", + parliamentCurrentLeader: "Победившая кандидатура", + parliamentProposalTitle: "Название", + parliamentOpenVote: "Открытое голосование", + parliamentStatus: "Статус", + parliamentLawQuestion: "Вопрос", + parliamentLawMethod: "Метод", + parliamentLawProposer: "Предложивший", + parliamentLawEnacted: "Принято", + parliamentLawVotes: "Голоса", + createdAt: "Создано", + courtsCaseTitle: "Дело", + courtsMethod: "Метод", + courtsMethodJUDGE: "Судья", + courtsMethodJUDGES: "Коллегия судей", + courtsMethodSINGLE_JUDGE: "Единый судья", + courtsMethodJURY: "Жюри", + courtsMethodCOUNCIL: "Совет", + courtsMethodCOMMUNITY: "Сообщество", + courtsMethodMEDIATION: "Медиация", + courtsMethodARBITRATION: "Арбитраж", + courtsMethodVOTE: "Голосование сообщества", + courtsAccuser: "Обвинитель", + courtsRespondent: "Ответчик", + courtsThStatus: "Статус", + courtsThAnswerBy: "Ответ до", + courtsThEvidenceBy: "Доказательства до", + courtsThDecisionBy: "Решение до", + courtsVotesNeeded: "Необходимо голосов", + courtsVotesSlashTotal: "ЗА/ВСЕГО", + courtsOpenVote: "Открытое голосование", + courtsAnswerTitle: "Ответ", + courtsStanceADMIT: "Признаю", + courtsStanceDENY: "Отрицаю", + courtsStancePARTIAL: "Частично", + courtsStanceCOUNTERCLAIM: "Встречный иск", + courtsStanceNEUTRAL: "Нейтрально", + courtsVerdictResult: "Результат", + courtsVerdictOrders: "Предписания", + courtsSettlementText: "Соглашение", + courtsSettlementAccepted: "Принято", + courtsSettlementPending: "В ожидании", + courtsJudge: "Судья", + courtsThSupports: "Поддержки", + courtsFilterOpenCase: "Открытое дело", + courtsEvidenceFileLabel: "Файл доказательства (изображение, аудио, видео или PDF)", + courtsCaseMediators: "Медиаторы", + courtsCaseMediatorsPh: "Oasis ID медиаторов через запятую", + courtsMediatorsLabel: "Медиаторы", + courtsThCase: "Дело", + courtsThCreatedAt: "Дата начала", + courtsThActions: "Действия", + courtsPublicPrefLabel: "Видимость после решения", + courtsPublicPrefYes: "Согласен, что это дело может быть полностью публичным", + courtsPublicPrefNo: "Предпочитаю сохранить детали в тайне", + courtsPublicPrefSubmit: "Сохранить настройку видимости", + courtsNoCases: "Дел нет.", + reportsTitle: "Отчёты", + reportsDescription: "Управляйте и отслеживайте отчёты, связанные с проблемами, ошибками, злоупотреблениями и предупреждениями в вашей сети.", + reportsFilterAll: "ВСЕ", + reportsFilterMine: "МОИ", + reportsFilterFeatures: "ФУНКЦИИ", + reportsFilterBugs: "ОШИБКИ", + reportsFilterAbuse: "ЗЛОУПОТРЕБЛЕНИЯ", + reportsFilterContent: "КОНТЕНТ", + reportsFilterConfirmed: "ПОДТВЕРЖДЁННЫЕ", + reportsFilterResolved: "РЕШЁННЫЕ", + reportsFilterOpen: "ОТКРЫТЫЕ", + reportsFilterUnderReview: "НА РАССМОТРЕНИИ", + reportsFilterInvalid: "НЕДЕЙСТВИТЕЛЬНЫЕ", + reportsCreateButton: "Создать отчёт", + reportsTitleLabel: "Название", + reportsDescriptionLabel: "Описание", + reportsDescriptionPlaceholder: "Пожалуйста, предоставьте подробное описание.", + reportsCategory: "Категория", + reportsCategoryLabel: "Категория", + reportsCategoryFeatures: "Функции", + reportsCategoryBugs: "Ошибки", + reportsCategoryAbuse: "Злоупотребления", + reportsCategoryContent: "Проблемы с контентом", + reportsUpdateButton: "Обновить", + reportsDeleteButton: "Удалить", + reportsDateLabel: "Дата", + reportsUploadFile: "Загрузить медиа (макс: 50MB)", + reportsCreatedBy: "Автор", + reportsMineSectionTitle: "Ваши отчёты", + reportsFeaturesSectionTitle: "Запросы функций", + reportsBugsSectionTitle: "Ошибки", + reportsAbuseSectionTitle: "Отчёты о злоупотреблениях", + reportsContentSectionTitle: "Проблемы с контентом", + reportsAllSectionTitle: "Отчёты", + reportsNoItems: "Отчётов нет.", + reportsValidationTitle: "Введите допустимое название.", + reportsValidationDescription: "Описание не может быть пустым.", + reportsValidationCategory: "Выберите категорию.", + reportsCreatedAt: "Создано", + reportsSeverity: "Серьёзность", + reportsSeverityLow: "Низкая", + reportsSeverityMedium: "Средняя", + reportsSeverityHigh: "Высокая", + reportsSeverityCritical: "Критическая", + reportsStatus: "Статус", + reportsStatusOpen: "Открытый", + reportsStatusUnderReview: "На рассмотрении", + reportsStatusResolved: "Решён", + reportsStatusInvalid: "Недействительный", + reportsUpdateStatusButton: "Обновить статус", + reportsAnonymityOption: "Отправить анонимно", + reportsAnonymousAuthor: "Анонимный", + reportsConfirmButton: "ПОДТВЕРДИТЬ ОТЧЁТ!", + reportsConfirmations: "Подтверждения", + reportsConfirmedSectionTitle: "Подтверждённые отчёты", + reportsCreateTaskButton: "СОЗДАТЬ ЗАДАЧУ", + reportsOpenSectionTitle: "Открытые отчёты", + reportsUnderReviewSectionTitle: "Отчёты на рассмотрении", + reportsResolvedSectionTitle: "Решённые отчёты", + reportsInvalidSectionTitle: "Недействительные отчёты", + reportsTemplateSectionTitle: "Шаблон отчёта", + reportsBugTemplateTitle: "Детали воспроизведения (Ошибки)", + reportsFeatureTemplateTitle: "Детали (Функции)", + reportsAbuseTemplateTitle: "Детали (Злоупотребления)", + reportsContentTemplateTitle: "Детали (Проблемы с контентом)", + reportsStepsToReproduceLabel: "Шаги воспроизведения", + reportsStepsToReproducePlaceholder: "1) ...\n2) ...\n3) ...", + reportsExpectedBehaviorLabel: "Ожидаемый результат", + reportsExpectedBehaviorPlaceholder: "Что должно произойти?", + reportsActualBehaviorLabel: "Фактический результат", + reportsActualBehaviorPlaceholder: "Что произошло на самом деле?", + reportsEnvironmentLabel: "Среда", + reportsEnvironmentPlaceholder: "Версия, устройство, ОС, браузер, настройки, журналы и т.д.", + reportsReproduceRateLabel: "Частота воспроизведения", + reportsReproduceRateAlways: "Всегда", + reportsReproduceRateOften: "Часто", + reportsReproduceRateSometimes: "Иногда", + reportsReproduceRateRarely: "Редко", + reportsReproduceRateUnable: "Невозможно воспроизвести", + reportsReproduceRateUnknown: "Не указано", + reportsProblemStatementLabel: "Проблема / потребность", + reportsProblemStatementPlaceholder: "Какую проблему решает эта функция?", + reportsUserStoryLabel: "История жителя", + reportsUserStoryPlaceholder: "Как <житель>, я хочу <действие>, чтобы <польза>.", + reportsAcceptanceCriteriaLabel: "Критерии приёмки", + reportsAcceptanceCriteriaPlaceholder: "- Дано...\n- Когда...\n- Тогда...", + reportsWhatHappenedLabel: "Что произошло?", + reportsWhatHappenedPlaceholder: "Опишите инцидент с контекстом и примерными датами.", + reportsReportedUserLabel: "Сообщаемый житель / сущность", + reportsReportedUserPlaceholder: "@житель, лента, ID, ссылка и т.д.", + reportsEvidenceLinksLabel: "Доказательства / ссылки", + reportsEvidenceLinksPlaceholder: "Вставьте ссылки, ID сообщений, скриншоты (если применимо) и т.д.", + reportsContentLocationLabel: "Где находится контент", + reportsContentLocationPlaceholder: "Ссылка, ID, канал, тред, автор и т.д.", + reportsWhyInappropriateLabel: "Почему это неуместно", + reportsWhyInappropriatePlaceholder: "Объясните причину и последствия.", + reportsRequestedActionLabel: "Запрашиваемое действие", + reportsRequestedActionPlaceholder: "Удалить, скрыть, пометить, предупредить и т.д.", + tribesTitle: "Племена", + tribeAllSectionTitle: "Племена", + tribeMineSectionTitle: "Ваши племена", + tribeCreateSectionTitle: "Создать племя", + tribeUpdateSectionTitle: "Обновить племя", + tribeGallerySectionTitle: "Галерея племён", + tribeRecentSectionTitle: "Недавние племена", + tribeTopSectionTitle: "Популярные племена", + tribeviewTribeButton: "Посетить племя", + tribeviewSubTribeButton: "Посетить под-племя", + tribeRootLabel: "КОРЕНЬ", + tribeDescription: "Исследуйте или создавайте племена в вашей сети.", + tribeFilterAll: "ВСЕ", + tribeFilterMine: "МОИ", + tribeFilterMembership: "ЧЛЕНСТВО", + tribeFilterRecent: "НЕДАВНИЕ", + tribeFilterTop: "ТОП", + tribeFilterSubtribes: "ПОДПЛЕМЕНА", + tribeFilterGallery: "ГАЛЕРЕЯ", + tribeMainTribeLabel: "ГЛАВНОЕ ПЛЕМЯ", + tribeCreateButton: "Создать племя", + tribeUpdateButton: "Обновить", + tribeDeleteButton: "Удалить", + tribeImageLabel: "Загрузить медиа (макс: 50MB)", + tribeTitleLabel: "Название", + searchTribesPlaceholder: "ФИЛЬТР племён ПО ИМЕНИ …", + tribeTitlePlaceholder: "Название племени", + tribeDescriptionLabel: "Описание", + tribeDescriptionPlaceholder: "Опишите это племя", + tribeLocationLabel: "Местоположение", + tribeLocationPlaceholder: "Где находится это племя?", + tribeTagsLabel: "Теги", + tribeTagsPlaceholder: "Введите теги через запятую", + tribeInviteMode: "Режим приглашений", + tribeModeLabel: "РЕЖИМ", + tribeIsAnonymousLabel: "СТАТУС", + tribeMembersCount: "Участники", + tribeInviteCodePlaceholder: "Введите код приглашения", + tribeJoinByCodeButton: "Войти с кодом", + tribeJoinButton: "ВСТУПИТЬ", + tribeEnterInvite: "ВВЕСТИ КОД", + tribeLeaveButton: "ПОКИНУТЬ", + tribeYes: "ДА", + tribeNo: "НЕТ", + tribePublic: "ПУБЛИЧНОЕ", + tribePrivate: "ЧАСТНОЕ", + tribeGenerateInvite: "РАЗОВОЕ ПРИГЛАШЕНИЕ", + tribeOpenInvitation: "ОТКРЫТОЕ ПРИГЛАШЕНИЕ", + tribeJoinOpen: "ПРИСОЕДИНИТЬСЯ К ПЛЕМЕНИ", + tribeRemoveInvitation: "УДАЛИТЬ ПРИГЛАШЕНИЕ", + tribeCreatedAt: "Создано", + tribeAuthor: "Автор", + tribeAuthorLabel: "АВТОР", + tribeStrict: "Строгое", + tribeOpen: "Открытое", + tribeFeedFilterRECENT: "НЕДАВНИЕ", + tribeFeedFilterMINE: "МОИ", + tribeFeedFilterALL: "ВСЕ", + tribeFeedFilterTOP: "ТОП", + tribeFeedRefeeds: "Переопубликации", + tribeFeedRefeed: "Переопубликовать", + tribeFeedMessagePlaceholder: "Написать пост…", + tribeFeedSend: "Отправить", + tribeFeedEmpty: "Пока нет постов ленты.", + noTribes: "Племена пока не найдены.", + tribeNotFound: "Племя не найдено!", + createTribeTitle: "Создать племя", + updateTribeTitle: "Обновить племя", + tribeSectionOverview: "Обзор", + tribeSectionInhabitants: "Жители", + tribeSectionVotations: "ГОЛОСОВАНИЯ", + tribeSectionEvents: "СОБЫТИЯ", + tribeSectionReports: "Отчёты", + tribeSectionTasks: "ЗАДАЧИ", + tribeSectionFeed: "ЛЕНТА", + tribeSectionForum: "ФОРУМ", + tribeSectionMarket: "Рынок", + tribeSectionJobs: "Вакансии", + tribeSectionProjects: "Проекты", + tribeSectionMedia: "Медиа", + tribeSectionImages: "ИЗОБРАЖЕНИЯ", + tribeSectionAudios: "АУДИО", + tribeSectionVideos: "ВИДЕО", + tribeSectionDocuments: "ДОКУМЕНТЫ", + tribeSectionBookmarks: "ЗАКЛАДКИ", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "В этом племени пока нет жителей.", + tribeEventCreate: "Создать событие", + tribeEventsEmpty: "Событий пока нет.", + tribeEventTitle: "Название", + tribeEventDescription: "Описание", + tribeEventDate: "Дата", + tribeEventLocation: "Местоположение", + tribeEventAttend: "УЧАСТВУЮ", + tribeEventUnattend: "ОТКАЗАТЬСЯ", + tribeEventAttendees: "Участники", + tribeTaskCreate: "Создать задачу", + tribeTasksEmpty: "Задач пока нет.", + tribeTaskTitle: "Название", + tribeTaskDescription: "Описание", + tribeTaskPriority: "Приоритет", + tribeTaskDeadline: "Срок", + tribeTaskAssignees: "Исполнители", + tribeTaskStatusInProgress: "В ПРОЦЕССЕ", + tribeTaskStatusClosed: "ЗАКРЫТЬ", + tribeTaskAssign: "НАЗНАЧИТЬ", + tribeTaskUnassign: "СНЯТЬ НАЗНАЧЕНИЕ", + tribeReportCreate: "Создать отчёт", + tribeReportsEmpty: "Отчётов пока нет.", + tribeReportTitle: "Название", + tribeReportDescription: "Описание", + tribeReportCategory: "Категория", + tribeVotationCreate: "Создать голосование", + tribeVotationsEmpty: "Голосований пока нет.", + tribeVotationTitle: "Название", + tribeVotationDescription: "Описание", + tribeVotationOptions: "Варианты", + tribeVotationDeadline: "Срок", + tribeVotationVote: "ГОЛОСОВАТЬ", + tribeVotationResults: "Голоса", + tribeVotationClose: "ЗАКРЫТЬ ГОЛОСОВАНИЕ", + tribeVotationOptionPlaceholder: "Вариант", + tribeForumCreate: "Создать форум", + tribeForumEmpty: "Тредов пока нет.", + tribeForumTitle: "Название", + tribeForumText: "Сообщение", + tribeForumCategory: "Категория", + tribeForumReply: "Ответить", + tribeForumReplies: "Ответы", + tribeMarketCreate: "Создать объявление", + tribeMarketEmpty: "Объявлений пока нет.", + tribeMarketTitle: "Название", + tribeMarketDescription: "Описание", + tribeMarketPrice: "Цена", + tribeMarketImage: "Изображение", + tribeMarketCategory: "Категория", + tribeJobCreate: "Создать вакансию", + tribeJobsEmpty: "Вакансий пока нет.", + tribeJobTitle: "Название", + tribeJobDescription: "Описание", + tribeJobLocation: "Местоположение", + tribeJobSalary: "Зарплата", + tribeJobDeadline: "Срок", + tribeProjectCreate: "Создать проект", + tribeProjectsEmpty: "Проектов пока нет.", + tribeProjectTitle: "Название", + tribeProjectDescription: "Описание", + tribeProjectGoal: "Цель", + tribeProjectFunded: "Финансировано", + tribeProjectDeadline: "Срок", + tribeMediaUpload: "Загрузить медиа", + readDocument: "Читать документ", + tribeCreateImage: "Создать изображение", + tribeCreateAudio: "Создать аудио", + tribeCreateVideo: "Создать видео", + tribeCreateDocument: "Создать документ", + tribeCreateBookmark: "Создать закладку", + tribeMediaEmpty: "Медиа пока нет.", + tribeMediaTitle: "Название", + tribeMediaDescription: "Описание", + tribeMediaType: "Тип", + tribeMediaTypeImage: "Изображение", + tribeMediaTypeVideo: "Видео", + tribeMediaTypeAudio: "Аудио", + tribeMediaTypeDocument: "Документ", + tribeMediaTypeBookmark: "Закладка", + tribeContentDelete: "УДАЛИТЬ", + tribeInviteCodeText: "Код приглашения: ", + oasisVersionLabel: "Версия Oasis", + tribeInviteCodeHint: "Поделитесь этим кодом с тем, кого хотите пригласить. Он может присоединиться через /invites → Tribes.", + tribeGroupTribe: "Племя", + tribeGroupOffice: "Офис", + tribeGroupNetwork: "Сеть", + tribeGroupEconomy: "Экономика", + tribeGroupMedia: "Медиа", + tribeStatusOpen: "ОТКРЫТОЕ", + tribeStatusClosed: "ЗАКРЫТОЕ", + tribeStatusInProgress: "В ПРОЦЕССЕ", + tribePriorityLow: "НИЗКИЙ", + tribePriorityMedium: "СРЕДНИЙ", + tribePriorityHigh: "ВЫСОКИЙ", + tribePriorityCritical: "КРИТИЧЕСКИЙ", + tribeTaskFilterAll: "ВСЕ", + tribeMediaFilterAll: "ВСЕ", + tribeReportCatBug: "ОШИБКА", + tribeReportCatAbuse: "ЗЛОУПОТРЕБЛЕНИЕ", + tribeReportCatContent: "КОНТЕНТ", + tribeReportCatOther: "ДРУГОЕ", + tribeForumCatGeneral: "ОБЩЕЕ", + tribeForumCatProposal: "ПРЕДЛОЖЕНИЕ", + tribeForumCatQuestion: "ВОПРОС", + tribeForumCatAnnouncement: "ОБЪЯВЛЕНИЕ", + tribeMarketCatGoods: "ТОВАРЫ", + tribeMarketCatServices: "УСЛУГИ", + tribeMarketCatFood: "ЕДА", + tribeMarketCatOther: "ДРУГОЕ", + tribeStatusLabel: "Статус", + tribeSubTribes: "ПОДПЛЕМЕНА", + tribeSubTribesCreate: "Создать подплемя", + tribeSubTribesStrictDenied: "Строгий режим племени не позволяет создавать новые подплемена. Пожалуйста, свяжитесь с администратором.", + tribeSubTribesEmpty: "Подплемён пока не создано.", + tribeActivityJoined: "ВСТУПИЛ", + tribeActivityLeft: "ПОКИНУЛ", + tribeActivityFeed: "ЛЕНТА", + tribeActivityRefeed: "ПЕРЕОПУБЛИКОВАЛ", + tribeGroupAnalytics: "Аналитика", + tribeGroupCreative: "Творчество", + tribeSectionActivity: "АКТИВНОСТЬ", + tribeSectionTrending: "ПОПУЛЯРНОЕ", + tribeSectionOpinions: "МНЕНИЯ", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "ТЕГИ", + tribeSectionSearch: "ПОИСК", + tribeActivityEmpty: "Активности пока нет.", + tribeActivityCreated: "создал", + tribeActivityPosted: "опубликовал", + tribeActivityReplied: "ответил", + tribeTrendingEmpty: "Популярного контента пока нет.", + tribeTrendingPeriodDay: "Сегодня", + tribeTrendingPeriodWeek: "Эта неделя", + tribeTrendingPeriodAll: "За всё время", + tribeTrendingEngagement: "вовлечённость", + tribeOpinionsEmpty: "Мнений пока нет.", + tribeOpinionsCast: "Голосовать", + tribeOpinionsRankings: "Рейтинги", + tribeOpinionsAlreadyVoted: "Уже проголосовали", + tribeTopCategory: "Больше голосов", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "Совместный пиксель-арт.", + tribePixeliaPaint: "Рисовать", + tribePixeliaContributors: "участников", + tribePixeliaTotalPixels: "пикселей нарисовано", + tribeTagsEmpty: "Тегов не найдено.", + tribeTagsCloud: "Облако тегов", + tribeTagsContentWith: "Контент с тегом", + tribeSearchPlaceholder: "Поиск контента племени...", + tribeSearchEmpty: "Результаты не найдены.", + tribeSearchResults: "Результаты", + tribeSearchMinChars: "Введите не менее 2 символов для поиска.", + agendaTitle: "Повестка дня", + agendaDescription: "Здесь вы можете найти все назначенные вам элементы.", + agendaFilterAll: "ВСЕ", + agendaFilterToday: "СЕГОДНЯ", + agendaFilterUpcoming: "БЛИЖАЙШИЕ", + agendaFilterOverdue: "ПРОСРОЧЕНО", + agendaFilterOpen: "ОТКРЫТЫЕ", + agendaFilterClosed: "ЗАКРЫТЫЕ", + agendaFilterTasks: "ЗАДАЧИ", + agendaFilterMarket: "РЫНОК", + agendaFilterTribes: "ПЛЕМЕНА", + agendaFilterEvents: "СОБЫТИЯ", + agendaFilterReports: "ОТЧЁТЫ", + agendaFilterTransfers: "ПЕРЕВОДЫ", + agendaFilterJobs: "ВАКАНСИИ", + agendaFilterProjects: "ПРОЕКТЫ", + agendaFilterCalendars: "КАЛЕНДАРИ", + agendaNoItems: "Назначений не найдено.", + agendaAuthor: "Автор", + agendaDiscardButton: "Отклонить", + agendaRestoreButton: "Восстановить", + agendaCreatedAt: "Создано", + agendaTitleLabel: "Название", + agendaMembersCount: "Участники", + agendaDescriptionLabel: "Описание", + agendaStatus: "Статус", + agendaVisibility: "Видимость", + agendaEventDate: "Дата события", + agendaEventLocation: "Местоположение", + agendaEventPrice: "Цена", + agendaEventUrl: "URL", + agendaTaskStart: "Время начала", + agendaLocationLabel: "Местоположение", + agendaYes: "ДА", + agendaNo: "НЕТ", + agendaInviteModeLabel: "Статус", + agendaAnonymousLabel: "Анонимный", + agendaMembersLabel: "Участники", + agendareportCategory: "Категория", + agendareportSeverity: "Серьёзность", + agendareportStatus: "Статус", + agendareportDescription: "Описание", + agendaTaskEnd: "Время окончания", + agendaTaskPriority: "Приоритет", + agendaTransferFrom: "От", + agendaTransferTo: "Кому", + agendaTransferConcept: "Концепция", + agendaTransferAmount: "Сумма", + agendaTransferDeadline: "Срок", + opinionsTitle: "Мнения", + shareYourOpinions: "Открывайте и голосуйте за мнения в вашей сети.", + voteNow: "Голосовать", + noOpinionsFound: "Мнений не найдено.", + interestingButton: "ИНТЕРЕСНОЕ", + necessaryButton: "НЕОБХОДИМОЕ", + funnyButton: "СМЕШНОЕ", + disgustingButton: "ОТВРАТИТЕЛЬНОЕ", + sensibleButton: "РАЗУМНОЕ", + propagandaButton: "ПРОПАГАНДА", + adultOnlyButton: "ТОЛЬКО ДЛЯ ВЗРОСЛЫХ", + boringButton: "СКУЧНОЕ", + confusingButton: "ЗАПУТАННОЕ", + inspiringButton: "ВДОХНОВЛЯЮЩЕЕ", + spamButton: "СПАМ", + usefulButton: "ПОЛЕЗНОЕ", + informativeButton: "ИНФОРМАТИВНОЕ", + wellResearchedButton: "ХОРОШО ИССЛЕДОВАННОЕ", + accurateButton: "ТОЧНОЕ", + needsSourcesButton: "НУЖНЫ ИСТОЧНИКИ", + wrongButton: "НЕПРАВИЛЬНОЕ", + lowQualityButton: "НИЗКОЕ КАЧЕСТВО", + creativeButton: "ТВОРЧЕСКОЕ", + insightfulButton: "ПРОНИЦАТЕЛЬНОЕ", + actionableButton: "ПРИМЕНИМОЕ", + loveButton: "ЛЮБЛЮ", + clearButton: "ЯСНОЕ", + upliftingButton: "ПОДНИМАЮЩЕЕ НАСТРОЙ", + unnecessaryButton: "НЕНУЖНОЕ", + rejectedButton: "ОТКЛОНЁННОЕ", + misleadingButton: "ВВОДЯЩЕЕ В ЗАБЛУЖДЕНИЕ", + offTopicButton: "НЕ ПО ТЕМЕ", + duplicateButton: "ДУБЛИРОВАНИЕ", + clickbaitButton: "КЛИКБЕЙТ", + trollButton: "ТРОЛЛЬ", + nsfwButton: "NSFW", + violentButton: "НАСИЛИЕ", + toxicButton: "ТОКСИЧНОЕ", + harassmentButton: "ПРЕСЛЕДОВАНИЕ", + hateButton: "НЕНАВИСТЬ", + scamButton: "МОШЕННИЧЕСТВО", + triggeringButton: "ПРОВОКАЦИОННОЕ", + opinionsCreatedAt: "Создано", + opinionsTotalCount: "Всего мнений", + voteInteresting: "Интересное", + voteNecessary: "Необходимое", + voteUseful: "Полезное", + voteInformative: "Информативное", + voteWellResearched: "Хорошо исследованное", + voteNeedsSources: "Нужны источники", + voteWrong: "Неправильное", + voteLowQuality: "Низкое качество", + voteLove: "Люблю", + voteClear: "Ясное", + voteMisleading: "Вводящее в заблуждение", + voteOffTopic: "Не по теме", + voteDuplicate: "Дублирование", + voteClickbait: "Кликбейт", + votePropaganda: "Пропаганда", + voteFunny: "Смешное", + voteInspiring: "Вдохновляющее", + voteUplifting: "Поднимающее настрой", + voteUnnecessary: "Ненужное", + voteRejected: "Отклонённое", + voteConfusing: "Запутанное", + voteTroll: "Тролль", + voteNsfw: "NSFW", + voteViolent: "Насилие", + voteToxic: "Токсичное", + voteHarassment: "Преследование", + voteHate: "Ненависть", + voteScam: "Мошенничество", + voteTriggering: "Провокационное", + voteInsightful: "Проницательное", + voteAccurate: "Точное", + voteActionable: "Применимое", + voteCreative: "Творческое", + voteSpam: "Спам", + voteAdultOnly: "Только для взрослых", + publishBlog: "Опубликовать блог", + privateMessage: "ЛС", + pmSendTitle: "Личные сообщения", + pmSend: "Отправить!", + pmDescription: "Используйте эту форму для отправки зашифрованного сообщения другим жителям.", + pmRecipients: "Получатели", + pmRecipientsHint: "Введите Oasis ID через запятую", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "Тема", + pmSubjectHint: "Введите тему сообщения", + pmText: "Сообщение", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "Расшифровать", + pmCrypterBadKey: "Общий ключ неверный!", + pmCrypterTooLong: "Сообщение слишком длинное для шифрования с помощью Crypter. Сократите его и попробуйте снова.", + pmCrypterCipherLabel: "Повторно зашифрованное сообщение", + pmCrypterCharsLabel: "символов", + pmInvalidRecipients: "Недействительный Oasis ID (должен быть вида @…=.ed25519).", + pmEncryptionLabel: "Шифрование", + pmFile: "Вложение", + pmInvalidMessage: "Недействительное сообщение", + pmNoSubject: "(без темы)", + pmSubjectLabel: "Тема:", + pmBodyLabel: "Тело", + pmBotJobs: "42-JobsBOT", + pmBotProjects: "42-ProjectsBOT", + pmBotMarket: "42-MarketBOT", + inboxJobSubscribedTitle: "Новая подписка на вашу вакансию", + pmInhabitantWithId: "Житель с OASIS ID:", + pmHasSubscribedToYourJobOffer: "подписался на вашу вакансию", + inboxProjectCreatedTitle: "Создан новый проект", + pmHasCreatedAProject: "создал проект", + inboxMarketItemSoldTitle: "Товар продан", + pmYourItem: "Ваш товар", + pmHasBeenSoldTo: "был продан", + pmFor: "за", + inboxProjectPledgedTitle: "Новый взнос в ваш проект", + pmHasPledged: "внёс", + pmToYourProject: "в ваш проект", + blockchain: "BlockExplorer", + blockchainTitle: "BlockExplorer", + blockchainDescription: "Исследуйте и визуализируйте блоки в блокчейне.", + blockchainNoBlocks: "Блоков в блокчейне не найдено.", + blockchainStatsTitle: "Статистика", + blockchainStatsTypeBreakdown: "Распределение по типам", + blockchainStatsTopAuthors: "Топ авторов", + blockchainStatsCount: "Блоков", + blockchainBlockID: "ID блока", + blockchainBlockAuthor: "Автор", + blockchainBlockCarbon: "Углеродный след", + blockchainBlockType: "Тип", + blockchainBlockTimestamp: "Временная метка", + blockchainBlockContent: "Блок", + blockchainBlockURL: "URL:", + blockchainContent: "Блок", + blockchainContentPreview: "Предварительный просмотр содержимого блока", + blockchainLatestDatagram: "Последняя датаграмма", + blockchainDatagram: "Датаграмма", + blockchainDetails: "Просмотр деталей блока", + blockchainViewBlockexplorer: "Открыть в blockexplorer", + blockchainBlockInfo: "Информация о блоке", + blockchainBlockDetails: "Детали выбранного блока", + blockchainBack: "Назад в BlockExplorer", + blockchainContentDeleted: "Этот контент был удалён", + visitContent: "Посетить контент", + banking: "Банкинг", + bankingTitle: "Банкинг", + bankingDescription: "Исследуйте текущую стоимость ECOin и соответствующее распределение UBI, распределяемое за эпоху на основе участия и доверия.", + bankOverview: "Обзор", + bankEpochs: "Эпохи", + bankRules: "Правила", + pending: "В ожидании", + closed: "Закрыто", + bankBack: "Назад в Банкинг", + bankViewTx: "Просмотр транзакции", + bankClaimNow: "Получить сейчас", + bankClaimUBI: "Получить UBI!", + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: "Нет ожидающих распределений UBI для этой эпохи.", + bankEpoch: "Эпоха", + bankPool: "Пул (эта эпоха)", + bankWeightsSum: "Сумма весов", + bankAllocations: "Распределения", + bankNoAllocations: "Распределений не найдено.", + bankNoEpochs: "Эпох не найдено.", + bankEpochAllocations: "Распределения эпохи", + bankAllocDate: "Дата", + bankAllocConcept: "Концепция", + bankAllocFrom: "От", + bankAllocTo: "Кому", + bankAllocAmount: "Сумма", + bankAllocStatus: "Статус", + bankEpochId: "ID эпохи", + bankRuleHash: "Хэш снимка правил", + bankViewEpoch: "Просмотр эпохи", + bankUserBalance: "Ваш баланс", + ecoWalletNotConfigured: "Кошелёк ECOin не настроен", + editWallet: "Редактировать кошелёк", + addWallet: "Добавить кошелёк", + bankAddresses: "Адреса", + bankNoAddresses: "Адресов не найдено.", + bankUser: "Oasis ID", + bankAddress: "Адрес", + bankAddAddressTitle: "Добавить адрес ECOIN", + bankAddAddressUser: "Oasis ID", + bankAddAddressAddress: "Адрес ECOIN", + bankAddAddressSave: "Сохранить", + bankAddressAdded: "Адрес добавлен", + bankAddressUpdated: "Адрес обновлён", + bankAddressExists: "Адрес уже существует", + bankAddressInvalid: "Недействительный адрес", + bankAddressDeleted: "Адрес удалён", + bankAddressNotFound: "Адрес не найден", + bankAddressForbidden: "Вы можете задать только свой собственный адрес для выплат", + bankAddressTotal: "Всего адресов", + bankAddressSearch: "Поиск @жителя или адреса", + bankAddressActions: "Действия", + bankAddressDelete: "Удалить", + bankAddressSource: "Источник", + bankAddressDeleteConfirm: "Удалить этот адрес?", + bankLocal: "Локальный", + bankFromOasis: "Oasis", + bankMyAddress: "Ваш адрес", + bankRemoveMyAddress: "Удалить мой адрес", + bankNotRemovableOasis: "Адреса нельзя удалить локально", + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "НЕТ СРЕДСТВ!", + bankUbiAvailableOk: "ДОСТУПНО!", + bankUbiAvailability: "Доступность UBI", + bankAlreadyClaimedThisMonth: "Уже получено в этом месяце", + bankUbiThisMonth: "БОД (этот месяц)", + bankUbiLastClaimed: "БОД (последнее получение)", + bankUbiNeverClaimed: "Никогда не получено", + bankUbiTotalClaimed: "БОД (всего получено)", + bankUbiPub: "PUB", + bankUbiInhabitant: "ЖИТЕЛЬ", + bankUbiClaimedAmount: "ПОЛУЧЕНО (ECO)", + typeBankUbiResult: "БАНКИНГ - БОД", + bankNoPubConfigured: "PUB не настроен. Укажите ID PUB в Настройках.", + shopsTitle: "Магазины", + shopDescription: "Откройте и управляйте магазинами в сети.", + shopTitle: "Магазин", + shopFilterAll: "ВСЕ", + shopFilterMine: "МОИ", + shopFilterRecent: "НЕДАВНИЕ", + shopFilterTop: "ТОП", + shopFilterProducts: "ТОВАРЫ", + shopFilterPrices: "ЦЕНЫ", + shopFilterFavorites: "ИЗБРАННЫЕ", + shopUpload: "Создать магазин", + shopAllSectionTitle: "Все магазины", + shopMineSectionTitle: "Мои магазины", + shopRecentSectionTitle: "Недавние магазины", + shopTopSectionTitle: "Лучшие магазины", + shopProductsSectionTitle: "Лучшие товары", + shopPricesSectionTitle: "Товары по цене", + shopFavoritesSectionTitle: "Избранные", + shopCreateSectionTitle: "Создать магазин", + shopUpdateSectionTitle: "Обновить магазин", + shopCreate: "Создать", + shopUpdate: "Обновить", + shopDelete: "Удалить", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "Опубликовать в Clearnet", + shopClearnetUnpublish: "Убрать из Clearnet", + shopClearnetUrlLabel: "URL в Clearnet", + shopClearnetWarning: "Публикация в Clearnet делает этот магазин доступным для индексации поисковыми системами и внешними сканерами.", + shopAddFavorite: "Добавить в избранное", + shopRemoveFavorite: "Убрать из избранного", + shopOpen: "ОТКРЫТ", + shopClosed: "ЗАКРЫТ", + shopOpenShop: "Открыть магазин", + shopCloseShop: "Закрыть магазин", + shopMakePrivate: "СДЕЛАТЬ ПРИВАТНОЙ (E2E)", + shopMakePublic: "СДЕЛАТЬ ПУБЛИЧНОЙ", + shopProducts: "Товары", + shopProductAdd: "Добавить товар", + shopProductTitle: "Товар", + shopProductUpdate: "Обновить товар", + shopProductPrice: "Цена (ECO)", + shopProductStock: "Наличие", + shopProductUntitled: "Товар без названия", + shopUntitled: "Магазин без названия", + shopNoItems: "Магазины не найдены.", + shopNoProducts: "Товаров пока нет.", + shopOutOfStock: "Нет в наличии", + shopBuy: "Купить", + shopBuyEncryptedNote: "Ваши данные доставки отправляются зашифрованно и видны только владельцу магазина.", + shopBuyDeliveryAddress: "Адрес доставки", + shopBuyContact: "Контакт", + shopBuyContactPlaceholder: "email, телефон и т.д.", + shopBuyNotes: "Заметки", + shopBuyDeliveryRequired: "Требуется адрес доставки.", + shopOrdersTitle: "Заказы", + shopOrdersDescription: "Заказы, полученные этим магазином.", + shopOrdersEmpty: "Пока нет заказов.", + shopOrderProduct: "Товар", + shopOrderPrice: "Цена", + shopOrderBuyer: "Покупатель", + shopBackToShop: "Назад в магазин", + shopShareUrl: "Ссылка для обмена", + shopVisitShop: "ПОСЕТИТЬ МАГАЗИН", + marketVisitItem: "ОТКРЫТЬ ТОВАР", + shopStatus: "СТАТУС", + shopCreatedAt: "СОЗДАН", + shopSearchPlaceholder: "Поиск магазинов...", + shopUrl: "URL", + shopLocation: "Местоположение", + shopTags: "Теги", + shopVisibility: "Видимость", + shopImage: "Изображение", + shopShortDescription: "Краткое описание", + shopShortDescriptionPlaceholder: "Краткое описание для карточек (макс. 160 символов)", + shopTitlePlaceholder: "Название вашего магазина", + shopDescriptionPlaceholder: "Подробное описание вашего магазина", + shopUrlPlaceholder: "https://url-vashego-magazina.ru", + shopLocationPlaceholder: "Город, Страна", + shopTagsPlaceholder: "тег1, тег2, тег3", + mapTitlePlaceholder: "Название карты", + shopProductFeatured: "Рекомендуемый товар", + shopSendToMarket: "Send to Market", + typeShop: "МАГАЗИНЫ", + typeShopProduct: "ТОВАР МАГАЗИНА", + bankExchange: "Обмен", + bankExchangeCurrentValue: "Стоимость ECOin (1ч)", + bankTotalSupply: "Общий объём ECOin", + bankEcoinHours: "Эквивалент ECOin во времени", + bankHoursOfWork: "часов", + bankUnitMs: "мс", + bankUnitSeconds: "секунды", + bankUnitMinutes: "минуты", + bankUnitDays: "дни", + bankExchangeNoData: "Данные недоступны", + bankExchangeIndex: "Стоимость ECOin (1ч)", + bankInflation: "Инфляция ECOin (anual)", + bankInflationMonthly: "Инфляция ECOin (mensual)", + bankExchangeChartTitle: "Динамика стоимости ECOin со временем", + bankExchangeChartValue: "Стоимость (ECO/ч)", + bankExchangeChartSupply: "Предложение", + bankExchangeChartInflation: "Инфляция %", + bankExchangeChartEmpty: "Пока недостаточно данных — загляните позже", + bankCurrentSupply: "Текущий объём ECOin", + bankingSyncStatus: "Статус ECOin", + bankingSyncStatusSynced: "Синхронизирован", + bankingSyncStatusOutdated: "Устарел", + statsTitle: "Статистика", + statistics: "Статистика", + statsInhabitant: "Статистика жителя", + statsDescription: "Откройте статистику вашей сети.", + TOMBSTONEButton: "НАДГРОБИЯ", + statsYou: "Вы", + statsUserId: "Oasis ID", + statsCreatedAt: "Создано", + statsYourContent: "Контент", + statsYourOpinions: "Мнения", + statsYourTombstone: "Надгробия", + statsNetwork: "Сеть", + statsTotalInhabitants: "Жители", + statsDiscoveredTribes: "Племена (публичные)", + statsPrivateDiscoveredTribes: "Племена (частные)", + statsNetworkContent: "Контент", + statsYourMarket: "Рынок", + statsYourJob: "Вакансии", + statsYourProject: "Проекты", + statsYourTransfer: "Переводы", + statsYourForum: "Форумы", + statsNetworkOpinions: "Мнения", + statsDiscoveredMarket: "Рынок", + statsDiscoveredJob: "Вакансии", + statsDiscoveredProject: "Проекты", + statsBankingTitle: "Банкинг", + statsEcoWalletLabel: "Кошелёк ECOIN", + statsEcoWalletNotConfigured: "Не настроен!", + statsTotalEcoAddresses: "Всего адресов", + statsDiscoveredTransfer: "Переводы", + statsDiscoveredForum: "Форумы", + statsNetworkTombstone: "Надгробия", + statsBookmark: "Закладки", + statsEvent: "События", + statsTask: "Задачи", + statsVotes: "Голоса", + statsMarket: "Рынок", + statsForum: "Форумы", + statsJob: "Вакансии", + statsProject: "Проекты", + statsReport: "Отчёты", + statsFeed: "Ленты", + statsTribe: "Племена", + statsImage: "Изображения", + statsAudio: "Аудио", + statsVideo: "Видео", + statsDocument: "Документы", + statsMap: "Карты", + statsShop: "Магазины", + statsShopProduct: "Товары магазина", + statsTransfer: "Переводы", + statsAiExchange: "ИИ", + statsPUBs: "PUBs", + statsPost: "Посты", + statsOasisID: "Oasis ID", + statsSize: "Итого (размер)", + statsBlockchainSize: "Блокчейн (размер)", + statsBlobsSize: "Блобы (размер)", + statsActivity7d: "Активность (последние 7 дней)", + statsActivity7dTotal: "Итого за 7 дней", + statsActivity30dTotal: "Итого за 30 дней", + statsKarmaScore: "Очки КАРМЫ", + statsPublic: "Публичное", + statsPrivate: "Частное", + messages: "Сообщения", + statsProjectsTitle: "Проекты", + statsProjectsTotal: "Всего проектов", + statsProjectsActive: "Активные", + statsProjectsCompleted: "Завершённые", + statsProjectsPaused: "На паузе", + statsProjectsCancelled: "Отменённые", + statsProjectsGoalTotal: "Общая цель", + statsProjectsPledgedTotal: "Всего поддержано", + statsProjectsSuccessRate: "Процент успеха", + statsProjectsAvgProgress: "Средний прогресс", + statsProjectsMedianProgress: "Медианный прогресс", + statsProjectsActiveFundingAvg: "Среднее активное финансирование", + statsJobsTitle: "Вакансии", + statsJobsTotal: "Всего вакансий", + statsJobsOpen: "Открытые", + statsJobsClosed: "Закрытые", + statsJobsOpenVacants: "Открытые места", + statsJobsSubscribersTotal: "Всего подписчиков", + statsJobsAvgSalary: "Средняя зарплата", + statsJobsMedianSalary: "Медианная зарплата", + statsMarketTitle: "Рынок", + statsMarketTotal: "Всего товаров", + statsMarketForSale: "На продаже", + statsMarketReserved: "Зарезервировано", + statsMarketClosed: "Закрыто", + statsMarketSold: "Продано", + statsMarketRevenue: "Выручка", + statsMarketAvgSoldPrice: "Средняя цена продажи", + statsUsersTitle: "Жители", + user: "Житель", + statsTombstoneTitle: "Надгробия", + statsNetworkTombstones: "Надгробия сети", + statsTombstoneRatio: "Доля надгробий (%)", + statsAITraining: "Обучение ИИ", + statsAIExchanges: "Обмены", + statsParliamentCandidature: "Кандидатуры парламента", + statsParliamentTerm: "Сроки парламента", + statsParliamentProposal: "Предложения парламента", + statsParliamentRevocation: "Отзывы парламента", + statsParliamentLaw: "Законы парламента", + statsCourtsCase: "Судебные дела", + statsCourtsEvidence: "Судебные доказательства", + statsCourtsAnswer: "Судебные ответы", + statsCourtsVerdict: "Судебные приговоры", + statsCourtsSettlement: "Судебные соглашения", + statsCourtsSettlementProposal: "Предложения соглашений", + statsCourtsSettlementAccepted: "Принятые соглашения", + statsCourtsNomination: "Назначения судей", + statsCourtsNominationVote: "Голоса за назначение", + ai: "ИИ", + aiDescription: "Коллективный искусственный интеллект (КИИ) под названием '42', обучающийся на основе вашей сети.", + aiInputPlaceholder: "Как дела?", + aiUserQuestion: "Вопрос", + aiResponseTitle: "Ответ", + aiSubmitButton: "Отправить!", + aiSettingsDescription: "Установите ваш запрос (макс 128 символов) для модели ИИ.", + aiPrompt: "Предоставьте информативный и точный ответ.", + aiConfiguration: "Установить запрос", + aiPromptUsed: "Запрос", + aiClearHistory: "Очистить историю чата", + aiSharePrompt: "Добавить этот ответ в коллективное обучение?", + aiShareYes: "Да", + aiShareNo: "Нет", + aiSharedLabel: "Добавлено в обучение", + aiRejectedLabel: "Не добавлено в обучение", + aiServerError: "ИИ не смог ответить. Пожалуйста, попробуйте снова.", + typeAiExchange: "ИИ", + aiApproveTrain: "Добавить в коллективное обучение", + aiRejectTrain: "Не обучать", + aiTrainPending: "Ожидает одобрения", + aiTrainApproved: "Одобрено для обучения", + aiTrainRejected: "Отклонено для обучения", + aiSnippetsUsed: "Использованные фрагменты", + aiSnippetsLearned: "Изученные фрагменты", + aiApproveCustomTrain: "Обучить используя этот пользовательский ответ", + aiCustomAnswerPlaceholder: "Напишите ваш пользовательский ответ…", + marketMineSectionTitle: "Ваши товары", + marketCreateSectionTitle: "Создать товар", + marketUpdateSectionTitle: "Обновить", + marketAllSectionTitle: "Рынок", + marketRecentSectionTitle: "Последние товары", + marketTitle: "Рынок", + marketDescription: "Торговая площадка для обмена товарами или услугами в вашей сети.", + marketFilterAll: "ВСЕ", + marketFilterMine: "МОИ", + marketFilterAuctions: "АУКЦИОНЫ", + marketFilterItems: "ОБМЕН", + marketFilterNew: "НОВЫЕ", + marketFilterUsed: "ИСПОЛЬЗОВАННЫЕ", + marketFilterBroken: "СЛОМАННЫЕ", + marketFilterForSale: "НА ПРОДАЖЕ", + marketFilterSold: "ПРОДАННЫЕ", + marketFilterDiscarded: "ОТКЛОНЁННЫЕ", + marketFilterRecent: "НЕДАВНИЕ", + marketFilterMyBids: "СТАВКИ", + marketCreateButton: "Создать товар", + marketItemType: "Тип", + marketItemTitle: "Название", + marketItemAvailable: "Срок", + marketItemDescription: "Описание", + marketItemDescriptionPlaceholder: "Опишите продаваемый товар", + marketItemStatus: "Статус", + marketShopLabel: "Магазин", + marketItemCondition: "Состояние", + marketItemPrice: "Цена", + marketItemTags: "Теги", + marketItemTagsPlaceholder: "Введите теги через запятую", + marketItemDeadline: "Срок", + marketItemIncludesShipping: "Включена доставка?", + marketItemHighestBid: "Наибольшая ставка", + marketItemHighestBidder: "Лидер аукциона", + marketItemStock: "Запас", + marketOutOfStock: "Нет в наличии", + marketItemBidTime: "Время ставки", + marketActionsUpdate: "Обновить", + marketUpdateButton: "Обновить", + marketActionsDelete: "Удалить", + marketActionsSold: "Отметить как проданное", + marketActionsChangeStatus: "ИЗМЕНИТЬ СТАТУС", + marketActionsBuy: "КУПИТЬ!", + marketAuctionBids: "Текущие ставки", + marketPlaceBidButton: "Сделать ставку", + marketItemSeller: "Продавец", + marketNoItems: "Товаров пока нет.", + marketYourBid: "Ваша ставка", + marketCreateFormImageLabel: "Загрузить медиа (макс: 50MB)", + marketSearchLabel: "Поиск", + marketSearchPlaceholder: "Поиск по названию или тегам", + marketMinPriceLabel: "Мин. цена", + marketMaxPriceLabel: "Макс. цена", + marketSortLabel: "Сортировать по", + marketSortRecent: "Самые новые", + marketSortPrice: "Цена", + marketSortDeadline: "Срок", + marketSearchButton: "Поиск", + marketAuctionEndsIn: "Завершается", + marketAuctionEnded: "Завершён", + marketMyBidBadge: "Ваша ставка", + marketNoItemsMatch: "Товаров не найдено.", + jobsTitle: "Вакансии", + jobsCandidatesTitle: "Рекомендуемые кандидаты", + jobsCandidatesDescription: "Жители, чьи навыки соответствуют этой вакансии. Отправьте им личное сообщение.", + jobsCandidatesPmBody: "Здравствуйте, думаю, ваш профиль подходит для моей вакансии", + jobsDescription: "Открывайте и управляйте вакансиями в вашей сети.", + jobsFilterRecent: "НЕДАВНИЕ", + jobsFilterMine: "МОИ", + jobsFilterAll: "ВСЕ", + jobsFilterRemote: "УДАЛЁННЫЕ", + jobsFilterOpen: "ОТКРЫТЫЕ", + jobsFilterClosed: "ЗАКРЫТЫЕ", + jobsCV: "Резюме", + jobsCreateJob: "Создать вакансию", + jobsRecentTitle: "Последние вакансии", + jobsMineTitle: "Ваши вакансии", + jobsAllTitle: "Вакансии", + jobsRemoteTitle: "Удалённые вакансии", + jobsOpenTitle: "Открытые вакансии", + jobsClosedTitle: "Закрытые вакансии", + jobsCVTitle: "Резюме", + jobsFilterPresencial: "ОЧНЫЕ", + jobsFilterFreelancer: "ФРИЛАНС", + jobsFilterEmployee: "СОТРУДНИК", + jobsPresencialTitle: "Очные вакансии", + jobsFreelancerTitle: "Фриланс вакансии", + jobsEmployeeTitle: "Вакансии сотрудников", + jobTitle: "Название", + jobLocation: "Местоположение", + jobSalary: "Зарплата (ECO/1ч)", + jobVacants: "Места", + jobDescription: "Описание", + jobRequirements: "Требования", + jobLanguages: "Языки", + jobStatus: "Статус", + jobStatusOPEN: "ОТКРЫТАЯ", + jobStatusCLOSED: "ЗАКРЫТАЯ", + jobSetOpen: "Открыть", + jobSetClosed: "Закрыть", + jobSubscribeButton: "Откликнуться!", + jobUnsubscribeButton: "Отозвать отклик!", + jobTitlePlaceholder: "Введите название вакансии", + jobDescriptionPlaceholder: "Опишите вакансию", + jobRequirementsPlaceholder: "Введите требования", + jobLanguagesPlaceholder: "Английский, Испанский, Французский, Баскский...", + jobTasksPlaceholder: "Перечислите задачи", + jobLocationPresencial: "На месте", + jobLocationRemote: "Удалённо", + jobVacantsPlaceholder: "Количество позиций", + jobSalaryPlaceholder: "Зарплата в ECO за 1 час работы", + jobImage: "Загрузить медиа (макс: 50MB)", + jobTasks: "Задачи", + jobType: "Тип занятости", + jobTime: "Время занятости", + jobSubscribers: "Подписчики", + noSubscribers: "Нет подписчиков", + jobsFilterTop: "ТОП", + jobsTopTitle: "Топ вакансий по зарплате", + createJobButton: "Опубликовать вакансию", + viewDetailsButton: "Просмотр деталей", + noJobsFound: "Вакансий не найдено.", + jobAuthor: "Автор", + jobTypeFreelance: "Фриланс", + jobTypeSalary: "Сотрудник", + jobTypeExchange: "Обмен часами", + jobTypeEXCHANGE: "ОБМЕН ЧАСАМИ", + jobsFilterExchange: "ОБМЕН", + jobsExchangeTitle: "Работа по обмену часами", + jobsHoursOffered: "Предлагаемые часы", + jobsHoursRequested: "Часы, запрашиваемые взамен", + jobsExchangeSkill: "Запрашиваемый навык в обмен", + jobsHoursOfferedPlaceholder: "напр. 4", + jobsHoursRequestedPlaceholder: "напр. 4", + jobsExchangeSkillPlaceholder: "напр. плотничество, дизайн", + jobExchangeHint: "Для работ по обмену часами заполните поля ниже вместо зарплаты.", + jobTimePartial: "Неполная занятость", + jobTimeComplete: "Полная занятость", + jobsDeleteButton: "УДАЛИТЬ", + jobsUpdateButton: "ОБНОВИТЬ", + jobsFilterApplied: "ПОДАННЫЕ", + jobsAppliedTitle: "Мои заявки", + jobsAppliedBadge: "Подано", + jobsFilterFavs: "Избранное", + jobsFavsTitle: "Избранное", + jobsFilterNeeds: "Нужна помощь", + jobsNeedsTitle: "Нужна помощь", + jobsSearchLabel: "Поиск", + jobsSearchPlaceholder: "Поиск по названию, тегам, описанию...", + jobsMinSalaryLabel: "Мин. зарплата", + jobsMaxSalaryLabel: "Макс. зарплата", + jobsSortLabel: "Сортировать по", + jobsSortRecent: "Самые новые", + jobsSortSalary: "Самая высокая зарплата", + jobsSortSubscribers: "Больше всего заявок", + jobsSearchButton: "Поиск", + jobsFavoriteButton: "Добавить в избранное", + jobsUnfavoriteButton: "Удалить из избранного", + jobsMessageAuthorButton: "ЛС", + jobsApplicants: "Заявители", + jobsUpdatedAt: "Обновлено", + jobNewBadge: "НОВАЯ", + jobsTagsLabel: "Теги", + jobsTagsPlaceholder: "тег1, тег2, тег3", + noJobsMatch: "Вакансий не найдено.", + projectsTitle: "Проекты", + projectsDescription: "Создавайте, финансируйте и следите за проектами сообщества в вашей сети.", + projectCreateProject: "Создать проект", + projectCreateButton: "Создать проект", + projectUpdateButton: "ОБНОВИТЬ", + projectDeleteButton: "УДАЛИТЬ", + projectNoProjectsFound: "Проектов не найдено.", + projectFilterAll: "ВСЕ", + projectFilterMine: "МОИ", + projectFilterActive: "АКТИВНЫЕ", + projectFilterPaused: "НА ПАУЗЕ", + projectFilterCompleted: "ЗАВЕРШЁННЫЕ", + projectFilterFollowing: "ОТСЛЕЖИВАЕМЫЕ", + projectFilterRecent: "НЕДАВНИЕ", + projectFilterTop: "ТОП", + projectAllTitle: "Проекты", + projectMineTitle: "Ваши проекты", + projectActiveTitle: "Активные проекты", + projectPausedTitle: "Проекты на паузе", + projectCompletedTitle: "Завершённые проекты", + projectFollowingTitle: "Отслеживаемые проекты", + projectRecentTitle: "Последние проекты", + projectTopTitle: "Топ по финансированию", + projectTitlePlaceholder: "Название проекта", + projectImage: "Загрузить медиа (макс: 50MB)", + projectDescription: "Описание", + projectDescriptionPlaceholder: "Расскажите историю и цели…", + projectGoal: "Цель (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "Срок", + projectProgress: "Начальный прогресс (%)", + projectStatus: "Статус", + projectFunding: "Финансирование", + projectPledged: "Поддержано", + projectSetStatus: "Установить статус", + projectSetProgress: "Обновить прогресс", + projectFollowButton: "СЛЕДИТЬ", + projectUnfollowButton: "ОТПИСАТЬСЯ", + projectStatusACTIVE: "АКТИВНЫЙ", + projectStatusPAUSED: "НА ПАУЗЕ", + projectStatusCOMPLETED: "ЗАВЕРШЁН", + projectStatusCANCELLED: "ОТМЕНЁН", + projectPledgeTitle: "Поддержать проект", + projectPledgePlaceholder: "Сумма в ECO", + projectBounties: "Награды", + projectBountiesInputLabel: "Награды (по одной в строке: Название|Сумма [ECO]|Описание)", + projectBountiesPlaceholder: "Исправить баг UI|100|Ссылка на задачу\nНаписать документацию|250|Примеры использования", + projectNoBounties: "Наград не найдено.", + projectTitle: "Название", + projectAddBountyTitle: "Новая награда", + projectBountyTitle: "Название награды", + projectBountyAmount: "Сумма (ECO)", + projectBountyDescription: "Описание", + projectMilestoneSelect: "Выбрать веху", + projectBountyCreateButton: "Создать награду", + projectBountyStatus: "Статус награды", + projectBountyOpen: "открытая", + projectBountyClaimed: "заявленная", + projectBountyDone: "завершена", + projectBountyClaimedBy: "заявлена", + projectBountyClaimButton: "заявить", + projectBountyCompleteButton: "Отметить завершённой", + projectMilestones: "Вехи", + projectAddMilestoneTitle: "Новая веха", + projectMilestoneTitle: "Название вехи", + projectMilestoneTargetPercent: "Процент (%)", + projectMilestoneDueDate: "Дата", + projectMilestoneCreateButton: "Создать веху", + projectMilestoneStatus: "Статус вехи", + projectMilestoneOpen: "открытая", + projectMilestoneDone: "завершена", + projectMilestoneMarkDone: "Отметить завершённой", + projectMilestoneDue: "дата", + projectNoMilestones: "Вех не найдено.", + projectMilestoneTitlePlaceholder: "Введите название вехи", + projectMilestoneDescriptionPlaceholder: "Введите описание этой вехи", + projectMilestoneDescription: "Описание вехи", + projectBudgetGoal: "Бюджет (Цель)", + projectBudgetAssigned: "Назначено на награды", + projectBudgetRemaining: "Остаток", + projectBudgetOver: "⚠ Превышение бюджета: назначено больше цели", + projectFollowers: "Подписчики", + projectFollowersTitle: "Подписчики", + projectFollowersNone: "Подписчиков пока нет.", + projectMore: "ещё", + projectYouFollowHint: "Вы следите за этим проектом", + projectBackers: "Спонсоры", + projectBackersTitle: "Спонсоры", + projectBackersTotal: "Всего спонсоров", + projectBackersTotalPledged: "Всего поддержано", + projectBackersYourPledge: "Ваш взнос", + projectBackersNone: "Взносов пока нет.", + projectNoRemainingBudget: "Оставшегося бюджета нет.", + projectFilterBackers: "СПОНСОРЫ", + projectFilterApplied: "ПОДАННЫЕ", + projectAppliedTitle: "ПОДАННЫЕ", + projectBackersLeaderboardTitle: "Топ спонсоров", + projectNoBackersFound: "Спонсоров не найдено.", + projectBackerAmount: "Всего вложено", + projectBackerPledges: "Взносы", + projectBackerProjects: "Проекты", + projectPledgeAmount: "Сумма", + projectSelectMilestoneOrBounty: "Выбрать веху или награду", + projectPledgeButton: "Поддержать", + footerLicense: "GPLv3", + footerPackage: "Пакет", + footerVersion: "Версия", + modulesModuleName: "Имя", + modulesModuleDescription: "Описание", + modulesModuleStatus: "Статус", + modulesTotalModulesLabel: "Загруженные модули", + modulesEnabledModulesLabel: "Включённые", + modulesDisabledModulesLabel: "Отключённые", + modulesPopularLabel: "Популярное", + modulesPopularDescription: "Модуль для получения популярных, наиболее просматриваемых или комментируемых постов.", + modulesTopicsLabel: "Темы", + modulesTopicsDescription: "Модуль для получения категорий обсуждений на основе общих интересов.", + modulesSummariesLabel: "Резюме", + modulesSummariesDescription: "Модуль для получения резюме длинных обсуждений или постов.", + modulesLatestLabel: "Последние", + modulesLatestDescription: "Модуль для получения последних постов и обсуждений.", + modulesThreadsLabel: "Треды", + modulesThreadsDescription: "Модуль для получения разговоров, сгруппированных по теме или вопросу.", + modulesMultiverseLabel: "Мультивселенная", + modulesMultiverseDescription: "Модуль для получения контента от других федеративных узлов.", + modulesInvitesLabel: "Приглашения", + modulesInvitesDescription: "Модуль для управления и применения кодов приглашений.", + modulesWalletLabel: "Кошелёк", + modulesWalletDescription: "Модуль для управления вашими цифровыми активами (ECOin).", + modulesLegacyLabel: "Наследие", + modulesLegacyDescription: "Модуль для быстрого и безопасного управления вашим секретом (приватным ключом).", + modulesCipherLabel: "Шифр", + modulesCipherDescription: "Модуль для симметричного шифрования и расшифровки текста (с использованием общего пароля).", + modulesBookmarksLabel: "Закладки", + modulesBookmarksDescription: "Модуль для открытия и управления закладками.", + modulesVideosLabel: "Видео", + modulesVideosDescription: "Модуль для открытия и управления видео.", + modulesDocsLabel: "Документы", + modulesDocsDescription: "Модуль для открытия и управления документами.", + modulesAudiosLabel: "Аудио", + modulesAudiosDescription: "Модуль для открытия и управления аудио.", + modulesTagsLabel: "Теги", + modulesTagsDescription: "Модуль для открытия и исследования паттернов таксономии (теги).", + modulesImagesLabel: "Изображения", + modulesImagesDescription: "Модуль для открытия и управления изображениями.", + modulesTrendingLabel: "Популярное", + modulesTrendingDescription: "Модуль для исследования наиболее популярного контента.", + modulesEventsLabel: "События", + modulesEventsDescription: "Модуль для открытия и управления событиями.", + modulesTasksLabel: "Задачи", + modulesTasksDescription: "Модуль для открытия и управления задачами.", + modulesMarketLabel: "Рынок", + modulesMarketDescription: "Модуль для обмена товарами или услугами.", + modulesShopsLabel: "Магазины", + modulesShopsDescription: "Модуль для управления и поиска магазинов.", + modulesTribesLabel: "Племена", + modulesTribesDescription: "Модуль для исследования или создания племён (групп).", + modulesVotationsLabel: "Голосования", + modulesVotationsDescription: "Модуль для открытия и управления голосованиями.", + modulesReportsLabel: "Отчёты", + modulesReportsDescription: "Модуль для управления и отслеживания отчётов, связанных с проблемами, ошибками, злоупотреблениями и предупреждениями.", + modulesOpinionsLabel: "Мнения", + modulesOpinionsDescription: "Модуль для открытия и голосования за мнения.", + modulesTransfersLabel: "Переводы", + modulesTransfersDescription: "Модуль для открытия и управления смарт-контрактами (переводами).", + modulesFediverseLabel: "Федиверс", + modulesFediverseDescription: "Управляйте своими другими аккаунтами федиверса, включая отправку и получение контента.", + modulesFeedLabel: "Лента", + modulesFeedDescription: "Модуль для открытия и публикации коротких текстов (лента).", + modulesParliamentLabel: "Парламент", + modulesParliamentDescription: "Модуль для выборов правительств и голосования за законы.", + modulesCourtsLabel: "Суды", + modulesCourtsDescription: "Модуль для разрешения конфликтов и вынесения приговоров.", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "Модуль для рисования на совместной сетке.", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "Модуль для генерации и обмена «звуком» вашего блокчейна.", + modulesAgendaLabel: "Повестка", + modulesAgendaDescription: "Модуль для управления всеми назначенными вам элементами.", + modulesAILabel: "ИИ", + modulesAIDescription: "Модуль для общения с LLM под названием '42'.", + modulesForumLabel: "Форумы", + modulesForumDescription: "Модуль для открытия и управления форумами.", + modulesJobsLabel: "Вакансии", + modulesJobsDescription: "Модуль для открытия и управления вакансиями.", + modulesProjectsLabel: "Проекты", + modulesProjectsDescription: "Модуль для исследования, краудфандинга и управления проектами.", + modulesBankingLabel: "Банкинг", + modulesBankingDescription: "Модуль для определения реальной стоимости ECOIN и распределения UBI из общей казны.", + modulesFavoritesLabel: "Избранное", + modulesFavoritesDescription: "Модуль для управления избранным контентом.", + fileTooLargeTitle: "Файл слишком большой", + fileTooLargeMessage: "Файл превышает максимально допустимый размер (50 МБ). Пожалуйста, выберите файл меньшего размера.", + goBack: "Назад", + errorPageTitle: "Что-то пошло не так", + visibilityLabel: "Видимость", + visibilityPublic: "Публичная", + visibilityHidden: "Скрытая", + visibilityMakePublic: "Сделать публичным", + visibilityMakeHidden: "Скрыть", + invitesInhabitantsTitle: "Жители", + invitesInhabitantsFollow: "Подписаться", + aiNavPlaceholder: "Куда вы хотите перейти?", + aiNavDisabled: "Навигация с ИИ отключена.", + aiNavResultsTitle: "Предложения навигации с ИИ", + aiNavQueryLabel: "Запрос", + aiNavResultsDescription: "Выберите маршрут, наиболее подходящий вашему запросу.", + aiNavResultPath: "Маршрут", + aiNavResultDescription: "Что можно сделать", + aiNavResultMatch: "Совпадение", + aiNavResultsEmpty: "Подходящих маршрутов нет. Попробуйте /search.", + aiNavSearchInstead: "Поиск вместо этого", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Модуль для запросов на естественном языке к содержимому сети.", + directConnect: "Прямое подключение", + directConnectDescription: "Подключитесь напрямую к узлу, введя его IP-адрес, порт и публичный ключ. Узел будет добавлен как отслеживаемое соединение.", + peerHost: "IP", + peerPort: "Порт (по умолчанию: 8008)", + peerPublicKey: "Публичный ключ (@...ed25519)", + connectAndFollow: "Подключиться", + deviceSourceLabel: "Источник устройства", + modulesPresetTitle: "Общие конфигурации", + modulesPreset_minimal: "Минимальный", + modulesPreset_basic: "Базовая", + modulesPreset_social: "Социальный", + modulesPreset_economy: "Экономическая", + modulesPreset_full: "Полная", + statsCarbonFootprintTitle: "Углеродный след", + statsCarbonFootprintNetwork: "Углеродный след сети", + statsCarbonFootprintYours: "Ваш углеродный след", + statsCarbonTombstone: "Углеродный след надгробий", + feedSuccessMsg: "Пост успешно опубликован!", + dominantOpinionLabel: "Доминирующее мнение", + uploadMedia: "Загрузить медиа (макс: 50MB)", + feedViewDetails: "Просмотр деталей", + feedOpenDiscussion: "Открыть обсуждение", + feedDetailTitle: "Лента", + feedPostComment: "Опубликовать комментарий", + eventFileLabel: "Прикрепить изображение", + taskFileLabel: "Прикрепить изображение", + courtsRespondentRequired: "ID ответчика обязателен", + courtsRespondentInvalid: "Должен быть действительным SSB ID (@...ed25519)", + noComments: "Пока нет комментариев", + bankAllocId: "ID распределения", + pmFromLabel: "От:", + pmToLabel: "Кому:", + aiTitle: "ИИ", + deviceLabel: "Устройство", + mapsLabel: "Карты", + mapTitle: "Карты", + mapDescription: "Исследуйте и управляйте офлайн-картами в вашей сети.", + mapMineSectionTitle: "Ваши Карты", + mapCreateSectionTitle: "Создать Карту", + mapUpdateSectionTitle: "Обновить Карту", + mapAllSectionTitle: "Карты", + mapRecentSectionTitle: "Недавние Карты", + mapFavoritesSectionTitle: "Избранное", + mapFilterAll: "ВСЕ", + mapFilterMine: "МОИ", + mapFilterRecent: "НЕДАВНИЕ", + mapFilterFavorites: "ИЗБРАННОЕ", + mapUploadButton: "Создать Карту", + mapCreateButton: "Создать Карту", + mapUpdateButton: "Обновить", + mapDeleteButton: "Удалить", + mapAddFavoriteButton: "В избранное", + mapRemoveFavoriteButton: "Из избранного", + mapLatLabel: "Широта", + mapLatPlaceholder: "напр. 55.7558", + mapLngLabel: "Долгота", + mapLngPlaceholder: "напр. 37.6173", + mapDescriptionLabel: "Описание", + mapDescriptionPlaceholder: "Опишите карту или местоположение...", + mapTypeLabel: "Тип карты", + mapTypeSingle: "ОДИНОЧНЫЙ (только начальное местоположение)", + mapTypeOpen: "ОТКРЫТЫЙ (любой может добавлять маркеры)", + mapTypeClosed: "ЗАКРЫТЫЙ (только создатель добавляет маркеры)", + mapTagsLabel: "Теги", + mapTagsPlaceholder: "Введите теги через запятую", + mapUrlLabel: "URL карты", + mapPickCoordLabel: "Или выберите местоположение на сетке:", + mapMarkersLabel: "маркеры", + mapMarkersTitle: "Маркеры", + mapMarkerDefault: "Маркер", + mapMarkerLatLabel: "Широта маркера", + mapMarkerLngLabel: "Долгота маркера", + mapMarkerLabelField: "Метка маркера", + mapMarkerLabelPlaceholder: "Опишите этот маркер...", + markerImageLabel: "Изображение Маркера", + mapAddMarkerTitle: "Добавить Маркер", + mapAddMarkerButton: "Добавить Маркер", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "Применить масштаб", + mapSearchPlaceholder: "Поиск по описанию, тегам, автору...", + mapSearchButton: "Искать", + mapUpdatedAt: "Обновлено", + mapNoMatch: "Нет карт, соответствующих вашему запросу.", + noMaps: "Нет доступных карт.", + mapLocationTitle: "Местоположение", + mapVisitLabel: "Посетить карту", + typeMap: "КАРТЫ", + typeMapMarker: "МАРКЕР КАРТЫ", + modulesMapLabel: "Карты", + modulesMapDescription: "Модуль для управления и обмена офлайн-картами.", + padsTitle: "Пады", + padTitle: "Пад", + modulesPadsLabel: "Пады", + modulesPadsDescription: "Модуль для управления совместными текстовыми редакторами.", + padFilterAll: "ВСЕ", + padFilterMine: "МОИ", + padFilterRecent: "НЕДАВНИЕ", + padFilterOpen: "ОТКРЫТЫЕ", + padFilterClosed: "ЗАКРЫТЫЕ", + padCreate: "Создать Пад", + padUpdate: "Обновить Пад", + padDelete: "Удалить Пад", + padTitleLabel: "Заголовок", + padTitlePlaceholder: "Введите заголовок пада...", + padStatusLabel: "Статус", + padStatusOpen: "ОТКРЫТ", + padStatusInviteOnly: "ТОЛЬКО ПО ПРИГЛАШЕНИЮ", + padStatusClosed: "ЗАКРЫТ", + padDeadlineLabel: "Срок", + padTagsLabel: "Теги", + padTagsPlaceholder: "тег1, тег2, ...", + padMembersLabel: "Участники", + padVisitPad: "Посетить Пад", + padShareUrl: "Поделиться URL", + padCreated: "Создано", + padAuthor: "Автор", + padGenerateCode: "Сгенерировать Код", + padInviteCodeLabel: "Код Приглашения", + padInviteCodePlaceholder: "Введите код приглашения...", + padValidateInvite: "Проверить", + padStartEditing: "НАЧАТЬ РЕДАКТИРОВАНИЕ!", + padEditorPlaceholder: "Начните писать...", + padSubmitEntry: "Отправить", + padNoEntries: "Записей пока нет.", + padAllSectionTitle: "Все Пады", + padMineSectionTitle: "Мои Пады", + padsDescription: "Управляйте совместными зашифрованными текстовыми редакторами в вашей сети.", + padRecentSectionTitle: "Недавние Пады", + padOpenSectionTitle: "Открытые Пады", + padClosedSectionTitle: "Закрытые Пады", + padCreateSectionTitle: "Создать Новый Пад", + padUpdateSectionTitle: "Обновить Пад", + padInviteGenerated: "Код Приглашения Сгенерирован", + typePad: "ПАДЫ", + padNew: "НОВЫЙ", + padAddFavorite: "Добавить в Избранное", + padRemoveFavorite: "Удалить из Избранного", + padClose: "Закрыть Pad", + padBackToEditor: "Назад к редактору", + padSearchPlaceholder: "Поиск падов...", + + modulesChatsLabel: "Чаты", + modulesChatsDescription: "Модуль для обнаружения и управления зашифрованными чатами.", + typeChat: "ЧАТЫ", + typeChatMessage: "СООБЩЕНИЕ ЧАТА", + chatLabel: "ЧАТЫ", + chatMessageLabel: "СООБЩЕНИЯ ЧАТА", + chatsTitle: "Чаты", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "Избранное", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "Описание", + chatCategory: "Категория", + chatStatus: "СТАТУС", + chatFilterAll: "ВСЕ", + chatFilterMine: "МОИ", + chatFilterRecent: "НЕДАВНИЕ", + chatFilterFavorites: "ИЗБРАННОЕ", + chatFilterOpen: "ОТКРЫТЫЕ", + chatFilterClosed: "ЗАКРЫТЫЕ", + chatCreate: "Создать Чат", + chatUpdate: "Обновить Чат", + chatDelete: "Удалить Чат", + chatClose: "Закрыть Чат", + chatVisitChat: "ПЕРЕЙТИ В ЧАТ", + chatUntitled: "Чат без названия", + chatNoItems: "Чаты не найдены.", + chatParticipants: "Участники", + chatStartChatting: "НАЧАТЬ ОБЩЕНИЕ!", + chatGenerateCode: "Создать Код", + chatShareUrl: "Поделиться URL", + chatCreatedAt: "СОЗДАН", + chatSearchPlaceholder: "Поиск чатов...", + chatStatusOpen: "ОТКРЫТЫЙ", + chatStatusInviteOnly: "ТОЛЬКО ПО ПРИГЛАШЕНИЮ", + chatStatusClosed: "ЗАКРЫТЫЙ", + chatSendMessage: "Отправить", + chatMessagePlaceholder: "Введите сообщение...", + chatNoMessages: "Сообщений пока нет.", + chatLeave: "Покинуть Чат", + chatInviteCodeLabel: "Введите код приглашения", + chatJoinByInvite: "Войти", + chatTitlePlaceholder: "Название чата", + chatDescriptionPlaceholder: "Описание чата", + chatTagsPlaceholder: "тег1, тег2, тег3", + chatImageLabel: "Выбрать файл изображения (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "Код Приглашения", + chatAuthor: "Автор", + chatCreated: "Создан", + chatAddFavorite: "Добавить в Избранное", + chatRemoveFavorite: "Убрать из Избранного", + chatPM: "ЛС", + chatStatusLabel: "Статус", + chatCategoryLabel: "Категория", + chatParticipantsLabel: "Участники", + gamesTitle: "Игры", + gamesDescription: "Откройте и играйте в игры.", + gamesFilterAll: "ВСЕ", + gamesPlayButton: "ИГРАТЬ!", + gamesBackToGames: "Назад к играм", + modulesGamesLabel: "Игры", + modulesGamesDescription: "Модуль для открытия и игры в игры.", + modulesGraphosLabel: "Графос", + modulesGraphosDescription: "Модуль для исследования сети в виде интерактивной карты узлов.", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "Соединяй PUB'ы с жителями через валидаторы, магазины и аккумуляторы. Противостой угрозе CBDC!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "Проникни в сеть, собери секретные данные, избегай дронов безопасности и сбеги. Сколько уровней ты пройдёшь?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "Остановите вторжение пришельцев! Уничтожайте волны захватчиков.", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "Разбейте все кирпичи своей ракеткой и мячом. Классическая аркада.", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "Классический пинг-понг против ИИ. Первый до 5 очков побеждает.", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "Гонка со временем! Уворачивайтесь от машин и доберитесь до финиша вовремя.", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "Пилотируйте корабль через астероидное поле. Уничтожайте их до столкновения.", + gamesRockPaperScissorsTitle: "Камень Ножницы Бумага", + gamesRockPaperScissorsDesc: "Камень, ножницы, бумага против ИИ. Лучший из трёх раундов побеждает.", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "Классические крестики-нолики против ИИ. Три в ряд — победа.", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "Подбросьте монету и угадайте орёл или решку. Насколько вы удачливы?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "Календари", + calendarTitle: "Календарь", + modulesCalendarsLabel: "Календари", + modulesCalendarsDescription: "Модуль для просмотра и управления календарями.", + typeCalendar: "КАЛЕНДАРИ", + calendarFilterAll: "ВСЕ", + calendarFilterMine: "МОИ", + calendarFilterOpen: "ОТКРЫТЫЕ", + calendarFilterClosed: "ЗАКРЫТЫЕ", + calendarFilterRecent: "НЕДАВНИЕ", + calendarFilterFavorites: "ИЗБРАННОЕ", + calendarCreate: "Создать календарь", + calendarUpdate: "Обновить", + calendarDelete: "Удалить", + calendarTitleLabel: "Название", + calendarTitlePlaceholder: "Название календаря...", + calendarStatusLabel: "Статус", + calendarStatusOpen: "ОТКРЫТЫЙ", + calendarStatusClosed: "ЗАКРЫТЫЙ", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "Теги", + calendarTagsPlaceholder: "тег1, тег2...", + calendarParticipantsLabel: "Участники", + calendarParticipantsCount: "Участники", + calendarVisitCalendar: "Открыть календарь", + calendarCreated: "Создан", + calendarAuthor: "Автор", + calendarJoin: "Присоединиться", + calendarGenerateInvite: "Создать приглашение", + calendarInviteCodePlaceholder: "Введите код приглашения...", + calendarValidateInvite: "Проверить код", + calendarJoined: "Участник", + calendarAddDate: "Добавить дату", + calendarAddNote: "Добавить заметку", + calendarDateLabel: "Дата", + calendarDatePlaceholder: "Опишите эту дату...", + calendarNoteLabel: "Заметка", + calendarNotePlaceholder: "Добавить заметку...", + calendarFirstDateLabel: "Дата", + calendarFirstNoteLabel: "Заметки", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "Описание", + calendarNoDates: "Даты не добавлены.", + calendarNoNotes: "Заметок нет.", + calendarsNoItems: "Календари не найдены.", + calendarsDescription: "Находите и управляйте календарями в вашей сети.", + calendarMonthPrev: "← Назад", + calendarMonthNext: "Вперёд →", + calendarMonthLabel: "Даты", + calendarsShareUrl: "Поделиться URL", + calendarAllSectionTitle: "Календари", + calendarRecentSectionTitle: "Недавние календари", + calendarFavoritesSectionTitle: "Избранное", + calendarMineSectionTitle: "Ваши Календари", + calendarOpenSectionTitle: "Открытые календари", + calendarClosedSectionTitle: "Закрытые календари", + calendarCreateSectionTitle: "Создать новый календарь", + calendarUpdateSectionTitle: "Обновить календарь", + calendarAddFavorite: "Добавить в избранное", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "Убрать из избранного", + calendarSearchPlaceholder: "Поиск календарей...", + calendarAddEntry: "Добавить запись", + calendarLeave: "Покинуть календарь", + statsCalendar: "Календари", + statsCalendarDate: "Даты календаря", + statsCalendarNote: "Заметки календаря", + chatAccessDenied: "У вас нет доступа к этому чату. Запросите приглашение для доступа к содержимому.", + padAccessDenied: "У вас нет доступа к этому блокноту. Запросите приглашение для доступа к содержимому.", + contentAccessDenied: "У вас нет доступа к этому содержимому.", + blockAccessRestricted: "Доступ ограничен", + tribeContentAccessDenied: "Доступ Запрещён", + tribeContentAccessDeniedMsg: "Этот контент принадлежит племени. Вы должны быть участником, чтобы получить к нему доступ.", + tribeViewTribes: "Посмотреть Племена", + torrentsTitle: "Торренты", + torrentsDescription: "Исследуйте и управляйте торрентами в вашей сети.", + torrentAllSectionTitle: "Торренты", + torrentMineSectionTitle: "Ваши Торренты", + torrentRecentSectionTitle: "Недавние Торренты", + torrentTopSectionTitle: "Лучшие Торренты", + torrentFavoritesSectionTitle: "Избранное", + torrentFilterAll: "ВСЕ", + torrentFilterMine: "МОИ", + torrentFilterRecent: "НЕДАВНИЕ", + torrentFilterTop: "ЛУЧШИЕ", + torrentFilterFavorites: "ИЗБРАННОЕ", + torrentCreateSectionTitle: "Загрузить Торрент", + torrentUpdateSectionTitle: "Обновить Торрент", + torrentCreateButton: "Загрузить Торрент", + torrentUpdateButton: "Обновить", + torrentDeleteButton: "Удалить", + torrentAddFavoriteButton: "Добавить в Избранное", + torrentRemoveFavoriteButton: "Убрать из Избранного", + torrentFileLabel: "Выберите торрент-файл (.torrent)", + torrentTitleLabel: "Название", + torrentTitlePlaceholder: "Название", + torrentDescriptionLabel: "Описание", + torrentDescriptionPlaceholder: "Описание", + torrentTagsLabel: "Теги", + torrentTagsPlaceholder: "Введите теги через запятую", + torrentSizeLabel: "Размер", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "Нет торрент-файла", + noTorrents: "Торренты не найдены.", + torrentSearchPlaceholder: "Поиск по названию, тегам, автору...", + torrentSearchButton: "Поиск", + torrentSortRecent: "Самые новые", + torrentSortOldest: "Самые старые", + torrentSortTop: "Самые популярные", + torrentNoMatch: "Совпадений не найдено.", + torrentMessageAuthorButton: "Написать Автору", + torrentUpdatedAt: "Обновлено", + statsTorrent: "Торренты", + typeTorrent: "ТОРРЕНТЫ", + modulesTorrentsLabel: "Торренты", + modulesTorrentsDescription: "Модуль для поиска и управления торрентами.", + favoritesFilterTorrents: "ТОРРЕНТЫ", + favoritesFilterMarket: "РЫНОК", + favoritesFilterShopProducts: "ТОВАРЫ МАГАЗИНА", + tribeSectionTorrents: "ТОРРЕНТЫ", + tribeCreateTorrent: "Загрузить Торрент", + tribeMediaTypeTorrent: "Торрент", + settingsWishTitle: "Желание", + settingsWishDesc: "Настройте желание вашего Аватара.", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "Личные сообщения", + settingsPmVisibilityDesc: "Настройте уровень открытости личных сообщений.", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "Исходящие — защита UX. Входящие — фильтр внимания.", + pmBlockedNonMutual: "Личные сообщения можно отправлять только жителям со взаимной поддержкой.", + inhabitantsPendingFollowsTitle: "Ожидающие запросы на поддержку", + inhabitantsPendingAccept: "Принять", + inhabitantsPendingReject: "Отклонить", + bxEncrypted: "ЗАШИФРОВАНО", + bxEncryptedHexLabel: "Шифротекст (превью)", + tribeSectionGovernance: "УПРАВЛЕНИЕ", + tribeSubStatusPublic: "ПУБЛИЧНАЯ", + tribeSubStatusPrivate: "ЧАСТНАЯ", + tribeSubInheritedPrivate: "ЧАСТНАЯ (унаследовано от главного племени)", + tribePadInviteRequired: "Нет доступа к паду. Запросите приглашение.", + tribeChatInviteRequired: "Нет доступа к чату. Запросите приглашение.", + tribeGovernanceDesc: "Внутреннее управление этим племенем.", + tribeGovernanceNoGov: "Нет активного правительства", + tribeGovernanceNoGovDesc: "Это племя ещё не выбрало правительство.", + tribeGovCardTitle: "Текущее правительство", + tribeGovCycleSince: "ЦИКЛ С", + tribeGovCycleEnd: "ЦИКЛ ДО", + tribeGovTimeRemaining: "ВРЕМЯ ОСТАЛОСЬ", + tribeGovPopulation: "НАСЕЛЕНИЕ", + tribeGovMethod: "МЕТОД", + tribeGovVotesReceived: "ПОЛУЧЕННЫЕ ГОЛОСА", + tribeGovLeader: "ЛИДЕР", + tribeGovFilterGovernment: "ПРАВИТЕЛЬСТВО", + tribeGovFilterCandidatures: "КАНДИДАТУРЫ", + tribeGovFilterLaws: "ЗАКОНЫ", + tribeGovCandidatureId: "Кандидатура", + tribeGovCandidatureMethod: "Метод", + tribeGovCandidatureProposeBtn: "Опубликовать кандидатуру", + tribeGovRuleTitle: "Название правила", + tribeGovRuleBody: "Содержание правила", + tribeGovProposals: "ПРЕДЛОЖЕНИЯ", + tribeGovRevocations: "ОТЗЫВЫ", + tribeGovHistorical: "ИСТОРИЯ", + tribeGovRules: "ПРАВИЛА", + tribeGovernanceAlreadyPublished: "У этого племени уже открытая кандидатура.", + tribeGovernanceProposeInternal: "Предложить внутреннюю кандидатуру", + tribeGovernanceInternalCandidatures: "Внутренние кандидатуры", + tribeGovernanceNoCandidatures: "Нет открытых кандидатур.", + tribeGovernanceAddRule: "Добавить правило", + tribeGovernanceNoRules: "Правил пока нет.", + tribeGovernanceNoLeaders: "Лидеры не избраны.", + tribeGovernanceComingSoon: "Скоро в модуле управления.", + tribeGovNoProposals: "Пока нет ни одного предложения", + tribeGovNoLaws: "Пока нет ни одного закона", + tribeGovNoRevocations: "Пока нет ни одного отзыва", + tribeGovNoHistorical: "Пока нет ни одной записи", + logsTitle: "Журнал", + logsDescription: "Записывайте свой опыт в сети.", + logsReadMore: "Читать далее", + logsViewTitle: "Журнал", + logsColumnType: "Тип", + logsView: "Смотреть", + logsManualPrompt: "Напишите ваш журнал", + logsUpdateButton: "Обновить", + logsEditTitle: "Редактировать запись", + logsCreateDescription: "Записывайте свой опыт...", + logsCreateTitle: "Создать запись", + logsWriteButton: "Написать", + logsTextPlaceholder: "Опишите ваши впечатления...", + logsTextField: "Текст", + logsLabelPlaceholder: "Метка...", + logsLabelField: "Напишите ваш журнал (метка)", + logsAiDisabledWarn: "Включите модуль ИИ в /modules для записей от ИИ.", + logsAiContextValue: "День блокчейна", + logsAiContext: "Контекст", + logsAiModStatus: "AImod", + logsModeApply: "Применить!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "Вручную", + logsModeAI: "ИИ", + logsColumnDelete: "Удалить", + logsColumnEdit: "Редактировать", + logsColumnLog: "Журнал", + logsColumnDate: "Дата", + logsDelete: "Удалить", + logsEdit: "Редактировать", + logsFilterAlways: "ВСЕГДА", + logsFilterYear: "ГОД", + logsFilterMonth: "МЕСЯЦ", + logsFilterWeek: "НЕДЕЛЯ", + logsFilterToday: "СЕГОДНЯ", + logsFilterRecent: "НЕДАВНИЕ", + logsFilterAll: "ВСЕ", + logsCreate: "Создать запись", + logsExport: "Экспорт журнала", + logsExportOne: "Экспорт", + logsViewDetails: "Подробнее", + logsGenerateButton: "Сгенерировать текст", + logsSearchText: "Искать в журналах...", + logsSearchAnyType: "Любой тип", + logsSearchButton: "Искать", + logsEmpty: "Записей пока нет.", + modulesLogsLabel: "Журнал", + modulesLogsDescription: "Модуль для записи (с помощью ИИ-ассистента) ваших впечатлений.", + statsLogsTitle: "Журнал", + statsLogsEntries: "Записи", + typeLog: "ЖУРНАЛ", + blockchainCycle: "Цикл", + + larpTitle: "L.A.R.P.", + larpDescription: "Слой ролевой игры в реальном времени для совместных экспериментов.", + larpAllHouses: "Дома:", + larpFilterRuling: "ПРАВИТ", + larpFilterHouses: "ДОМА", + larpFilterRules: "FAQ", + larpRulesTitle: "L.A.R.P. FAQ", + larpRulesEntryTitle: "Стартовый дом", + larpRulesEntryText: "Каждый житель по умолчанию начинает в ACADEMIA. Из ACADEMIA выберите дом в панели «Вступить в дом»: это откроет вступительный тест. Ответьте на 10 вопросов и отправьте. Если вы преодолеете порог (7/10), вы будете автоматически приняты в этот дом; иначе вас отклонят, и вы останетесь в ACADEMIA. В любом случае система записывает ваш OasisID и цикл попытки и не даёт повторить попытку до окончания периода ожидания в 30 циклов.", + larpRulesLeaveText: "Участники любого дома могут вернуться в ACADEMIA в любое время со страницы своего дома; это сбрасывает их членство, но не отменяет ожидание теста.", + larpRulesGovernanceTitle: "Стена управления", + larpRulesGovernanceText: "В свой цикл правящий дом носит значок «Правит» и является единственным, чья Стена показывается публично под вкладкой ПРАВИТ.", + larpRulesSignTitle: "Эмблема L.A.R.P.", + larpRulesSignText: "Жители могут включить (в /profile/edit > Сенсоры) показ изображения своего текущего дома как знака в профиле, на карточке автора и жителя. Знак ведёт на страницу дома.", + larpPostsTitle: "Стена", + larpPostsEmpty: "Пока нет публикаций.", + larpPostLabel: "Новая публикация", + larpPostPlaceholder: "Что должен сказать этот дом?", + larpPostPublic: "Публичная (видно всем, иначе только участникам)", + larpPostSubmit: "Опубликовать", + larpPostMembersOnly: "Только участники", + larpCycleLabel: "Цикл:", + audioBackToBcs: "Back to BCS", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeCompositionEmpty: "This audio does not include a stored blockchain composition.", + audioTranscodeDetailDescription: "Decode the embedded payload and the original blockchain composition map.", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeStegoEmpty: "(none)", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoNotFound: "No steganographic payload could be decoded from this audio.", + audioTranscodeStegoOasisId: "By", + audioTranscodeStegoTimestamp: "Generated at", + audioTranscodeStegoUnknown: "—", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (monthly)", + bankOverviewYourTaxes: "Your taxes", + bankRulesAlpha: "Alpha (max share of pub balance per epoch)", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesArchTaxRate: "Rate", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesCapPerEpoch: "Cap per epoch (absolute)", + bankRulesCapUser: "Cap per user per epoch", + bankRulesCarbonFactor: "Carbon factor", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesChipTitle: "ECO Tax chip color band", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesEcoTaxRate: "Rate", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesEpochKind: "Epoch granularity", + bankRulesFloor: "Floor per user (gross)", + bankRulesGraceDays: "Claim grace period", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesKarmaTitle: "Karma (user engagement score)", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesPoolTitle: "Monthly pool", + bankRulesReserveMin: "Reserve minimum (kept in pub balance)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesShareTitle: "Per-user share", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankRulesWMax: "Maximum weight (w_max)", + bankRulesWMin: "Minimum weight (w_min)", + bankTaxes: "Taxes", + bankTaxesAnnualEcoin: "ECO Tax (annual)", + bankTaxesArchTaxAnnual: "ARCH Tax (annual)", + bankTaxesArchTaxFirstBlock: "Your first block age", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxLifetime: "ARCH Tax (lifetime)", + bankTaxesArchTaxMonthly: "ARCH Tax (monthly)", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxRate: "Rate", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesArchTaxUserAmount: "Your ARCH tax (price to return)", + bankTaxesEcoTaxLifetime: "ECO Tax (lifetime)", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesLookupAuthor: "Author", + bankTaxesLookupButton: "Inspect", + bankTaxesLookupCarbon: "Carbon footprint", + bankTaxesLookupEcoin: "ECO Tax", + bankTaxesLookupNotFound: "No block found in your local feed with that ID.", + bankTaxesLookupPlaceholder: "Block ID (e.g. %abc...=.sha256)", + bankTaxesLookupSize: "Size", + bankTaxesLookupTitle: "Inspect a block", + bankTaxesLookupType: "Type", + bankTaxesMonthlyEcoin: "ECO Tax (monthly)", + bankTaxesRate: "Rate", + bankTaxesSpan: "Sampling span", + bankTaxesTotalAnnual: "Total (annual)", + bankTaxesTotalBlocks: "Total blocks", + bankTaxesTotalBytes: "Total bytes", + bankTaxesTotalCarbon: "Total carbon", + bankTaxesTotalEcoin: "Total ECO tax (lifetime)", + bankTaxesTotalLifetime: "Total (lifetime)", + bankTaxesTotalMonthly: "Total (monthly)", + bankTaxesTypesApply: "Set my taxes", + bankTaxesTypesLabel: "Select which taxes you want to pay", + bankTaxesUserAmount: "Your ECO tax (price to return)", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + blockchainBackToBlockexplorer: "Back to blockexplorer", + blockchainBlockNotAvailable: "This block is not available in your local feed. It may belong to a peer you are not yet replicating from.", + ecoTaxLabel: "ECO Tax", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + melodyAllEmpty: "No BCS compositions from other inhabitants yet.", + melodyByLabel: "By", + melodyNoteUnavailable: "block not in your local feed", + melodyUploadBcsButton: "Publish", + melodyUploadBcsNameNote: "The audio will be published with the auto-generated name", + melodyUploadBcsTitle: "Publish", + melodyUploadStegoLabel: "Hidden message (steganography)", + melodyUploadStegoMaxLabel: "max 280 characters", + melodyUploadStegoPlaceholder: "Optional.", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + statsTombstoneEmpty: "No tombstones in the network yet.", + typeGameScore: "GAME SCORES", + bankTaxesLookupNote: "Введите ID блока, чтобы найти его в вашем локальном канале.", + bankTaxesUserNoteChipsClick: "Нажмите на любой чип, чтобы проверить его блок в blockexplorer.", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "Код приглашения", + larpRulesInviteEntryText: "Участники домов могут создавать коды приглашения, дающие прямой доступ к дому.", + larpRulesOneHouseText: "Вы можете быть участником не более одного дома в любой момент. Если ни в одном — вы в ACADEMIA. Каждая страница дома (кроме ACADEMIA) показывает участникам кнопку «Покинуть Дом», которая сбрасывает членство к ACADEMIA. Выход НЕ отменяет ожидание теста.", + larpRulesOneHouseTitle: "По одному дому за раз", + larpRulesTestEntry: "WILL-тест", + larpRulesTestEntryText: "Каждый вариант взвешивает один или несколько домов; при отправке система суммирует баллы и автоматически принимает вас в дом с наибольшим количеством баллов.", + larpWillTestHint: "После завершения вам будет назначен дом, который лучше всего соответствует вашим ответам. Ваши индивидуальные ответы обрабатываются локально и никогда не сохраняются — в ваш канал публикуется только результат назначения дома.", + larpWillTestTitle: "WILL-тест", + settingsLanDesc: "Периодически анонсировать этот узел другим экземплярам Oasis в той же локальной сети. Отключите, чтобы остановить UDP-вещание.", + settingsLanEnable: "Включить LAN-вещание", + settingsLanTitle: "LAN-вещание", + settingsReplicationTitle: "Репликация", + settingsReplicationDesc: "Настройте количество переходов, которые ваш узел делает от вашего собственного канала при репликации содержимого.", + settingsReplicationHopsLabel: "Переходы", + aiApproveTagsLabel: "Теги (через запятую)", + aiApproveTagsPlaceholder: "напр. oasis, управление, экология", + aiApproveRatingLabel: "Оценка", + aiExchangeLang: "Язык", + aiExchangeTags: "Теги", + aiExchangeRating: "Оценка", + aiExchangeHelpful: "Полезно", + aiExchangeMarkHelpful: "+1 полезно", + larpCyclesUntilRuling: "Следующий цикл управления", + larpVisitTribe: "Посетить племя", + larpGoverning: "Правит:", + larpMyHouse: "Мой дом:", + larpMembersCount: "Участники", + larpMembersTitle: "Участники", + larpNoMembers: "Пока нет участников.", + larpRolesLabel: "Роли", + larpFunctionLabel: "Функция", + larpMonthLabel: "Цикл управления", + larpLeaveToAcademia: "Вернуться в ACADEMIA", + larpAcademiaJoinTitle: "Вступить в дом", + larpAcademiaJoinHint: "Пройдите психологический тест профиля, чтобы вас отнесли к дому, который больше всего вам подходит, или используйте код приглашения, которым поделился участник одного из домов.", + larpTakeTestButton: "Пройти тест профиля", + larpInviteRedeem: "Вступить в Дом", + larpInviteCreate: "Создать код приглашения", + larpInvitePlaceholder: "Код приглашения", + larpInviteBannerTitle: "Новый код приглашения", + larpInviteBannerHint: "Поделитесь этим кодом с кем-то в ACADEMIA. Срок действия — 30 циклов или одно использование.", + larpTestAssignedHouse: "Назначенный дом", + larpTestRankingTitle: "Очки по домам", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "Код приглашения дома", + invitesHouseJoinButton: "Вступить в Дом", + larpLastAttemptTitle: "Ваша последняя попытка", + larpLastAttemptHouse: "Дом", + larpLastAttemptResult: "Результат", + larpLastAttemptWhen: "Когда", + larpLastAttemptCooldown: "Следующая попытка через", + larpTestTitle: "Вступительный тест", + larpTestIntro: "Ответьте на 10 вопросов. Нужно не менее 7 правильных ответов для принятия. Вы можете пробовать тест каждые 30 циклов независимо от результата.", + larpTestSubmit: "Вступить в дом", + larpTestCooldownActive: "Следующий тест доступен через", + larpTestCooldownDays: "циклов", + larpTestResultTitle: "Результат теста", + larpTestPassed: "Тест пройден — добро пожаловать!", + larpTestFailed: "Тест не пройден", + larpTestScore: "Очки", + larpTestNextAttempt: "Вы сможете пройти ещё один тест через 30 циклов.", + larpGoToHouse: "Перейти в свой дом", + larpBackToAcademia: "Назад в ACADEMIA", + larpBackToHouses: "◀ Дома", + larpBadgeYou: "Вы", + larpBadgeRuling: "Правит", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Модуль слоя ролевой игры в реальном времени Oasis (9 Домов).", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + ecoinTaxLabel: "ECOin Tax", + bankTaxesNetworkTitle: "Network Taxes", + bankTaxesUserTitle: "Your taxes", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + statsEcoTaxTitle: "ECO Tax", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + audioTranscodeStegoTitle: "Embedded in the waveform", + audioBackToAudio: "Back to audio", + audioAuthorLabel: "Author", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "Столкнувшись со сложной проблемой, что вы делаете в первую очередь?", + larpProfileQ1O1: "Говорить с другими, чтобы найти консенсус", + larpProfileQ1O2: "Создать прототип для проверки", + larpProfileQ1O3: "Изучить литературу", + larpProfileQ1O4: "Подорвать систему, которая её создаёт", + larpProfileQ2: "Что придаёт смысл вашей ежедневной работе?", + larpProfileQ2O1: "Защищать тех, кого я люблю", + larpProfileQ2O2: "Создавать нечто осязаемое", + larpProfileQ2O3: "Исцелять или поддерживать жизнь", + larpProfileQ2O4: "Работать со словами и идеями", + larpProfileQ2O5: "Заставлять других смеяться", + larpProfileQ3: "Когда в вашей группе возникает конфликт, вы…", + larpProfileQ3O1: "Ведёте диалог", + larpProfileQ3O2: "Принимаете сторону и стоите твёрдо", + larpProfileQ3O3: "Отпускаете шутку, чтобы разрядить обстановку", + larpProfileQ3O4: "Ставите под сомнение реальность конфликта", + larpProfileQ3O5: "Ищете коренные экологические причины", + larpProfileQ4: "Ваш любимый долгосрочный проект был бы…", + larpProfileQ4O1: "Построить город", + larpProfileQ4O2: "Восстановить лес", + larpProfileQ4O3: "Написать конституцию", + larpProfileQ4O4: "Организовать фестиваль", + larpProfileQ4O5: "Создать сеть обороны", + larpProfileQ4O6: "Разработать новую машину", + larpProfileQ4O7: "Курировать архив", + larpProfileQ4O8: "Разрушить несправедливый порядок", + larpProfileQ5: "Что делает хорошего лидера?", + larpProfileQ5O1: "Тот, кто слушает и посредничает", + larpProfileQ5O2: "Тот, кто может сражаться и защищать", + larpProfileQ5O3: "Тот, кто знает историю", + larpProfileQ5O4: "Тот, кто заставляет вас улыбаться", + larpProfileQ6: "Ваше отношение к правилам:", + larpProfileQ6O1: "Правила возникают из диалога и закона", + larpProfileQ6O2: "Правила следует соблюдать строго", + larpProfileQ6O3: "Правила следует часто нарушать", + larpProfileQ6O4: "Правила должны служить жизни", + larpProfileQ6O5: "Правила создают прочную инфраструктуру", + larpProfileQ7: "Как вы обращаетесь с информацией?", + larpProfileQ7O1: "Я систематизирую и сохраняю её", + larpProfileQ7O2: "Я делюсь ею через истории", + larpProfileQ7O3: "Я ставлю под сомнение её источники", + larpProfileQ7O4: "Я извлекаю полезные части", + larpProfileQ7O5: "Я использую её для исцеления", + larpProfileQ8: "Ваша идея успеха — это…", + larpProfileQ8O1: "Работающая машина", + larpProfileQ8O2: "Мирное сообщество", + larpProfileQ8O3: "Оживлённый фестиваль", + larpProfileQ8O4: "Безопасная семья", + larpProfileQ8O5: "Неповреждённый архив", + larpProfileQ8O6: "Треснувшая догма", + larpProfileQ8O7: "Процветающий урожай", + larpProfileQ8O8: "Завершённое здание", + larpProfileQ9: "Просыпаясь, вы хотите…", + larpProfileQ9O1: "Тренировать тело", + larpProfileQ9O2: "Читать или писать", + larpProfileQ9O3: "Заниматься садом или готовить", + larpProfileQ9O4: "Возиться с чем-то", + larpProfileQ9O5: "Подвергать сомнению власть", + larpProfileQ9O6: "Планировать проект", + larpProfileQ9O7: "Говорить с друзьями", + larpProfileQ9O8: "Создавать искусство", + larpProfileQ10: "Вашей слабостью может быть:", + larpProfileQ10O1: "Слишком много говорить", + larpProfileQ10O2: "Быть слишком прагматичной", + larpProfileQ10O3: "Быть слишком идеалистичной", + larpProfileQ10O4: "Быть слишком разрушительной", + larpProfileQ10O5: "Быть слишком жёсткой", + larpProfileQ10O6: "Быть слишком беспечной", + larpProfileQ10O7: "Быть слишком осторожной", + larpProfileQ10O8: "Быть слишком амбициозной", + uxModeTitle: "UX", + uxModeDescription: "Выберите режим UX-навигации для вашего интерфейса.", + uxModeMenus: "Блоки", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/assets/translations/oasis_zh.js b/src/client/assets/translations/oasis_zh.js new file mode 100644 index 0000000..2b7b412 --- /dev/null +++ b/src/client/assets/translations/oasis_zh.js @@ -0,0 +1,3873 @@ +const { a, em, strong } = require('../../../server/node_modules/hyperaxe'); +module.exports = { + zh: { + languageName: "中文", + shopOrderStatusPaid: "已收款", + shopOrderStatusReceived: "已收到", + shopOrderMarkPaid: "标记已收款", + shopOrderConfirmReceived: "确认收货", + shopMyOrdersTitle: "我的订单", + shopPurchasesButton: "购买记录", + shopMyOrdersDescription: "你的购买订单。", + shopMyOrdersEmpty: "你还没有购买记录。", + shopOrderPmSeller: "PM Seller", + shopOrderSeller: "卖家", + shopOrderPaymentConcept: "付款", + shopOrderInvoice: "发票", + shopOrderInvoiceConcept: "发票", + shopOrderStatusPending: "待处理", + shopOrderStatusAccepted: "已接受", + shopOrderStatusRejected: "已拒绝", + shopOrderStatusShipped: "已发货", + shopOrderStatusDelivered: "已送达", + shopOrderAccept: "接受", + shopOrderReject: "拒绝", + shopOrderMarkShipped: "标记为已发货", + shopOrderMarkDelivered: "标记为已送达", + shopOrderPmBuyer: "PM Buyer", + shopOrderGenerateContract: "智能合约", + shopOrderContractConcept: "商店订单", + shopOrdersPending: "待处理订单", + all: "全部", + mine: "我的", + recent: "最近", + anonymous: "匿名", + unnamed: "匿名", + authorLabel: "作者", + delete: "删除", + save: "保存", + vote: "投票", + url: "网址", + price: "价格", + priority: "优先级", + severity: "严重程度", + organizer: "组织者", + edited: "已编辑", + isPublic: "公开", + searchIsPublicLabel: "公开", + privacyPrivate: "私密", + privacyPublic: "公开", + notFound: "未找到", + no_results: "无数据", + searchButton: "搜索", + mapZoomLabel: "缩放", + modulesTitle: "模块", + viewJson: "查看 JSON", + matchScore: "匹配度", + preferencesLabel: "偏好", + inhabitantProfileTitle: "居民资料", + contentWarningLabel: "内容警告", + invalidPost: "无效内容", + invalidPostHint: "此消息文本无效或为空。", + spreadBy: "由", + spreadChron: "传播", + spreaded: "已传播", + spreadMore: "更多", + spreadContentUnavailable: "内容尚不可用(等待同步)", + filteredByTag: "按标签筛选", + filteredByTagTitle: "按标签筛选", + feedPublishedSuccess: "动态发布成功!", + tribeFeedSent: "消息发送成功!", + tribeContentEncrypted: "加密内容", + tribeTorrentsEmpty: "暂无种子。", + torrentDescription: "描述", + torrentDownload: "下载", + chatInviteMode: "邀请", + padInviteMode: "邀请", + noInviteMode: "无邀请模式", + noDeadline: "无截止日期", + noStatus: "无状态", + noSeverity: "无严重程度", + calendarDeleteDate: "删除日期", + calendarIntervalUntil: "直到", + bookmarkCategory: "分类", + bookmarkLastVisit: "最近访问", + profileClearnetBookmarksLabel: "书签", + forumFilterHot: "热门", + invitesPort: "端口", + currentlyUnrecheable: "错误!", + peerHostValidation: "有效的 IPv4(如 192.168.1.100)或主机名(如 pub.example.com)", + peerPortValidation: "端口 1-65535", + peerKeyValidation: "SSB ed25519 公钥 (@<44 字符 base64>=.ed25519)", + inboxJobUnsubscribedTitle: "取消订阅你的招聘", + inboxProjectFollowedTitle: "你的项目有了新关注者", + inboxProjectUnfollowedTitle: "取消关注你的项目", + pmHasFollowedYourProject: "关注了你的项目", + pmHasUnfollowedYourProject: "取消关注了你的项目", + pmHasUnsubscribedFromYourJobOffer: "取消订阅了你的招聘", + marketDeleteButton: "删除", + marketNoBids: "暂无出价", + marketAuctionBidAmount: "出价金额", + marketAuctionBidTime: "出价时间", + marketAuctionUser: "出价者", + parliamentActorInPower: "执政部落", + parliamentWinningCandidature: "获胜的候选", + projectBounty: "赏金", + projectFollowing: "已关注", + projectBackerAuthor: "支持者", + projectBackerDate: "日期", + projectConfirmTransferButton: "确认转账", + projectPendingTransfersTitle: "待处理转账", + transfersUpdateSectionTitle: "更新转账", + taskUnassignedFrom: "已取消分配", + taskDescriptionPlaceholder: "任务描述", + exportPasswordLabel: "导出密码", + importPasswordPlaceholder: "输入密码", + searchCasesPlaceholder: "搜索案件…", + shopBuyDeliveryAddressPlaceholder: "收货地址", + shopBuyNotesPlaceholder: "备注", + bankTaxesUserNoteBold: "直接降低 ECO 税", + bankTaxesUserNoteIntro: "你的 ECO 税从网络在你的 UBI 之上产生的盈余中扣除;UBI 基础值被设为不可变动的下限。该税永远不会从每位居民作为 UBI 的固定金额中扣除。扣除的资金供给财富再分配算法,并通过其 UBI 申领回流给其他居民。那些为其项目获得更多 UBI 的居民,很可能会有某项任务专门帮助你降低 ECO 税。或者他们可能致力于", + bankTaxesUserNoteOutro: ",并且网络共识可能要求他们持续作出贡献。", + courtsViewDetails: "查看", + courtsViewDetailsShort: "详情", + courtsThDetails: "详情", + courtsDetailsAnswersTitle: "回答", + courtsDetailsEvidenceTitle: "证据", + courtsDetailsSettlementsTitle: "和解", + courtsDetailsVerdictTitle: "裁决", + courtsDetailsNotPublic: "非公开", + courtsEvidenceSideAccuser: "原告证据", + courtsEvidenceSideRespondent: "辩方证据", + courtsEvidenceSideUnknown: "未知", + courtsJudgeNotAssigned: "-", + courtsSupportCase: "支持该案", + courtsSettlementAcceptBtn: "接受和解", + courtsSupportCount: "支持者", + courtsVerdictVoteTitle: "对裁决投票", + courtsVerdictVoteLabel: "裁决投票", + courtsVerdictVoteAccept: "接受", + courtsVerdictVoteReject: "拒绝", + courtsVerdictVoteSubmit: "提交投票", + reportsSetStatus: "设置状态", + reportsStatusClosed: "已关闭", + statsAvgPerInhabitant: "人均", + statsCarbonMaxAnnual: "年度最大估计", + statsCarbonNetwork: "网络总计", + statsCarbonOfEstMax: "占估计最大容量", + statsCarbonOfNetwork: "占网络总计", + statsCarbonUser: "你的足迹", + statsContentCountColumn: "数量", + statsContentTypeColumn: "类型", + statsMsgsPerDay: "消息/天(累计)", + statsMyShare: "你在网络中的占比", + statsNetworkSpan: "网络跨度", + statsTombstoneRatioLabel: "删除占比", + statsTopTagsTitle: "热门标签", + statsTopTypesTitle: "热门内容类型", + statsTotalMsgs: "消息总数", + feedViewDetails: "查看详情", + eventFileLabel: "附加图片", + taskFileLabel: "附加图片", + courtsRespondentRequired: "需要被告ID", + extended: "多元宇宙", + extendedDescription: [ + "当你支持某人时,你可能会下载他们所支持的居民的帖子,这些帖子会显示在这里,按时间倒序排列。", + ], + popular: "精选", + popularDescription: [ + "来自你网络中居民的帖子,", + strong("按传播量排序"), + "。选择时间段以获取列表。", + ], + week: "周", + month: "月", + year: "年", + latest: "最新", + latestDescription: [ + strong("帖子"), + " 来自你自己和你支持的居民,按时间倒序排列。", + ], + topics: "话题", + topicsDescription: [ + strong("话题"), + " 来自你自己和你支持的居民,按时间倒序排列。", + ], + summaries: "摘要", + summariesDescription: [ + strong("有评论的话题"), + " 来自你自己和你支持的居民,按时间倒序排列。", + ], + threads: "讨论串", + threadsDescription: [ + strong("有评论的帖子"), + " 来自你支持的居民(包括多元宇宙),按时间倒序排列。", + ], + profile: "头像", + inhabitants: "居民", + peersReplicatedFeeds: "复制的 Feed", + graphos: "Graphos", + graphosDescription: "你周围网络的互动地图。", + graphosViewingGraphOf: "正在查看", + graphosBackToMine: "← 我的网络", + graphosShowing: "显示", + graphosYou: "你", + graphosTotalNodes: "节点总数", + manualMode: "手动模式", + mentions: "提及", + mentionsDescription: [ + strong("@提及你的帖子"), + ",按时间倒序排列。", + ], + nextPage: "下一页", + previousPage: "上一页", + noMentions: "你还没有收到@提及。", + privateReminders: "提醒", + inboxFiltersLabel: "筛选:", + inboxToggleToMutuals: "切换为相互关注", + inboxToggleToWhole: "切换为所有人", + privateFrom: "发件人", + privateTo: "收件人", + privateDate: "日期", + pmReply: "回复", + pmReplies: "回复", + pmNew: "新消息", + pmMarkRead: "标记为已读", + inReplyTo: "回复", + pmPreview: "预览", + pmPreviewTitle: "消息预览", + peers: "节点", + searchDescription: "描述", + imageSearch: "图片搜索", + searchFromLabel: "从", + searchToLabel: "到", + searchMinOpinionsLabel: "最少意见", + searchInhabitantLabel: "居民 (Oasis ID)", + searchQueryLabel: "查询", + searchPerPageLabel: "每页结果数", + settings: "设置", + continueReading: "继续阅读", + moreComments: "更多评论", + readThread: "阅读剩余讨论串", + pixeliaTitle: 'Pixelia', + pixeliaDescription: '在网格上绘制像素,与网络中的其他人协作创作。', + melodyTitle: 'Melody', + melodyDescription: '播放你区块链的旋律 — 每个区块都会变成一个音符。', + melodyEmpty: '还没有音符 — 你的区块链尚未产生任何区块。', + melodyCompositionTitle: '作曲', + melodyCompositionHelp: '区块链中的每个区块都会根据其类型和大小变成一个音符。', + melodyStatsTitle: '类型分布', + melodyInhabitantLabel: '居民', + melodyTotalBlocks: '音符', + melodyType: '类型', + melodyCount: '区块', + melodyFilterMine: '我的', + melodyFilterAll: '全部', + melodyFilterShare: '上传旋律', + melodyShareTitle: '分享你的旋律', + melodyShareHelp: '发布你当前旋律的快照,让别人收听和重新混音。', + melodyShareTitleLabel: '标题(可选)', + melodyShareTitlePlaceholder: '区块链幽灵', + melodyShareSubmit: '发布旋律', + melodyNoShared: '还没有区块链旋律。', + melodyRegenerate: '重新生成', + melodyDownload: "下载 BCS", + profileActivityTitle: '活动', + profileActivityMore: '查看所有活动', + profileContentSectionTitle: '头像内容', + profileContentHelp: '选择在你的头像中显示哪些模块。', + profileSensorsHelp: '头像中显示的可选指标。', + profileClearnetHelp: '可以从 Oasis 外部访问的模块。', + profileHubAll: '全部', + profileHubTitle: '公开内容', + footerPulseTitle: '脉搏', + footerPulsePeers: '节点', + footerPulseInbox: '收件箱', + footerPulseSync: '上次同步', + footerPulseEcoValue: 'ECO/小时', + footerPulseLastActivity: '最近活动', + footerLicenseLabel: '许可证', + profileSwitchToOasis: '返回 Oasis', + profileSwitchToClearnet: '进入 Clearnet', + profileVisibilityFediverse: "联邦宇宙", + profileSwitchToFediverse: "潜入联邦宇宙", + coordLabel: '坐标(例如 A3)', + coordPlaceholder: '输入坐标', + contributorsTitle: "贡献者", + pixeliaBy: "作者", + colorLabel: '选择颜色', + paintButton: '画!', + invalidCoordinate: '坐标无效', + goToMuralButton: "查看壁画", + totalPixels: '总像素数', + modules: "模块", + modulesViewTitle: "模块", + modulesViewDescription: "通过启用或禁用模块来设置你的环境。", + inbox: "收件箱", + multiverse: "多元宇宙", + fediverse: "联邦宇宙", + fediverseDescription: "管理你的其他联邦宇宙账户,包括发送和接收内容。", + fediverseStatus: "状态", + fediverseDisconnected: "联邦宇宙已断开。请检查%LINK%或连接状态。", + fediverseSettingsTitle: "联邦宇宙", + fediverseInstanceLabel: "地址", + fediverseTokenLabel: "访问令牌", + fediverseTokenHelp: "设置你的访问令牌。它将用于在 Oasis 中管理你的 Mastodon 账户。", + fediverseConnect: "连接", + fediverseConnectedAs: "已连接为", + fediverseDisconnect: "断开连接", + fediverseEmpty: "当你从%LINK%连接其他联邦宇宙账户后,便可在此管理它们。", + fediverseEmptyLink: "设置", + fediverseComposePlaceholder: "在想什么?", + fediverseReplyPlaceholder: "写下你的回复…", + fediverseAttach: "添加媒体", + fediversePublish: "发布", + fediverseReply: "回复", + fediverseBoosted: "转嗷了", + fediverseNoPosts: "你的时间线是空的。", + fediverseThread: "讨论串", + fediverseTimeline: "时间线", + fediverseManageTimeline: "管理时间线", + fediverseFollowers: "关注者", + fediverseFollowing: "正在关注", + fediversePosts: "嘟文", + fediverseJoined: "加入于", + fediverseOverview: "概览", + fediverseRefresh: "刷新", + fediverseWrite: "撰写", + fediversePreview: "预览", + fediverseEdit: "编辑", + fediverseOpenFeed: "查看信息流", + fediverseManage: "管理", + fediverseStatusNotConnected: "未连接", + fediverseError: "无法连接 Mastodon。", + fediverseErrInstance: "实例地址无效。", + fediverseErrAuth: "令牌无效或已过期。", + fediverseErrConnect: "无法连接到实例。", + fediverseErrToken: "需要令牌。", + fediverseErrPublic: "公开模式下不可用。", + fediverseErrFetch: "无法加载时间线。", + fediverseErrPost: "无法发布。", + fediverseErrMedia: "无法上传文件。", + fediverseErrEmpty: "写点什么或添加文件。", + popularLabel: "精选", + topicsLabel: "话题", + latestLabel: "最新", + summariesLabel: "摘要", + threadsLabel: "讨论串", + multiverseLabel: "多元宇宙", + inboxLabel: "收件箱", + invitesLabel: "邀请", + walletLabel: "钱包", + legacyLabel: "密钥", + cipherLabel: "加密器", + bookmarksLabel: "书签", + videosLabel: "视频", + torrentsLabel: "种子", + docsLabel: "文档", + audiosLabel: "音频", + tagsLabel: "标签", + imagesLabel: "图片", + inhabitantsLabel: "居民", + trendingLabel: "热门", + eventsLabel: "活动", + tasksLabel: "任务", + apply: "应用", + menuPersonal: "个人", + menuContent: "内容", + menuBlogs: "博客", + menuGovernance: "治理", + menuOffice: "办公", + menuMultiverse: "多元宇宙", + menuNetwork: "网络", + menuCreative: "创意", + menuEconomy: "经济", + menuMedia: "媒体", + menuTools: "工具", + comment: "评论", + subtopic: "子话题", + json: "JSON", + createdBy: "作者", + unfollow: "取消支持", + follow: "支持", + block: "屏蔽", + unblock: "取消屏蔽", + newerPosts: "更新的帖子", + olderPosts: "更早的帖子", + feedRangeEmpty: "该范围内没有内容。请尝试查看", + seeFullFeed: "完整动态", + feedEmpty: "Oasis 网络从未见过此账户的帖子。", + beginningOfFeed: "这是动态的起始位置", + noNewerPosts: "尚未收到更新的帖子。", + relationshipNotFollowing: "你未被支持", + relationshipTheyFollow: "支持你", + relationshipMutuals: "互相支持", + relationshipFollowing: "你正在支持", + relationshipYou: "你", + relationshipBlocking: "你正在屏蔽", + relationshipBlockedBy: "你被屏蔽了", + relationshipMutualBlock: "互相屏蔽", + relationshipNone: "你未支持", + relationshipConflict: "冲突", + relationshipBlockingPost: "已屏蔽的帖子", + viewLikes: "查看传播", + spreadedDescription: "该居民传播的帖子列表。", + totalspreads: "总传播数", + attachFiles: "附加文件", + preview: "预览", + publish: "写作", + contentWarningPlaceholder: "为帖子添加主题(可选)", + privateWarningPlaceholder: "添加居民以发送私密帖子(可选)", + publishWarningPlaceholder: "...", + publishCustomDescription: [ + "提醒:由于区块链技术,帖子一旦发布就无法编辑或删除。", + ], + commentWarning: [ + "提醒:由于区块链技术,帖子一旦发布就无法编辑或删除。", + ], + commentPublic: "公开", + commentPrivate: "私密", + commentLabel: ({ publicOrPrivate, markdownUrl }) => [ + ], + publishLabel: ({ markdownUrl, linkTarget }) => [ + "提醒:由于区块链技术,帖子一旦发布就无法编辑或删除。", + ], + replyLabel: ({ markdownUrl }) => [ + "提醒:由于区块链技术,帖子一旦发布就无法编辑或删除。", + ], + publishCustomInfo: ({ href }) => [ + "如果你有经验,也可以", + a({ href }, "撰写高级帖子"), + "。", + ], + publishBasicInfo: ({ href }) => [ + "如果你没有经验,应该", + a({ href }, "撰写帖子"), + "。", + ], + publishCustom: "撰写高级帖子", + subtopicLabel: "创建此帖子的子话题", + messagePreview: "帖子预览", + mentionsMatching: "匹配的提及", + mentionsName: "名称", + mentionsRelationship: "关系", + updateit: "获取更新!", + updateBannerText: "Oasis 有新版本可用。", + updateBannerAction: "立即更新 →", + info: "信息", + settingsIntro: ({ version }) => [ + `[SNH] ꖒ OASIS [ v.${version} ]`, + ], + timeAgo: "前", + sendTime: "大约 ", + theme: "主题", + legacy: "密钥", + legacyTitle: "密钥", + legacyDescription: "快速安全地管理你的密钥(私钥)。", + legacyExportButton: "导出", + legacyImportButton: "导入", + ssbLogStream: "区块链", + ssbLogStreamDescription: "配置区块链流的消息限制。", + saveSettings: "保存设置", + exportTitle: "导出数据", + exportDescription: "设置密码(至少32个字符)以加密你的密钥", + exportDataTitle: "备份", + exportDataDescription: "下载你的数据(不包含私钥!)", + exportDataButton: "下载数据库", + pubWallet: "PUB 钱包", + pubWalletDescription: "设置 PUB 钱包 URL。这将用于 PUB 交易(包括 UBI)。", + pubWalletConfiguration: "保存配置", + importTitle: "导入数据", + importDescription: "导入你的加密私钥以启用你的头像", + importAttach: "附加加密文件 (.enc)", + passwordLengthInfo: "密码至少需要32个字符。", + passwordImport: "输入密码以解密将保存在系统主目录的数据(文件名:secret)", + exportPasswordPlaceholder: "使用小写字母、大写字母、数字和符号", + fileInfo: "你的加密私钥将保存在系统主目录(文件名:oasis.enc)", + themeIntro: + "选择一个主题。", + setTheme: "设置主题", + language: "语言", + languageDescription: + "如果你想使用其他语言,请在此选择。", + setLanguage: "设置语言", + peerConnections: "节点", + peerConnectionsIntro: "管理与其他节点的所有连接。", + peerConnectionsTitle: "连接", + online: "在线", + offline: "离线", + discovered: '已发现', + unknown: '未知', + lanBroadcastLabel: "LAN 广播", + lanBroadcastActive: "已启用(LAN 上的 UDP 组播)", + lanBroadcastDisabled: "已停用(pub 模式)", + peerSourceRpc: "RPC", + peerSourceGossip: "Gossip", + peerSourceEbt: "EBT", + peerSourceRecent: "最近", + peerSourceLan: "LAN", + pub: 'PUB', + supported: "已支持", + recommended: "推荐", + blocked: "已屏蔽", + noConnections: "没有已连接的节点。", + noDiscovered: "没有已发现的节点。", + noUnknownPeers: "好友图中没有未知节点。", + peersTechnicalTitle: "Peers (technical)", + peersTechnicalIntro: "Technical info from gossip.json + conn.json. Toggle the connection from here.", + peersTechnicalEmpty: "No peers registered yet.", + peerConnect: "Connect", + peerDisconnect: "Disconnect", + peerRefresh: "刷新", + peerPruneIdle: "移除空闲", + peerExport: "导出", + peerImport: "导入", + peerImportTitle: "导入对等节点列表", + peerImportPlaceholder: "每行粘贴一个 multiserver 地址,或上传 .txt 文件……", + noSupportedConnections: "没有已支持的节点。", + noBlockedConnections: "没有已屏蔽的节点。", + noRecommendedConnections: "没有推荐的节点。", + connectionActionIntro: + "", + startNetworking: "启动网络", + stopNetworking: "停止网络", + restartNetworking: "重启网络", + sync: "同步网络", + indexes: "索引", + indexesDescription: + "重建索引是安全的,可能修复某些类型的错误。", + homePageTitle: "主页", + homePageDescription: "选择你想要作为主页的模块。", + saveHomePage: "设置主页", + invites: "邀请", + invitesTitle: "邀请", + invitesInvites: "邀请", + invitesDescription: "管理和应用你网络中的邀请码。", + invitesTribesTitle: "部落", + invitesTribeInviteCodePlaceholder: "输入部落邀请码", + invitesTribeJoinButton: "加入部落", + invitesChatsTitle: "聊天", + invitesChatInviteCodePlaceholder: "输入聊天邀请码", + invitesChatJoinButton: "加入聊天", + invitesPadsTitle: "协作板", + invitesPadInviteCodePlaceholder: "输入协作板邀请码", + invitesPadJoinButton: "加入协作板", + invitesCalendarsTitle: "日历", + invitesCalendarInviteCodePlaceholder: "输入日历邀请码", + invitesCalendarJoinButton: "加入日历", + invitesEventsTitle: "活动", + invitesEventInviteCodePlaceholder: "输入活动邀请码", + invitesEventJoinButton: "加入活动", + invitesForumsTitle: "论坛", + invitesForumInviteCodePlaceholder: "输入论坛邀请码", + invitesForumJoinButton: "加入论坛", + invitesMapsTitle: "地图", + invitesMapInviteCodePlaceholder: "输入地图邀请码", + invitesMapJoinButton: "加入地图", + invitesShopsTitle: "商店", + invitesShopInviteCodePlaceholder: "输入商店邀请码", + invitesShopJoinButton: "加入商店", + invitesPubsTitle: "PUBs", + invitesPubInviteCodePlaceholder: "输入 PUB 邀请码", + invitesAcceptInvite: "加入 PUB", + invitesAlreadyFederated: "您已与该 pub 建立联邦。", + invitesFederationsTitle: "联邦", + invitesAcceptedInvites: "已联邦", + invitesNoInvites: "尚未接受任何邀请。", + invitesUnfollow: "取消关注", + invitesFollow: "关注", + invitesUnfollowedInvites: "未联邦", + invitesNoFederatedPubs: "没有已联邦的 pub。", + invitesNoUnfollowed: "没有未联邦的 pub。", + invitesUnreachablePubs: "不可达", + invitesNoUnreachablePubs: "没有不可达的 pub。", + invitesPubsRefresh: "刷新", + invitesPubsClearUnreachable: "移除不可达", + invitesPubsExport: "导出", + invitesPubsImport: "导入", + invitesPubsImportTitle: "导入 pubs", + invitesPubsImportPlaceholder: "每行粘贴一个 multiserver 地址或邀请码,或上传 .txt 文件……", + currentlyUnreachable: "错误!", + errorDetails: "错误详情", + genericError: "发生了一个错误。", + panicMode: "紧急模式!", + encryptData: "设置密码(至少32个字符)以加密你的区块链", + decryptData: "输入密码以解密你的区块链", + panicModeDescription: "加密/解密或删除你的区块链", + removeDataDescription: "警告:此操作不可撤销。", + encryptPanicButton: "加密区块链", + decryptPanicButton: "解密区块链", + removePanicButton: "删除所有数据!", + searchTitle: "搜索", + searchDescriptionLabel: "在你的网络中搜索内容。", + searchLanguagesLabel: "语言", + searchSkillsLabel: "技能", + searchPlaceholder:"搜索内容...", + searchDateLabel:"日期", + searchLocationLabel:"位置", + searchPriceLabel:"价格", + searchUrlLabel:"URL", + searchCategoryLabel:"类别", + searchStartLabel:"开始时间", + searchEndLabel:"结束时间", + searchPriorityLabel:"优先级", + searchStatusLabel:"状态", + statusLabel:"状态", + totalVotesLabel:"总票数", + noResultsFound:"未找到结果。", + votesOption:"投票选项", + voteYesLabel:"赞成", + voteNoLabel:"反对", + allTypesLabel: "无限制", + ABSTENTIONLabel:"弃权", + YESLabel:"赞成", + NOLabel:"反对", + FOLLOW_MAJORITYLabel: "跟随多数", + CONFUSEDLabel: "困惑", + NOT_INTERESTEDLabel: "不感兴趣", + StatusLabel:"状态", + votesOptionYesLabel:"赞成", + votesOptionNoLabel:"反对", + votesOptionAbstentionLabel:"弃权", + votesQuestionLabel:"问题", + votesCreatedByLabel:"创建者", + voteStatusOpen:"进行中", + voteStatusClosed:"已关闭", + imageSearchLabel: "输入关键词以搜索带有相应标签的图片。", + commentDescription: ({ parentUrl }) => [ + " 评论了 ", + a({ href: parentUrl }, " 讨论串"), + ], + commentTitle: ({ authorName }) => [`对 @${authorName} 帖子的评论`], + subtopicDescription: ({ parentUrl }) => [ + " 从 ", + a({ href: parentUrl }, " 一篇帖子"), + " 创建了子话题", + ], + subtopicTitle: ({ authorName }) => [`@${authorName} 帖子的子话题`], + mysteryDescription: "发布了一篇神秘的帖子", + oasisDescription: "OASIS 项目网络", + searchSubmit: "搜索...", + postLabel: "帖子", + aboutLabel: "居民", + feedLabel: "动态", + votesLabel: "投票", + reportLabel: "报告", + imageLabel: "图片", + videoLabel: "视频", + audioLabel: "音频", + documentLabel: "文档", + torrentLabel: "种子", + pdfFallbackLabel: "PDF 文档", + eventLabel: "活动", + taskLabel: "任务", + transferLabel: "转账", + curriculumLabel: "简历", + bookmarkLabel: "书签", + tribeLabel: "部落", + marketLabel: "市场", + shopLabel: "SHOPS", + shopProductLabel: "SHOP PRODUCTS", + mapLabel: "MAPS", + jobLabel: "JOBS", + forumLabel: "FORUMS", + projectLabel: "PROJECTS", + bankWalletLabel: "钱包", + bankClaimLabel: "UBI CLAIMS", + voteLabel: "VOTES", + contactLabel: "CONTACTS", + pubLabel: "PUBS", + submit: "提交", + subjectLabel: "主题", + editProfile: "编辑头像", + profileQrAlt: "个人资料二维码", + profileQrHint: "扫码以复制此 Oasis ID。", + oasisIdLabel: "OasisID", + spreadLabel: "转发", + spreadHint: "转发给你的支持者(通过你的 feed 复制)。", + welcomePmSubject: "Hello World!", + welcomePmBody: "欢迎来到 Oasis — 一个自由、点对点、联邦化且加密的社交网络,采用仅限邀请的分布式架构。\n\n一些值得开始探索的地方:\n\n- /profile —— 编辑你的头像、名字、简介以及在公开侧显示哪些模块。\n- /invites —— 添加一个 pub,与更广泛的网络联邦。\n- /peers —— 查看谁已连接,重新扫描局域网,导出或导入对等节点列表。\n- /inhabitants —— 发现并访问其他人的头像。\n- /tribes —— 创建或加入端到端加密的私人小组。\n- /inbox —— 阅读和发送私信。\n- /activity —— 查看最近的活动,你的和你网络中的。\n- /publish —— 写一篇帖子,或分享图片、音频、视频、文档。\n\n一些值得连接的地方:\n\n- /fediverse — 管理你的其他联邦宇宙账户,包括发送和接收内容。\n\n这条消息是私下从你发给你自己的,因此无需任何连接即可接收。", + profileVisibilityTitle: "公开资料可见性", + profileVisibilityHint: "选择其他居民在你的资料中可见的字段。默认仅显示 Karma。", + profileSensorsSectionTitle: "传感器", + profileVisibilityActivity: "活跃度", + profileVisibilityDevice: "设备", + profileDeviceLockedHint: "此传感器始终开启:它让其他人看到你从哪个设备连接,并且无法关闭。", + profileVisibilityKarma: "KARMA 评分", + profileVisibilityUbi: "UBI", + profileVisibilityWallet: "ECOIN 钱包", + profileVisibilityEcoTax: "ECO Tax", + profileVisibilityLarpSign: "L.A.R.P. Sign", + profileVisibilityGpg: "GPG 密钥", + profileVisibilityClearnet: "发布我的资料", + clearnetSectionTitle: "Clearnet", + profileClearnetShopsLabel: "店铺", + profileClearnetJobsLabel: "工作", + profileClearnetEventsLabel: "活动", + profileClearnetProjectsLabel: "项目", + profileClearnetPostsLabel: "博客", + profileClearnetAudiosLabel: "音频", + profileClearnetVideosLabel: "视频", + profileClearnetImagesLabel: "图片", + profileClearnetDocumentsLabel: "文档", + profileClearnetTorrentsLabel: "种子", + editProfileDescription: + "", + profileName: "名称", + profileImage: "头像图片", + profileGpgKey: "GPG 公钥 (.asc)", + profileGpgChip: "GPG", + profileGpgDownload: "下载", + profileGpgRemove: "移除", + profileDescription: "描述", + hashtagDescription: + "来自你网络中引用此 #标签 的居民的帖子,按时间倒序排列。", + rebuildName: "重建数据库", + wallet: "钱包", + walletAddress: "地址", + walletAmount: "金额", + walletAddressLine: ({ address }) => `地址:${address}`, + walletAmountLine: ({ amount }) => `金额:${amount} ECO`, + walletBack: "返回", + walletBalanceTitle: "余额", + walletWalletSendTitle: "发送", + walletReceiveTitle: "接收", + walletHistoryTitle: "历史", + walletBalanceLine: ({ balance }) => `${balance} ECO`, + walletCnfrs: "确认数", + walletConfirm: "确认", + walletDescription: "管理你的数字资产,包括发送和接收 ECOin、查看余额和访问交易历史。", + walletDate: "日期", + walletFee: "手续费(手续费越高,交易处理越快)", + walletFeeLine: ({ fee }) => `手续费:ECO ${fee}`, + walletHistory: "历史", + walletReceive: "接收", + walletReset: "重置", + walletSend: "发送", + walletStatus: "状态", + walletDisconnected: [ + "ECOin ", + strong("钱包已断开连接"), + "。请检查 ", + a({ href: '/settings#wallet' }, "你的设置"), + " 或连接状态。", + ], + walletSentToLine: ({ destination, amount }) => `已发送 ECO ${amount} 至 ${destination}`, + walletSettingsTitle: "钱包", + walletSettingsDescription: "将 Oasis 与你的 ECOin 钱包集成。", + walletSettingsDocLink: "ECOin 安装指南", + walletStatusMessages: { + invalid_amount: "无效金额", + invalid_dest: "无效目标地址", + invalid_fee: "无效手续费", + validation_errors: "验证错误", + send_tx_success: "交易成功", + }, + walletTitle: "钱包", + walletTotalCostLine: ({ totalCost }) => `总费用:ECO ${totalCost}`, + walletTransactionId: "交易 ID", + walletTxId: "交易ID", + walletType: "类型", + walletUser: "用户名", + walletPass: "密码", + walletConfiguration: "设置钱包", + cipher: "加密器", + cipherTitle: "加密器", + cipherDescription: "使用共享密码对文本进行对称加密和解密。", + randomPassword: "随机密码", + cipherEncryptTitle: "加密文本", + cipherEncryptDescription: "输入要加密的文本", + cipherTextLabel: "要加密的文本", + cipherTextPlaceholder: "输入要加密的文本...", + cipherPasswordLabel: "设置密码(至少32个字符)以加密你的文本", + cipherPasswordDecryptLabel: "设置密码(至少32个字符)以解密你的文本", + cipherPasswordPlaceholder: "输入密码...", + cipherEncryptButton: "加密", + cipherDecryptTitle: "解密文本", + cipherDecryptDescription: "输入要解密的文本", + cipherEncryptedMessageLabel: "加密文本", + cipherDecryptedMessageLabel: "解密文本", + cipherPasswordUsedLabel: "加密所用密码(请妥善保管!)", + cipherEncryptedTextPlaceholder: "输入加密文本...", + cipherIvLabel: "IV", + cipherIvPlaceholder: "输入初始化向量...", + cipherDecryptButton: "解密", + password: "密码", + text: "文本", + encryptedText: "加密文本", + iv: "初始化向量 (IV)", + encryptTitle: "加密你的文本", + encryptDescription: "输入要加密的文本并提供密码。", + encryptButton: "加密", + decryptTitle: "解密你的文本", + decryptDescription: "输入加密文本并提供相同的加密密码。", + decryptButton: "解密", + passwordLengthError: "密码至少需要32个字符。", + missingFieldsError: "未提供文本、密码或 IV。", + encryptionError: "加密文本时出错。", + decryptionError: "解密文本时出错。", + bookmarkTitle: "书签", + bookmarkDescription: "发现和管理你网络中的书签。", + bookmarkAllSectionTitle: "书签", + bookmarkMineSectionTitle: "你的书签", + bookmarkRecentSectionTitle: "最近的书签", + bookmarkTopSectionTitle: "热门书签", + bookmarkFavoritesSectionTitle: "收藏", + bookmarkCreateSectionTitle: "创建书签", + bookmarkUpdateSectionTitle: "更新书签", + bookmarkFilterAll: "全部", + bookmarkFilterMine: "我的", + bookmarkFilterTop: "热门", + bookmarkFilterFavorites: "收藏", + bookmarkFilterRecent: "最近", + bookmarkCreateButton: "创建书签", + bookmarkUpdateButton: "更新", + bookmarkDeleteButton: "删除", + bookmarkAddFavoriteButton: "添加收藏", + bookmarkRemoveFavoriteButton: "取消收藏", + bookmarkUrlLabel: "链接", + bookmarkUrlPlaceholder: "https://example.com", + bookmarkDescriptionLabel: "描述", + bookmarkDescriptionPlaceholder: "可选", + bookmarkTagsLabel: "标签", + bookmarkTagsPlaceholder: "输入标签,用逗号分隔", + bookmarkCategoryLabel: "类别", + bookmarkCategoryPlaceholder: "可选", + bookmarkLastVisitLabel: "上次访问", + bookmarkSearchPlaceholder: "搜索 URL、标签、类别、作者...", + bookmarkSortRecent: "最近", + bookmarkSortOldest: "最早", + bookmarkSortTop: "最多投票", + bookmarkSearchButton: "搜索", + bookmarkUpdatedAt: "已更新", + bookmarkNoMatch: "没有匹配的书签。", + noBookmarks: "没有可用的书签。", + noUrl: "无链接", + noCategory: "无类别", + noLastVisit: "无访问记录", + videoTitle: "视频", + videoDescription: "探索和管理你网络中的视频内容。", + videoPluginTitle: "标题", + videoPluginDescription: "描述", + videoMineSectionTitle: "你的视频", + videoCreateSectionTitle: "上传视频", + videoUpdateSectionTitle: "更新视频", + videoAllSectionTitle: "视频", + videoRecentSectionTitle: "最近的视频", + videoTopSectionTitle: "热门视频", + videoFavoritesSectionTitle: "收藏", + videoFilterAll: "全部", + videoFilterMine: "我的", + videoFilterRecent: "最近", + videoFilterTop: "热门", + videoFilterFavorites: "收藏", + videoCreateButton: "上传视频", + videoUpdateButton: "更新", + videoDeleteButton: "删除", + videoAddFavoriteButton: "添加收藏", + videoRemoveFavoriteButton: "取消收藏", + videoFileLabel: "选择视频文件 (.mp4, .webm, .ogv, .mov)", + videoTagsLabel: "标签", + videoTagsPlaceholder: "输入标签,用逗号分隔", + videoTitleLabel: "标题", + videoTitlePlaceholder: "可选", + videoDescriptionLabel: "描述", + videoDescriptionPlaceholder: "可选", + videoNoFile: "未提供视频文件", + noVideos: "没有可用的视频。", + videoSearchPlaceholder: "搜索标题、标签、作者...", + videoSortRecent: "最近", + videoSortOldest: "最早", + videoSortTop: "最多投票", + videoSearchButton: "搜索", + videoMessageAuthorButton: "私信", + videoUpdatedAt: "已更新", + videoNoMatch: "没有匹配的视频。", + documentTitle: "文档", + documentDescription: "发现和管理你网络中的文档。", + documentAllSectionTitle: "文档", + documentMineSectionTitle: "你的文档", + documentRecentSectionTitle: "最近的文档", + documentTopSectionTitle: "热门文档", + documentFavoritesSectionTitle: "收藏", + documentCreateSectionTitle: "上传文档", + documentUpdateSectionTitle: "编辑文档", + documentFilterAll: "全部", + documentFilterMine: "我的", + documentFilterRecent: "最近", + documentFilterTop: "热门", + documentFilterFavorites: "收藏", + documentCreateButton: "上传文档", + documentUpdateButton: "更新", + documentDeleteButton: "删除", + documentAddFavoriteButton: "添加收藏", + documentRemoveFavoriteButton: "取消收藏", + documentMessageAuthorButton: "私信", + documentFileLabel: "上传文档 (.pdf)", + documentTagsLabel: "标签", + documentTagsPlaceholder: "输入标签,用逗号分隔", + documentTitleLabel: "标题", + documentTitlePlaceholder: "可选", + documentDescriptionLabel: "描述", + documentDescriptionPlaceholder: "可选", + documentNoFile: "无文件。", + noDocuments: "没有可用的文档。", + documentSearchPlaceholder: "搜索标题、标签、描述、作者...", + documentSortRecent: "最近", + documentSortOldest: "最早", + documentSortTop: "最多投票", + documentSearchButton: "搜索", + documentNoMatch: "没有匹配的文档。", + documentUpdatedAt: "已更新", + audioTitle: "音频", + audioDescription: "探索和管理你网络中的音频内容。", + audioPluginTitle: "标题", + audioPluginDescription: "描述", + audioMineSectionTitle: "你的音频", + audioCreateSectionTitle: "上传音频", + audioUpdateSectionTitle: "更新音频", + audioAllSectionTitle: "音频", + audioRecentSectionTitle: "最近的音频", + audioTopSectionTitle: "热门音频", + audioFilterAll: "全部", + audioFilterMine: "我的", + audioFilterRecent: "最近", + audioFilterTop: "热门", + audioFilterBlockchain: "区块链", + audioCreateButton: "上传音频", + audioUpdateButton: "更新", + audioDeleteButton: "删除", + audioAddFavoriteButton: "添加收藏", + audioRemoveFavoriteButton: "取消收藏", + audioFileLabel: "选择音频文件 (.mp3, .wav, .ogg)", + audioTagsLabel: "标签", + audioTagsPlaceholder: "输入标签,用逗号分隔", + audioTitleLabel: "标题", + audioTitlePlaceholder: "可选", + audioDescriptionLabel: "描述", + audioDescriptionPlaceholder: "可选", + audioNoFile: "未提供音频文件", + noAudios: "没有可用的音频。", + audioSearchPlaceholder: "搜索标题、标签、作者...", + audioSortRecent: "最近", + audioSortOldest: "最早", + audioSortTop: "最多投票", + audioSearchButton: "搜索", + audioMessageAuthorButton: "私信", + audioUpdatedAt: "已更新", + audioNoMatch: "没有匹配的音频。", + audioFavoritesSectionTitle: "收藏", + audioFilterFavorites: "收藏", + favoritesTitle: "收藏", + favoritesDescription: "所有你收藏的内容集中在一处。", + favoritesFilterAll: "全部", + favoritesFilterRecent: "最近", + favoritesFilterAudios: "音频", + favoritesFilterBookmarks: "书签", + favoritesFilterDocuments: "文档", + favoritesFilterImages: "图片", + favoritesFilterMaps: "地图", + favoritesFilterPads: "记事本", + favoritesFilterChats: "聊天", + favoritesFilterCalendars: "日历", + favoritesFilterVideos: "视频", + favoritesRemoveButton: "取消收藏", + favoritesNoItems: "还没有收藏。", + yourContacts: "你的联系人", + allInhabitants: "居民", + allCVs: "所有简历", + discoverPeople: "发现你网络中的居民。", + allInhabitantsButton: "全部", + contactsButton: "支持", + CVsButton: "简历", + matchSkills: "匹配技能", + matchSkillsButton: "匹配技能", + suggestedButton: "推荐", + searchInhabitantsPlaceholder: "按名称筛选居民...", + filterLocation: "按位置筛选居民...", + filterLanguage: "按语言筛选居民...", + filterSkills: "按技能筛选居民...", + applyFilters: "应用筛选", + locationLabel: "位置", + languagesLabel: "语言", + skillsLabel: "技能", + commonSkills: "共同技能", + mutualFollowers: "共同关注者", + suggestedFollowsYou: "关注了你", + latestInteractions: "最近互动", + viewAvatar: "查看头像", + viewCV: "查看简历", + suggestedSectionTitle: "推荐", + topkarmaSectionTitle: "最高因缘值", + topecoSectionTitle: "最佳生态", + topactivitySectionTitle: "最高活跃度", + blockedSectionTitle: "已屏蔽", + gallerySectionTitle: "画廊", + blockedButton: "已屏蔽", + blockedLabel: "已屏蔽用户", + inhabitantviewDetails: "查看详情", + keepReading: "继续阅读...", + oasisId: "ID", + noInhabitantsFound: "尚未找到居民。", + inhabitantActivityLevel: "活跃等级", + lifespanLabel: "存续期", + deviceLabel: "设备", + parliamentTitle: "议会", + parliamentDescription: "探索政府形式和集体管理法律。", + parliamentFilterGovernment: "政府", + parliamentFilterCandidatures: "候选", + parliamentFilterProposals: "提案", + parliamentFilterLaws: "法律", + parliamentFilterHistorical: "历史", + parliamentFilterLeaders: "领袖", + parliamentFilterRules: "规则", + parliamentGovernmentCard: "现任政府", + parliamentActorInPowerInhabitant: '执政居民', + parliamentActorInPowerTribe: '执政部落', + parliamentHistoricalGovernmentsTitle: '政府', + parliamentHistoricalElectionsTitle: '选举周期', + parliamentHistoricalLawsTitle: '历史', + parliamentHistoricalLeadersTitle: '领袖', + parliamentThCycles: '周期', + parliamentThTimesInPower: '执政', + parliamentThTotalCandidatures: '候选', + parliamentThProposed: '提议的法律', + parliamentThApproved: '通过的法律', + parliamentThDeclined: '否决的法律', + parliamentThDiscarded: '废弃的法律', + parliamentMembers: "成员", + parliamentLegSince: "周期起始", + parliamentLegEnd: "周期结束", + parliamentPoliciesProposal: "法律提案", + parliamentPoliciesApproved: "通过的法律", + parliamentPoliciesDeclined: "否决的法律", + parliamentPoliciesDiscarded: "废弃的法律", + parliamentEfficiency: "% 效率", + parliamentFilterRevocations: '撤销', + parliamentRevocationFormTitle: '撤销法律', + parliamentRevocationLaw: '法律', + parliamentRevocationTitle: '标题', + parliamentRevocationReasons: '理由', + parliamentRevocationPublish: '发布撤销', + parliamentCurrentRevocationsTitle: '当前撤销', + parliamentFutureRevocationsTitle: '未来撤销', + parliamentPoliciesRevocated: '已撤销的法律', + parliamentPoliciesTitle: '历史', + parliamentLawsTitle: '已通过的法律', + parliamentRulesRevocations: '任何已通过的法律都可以使用当前政府执政方式进行撤销。', + parliamentNoStableGov: "尚未选择政府。", + parliamentNoGovernments: "还没有政府。", + parliamentCandidatureFormTitle: "提议候选", + parliamentCandidatureIdPh: "Oasis ID (@...) 或部落名称", + parliamentCandidatureSlogan: "口号(最多140字)", + parliamentCandidatureSloganPh: "一个简短的口号", + parliamentCandidatureMethod: "方式", + parliamentCandidatureProposeBtn: "发布候选", + parliamentThDate: "提议日期", + parliamentThSlogan: "口号", + parliamentThSince: "注册时间", + parliamentThVotes: "获得票数", + parliamentThVoteAction: "投票", + parliamentTypeUser: "居民", + parliamentTypeTribe: "部落", + parliamentVoteBtn: "投票", + parliamentProposalFormTitle: "提议法律", + parliamentProposalDescription: "描述(不超过1000字)", + parliamentProposalPublish: "发布提案", + parliamentFinalize: "结束", + parliamentDeadline: "截止日期", + parliamentNoProposals: "还没有法律提案。", + parliamentNoLaws: "还没有已通过的法律。", + parliamentMethodDEMOCRACY: "民主", + parliamentMethodMAJORITY: "多数(80%)", + parliamentMethodMINORITY: "少数(20%)", + parliamentMethodDICTATORSHIP: "独裁", + parliamentMethodKARMATOCRACY: "Karma 制", + parliamentThId: "ID", + parliamentThProposalDate: "提议日期", + parliamentThMethod: "方式", + parliamentThKarma: "Karma", + parliamentThSupports: "支持", + parliamentThVote: "投票", + parliamentCurrentProposalsTitle: "当前提案", + parliamentVotesSlashTotal: "票数/总计", + parliamentVotesNeeded: "所需票数", + parliamentFutureLawsTitle: "未来法律", + parliamentNoFutureLaws: "还没有未来法律。", + parliamentVoteAction: "投票", + parliamentLeadersTitle: "领袖", + parliamentThLeader: "头像", + parliamentPopulation: "人口", + parliamentThType: "类型", + parliamentThInPower: "执政中", + parliamentThPresented: "候选", + parliamentNoLeaders: "还没有领袖。", + parliamentCandidaturesListTitle: "候选列表", + parliamentTimeRemaining: "剩余时间", + parliamentNoLeader: "尚无领先候选。", + parliamentElectionsStatusTitle: "下届政府", + parliamentProposalDeadlineLabel: "截止日期", + parliamentProposalTimeLeft: "剩余时间", + parliamentProposalOnTrack: "有望通过", + parliamentProposalOffTrack: "支持不足", + parliamentRulesTitle: "议会运作方式", + parliamentRulesIntro: "选举每2个月进行一次;候选是持续的,在政府选定后重置。", + parliamentRulesCandidates: "任何居民都可以提名自己、其他居民或任何部落。每位居民每个周期最多可提出3项候选;同一周期内的重复候选将被拒绝。", + parliamentRulesElection: "获胜者是在结算时获得最多选票的候选。", + parliamentRulesTies: "平局规则:最高居民 Karma;如果仍然平局,最早注册的;如果仍然平局,最早提出的;然后按 ID 字典序。", + parliamentRulesFallback: "如果没有人投票,最后提出的候选获胜。如果没有候选,将随机选择一个部落。", + parliamentRulesTerm: "政府标签显示当前政府和统计数据。", + parliamentRulesMethods: "形式:无政府(简单多数)、民主(50%+1)、多数(80%)、少数(20%)、Karma 制(最高 Karma 提案)、独裁(即时通过)。", + parliamentRulesAnarchy: "无政府是默认模式:如果在结算时没有候选被选出,宣布无政府状态。在无政府状态下,任何居民都可以提议法律。", + parliamentRulesProposals: "如果你是执政居民或执政部落的成员,你可以发布法律提案。非独裁方式会创建公开投票。", + parliamentRulesLimit: "每位居民每个周期最多可发布3项法律提案。", + parliamentRulesLaws: "当提案达到其门槛时,它成为法律并出现在法律标签中,附有生效日期。", + parliamentRulesHistorical: "在历史标签中,你可以看到已发生的每个政府周期及其管理数据。", + parliamentRulesLeaders: "在领袖标签中,你可以看到曾经执政(或作为候选人)的居民/部落排名,按效率排序。", + parliamentProposalVoteStatusLabel: "投票状态", + parliamentProposalOnTrackYes: "已达门槛", + parliamentProposalOnTrackNo: "低于门槛", + courtsTitle: "法庭", + courtsDescription: "探索冲突解决和集体司法管理形式。", + courtsFilterCases: "案件", + courtsFilterMyCases: "我的", + courtsFilterJudges: "法官", + courtsFilterHistory: "历史", + courtsFilterRules: "规则", + courtsCaseFormTitle: "开立案件", + courtsCaseRespondent: "被告/答辩人", + courtsCaseRespondentPh: "Oasis ID (@...) 或部落名称", + courtsCaseMediatorsAccuser: "调解员(原告方)", + courtsCaseMethod: "解决方式", + courtsCaseDescription: "描述(最多1000字)", + courtsCaseEvidenceTitle: "案件证据", + courtsCaseEvidenceHelp: "附加图片、音频、文档(PDF)或视频以支持你的案件。", + courtsCaseSubmit: "提交案件", + courtsNominateJudge: "提名法官", + courtsJudgeId: "法官", + courtsJudgeIdPh: "Oasis ID (@...) 或居民名称", + courtsNominateBtn: "提名", + courtsAddEvidence: "添加证据", + courtsEvidenceText: "文本", + courtsEvidenceLink: "链接", + courtsEvidenceLinkPh: "https://…", + courtsEvidenceSubmit: "附加", + courtsAnswerText: "答辩摘要", + courtsAnswerSubmit: "发送回应", + courtsVerdictTitle: "发布裁决", + courtsVerdictOrdersPh: "措施、期限、修复步骤...", + courtsIssueVerdict: "发布裁决", + courtsMediationPropose: "提议和解", + courtsSettlementProposeBtn: "提议", + courtsNominationsTitle: "司法提名", + courtsThJudge: "法官", + courtsThDate: "日期", + courtsThVote: "投票", + courtsNoNominations: "还没有提名。", + courtsCaseMediatorsRespondentTitle: "添加辩护调解员", + courtsCaseMediatorsRespondent: "调解员(辩护方)", + courtsMediatorsAccuserLabel: "调解员(原告方)", + courtsMediatorsRespondentLabel: "调解员(辩护方)", + courtsMediatorsSubmit: "保存调解员", + courtsNoMyCases: "你还没有冲突。", + courtsNoHistory: "还没有审判记录。", + courtsMethodDICTATOR: "独裁者", + courtsMethodPOPULAR: "民意", + courtsMethodKARMATOCRACY: "Karma 制", + courtsRulesTitle: "法庭运作方式", + courtsRulesIntro: "法庭是社区驱动的冲突解决和恢复性司法流程。优先考虑对话、清晰证据和适当补救。", + courtsRulesLifecycle: "流程:1) 开立案件 2) 选择方式 3) 提交证据 4) 听证和审议 5) 裁决和补救 6) 执行和结案 7) 上诉(如适用)。", + courtsRulesRoles: "原告:开立案件。辩护方:被告人或部落。方式:社区选择的用于促进、评估证据和发布裁决的机制。证人:提供证词或证据。调解员:由原告和/或辩护方邀请的中立人士,可访问所有细节,帮助缓和冲突并共同达成协议。", + courtsRulesEvidence: "描述不超过1000字。附加相关合法的图片、音频、视频和 PDF 文档。未经同意不要分享敏感私人数据。", + courtsRulesDeliberation: "听证可以是公开的或私密的。法官确保尊重、要求澄清,并可能排除不相关或不合法的材料。", + courtsRulesVerdict: "优先考虑修复:道歉、调解协议、内容审核、临时限制或其他适当措施。必须记录推理过程。", + courtsRulesAppeals: "上诉:在有新证据或明显程序错误时允许。除非另有规定,必须在7天内提出。", + courtsRulesPrivacy: "尊重隐私和安全。人肉搜索、仇恨或威胁将被删除。法官可以编辑或封存记录的部分内容以保护有风险的人。", + courtsRulesMisconduct: "骚扰、操纵或伪造证据可能导致立即不利裁决。", + courtsRulesGlossary: "案件:冲突记录。证据:支持诉求的材料。裁决:附有补救措施的决定。上诉:请求审查裁决。", + courtsFilterActions: "操作", + courtsNoActions: "你的角色没有待处理的操作。", + courtsCaseTitlePlaceholder: "冲突的简要描述", + courtsCaseSeverity: "严重程度", + courtsCaseSeverityNone: "无严重程度标签", + courtsCaseSeverityLOW: "低", + courtsCaseSeverityMEDIUM: "中", + courtsCaseSeverityHIGH: "高", + courtsCaseSeverityCRITICAL: "严重", + courtsCaseSubject: "主题", + courtsCaseSubjectNone: "无主题标签", + courtsCaseSubjectBEHAVIOUR: "行为", + courtsCaseSubjectCONTENT: "内容", + courtsCaseSubjectGOVERNANCE: "治理/规则", + courtsCaseSubjectFINANCIAL: "财务/资源", + courtsCaseSubjectOTHER: "其他", + courtsHiddenRespondent: "已隐藏(仅相关角色可见)。", + courtsThRole: "角色", + courtsRoleAccuser: "原告", + courtsRoleDefence: "辩护方", + courtsRoleMediator: "调解员", + courtsRoleJudge: "法官", + courtsRoleDictator: "独裁者", + courtsAssignJudgeTitle: "选择法官", + courtsAssignJudgeBtn: "选择法官", + trendingTitle: "热门", + exploreTrending: "探索你网络中最受欢迎的内容。", + bookmarkButton: "书签", + transferButton: "转账", + eventButton: "活动", + taskButton: "任务", + votesButton: "投票", + reportButton: "报告", + feedButton: "动态", + marketButton: "市场", + imageButton: "图片", + audioButton: "音频", + videoButton: "视频", + documentButton: "文档", + torrentButton: "种子", + noTrendingFound: "未找到热门内容。", + noContentMessage: "暂无热门内容。", + trendingDescription: "描述", + trendingDate: "日期", + trendingLocation: "位置", + trendingPrice: "价格", + trendingUrl: "URL", + trendingCategory: "类别", + trendingStart: "开始", + trendingEnd: "结束", + trendingPriority: "优先级", + trendingStatus: "状态", + trendingFrom: "发件人", + trendingTo: "收件人", + trendingConcept: "概念", + trendingAmount: "金额", + trendingDeadline: "截止日期", + trendingItemStatus: "项目状态", + trendingTotalVotes: "总票数", + trendingTotalOpinions: "总观点数", + trendingNoContentMessage: "暂无热门内容。", + trendingAuthor: "作者", + trendingCreatedAtLabel: "创建于", + trendingTotalCount: "总计", + tasksTitle: "任务", + tasksDescription: "发现和管理你网络中的任务。", + taskTitleLabel: "标题", + taskDescriptionLabel: "描述", + taskStartTimeLabel: "开始时间", + taskEndTimeLabel: "结束时间", + taskPriorityLabel: "优先级", + taskPrioritySelect: "选择优先级", + taskPriorityUrgent: "紧急", + taskPriorityHigh: "高", + taskPriorityMedium: "中", + taskPriorityLow: "低", + taskLocationLabel: "位置", + taskTagsLabel: "标签", + taskVisibilityLabel: "可见性", + taskPublic: "公开", + taskPrivate: "私密", + taskCreatedAt: "创建于", + taskBy: "作者", + taskStatus: "状态", + taskStatusOpen: "开放", + taskStatusInProgress: "进行中", + taskStatusClosed: "已关闭", + taskAssignedTo: "分配给", + taskAssignees: "被分配者", + taskAssignButton: "分配给我", + taskUnassignButton: "取消分配", + taskCreateButton: "创建任务", + taskUpdateButton: "更新", + taskDeleteButton: "删除", + taskFilterAll: "全部", + taskFilterMine: "我的", + taskFilterOpen: "开放", + taskFilterInProgress: "进行中", + taskFilterClosed: "已关闭", + taskFilterAssigned: "已分配", + taskFilterArchived: "已归档", + taskFilterUrgent: "紧急", + taskFilterHigh: "高", + taskFilterMedium: "中", + taskFilterLow: "低", + taskAllSectionTitle: "任务", + taskMineSectionTitle: "你的任务", + taskCreateSectionTitle: "创建任务", + taskUpdateSectionTitle: "更新", + taskOpenTitle: "开放任务", + taskInProgressTitle: "进行中的任务", + taskClosedTitle: "已关闭的任务", + taskAssignedTitle: "已分配的任务", + taskArchivedTitle: "已归档的任务", + taskPublicTitle: "公开任务", + taskPrivateTitle: "私密任务", + notasks: "没有可用的任务。", + noLocation: "未指定位置", + taskSetStatus: "设置状态", + eventTitle: "活动", + eventDateLabel: "日期", + eventsTitle: "活动", + eventsDescription: "发现和管理你网络中的活动。", + eventDescription: "描述", + eventPrice: "价格", + eventStatus: "状态", + eventOrganizer: "组织者", + eventAllSectionTitle: "活动", + eventMineSectionTitle: "你的活动", + eventArchivedTitle: "已归档的活动", + eventCreateSectionTitle: "创建活动", + eventUpdateSectionTitle: "更新活动", + eventDeleteButton: "删除", + eventCreateButton: "创建活动", + eventAddToCalendar: "添加到日历", + eventVisitCalendar: "查看日历", + viewTask: "查看任务", + viewReport: "查看报告", + viewTransfer: "查看转账", + viewItem: "查看物品", + viewShop: "查看商店", + viewProject: "查看项目", + viewVotation: "查看投票", + voteCastTitle: "投票", + voteResults: "结果", + eventTitleLabel: "标题", + eventDescriptionLabel: "描述", + eventDescriptionPlaceholder: "输入活动描述...", + eventUpdateButton: "更新", + eventAttendeesLabel: "参与者", + eventAttendeesPlaceholder: "输入参与者,用逗号分隔...", + eventTagsLabel: "标签", + eventTagsPlaceholder: "输入活动标签,用逗号分隔...", + eventTags: "标签", + eventPriceLabel: "价格", + eventUrlLabel: "URL", + eventAttendees: "参与者", + noAttendees: "暂无参与者", + eventCreatedAt: "创建于", + eventLocation: "位置", + eventLocationLabel: "位置", + eventNoLocation: "未指定位置", + eventNoURL: "未指定 URL", + eventBy: "作者", + noevents: "没有可用的活动。", + eventDate: "日期", + eventDateFormat: "YYYY年MM月DD日 HH:mm", + eventAttendButton: "参加活动", + eventUnattendButton: "取消参加", + eventCreatedBy: "创建者", + eventAttendeesCount: "参与人数", + eventCreatedByYou: "你创建了此活动", + eventFilterAll: "全部", + eventFilterMine: "我的", + eventFilterToday: "今天", + eventFilterWeek: "本周", + eventFilterMonth: "本月", + eventFilterYear: "今年", + eventFilterArchived: "已归档", + eventTodayTitle: "今天的活动", + eventThisWeekTitle: "本周的活动", + eventThisMonthTitle: "本月的活动", + eventThisYearTitle: "今年的活动", + eventPrivacyLabel: "可见性", + eventPublic: "公开", + eventPrivate: "私密", + eventPublicTitle: "公开活动", + eventNoPrice: "免费活动", + eventNoImage: "未上传图片", + eventAttendConfirmation: "你现在正在参加此活动", + eventUnattendConfirmation: "你已取消参加此活动", + eventAttended: "已参加", + eventUnattended: "未参加", + eventStatusOpen: "开放", + eventStatusClosed: "已关闭", + tagsTitle: "标签", + tagsDescription: "发现和探索你网络中的分类模式。", + tagsAllSectionTitle: "标签", + tagsTopSectionTitle: "热门标签", + tagsCloudSectionTitle: "标签云", + tagsFilterAll: "全部", + tagsFilterTop: "热门", + tagsFilterCloud: "标签云", + tagsNoItems: "没有可用的标签。", + tagsTableHeaderTag: "标签/链接:", + tagsTableHeaderCount: "计数:", + transfersTitle: "转账", + transfersDescription: "发现和管理你网络中的转账。", + transfersFrom: "发起人", + transfersTo: "接收人", + transfersFilterAll: "全部", + transfersFilterMine: "我的", + transfersFilterUBI: "UBI", + transfersFilterMarket: "市场", + transfersFilterTop: "热门", + transfersFilterPending: "待处理", + transfersFilterUnconfirmed: "未确认", + transfersFilterClosed: "已关闭", + transfersFilterDiscarded: "已废弃", + transfersFilterEconomic: "经济", + transfersFilterTime: "时间", + transfersFilterTrust: "信任", + transfersCategory: "类别", + transfersCategoryEconomic: "经济", + transfersCategoryTime: "时间", + transfersCategoryTrust: "信任", + transfersCategoryApply: "应用", + transfersUnitEco: "ECO", + transfersUnitHours: "小时", + transfersUnitTrust: "信任", + transfersEconomicSectionTitle: "经济转账", + transfersTimeSectionTitle: "时间转账", + transfersTrustSectionTitle: "信任转账", + transfersCreateButton: "创建转账", + transfersUpdateButton: "更新", + transfersDeleteButton: "删除", + transfersToUser: "Oasis ID", + transfersToUserValidation: "有效的 Oasis ID,例如 @…=.ed25519", + transfersConcept: "概念", + transfersAmount: "金额", + transfersDeadline: "截止日期", + transfersTags: "标签", + transfersStatus: "状态", + transfersStatusUnconfirmed: "未确认", + transfersStatusClosed: "已关闭", + transfersStatusDiscarded: "已废弃", + transfersCreatedAt: "创建于", + transfersConfirmations: "确认", + transfersContainingBlock: "所在区块", + transfersExportContract: "创建合约", + transfersConfirmButton: "确认转账", + transfersNoItems: "未找到转账。", + transfersMineSectionTitle: "你的转账", + transfersUBISectionTitle: "UBI 转账", + transfersMarketSectionTitle: "市场转账", + transfersTopSectionTitle: "热门转账", + transfersPendingSectionTitle: "待处理转账", + transfersUnconfirmedSectionTitle: "未确认转账", + transfersClosedSectionTitle: "已关闭转账", + transfersDiscardedSectionTitle: "已废弃转账", + transfersCreateSectionTitle: "创建转账", + transfersAllSectionTitle: "转账", + transfersFilterFavs: "收藏", + transfersFavsSectionTitle: "收藏的转账", + transfersSearchLabel: "搜索", + transfersSearchPlaceholder: "搜索概念、标签、用户...", + transfersMinAmountLabel: "最低金额", + transfersMaxAmountLabel: "最高金额", + transfersSortLabel: "排序", + transfersSortRecent: "最近", + transfersSortAmount: "最高金额", + transfersSortDeadline: "最近截止", + transfersSearchButton: "搜索", + transfersFavoriteButton: "收藏", + transfersUnfavoriteButton: "取消收藏", + transfersMessageUserButton: "消息", + transfersExpiringSoonBadge: "即将到期", + transfersExpiredBadge: "已过期", + transfersUpdatedAt: "已更新", + transfersNoMatch: "没有匹配的转账。", + votationsTitle: "投票", + votationsDescription: "发现和管理你网络中的投票。", + voteMineSectionTitle: "你的投票", + voteCreateSectionTitle: "创建投票", + voteUpdateSectionTitle: "更新", + voteOpenTitle: "进行中的投票", + voteClosedTitle: "已关闭的投票", + voteAllSectionTitle: "投票", + voteCreateButton: "创建投票", + voteUpdateButton: "更新", + voteDeleteButton: "删除", + voteOptionYes: "赞成", + voteOptionNo: "反对", + voteOptionAbstention: "弃权", + voteConfused: "困惑", + voteFollowMajority: "跟随多数", + voteNotInterested: "不感兴趣", + voteFilterAll: "全部", + voteFilterMine: "我的", + voteFilterOpen: "进行中", + voteFilterClosed: "已关闭", + voteQuestionLabel: "问题", + voteDeadlineLabel: "截止日期", + voteOptionsLabel: "你的投票", + voteBreakdown: "分布", + voteFinalResult: "结果", + voteNoQuorum: "未达法定人数", + voteTagsLabel: "标签", + voteDeadline: "截止日期", + voteStatus: "状态", + voteTags: "标签", + voteOpinions: "观点", + novotes: "没有可用的投票提案。", + voteBy: "作者", + voteCreatedAt: "创建于", + voteNoQuestion: "未提供问题", + voteUnknownCreator: "未知创建者", + voteUnknownDate: "未知日期", + errorVoteNotFound: "未找到投票", + errorAlreadyVoted: "你已经表达了意见。", + errorVoteClosedCannotEdit: "无法编辑已关闭的投票", + errorVoteDeadlinePassed: "此投票的截止日期已过", + errorRetrievingVote: "获取投票时出错", + errorCreatingVote: "创建投票时出错", + errorVoteAlreadyVoted: "意见提交后无法编辑", + errorDeletingOldVote: "删除旧意见时出错", + errorCreatingUpdatedVote: "创建更新的意见时出错", + errorCreatingTombstone: "创建墓碑时出错", + voteDetailSectionTitle: '投票详情', + voteCommentsLabel: '评论', + voteCommentsForumButton: '公开讨论', + voteCommentsSectionTitle: '公开讨论', + voteNoCommentsYet: '还没有评论。成为第一个回复的人。', + voteNewCommentPlaceholder: '在此写下你的评论…', + voteNewCommentButton: '发表评论', + voteNewCommentLabel: '添加评论', + cvTitle: "简历", + cvLabel: "简历 (CV)", + cvEditSectionTitle: "编辑简历", + cvCreateSectionTitle: "创建简历", + cvDescription: "管理和分享你的专业技能和信息。", + cvNameLabel: "全名", + cvDescriptionLabel: "摘要", + cvPhotoLabel: "照片", + cvPersonalExperiencesLabel: "个人经历", + cvPersonalSkillsLabel: "个人技能(逗号分隔)", + cvOasisExperiencesLabel: "Oasis 贡献经历", + cvOasisSkillsLabel: "Oasis 贡献技能(逗号分隔)", + cvEducationExperiencesLabel: "教育经历", + cvEducationalSkillsLabel: "教育技能(逗号分隔)", + cvProfessionalExperiencesLabel: "专业经历", + cvProfessionalSkillsLabel: "专业技能(逗号分隔)", + cvLanguagesLabel: "语言", + cvLocationLabel: "位置", + cvStatusLabel: "状态", + cvPreferencesLabel: "偏好", + cvOasisContributorLabel: "Oasis 贡献者", + cvPersonal: "个人", + cvOasis: "Oasis 贡献者(可选)", + cvOasisContributorView: "Oasis 贡献", + cvEducational: "教育(可选)", + cvEducationalView: "教育", + cvProfessional: "专业(可选)", + cvProfessionalView: "专业", + cvAvailability: "可用性(可选)", + cvAvailabilityView: "可用性", + cvUpdateButton: "更新", + cvCreateButton: "创建简历", + cvContactLabel: "联系方式", + cvCreatedAt: "创建于", + cvUpdatedAt: "更新于", + cvEditButton: "更新", + cvDeleteButton: "删除", + cvNoCV: "未找到简历。", + blogSubject: "主题", + blogMessage: "正文限制为 8096 个字符。", + blogImage: "上传媒体(最大:50MB)", + blogPublish: "预览", + noPopularMessages: "还没有发布热门消息", + forumTitle: "论坛", + forumCategoryLabel: "类别", + forumTitleLabel: "标题", + forumTitlePlaceholder: "论坛标题...", + forumCreateButton: "创建论坛", + forumCreateSectionTitle: "创建论坛", + forumDescription: "与你网络中的其他居民公开交流。", + forumFilterAll: "全部", + forumFilterMine: "我的", + forumFilterRecent: "最近", + forumFilterTop: "热门", + forumMineSectionTitle: "你的论坛", + forumRecentSectionTitle: "最近的论坛", + forumAllSectionTitle: "论坛", + forumDeleteButton: "删除", + forumParticipants: "参与者", + forumMessages: "消息", + forumLastMessage: "最后消息", + forumMessageLabel: "消息", + forumMessagePlaceholder: "写下你的消息...", + forumSendButton: "发送", + forumVisitForum: "访问论坛", + noForums: "未找到论坛。", + forumVisitButton: "访问论坛", + forumCatGENERAL: "综合", + forumCatOASIS: "Oasis", + forumCatLARP: "L.A.R.P.", + forumCatPOLITICS: "政治", + forumCatTECH: "科技", + forumCatSCIENCE: "科学", + forumCatMUSIC: "音乐", + forumCatART: "艺术", + forumCatGAMING: "游戏", + forumCatBOOKS: "书籍", + forumCatFILMS: "电影", + forumCatPHILOSOPHY: "哲学", + forumCatSOCIETY: "社会", + forumCatPRIVACY: "隐私", + forumCatCYBERWARFARE: "网络战争", + forumCatSURVIVALISM: "生存主义", + imageTitle: "图片", + imageDescription: "探索和管理你网络中的图片内容。", + imagePluginTitle: "标题", + imagePluginDescription: "描述", + imageMineSectionTitle: "你的图片", + imageCreateSectionTitle: "上传图片", + imageUpdateSectionTitle: "更新图片", + imageAllSectionTitle: "图片", + imageRecentSectionTitle: "最近的图片", + imageTopSectionTitle: "热门图片", + imageFavoritesSectionTitle: "收藏", + imageGallerySectionTitle: "画廊", + imageMemeSectionTitle: "梗图", + imageFilterAll: "全部", + imageFilterMine: "我的", + imageFilterRecent: "最近", + imageFilterTop: "热门", + imageFilterFavorites: "收藏", + imageFilterGallery: "画廊", + imageFilterMeme: "梗图", + imageCreateButton: "上传图片", + imageUpdateButton: "更新", + imageDeleteButton: "删除", + imageAddFavoriteButton: "添加收藏", + imageRemoveFavoriteButton: "取消收藏", + imageFileLabel: "选择图片文件 (.jpeg, .jpg, .png, .gif)", + imageTagsLabel: "标签", + imageTagsPlaceholder: "输入标签,用逗号分隔", + imageTitleLabel: "标题", + imageTitlePlaceholder: "可选", + imageDescriptionLabel: "描述", + imageDescriptionPlaceholder: "可选", + imageMemeLabel: "标记为表情包", + imageNoFile: "未提供图片文件", + noImages: "没有可用的图片。", + imageSearchPlaceholder: "搜索标题、标签、描述、作者...", + imageSortRecent: "最近", + imageSortOldest: "最早", + imageSortTop: "最多投票", + imageSearchButton: "搜索", + imageMessageAuthorButton: "消息", + imageUpdatedAt: "已更新", + imageNoMatch: "没有匹配的图片。", + feedTitle: "动态", + createFeedTitle: "创建动态", + createFeedButton: "发送动态!", + feedPlaceholder: "有什么新鲜事?(最多280字)", + TODAYButton: "今天", + CREATEButton: "创建动态", + totalOpinions: "总观点数", + moreVoted: "最多投票", + noFeedsFound: "未找到动态。", + createdAtLabel: "创建于", + FeedshareYourOpinions: "发现和分享你网络中的短文。", + refeedButton: "转发", + alreadyRefeeded: "你已经转发过了。", + activityTitle: "活动记录", + yourActivity: "你的活动", + globalActivity: "全局活动", + activityList: "活动记录", + activityDesc: "查看你网络中的最新活动。", + allButton: "全部", + mineButton: "我的", + noActions: "没有可用的活动。", + performed: "→", + from: "发起人", + to: "接收人", + amount: "金额", + concept: "概念", + description: "描述", + meme: "表情包", + activityContact: "联系人", + activityBy: "名称", + activityPixelia: "新增像素", + viewImage: "查看图片", + playAudio: "播放音频", + playVideo: "播放视频", + typeRecent: "最近", + errorActivity: "获取活动时出错", + typePost: "博客", + recentButton: "最近", + typeTribe: "部落", + typeLarp: "L.A.R.P.", + typeInhabitants: "居民", + activityProfileUpdated: "更新了个人资料", + typeAbout: "居民", + typeCurriculum: "简历", + typeImage: "图片", + typeBookmark: "书签", + typeDocument: "文档", + typeVotes: "投票", + typeAudio: "音频", + typeMarket: "市场", + typeJob: "工作", + typeProject: "项目", + typeVideo: "视频", + typeVote: "传播", + typeEvent: "活动", + typeTransfer: "转账", + typeTask: "任务", + typePixelia: "像素画", + typeForum: "论坛", + typeReport: "报告", + typeFeed: "动态", + typeContact: "联系人", + typePub: "PUB", + typeTombstone: "墓碑", + typeBanking: "银行", + typeBankWallet: "银行/钱包", + typeBankClaim: "银行/UBI", + typeKarmaScore: "因缘值", + typeParliament: "议会", + typeSpread: "传播", + typeParliamentCandidature: "议会 · 候选", + typeParliamentTerm: "议会 · 任期", + typeParliamentProposal:"议会 · 提案", + typeParliamentRevocation:"议会 · 撤销", + typeParliamentLaw: "议会 · 新法律", + typeCourts: "法庭", + typeCourtsCase: "法庭 · 案件", + typeCourtsEvidence: "法庭 · 证据", + typeCourtsAnswer: "法庭 · 答辩", + typeCourtsVerdict: "法庭 · 裁决", + typeCourtsSettlement: "法庭 · 和解", + typeCourtsSettlementProposal: "法庭 · 和解提议", + typeCourtsSettlementAccepted: "法庭 · 和解接受", + typeCourtsNomination: "法庭 · 提名", + typeCourtsNominationVote: "法庭 · 提名投票", + activitySupport: "新联盟建立", + activityJoin: "加入新 PUB", + question: "问题", + deadline: "截止日期", + status: "状态", + votes: "投票", + totalVotes: "总票数", + voteTotalVotes: "总票数", + name: "名称", + skills: "技能", + tags: "标签", + title: "标题", + date: "日期", + category: "类别", + attendees: "参与者", + activitySpread: "->", + visitLink: "访问链接", + viewDocument: "查看文档", + location: "位置", + contentWarning: "主题", + personName: "居民名称", + bankWalletConnected: "ECOin 钱包", + bankUbiReceived: "已收到 UBI", + bankTx: "交易", + bankEpochShort: "纪元", + bankingUserEngagementScore: "KARMA 评分", + viewDetails: "查看详情", + link: "链接", + activityProjectFollow: "%OASIS% 正在 %ACTION% 此项目 %PROJECT%", + activityProjectUnfollow: "%OASIS% 正在 %ACTION% 此项目 %PROJECT%", + activityProjectPledged: "%OASIS% 已 %ACTION% %AMOUNT% 至项目 %PROJECT%", + following: "关注中", + unfollowing: "取消关注中", + pledged: "已质押", + parliamentCandidatureId: "候选", + parliamentGovMethod: "方式", + parliamentVotesReceived: "获得票数", + parliamentMethodANARCHY: "无政府", + parliamentMethodVOTE: "社区投票", + parliamentMethodRANKED: "排序选择", + parliamentMethodPLURALITY: "多数制", + parliamentMethodCOUNCIL: "委员会", + parliamentMethodJURY: "陪审团", + parliamentAnarchy: "无政府", + parliamentElectionsStart: "选举开始", + parliamentElectionsEnd: "选举结束", + parliamentCurrentLeader: "领先候选", + parliamentProposalTitle: "标题", + parliamentOpenVote: "公开投票", + parliamentStatus: "状态", + parliamentLawQuestion: "问题", + parliamentLawMethod: "方式", + parliamentLawProposer: "提议者", + parliamentLawEnacted: "生效于", + parliamentLawVotes: "投票", + createdAt: "创建于", + courtsCaseTitle: "案件", + courtsMethod: "方式", + courtsMethodJUDGE: "法官", + courtsMethodJUDGES: "法官组", + courtsMethodSINGLE_JUDGE: "独任法官", + courtsMethodJURY: "陪审团", + courtsMethodCOUNCIL: "委员会", + courtsMethodCOMMUNITY: "社区", + courtsMethodARBITRATION: "仲裁", + courtsMethodVOTE: "社区投票", + courtsAccuser: "原告", + courtsRespondent: "被告", + courtsThStatus: "状态", + courtsThAnswerBy: "答辩人", + courtsThEvidenceBy: "举证人", + courtsThDecisionBy: "裁决者", + courtsVotesNeeded: "所需票数", + courtsVotesSlashTotal: "赞成/总计", + courtsOpenVote: "公开投票", + courtsAnswerTitle: "答辩", + courtsStanceADMIT: "承认", + courtsStanceDENY: "否认", + courtsStancePARTIAL: "部分承认", + courtsStanceCOUNTERCLAIM: "反诉", + courtsStanceNEUTRAL: "中立", + courtsVerdictResult: "结果", + courtsVerdictOrders: "命令", + courtsSettlementText: "和解", + courtsSettlementAccepted: "已接受", + courtsSettlementPending: "待处理", + courtsJudge: "法官", + courtsThSupports: "支持", + courtsFilterOpenCase: '开立案件', + courtsEvidenceFileLabel: '证据文件(图片、音频、视频或 PDF)', + courtsCaseMediators: '调解员', + courtsCaseMediatorsPh: '调解员 Oasis ID,用逗号分隔', + courtsMediatorsLabel: '调解员', + courtsThCase: '案件', + courtsThCreatedAt: '开始日期', + courtsThActions: '操作', + courtsPublicPrefLabel: '解决后的可见性', + courtsPublicPrefYes: '我同意此案件可完全公开', + courtsPublicPrefNo: '我倾向于保持细节私密', + courtsPublicPrefSubmit: '保存可见性偏好', + courtsMethodMEDIATION: '调解', + courtsNoCases: '没有案件。', + reportsTitle: "报告", + reportsDescription: "管理和追踪你网络中与问题、漏洞、滥用和内容警告相关的报告。", + reportsFilterAll: "全部", + reportsFilterMine: "我的", + reportsFilterFeatures: "功能", + reportsFilterBugs: "漏洞", + reportsFilterAbuse: "滥用", + reportsFilterContent: "内容", + reportsFilterConfirmed: "已确认", + reportsFilterResolved: "已解决", + reportsFilterOpen: "开放", + reportsFilterUnderReview: "审查中", + reportsFilterInvalid: "无效", + reportsCreateButton: "创建报告", + reportsTitleLabel: "标题", + reportsDescriptionLabel: "描述", + reportsDescriptionPlaceholder: "请提供详细描述。", + reportsCategory: "类别", + reportsCategoryLabel: "类别", + reportsCategoryFeatures: "功能", + reportsCategoryBugs: "漏洞", + reportsCategoryAbuse: "滥用", + reportsCategoryContent: "内容问题", + reportsUpdateButton: "更新", + reportsDeleteButton: "删除", + reportsDateLabel: "日期", + reportsUploadFile: "上传媒体(最大:50MB)", + reportsMineSectionTitle: "你的报告", + reportsFeaturesSectionTitle: "功能请求", + reportsBugsSectionTitle: "漏洞", + reportsAbuseSectionTitle: "滥用报告", + reportsContentSectionTitle: "内容问题", + reportsAllSectionTitle: "报告", + reportsNoItems: "没有可用的报告。", + reportsValidationTitle: "请输入有效的标题。", + reportsValidationDescription: "描述不能为空。", + reportsValidationCategory: "请选择一个类别。", + reportsCreatedAt: "创建于", + reportsCreatedBy: "作者", + reportsSeverity: "严重程度", + reportsSeverityLow: "低", + reportsSeverityMedium: "中", + reportsSeverityHigh: "高", + reportsSeverityCritical: "严重", + reportsStatus: "状态", + reportsStatusOpen: "开放", + reportsStatusUnderReview: "审查中", + reportsStatusResolved: "已解决", + reportsStatusInvalid: "无效", + reportsUpdateStatusButton: "更新状态", + reportsAnonymityOption: "匿名提交", + reportsAnonymousAuthor: "匿名", + reportsConfirmButton: "确认报告!", + reportsConfirmations: "确认", + reportsConfirmedSectionTitle: "已确认的报告", + reportsCreateTaskButton: "创建任务", + reportsOpenSectionTitle: "开放报告", + reportsUnderReviewSectionTitle: "审查中的报告", + reportsResolvedSectionTitle: "已解决的报告", + reportsInvalidSectionTitle: "无效报告", + reportsTemplateSectionTitle: '报告模板', + reportsBugTemplateTitle: '复现详情(漏洞)', + reportsFeatureTemplateTitle: '详情(功能)', + reportsAbuseTemplateTitle: '详情(滥用)', + reportsContentTemplateTitle: '详情(内容问题)', + reportsStepsToReproduceLabel: '复现步骤', + reportsStepsToReproducePlaceholder: '1) ...\n2) ...\n3) ...', + reportsExpectedBehaviorLabel: '预期结果', + reportsExpectedBehaviorPlaceholder: '应该发生什么?', + reportsActualBehaviorLabel: '实际结果', + reportsActualBehaviorPlaceholder: '实际发生了什么?', + reportsEnvironmentLabel: '环境', + reportsEnvironmentPlaceholder: '版本、设备、操作系统、浏览器、设置、日志等。', + reportsReproduceRateLabel: '复现率', + reportsReproduceRateAlways: '总是', + reportsReproduceRateOften: '经常', + reportsReproduceRateSometimes: '有时', + reportsReproduceRateRarely: '偶尔', + reportsReproduceRateUnable: '无法复现', + reportsReproduceRateUnknown: '未指定', + reportsProblemStatementLabel: '问题/需求', + reportsProblemStatementPlaceholder: '此功能解决什么问题?', + reportsUserStoryLabel: '居民故事', + reportsUserStoryPlaceholder: '作为一个 <居民>,我希望 <动作>,以便 <收益>。', + reportsAcceptanceCriteriaLabel: '验收标准', + reportsAcceptanceCriteriaPlaceholder: '- 假设...\n- 当...\n- 那么...', + reportsWhatHappenedLabel: '发生了什么?', + reportsWhatHappenedPlaceholder: '描述事件的背景和大致日期。', + reportsReportedUserLabel: '被举报的居民/实体', + reportsReportedUserPlaceholder: '@居民、动态、ID、链接等。', + reportsEvidenceLinksLabel: '证据/链接', + reportsEvidenceLinksPlaceholder: '粘贴链接、消息 ID、截图(如适用)等。', + reportsContentLocationLabel: '内容位置', + reportsContentLocationPlaceholder: '链接、ID、频道、讨论串、作者等。', + reportsWhyInappropriateLabel: "为什么不合适", + reportsWhyInappropriatePlaceholder: '说明原因和影响。', + reportsRequestedActionLabel: '请求的操作', + reportsRequestedActionPlaceholder: '删除、隐藏、标记、警告等。', + tribesTitle: "部落", + tribeAllSectionTitle: "部落", + tribeMineSectionTitle: "你的部落", + tribeCreateSectionTitle: "创建部落", + tribeUpdateSectionTitle: "更新部落", + tribeGallerySectionTitle: "部落画廊", + tribeRecentSectionTitle: "最近的部落", + tribeTopSectionTitle: "热门部落", + tribeviewTribeButton: "访问部落", + tribeviewSubTribeButton: "访问子部落", + tribeRootLabel: "根", + tribeDescription: "探索或创建你网络中的部落。", + tribeFilterAll: "全部", + tribeFilterMine: "我的", + tribeFilterMembership: "成员资格", + tribeFilterRecent: "最近", + tribeFilterTop: "热门", + tribeFilterSubtribes: "子部落", + tribeFilterGallery: "画廊", + tribeMainTribeLabel: "主部落", + tribeCreateButton: "创建部落", + tribeUpdateButton: "更新", + tribeDeleteButton: "删除", + tribeImageLabel: "上传媒体(最大:50MB)", + tribeTitleLabel: "标题", + searchTribesPlaceholder: "按名称筛选部落...", + tribeTitlePlaceholder: "部落名称", + tribeDescriptionLabel: "描述", + tribeDescriptionPlaceholder: "描述这个部落", + tribeLocationLabel: "位置", + tribeLocationPlaceholder: "这个部落位于哪里?", + tribeTagsLabel: "标签", + tribeTagsPlaceholder: "输入标签,用逗号分隔", + tribeInviteMode: "邀请模式", + tribeModeLabel: "模式", + tribeIsAnonymousLabel: "状态", + tribeMembersCount: "成员", + tribeInviteCodePlaceholder: "输入邀请码", + tribeJoinByCodeButton: "使用邀请码加入", + tribeJoinButton: "加入", + tribeEnterInvite: "输入邀请码", + tribeLeaveButton: "离开", + tribeYes: "是", + tribeNo: "否", + tribePublic: "公开", + tribePrivate: "私密", + tribeGenerateInvite: "单次邀请", + tribeOpenInvitation: "公开邀请", + tribeJoinOpen: "加入部落", + tribeRemoveInvitation: "移除邀请", + tribeCreatedAt: "创建于", + tribeAuthor: "作者", + tribeAuthorLabel: "作者", + tribeStrict: "严格", + tribeOpen: "开放", + tribeFeedFilterRECENT: "最近", + tribeFeedFilterMINE: "我的", + tribeFeedFilterALL: "全部", + tribeFeedFilterTOP: "热门", + tribeFeedRefeeds: "转发", + tribeFeedRefeed: "转发", + tribeFeedMessagePlaceholder: "写一条动态…", + tribeFeedSend: "发送", + tribeFeedEmpty: "暂无动态消息。", + noTribes: "尚未找到部落。", + tribeNotFound: "未找到部落!", + createTribeTitle: "创建部落", + updateTribeTitle: "更新部落", + tribeSectionOverview: "概览", + tribeSectionInhabitants: "居民", + tribeSectionVotations: "投票", + tribeSectionEvents: "活动", + tribeSectionReports: "报告", + tribeSectionTasks: "任务", + tribeSectionFeed: "动态", + tribeSectionForum: "论坛", + tribeSectionMarket: "市场", + tribeSectionJobs: "工作", + tribeSectionProjects: "项目", + tribeSectionMedia: "媒体", + tribeSectionImages: "图片", + tribeSectionAudios: "音频", + tribeSectionVideos: "视频", + tribeSectionDocuments: "文档", + tribeSectionBookmarks: "书签", + tribeSectionMaps: "MAPS", + tribeSectionPads: "PADS", + tribeSectionChats: "CHATS", + tribeSectionCalendars: "CALENDARS", + tribePadCreate: "Create Pad", + tribeChatCreate: "Create Chat", + tribeCalendarCreate: "Create Calendar", + tribePadsEmpty: "No pads, yet.", + tribeChatsEmpty: "No chats, yet.", + tribeCalendarsEmpty: "No calendars, yet.", + tribeInhabitantsEmpty: "此部落中还没有居民。", + tribeEventCreate: "创建活动", + tribeEventsEmpty: "还没有活动。", + tribeEventTitle: "标题", + tribeEventDescription: "描述", + tribeEventDate: "日期", + tribeEventLocation: "位置", + tribeEventAttend: "参加", + tribeEventUnattend: "离开", + tribeEventAttendees: "参与者", + tribeTaskCreate: "创建任务", + tribeTasksEmpty: "还没有任务。", + tribeTaskTitle: "标题", + tribeTaskDescription: "描述", + tribeTaskPriority: "优先级", + tribeTaskDeadline: "截止日期", + tribeTaskAssignees: "被分配者", + tribeTaskStatusInProgress: "进行中", + tribeTaskStatusClosed: "关闭", + tribeTaskAssign: "分配", + tribeTaskUnassign: "取消分配", + tribeReportCreate: "创建报告", + tribeReportsEmpty: "还没有报告。", + tribeReportTitle: "标题", + tribeReportDescription: "描述", + tribeReportCategory: "类别", + tribeVotationCreate: "创建投票", + tribeVotationsEmpty: "还没有投票。", + tribeVotationTitle: "标题", + tribeVotationDescription: "描述", + tribeVotationOptions: "选项", + tribeVotationDeadline: "截止日期", + tribeVotationVote: "投票", + tribeVotationResults: "票数", + tribeVotationClose: "关闭投票", + tribeVotationOptionPlaceholder: "选项", + tribeForumCreate: "创建论坛", + tribeForumEmpty: "还没有讨论串。", + tribeForumTitle: "标题", + tribeForumText: "消息", + tribeForumCategory: "类别", + tribeForumReply: "回复", + tribeForumReplies: "回复", + tribeMarketCreate: "创建商品", + tribeMarketEmpty: "还没有商品。", + tribeMarketTitle: "标题", + tribeMarketDescription: "描述", + tribeMarketPrice: "价格", + tribeMarketImage: "图片", + tribeMarketCategory: "类别", + tribeJobCreate: "创建工作", + tribeJobsEmpty: "还没有工作。", + tribeJobTitle: "标题", + tribeJobDescription: "描述", + tribeJobLocation: "位置", + tribeJobSalary: "薪资", + tribeJobDeadline: "截止日期", + tribeProjectCreate: "创建项目", + tribeProjectsEmpty: "还没有项目。", + tribeProjectTitle: "标题", + tribeProjectDescription: "描述", + tribeProjectGoal: "目标", + tribeProjectFunded: "已筹资", + tribeProjectDeadline: "截止日期", + tribeMediaUpload: "上传媒体", + readDocument: "阅读文档", + tribeCreateImage: "创建图片", + tribeCreateAudio: "创建音频", + tribeCreateVideo: "创建视频", + tribeCreateDocument: "创建文档", + tribeCreateBookmark: "创建书签", + tribeMediaEmpty: "还没有媒体。", + tribeMediaTitle: "标题", + tribeMediaDescription: "描述", + tribeMediaType: "类型", + tribeMediaTypeImage: "图片", + tribeMediaTypeVideo: "视频", + tribeMediaTypeAudio: "音频", + tribeMediaTypeDocument: "文档", + tribeMediaTypeBookmark: "书签", + tribeContentDelete: "删除", + tribeInviteCodeText: "邀请码:", + oasisVersionLabel: "Oasis 版本", + tribeInviteCodeHint: "将此邀请码分享给你想邀请的人,他们可在 /invites → Tribes 加入。", + tribeGroupTribe: "部落", + tribeGroupOffice: "办公", + tribeGroupNetwork: "网络", + tribeGroupEconomy: "经济", + tribeGroupMedia: "媒体", + tribeStatusOpen: "开放", + tribeStatusClosed: "已关闭", + tribeStatusInProgress: "进行中", + tribePriorityLow: "低", + tribePriorityMedium: "中", + tribePriorityHigh: "高", + tribePriorityCritical: "严重", + tribeTaskFilterAll: "全部", + tribeMediaFilterAll: "全部", + tribeReportCatBug: "漏洞", + tribeReportCatAbuse: "滥用", + tribeReportCatContent: "内容", + tribeReportCatOther: "其他", + tribeForumCatGeneral: "综合", + tribeForumCatProposal: "提案", + tribeForumCatQuestion: "问题", + tribeForumCatAnnouncement: "公告", + tribeMarketCatGoods: "商品", + tribeMarketCatServices: "服务", + tribeMarketCatFood: "食品", + tribeMarketCatOther: "其他", + tribeStatusLabel: "状态", + tribeSubTribes: "子部落", + tribeSubTribesCreate: "创建子部落", + tribeSubTribesStrictDenied: "部落的严格模式不允许您创建新的子部落。请联系管理员。", + tribeSubTribesEmpty: "还没有创建子部落。", + tribeActivityJoined: "已加入", + tribeActivityLeft: "已离开", + tribeActivityFeed: "动态", + tribeActivityRefeed: "转发", + tribeGroupAnalytics: "分析", + tribeGroupCreative: "创意", + tribeSectionActivity: "活动记录", + tribeSectionTrending: "热门", + tribeSectionOpinions: "观点", + tribeSectionPixelia: "PIXELIA", + tribeSectionTags: "标签", + tribeSectionSearch: "搜索", + tribeActivityEmpty: "还没有活动。", + tribeActivityCreated: "已创建", + tribeActivityPosted: "已发布", + tribeActivityReplied: "已回复", + tribeTrendingEmpty: "还没有热门内容。", + tribeTrendingPeriodDay: "今天", + tribeTrendingPeriodWeek: "本周", + tribeTrendingPeriodAll: "全部时间", + tribeTrendingEngagement: "参与度", + tribeOpinionsEmpty: "还没有观点。", + tribeOpinionsCast: "投票", + tribeOpinionsRankings: "排名", + tribeOpinionsAlreadyVoted: "已投票", + tribeTopCategory: "最多投票", + tribePixeliaTitle: "Pixelia", + tribePixeliaDescription: "协作像素艺术画布。", + tribePixeliaPaint: "画", + tribePixeliaContributors: "贡献者", + tribePixeliaTotalPixels: "已绘制像素", + tribeTagsEmpty: "未找到标签。", + tribeTagsCloud: "标签云", + tribeTagsContentWith: "带有标签的内容", + tribeSearchPlaceholder: "搜索部落内容...", + tribeSearchEmpty: "未找到结果。", + tribeSearchResults: "结果", + tribeSearchMinChars: "请输入至少2个字符进行搜索。", + agendaTitle: "日程", + agendaDescription: "在这里你可以找到所有分配给你的项目。", + agendaFilterAll: "全部", + agendaFilterToday: "今天", + agendaFilterUpcoming: "即将到来", + agendaFilterOverdue: "逾期", + agendaFilterOpen: "开放", + agendaFilterClosed: "已关闭", + agendaFilterTasks: "任务", + agendaFilterMarket: "市场", + agendaFilterTribes: "部落", + agendaFilterEvents: "活动", + agendaFilterReports: "报告", + agendaFilterTransfers: "转账", + agendaFilterJobs: "工作", + agendaFilterProjects: "项目", + agendaFilterCalendars: "日历", + agendaNoItems: "未找到分配项目。", + agendaAuthor: "作者", + agendaDiscardButton: "丢弃", + agendaRestoreButton: "恢复", + agendaCreatedAt: "创建于", + agendaTitleLabel: "标题", + agendaMembersCount: "成员", + agendaDescriptionLabel: "描述", + agendaStatus: "状态", + agendaVisibility: "可见性", + agendaEventDate: "活动日期", + agendaEventLocation: "位置", + agendaEventPrice: "价格", + agendaEventUrl: "URL", + agendaTaskStart: "开始时间", + agendaLocationLabel: "位置", + agendaYes: "是", + agendaNo: "否", + agendaInviteModeLabel: "状态", + agendaAnonymousLabel: "匿名", + agendaMembersLabel: "成员", + agendareportCategory: "类别", + agendareportSeverity: "严重程度", + agendareportStatus: "状态", + agendareportDescription: "描述", + agendaTaskEnd: "结束时间", + agendaTaskPriority: "优先级", + agendaTransferFrom: "发起人", + agendaTransferTo: "接收人", + agendaTransferConcept: "概念", + agendaTransferAmount: "金额", + agendaTransferDeadline: "截止日期", + opinionsTitle: "观点", + shareYourOpinions: "发现并为你网络中的观点投票。", + author: "作者", + voteNow: "立即投票", + alreadyVoted: "你已经表达了意见。", + noOpinionsFound: "未找到观点。", + RECENTButton: "最近", + TOPButton: "热门", + interestingButton: "有趣", + necessaryButton: "必要", + funnyButton: "搞笑", + disgustingButton: "恶心", + sensibleButton: "明智", + propagandaButton: "宣传", + adultOnlyButton: "仅限成人", + boringButton: "无聊", + confusingButton: "令人困惑", + usefulButton: "有用", + informativeButton: "信息丰富", + wellResearchedButton: "研究充分", + accurateButton: "准确", + needsSourcesButton: "需要来源", + wrongButton: "错误", + lowQualityButton: "低质量", + creativeButton: "有创意", + insightfulButton: "有洞察力", + actionableButton: "可操作", + inspiringButton: "鼓舞人心", + loveButton: "喜爱", + clearButton: "清晰", + upliftingButton: "振奋人心", + unnecessaryButton: "不必要", + rejectedButton: "被拒绝", + misleadingButton: "误导", + offTopicButton: "离题", + duplicateButton: "重复", + clickbaitButton: "标题党", + spamButton: "垃圾信息", + trollButton: "恶搞", + nsfwButton: "NSFW", + violentButton: "暴力", + toxicButton: "有毒", + harassmentButton: "骚扰", + hateButton: "仇恨", + scamButton: "诈骗", + triggeringButton: "触发性", + opinionsCreatedAt: "创建于", + opinionsTotalCount: "总观点数", + voteInteresting: "有趣", + voteNecessary: "必要", + voteUseful: "有用", + voteInformative: "信息丰富", + voteWellResearched: "研究充分", + voteNeedsSources: "需要来源", + voteWrong: "错误", + voteLowQuality: "低质量", + voteLove: "喜爱", + voteClear: "清晰", + voteMisleading: "误导", + voteOffTopic: "离题", + voteDuplicate: "重复", + voteClickbait: "标题党", + votePropaganda: "宣传", + voteFunny: "搞笑", + voteInspiring: "鼓舞人心", + voteUplifting: "振奋人心", + voteUnnecessary: "不必要", + voteRejected: "被拒绝", + voteConfusing: "令人困惑", + voteTroll: "恶搞", + voteNsfw: "NSFW", + voteViolent: "暴力", + voteToxic: "有毒", + voteHarassment: "骚扰", + voteHate: "仇恨", + voteScam: "诈骗", + voteTriggering: "触发性", + voteInsightful: "有洞察力", + voteAccurate: "准确", + voteActionable: "可操作", + voteCreative: "有创意", + voteSpam: "垃圾信息", + voteAdultOnly: "仅限成人", + publishBlog: "发布博客", + privateMessage: "私信", + pmSendTitle: "私信", + pmSend: "发送!", + pmDescription: "使用此表单向其他居民发送加密消息。", + pmRecipients: "收件人", + pmRecipientsHint: "输入 Oasis ID,用逗号分隔", + pmLevelOfExposition: "Level of exposition", + pmLimitsHint: "Up to 7 recipients per message. Body capped at 8096 characters.", + pmSubject: "主题", + pmSubjectHint: "输入消息主题", + pmText: "消息", + pmCrypterChip: "2xE2E", + pmCrypterDecryptButton: "解密", + pmCrypterBadKey: "共享密钥不正确!", + pmCrypterTooLong: "消息过长,无法用 Crypter 加密。请缩短后重试。", + pmCrypterCipherLabel: "重新加密的消息", + pmCrypterCharsLabel: "字符", + pmInvalidRecipients: "无效的 Oasis ID(应形如 @…=.ed25519)。", + pmEncryptionLabel: "加密", + pmFile: "附件", + private: "私密", + privateDescription: "你的加密消息。", + privateInbox: "收件箱", + privateSent: "已发送", + privateDelete: "删除", + pmCreateButton: "写私信", + noPrivateMessages: "没有私信。", + pmFromLabel: "发件人:", + pmToLabel: "收件人:", + pmInvalidMessage: "无效消息", + pmNoSubject: "(无主题)", + pmSubjectLabel: "主题:", + pmBodyLabel: "正文", + pmBotJobs: "42-JobsBOT", + pmBotProjects: "42-ProjectsBOT", + pmBotMarket: "42-MarketBOT", + inboxJobSubscribedTitle: "你的工作机会有新订阅", + pmInhabitantWithId: "Oasis ID 为以下的居民:", + pmHasSubscribedToYourJobOffer: "已订阅你的工作机会", + inboxProjectCreatedTitle: "新项目已创建", + pmHasCreatedAProject: "已创建一个项目", + inboxMarketItemSoldTitle: "商品已售出", + pmYourItem: "你的商品", + pmHasBeenSoldTo: "已售出给", + pmFor: "售价", + inboxProjectPledgedTitle: "你的项目有新质押", + pmHasPledged: "已质押", + pmToYourProject: "至你的项目", + blockchain: '区块浏览器', + blockchainTitle: '区块浏览器', + blockchainDescription: '探索和可视化区块链中的区块。', + blockchainNoBlocks: '区块链中未找到区块。', + blockchainStatsTitle: '统计', + blockchainStatsTypeBreakdown: '按类型分布', + blockchainStatsTopAuthors: '热门作者', + blockchainStatsCount: '区块', + blockchainBlockID: '区块 ID', + blockchainBlockAuthor: '作者', + blockchainBlockCarbon: '碳足迹', + blockchainBlockType: '类型', + blockchainBlockTimestamp: '时间戳', + blockchainBlockContent: '区块', + blockchainBlockURL: 'URL:', + blockchainContent: '区块', + blockchainContentPreview: '区块内容预览', + blockchainLatestDatagram: '最新数据报', + blockchainDatagram: '数据报', + blockchainDetails: '查看区块详情', + blockchainViewBlockexplorer: "在区块浏览器中查看", + blockchainBlockInfo: '区块信息', + blockchainBlockDetails: '所选区块的详情', + blockchainBack: '返回区块浏览器', + blockchainContentDeleted: "此内容已被标记为墓碑", + visitContent: "访问内容", + banking: '银行', + bankingTitle: '银行', + bankingDescription: '探索 ECOin 的当前价值和相应的 UBI 分配,根据参与度和信任度按纪元分配。', + bankOverview: '概览', + bankEpochs: '纪元', + bankRules: '规则', + pending: '待处理', + closed: '已关闭', + bankBack: '返回银行', + bankViewTx: '查看交易', + bankClaimNow: '立即领取', + bankClaimUBI: '领取 UBI!', + bankClaimAndPay: 'Claim & Pay', + bankClaimedPending: 'Claim pending...', + bankStatusUnclaimed: 'Unclaimed', + bankStatusClaimed: 'Claimed', + bankStatusExpired: 'Expired', + bankPubOnly: 'PUB-only operation', + bankNoPendingUBI: '本期无待领取的 UBI 分配。', + bankEpoch: '纪元', + bankPool: '资金池(本纪元)', + bankWeightsSum: '权重总和', + bankAllocations: '分配', + bankNoAllocations: '未找到分配。', + bankNoEpochs: '未找到纪元。', + bankEpochAllocations: '纪元分配', + bankAllocId: '分配 ID', + bankAllocDate: '日期', + bankAllocConcept: '概念', + bankAllocFrom: '发起人', + bankAllocTo: '接收人', + bankAllocAmount: '金额', + bankAllocStatus: '状态', + bankEpochId: '纪元 ID', + bankRuleHash: '规则快照哈希', + bankViewEpoch: '查看纪元', + bankUserBalance: '你的余额', + ecoWalletNotConfigured: '未配置 ECOin 钱包', + editWallet: '编辑钱包', + addWallet: '添加钱包', + bankAddresses: '地址', + bankNoAddresses: '未找到地址。', + bankUser: 'Oasis ID', + bankAddress: '地址', + bankAddAddressTitle: '添加 ECOIN 地址', + bankAddAddressUser: 'Oasis ID', + bankAddAddressAddress: 'ECOIN 地址', + bankAddAddressSave: '保存', + bankAddressAdded: '地址已添加', + bankAddressUpdated: '地址已更新', + bankAddressExists: '地址已存在', + bankAddressInvalid: '无效地址', + bankAddressDeleted: '地址已删除', + bankAddressNotFound: '未找到地址', + bankAddressForbidden: '您只能设置自己的收款地址', + bankAddressTotal: '地址总数', + bankAddressSearch: '搜索 @居民或地址', + bankAddressActions: '操作', + bankAddressDelete: '删除', + bankAddressSource: '来源', + bankAddressDeleteConfirm: '删除此地址?', + search: '搜索!', + bankLocal: '本地', + bankFromOasis: 'Oasis', + bankMyAddress: '你的地址', + bankRemoveMyAddress: '移除我的地址', + bankNotRemovableOasis: '无法在本地移除地址', + bankingFutureUBI: "UBI", + pubIdTitle: "PUB Wallet", + pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).", + pubIdLabel: "PUB ID", + pubIdSave: "Save configuration", + pubIdPlaceholder: "@PUB_ID.ed25519", + bankUbiAvailableNo: "资金不足!", + bankUbiAvailableOk: "可用!", + bankUbiAvailability: "UBI 可用性", + bankAlreadyClaimedThisMonth: "本月已领取", + bankUbiThisMonth: "UBI(本月)", + bankUbiLastClaimed: "UBI(上次领取)", + bankUbiNeverClaimed: "从未领取", + bankUbiTotalClaimed: "UBI(累计领取)", + bankUbiPub: "PUB", + bankUbiInhabitant: "居民", + bankUbiClaimedAmount: "已领取(ECO)", + typeBankUbiResult: "银行 - UBI", + bankNoPubConfigured: "未配置 PUB。请在设置中设置您的 PUB ID。", + shopsTitle: "商店", + shopDescription: "发现和管理网络中的商店。", + shopTitle: "商店", + shopFilterAll: "全部", + shopFilterMine: "我的", + shopFilterRecent: "最近", + shopFilterTop: "热门", + shopFilterProducts: "产品", + shopFilterPrices: "价格", + shopFilterFavorites: "收藏", + shopUpload: "创建商店", + shopAllSectionTitle: "所有商店", + shopMineSectionTitle: "我的商店", + shopRecentSectionTitle: "最近商店", + shopTopSectionTitle: "热门商店", + shopProductsSectionTitle: "热门产品", + shopPricesSectionTitle: "按价格排列的产品", + shopFavoritesSectionTitle: "收藏", + shopCreateSectionTitle: "创建商店", + shopUpdateSectionTitle: "更新商店", + shopCreate: "创建", + shopUpdate: "更新", + shopDelete: "删除", + shopReachClearnet: "Clearnet", + shopReachOasis: "Oasis", + encryptedChipLabel: "E2E", + shopClearnetPublish: "发布到 Clearnet", + shopClearnetUnpublish: "从 Clearnet 撤下", + shopClearnetUrlLabel: "Clearnet 网址", + shopClearnetWarning: "发布到 Clearnet 后,本店铺可被外部搜索引擎和爬虫索引。", + shopAddFavorite: "添加收藏", + shopRemoveFavorite: "取消收藏", + shopOpen: "开放", + shopClosed: "关闭", + shopOpenShop: "开放商店", + shopCloseShop: "关闭商店", + shopMakePrivate: "设为私密 (E2E)", + shopMakePublic: "设为公开", + shopProducts: "产品", + shopProductAdd: "添加产品", + shopProductTitle: "产品", + shopProductUpdate: "更新产品", + shopProductPrice: "价格 (ECO)", + shopProductStock: "库存", + shopProductUntitled: "无标题产品", + shopUntitled: "无标题商店", + shopNoItems: "未找到商店。", + shopNoProducts: "暂无产品。", + shopOutOfStock: "已售罄", + shopBuy: "购买", + shopBuyEncryptedNote: "您的配送信息将加密发送,仅店主可见。", + shopBuyDeliveryAddress: "配送地址", + shopBuyContact: "联系方式", + shopBuyContactPlaceholder: "邮箱、电话等", + shopBuyNotes: "备注", + shopBuyDeliveryRequired: "配送地址为必填项。", + shopOrdersTitle: "订单", + shopOrdersDescription: "此商店收到的订单。", + shopOrdersEmpty: "暂无订单。", + shopOrderProduct: "商品", + shopOrderPrice: "价格", + shopOrderBuyer: "买家", + shopBackToShop: "返回商店", + shopShareUrl: "分享链接", + shopVisitShop: "访问商店", + marketVisitItem: "查看商品", + shopStatus: "状态", + shopCreatedAt: "创建时间", + shopSearchPlaceholder: "搜索商店...", + shopUrl: "网址", + shopLocation: "位置", + shopTags: "标签", + shopVisibility: "可见性", + shopImage: "图片", + shopShortDescription: "简短描述", + shopShortDescriptionPlaceholder: "店铺卡片简短描述(最多160个字符)", + shopTitlePlaceholder: "您的店铺名称", + shopDescriptionPlaceholder: "您的店铺详细描述", + shopUrlPlaceholder: "https://您的店铺网址.com", + shopLocationPlaceholder: "城市,国家", + shopTagsPlaceholder: "标签1, 标签2, 标签3", + mapTitlePlaceholder: "地图标题", + shopProductFeatured: "推荐产品", + shopSendToMarket: "Send to Market", + typeShop: "商店", + typeShopProduct: "店铺商品", + bankExchange: '交易所', + bankExchangeCurrentValue: 'ECOin 价值(1小时)', + bankTotalSupply: 'ECOin 总供应量', + bankEcoinHours: "ECOin 时间等价", + bankHoursOfWork: '小时', + bankUnitMs: "毫秒", + bankUnitSeconds: "秒", + bankUnitMinutes: "分钟", + bankUnitDays: "天", + bankExchangeNoData: '无可用数据', + bankExchangeIndex: 'ECOin 价值(1小时)', + bankInflation: 'ECOin 通胀率 (anual)', + bankInflationMonthly: 'ECOin 通胀率 (mensual)', + bankExchangeChartTitle: 'ECOin 价值随时间的演进', + bankExchangeChartValue: '价值 (ECO/小时)', + bankExchangeChartSupply: '供应量', + bankExchangeChartInflation: '通胀率 %', + bankExchangeChartEmpty: '采样不足 — 稍后再来', + bankCurrentSupply: 'ECOin 当前供应量', + bankingSyncStatus: 'ECOin 状态', + bankingSyncStatusSynced: '已同步', + bankingSyncStatusOutdated: '已过时', + statsTitle: '统计', + statistics: "统计", + statsInhabitant: "居民统计", + statsDescription: "发现你网络的统计数据。", + ALLButton: "全部", + MINEButton: "我的", + TOMBSTONEButton: "墓碑", + statsYou: "你", + statsUserId: "Oasis ID", + statsCreatedAt: "创建于", + statsYourContent: "内容", + statsYourOpinions: "观点", + statsYourTombstone: "墓碑", + statsNetwork: "网络", + statsTotalInhabitants: "居民", + statsDiscoveredTribes: "部落(公开)", + statsPrivateDiscoveredTribes: "部落(私密)", + statsNetworkContent: "内容", + statsYourMarket: "市场", + statsYourJob: "工作", + statsYourProject: "项目", + statsYourTransfer: "转账", + statsYourForum: "论坛", + statsNetworkOpinions: "观点", + statsDiscoveredMarket: "市场", + statsDiscoveredJob: "工作", + statsDiscoveredProject: "项目", + statsBankingTitle: "银行", + statsEcoWalletLabel: "ECOIN 钱包", + statsEcoWalletNotConfigured: "未配置!", + statsTotalEcoAddresses: "地址总数", + statsDiscoveredTransfer: "转账", + statsDiscoveredForum: "论坛", + statsNetworkTombstone: "墓碑", + statsBookmark: "书签", + statsEvent: "活动", + statsTask: "任务", + statsVotes: "投票", + statsMarket: "市场", + statsForum: "论坛", + statsJob: "工作", + statsReport: "报告", + statsFeed: "动态", + statsTribe: "部落", + statsImage: "图片", + statsAudio: "音频", + statsVideo: "视频", + statsDocument: "文档", + statsMap: "地图", + statsShop: "店铺", + statsShopProduct: "店铺商品", + statsTransfer: "转账", + statsAiExchange: "AI", + statsPUBs: 'PUB', + statsPost: "帖子", + statsOasisID: "Oasis ID", + statsSize: "总计(大小)", + statsBlockchainSize: "区块链(大小)", + statsBlobsSize: "Blob(大小)", + statsActivity7d: "活动(最近7天)", + statsActivity7dTotal: "7天总计", + statsActivity30dTotal: "30天总计", + statsKarmaScore: "KARMA 评分", + statsPublic: "公开", + statsPrivate: "私密", + day: "日", + messages: "消息", + statsProject: "项目", + statsProjectsTitle: "项目", + statsProjectsTotal: "项目总数", + statsProjectsActive: "活跃", + statsProjectsCompleted: "已完成", + statsProjectsPaused: "已暂停", + statsProjectsCancelled: "已取消", + statsProjectsGoalTotal: "目标总额", + statsProjectsPledgedTotal: "质押总额", + statsProjectsSuccessRate: "成功率", + statsProjectsAvgProgress: "平均进度", + statsProjectsMedianProgress: "中位数进度", + statsProjectsActiveFundingAvg: "平均活跃筹资", + statsJobsTitle: "工作", + statsJobsTotal: "工作总数", + statsJobsOpen: "开放", + statsJobsClosed: "已关闭", + statsJobsOpenVacants: "开放职位", + statsJobsSubscribersTotal: "订阅者总数", + statsJobsAvgSalary: "平均薪资", + statsJobsMedianSalary: "中位数薪资", + statsMarketTitle: "市场", + statsMarketTotal: "商品总数", + statsMarketForSale: "在售", + statsMarketReserved: "已预订", + statsMarketClosed: "已关闭", + statsMarketSold: "已售出", + statsMarketRevenue: "收入", + statsMarketAvgSoldPrice: "平均售价", + statsUsersTitle: "居民", + user: "居民", + statsTombstoneTitle: "墓碑", + statsNetworkTombstones: "网络墓碑", + statsTombstoneRatio: "墓碑比率 (%)", + statsParliamentCandidature: "议会候选", + statsParliamentTerm: "议会任期", + statsParliamentProposal: "议会提案", + statsParliamentRevocation: "议会撤销", + statsParliamentLaw: "议会法律", + statsCourtsCase: "法庭案件", + statsCourtsEvidence: "法庭证据", + statsCourtsAnswer: "法庭答辩", + statsCourtsVerdict: "法庭裁决", + statsCourtsSettlement: "法庭和解", + statsCourtsSettlementProposal: "和解提议", + statsCourtsSettlementAccepted: "已接受的和解", + statsCourtsNomination: "法官提名", + statsCourtsNominationVote: "提名投票", + ai: "AI", + aiTitle: "AI", + aiDescription: "一个名为 '42' 的集体人工智能 (CAI),从你的网络中学习。", + aiUserQuestion: "问题", + aiResponseTitle: "回复", + aiSubmitButton: "发送!", + aiSettingsDescription: "为 AI 模型设置你的提示语(最多128个字符)。", + aiPrompt: "提供信息丰富且精确的回答。", + aiConfiguration: "设置提示语", + aiPromptUsed: "提示语", + aiClearHistory: "清除聊天记录", + aiSharePrompt: "将此回答添加到集体训练?", + aiShareYes: "是", + aiShareNo: "否", + aiSharedLabel: "已添加到训练", + aiRejectedLabel: "未添加到训练", + aiServerError: "AI 无法回答。请重试。", + aiInputPlaceholder: "什么是 Oasis?", + typeAiExchange: "AI", + aiApproveTrain: "添加到集体训练", + aiRejectTrain: "不训练", + aiTrainPending: "待批准", + aiTrainApproved: "已批准训练", + aiTrainRejected: "已拒绝训练", + aiSnippetsUsed: "使用的片段", + aiSnippetsLearned: "已学习的片段", + statsAITraining: "AI 训练", + aiApproveCustomTrain: "使用此自定义回答训练", + aiCustomAnswerPlaceholder: "写下你的自定义回答…", + statsAIExchanges: "模型交流", + marketMineSectionTitle: "你的商品", + marketCreateSectionTitle: "创建商品", + marketUpdateSectionTitle: "更新", + marketAllSectionTitle: "市场", + marketRecentSectionTitle: "最近的市场", + marketTitle: "市场", + marketDescription: "在你的网络中交换商品或服务的市场。", + marketFilterAll: "全部", + marketFilterMine: "我的", + marketFilterAuctions: "拍卖", + marketFilterItems: "交换", + marketFilterNew: "全新", + marketFilterUsed: "二手", + marketFilterBroken: "损坏", + marketFilterForSale: "出售中", + marketFilterSold: "已售出", + marketFilterDiscarded: "已废弃", + marketFilterRecent: "最近", + marketFilterMyBids: "出价", + marketCreateButton: "创建商品", + marketItemType: "类型", + marketItemTitle: "标题", + marketItemAvailable: "截止日期", + marketItemDescription: "描述", + marketItemDescriptionPlaceholder: "描述你要出售的商品", + marketItemStatus: "状态", + marketShopLabel: "商店", + marketItemCondition: "状况", + marketItemPrice: "价格", + marketItemTags: "标签", + marketItemTagsPlaceholder: "输入标签,用逗号分隔", + marketItemDeadline: "截止日期", + marketItemIncludesShipping: "包含运费?", + marketItemHighestBid: "最高出价", + marketItemHighestBidder: "最高出价者", + marketItemStock: "库存", + marketOutOfStock: "缺货", + marketItemBidTime: "出价时间", + marketActionsUpdate: "更新", + marketUpdateButton: "更新", + marketActionsDelete: "删除", + marketActionsSold: "标记为已售出", + marketActionsChangeStatus: "更改状态", + marketActionsBuy: "购买!", + marketAuctionBids: "当前出价", + marketPlaceBidButton: "出价", + marketItemSeller: "卖家", + marketNoItems: "暂无可用商品。", + marketYourBid: "你的出价", + marketCreateFormImageLabel: "上传媒体(最大:50MB)", + marketSearchLabel: "搜索", + marketSearchPlaceholder: "搜索标题或标签", + marketMinPriceLabel: "最低价格", + marketMaxPriceLabel: "最高价格", + marketSortLabel: "排序", + marketSortRecent: "最近", + marketSortPrice: "价格", + marketSortDeadline: "截止日期", + marketSearchButton: "搜索", + marketAuctionEndsIn: "结束于", + marketAuctionEnded: "已结束", + marketMyBidBadge: "你已出价", + marketNoItemsMatch: "没有匹配的商品。", + jobsTitle: "工作", + jobsCandidatesTitle: "推荐候选人", + jobsCandidatesDescription: "技能与此工作匹配的居民。请发送私信邀请他们。", + jobsCandidatesPmBody: "你好,我认为你的资料与我的工作机会相匹配", + jobsDescription: "发现和管理你网络中的工作。", + jobsFilterRecent: "最近", + jobsFilterMine: "我的", + jobsFilterAll: "全部", + jobsFilterRemote: "远程", + jobsFilterOpen: "开放", + jobsFilterClosed: "已关闭", + jobsCV: "简历", + jobsCreateJob: "创建工作", + jobsRecentTitle: "最近的工作", + jobsMineTitle: "你的工作", + jobsAllTitle: "工作", + jobsRemoteTitle: "远程工作", + jobsOpenTitle: "开放的工作", + jobsClosedTitle: "已关闭的工作", + jobsCVTitle: "简历", + jobsFilterPresencial: "现场", + jobsFilterFreelancer: "自由职业", + jobsFilterEmployee: "全职", + jobsPresencialTitle: "现场工作", + jobsFreelancerTitle: "自由职业工作", + jobsEmployeeTitle: "全职工作", + jobTitle: "标题", + jobLocation: "位置", + jobSalary: "薪资 (ECO/1小时)", + jobVacants: "空缺", + jobDescription: "描述", + jobRequirements: "要求", + jobLanguages: "语言", + jobStatus: "状态", + jobStatusOPEN: "开放", + jobStatusCLOSED: "已关闭", + jobSetClosed: "设为已关闭", + jobSubscribeButton: "加入此机会!", + jobUnsubscribeButton: "离开此机会!", + jobTitlePlaceholder: "输入工作标题", + jobDescriptionPlaceholder: "描述工作内容", + jobRequirementsPlaceholder: "输入要求", + jobLanguagesPlaceholder: "中文、英文、西班牙文...", + jobTasksPlaceholder: "列出任务", + jobLocationPresencial: "现场", + jobLocationRemote: "远程", + jobVacantsPlaceholder: "职位数量", + jobSalaryPlaceholder: "每小时 ECO 薪资", + jobImage: "上传媒体(最大:50MB)", + jobTasks: "任务", + jobType: "工作类型", + jobTime: "工作时间", + jobSubscribers: "订阅者", + noSubscribers: "没有订阅者", + jobsFilterTop: "热门", + jobsTopTitle: "最高薪资工作", + createJobButton: "发布工作", + viewDetailsButton: "查看详情", + noJobsFound: "未找到工作机会。", + jobAuthor: "作者", + jobTypeFreelance: "自由职业", + jobTypeSalary: "全职", + jobTypeExchange: "时间交换", + jobTypeEXCHANGE: "时间交换", + jobsFilterExchange: "交换", + jobsExchangeTitle: "时间交换工作", + jobsHoursOffered: "提供的小时数", + jobsHoursRequested: "请求的小时数", + jobsExchangeSkill: "交换需要的技能", + jobsHoursOfferedPlaceholder: "如 4", + jobsHoursRequestedPlaceholder: "如 4", + jobsExchangeSkillPlaceholder: "如 木工、设计", + jobExchangeHint: "对于时间交换工作,请填写下面的字段,而不是薪水。", + jobTimePartial: "兼职", + jobTimeComplete: "全职", + jobsDeleteButton: "删除", + jobsUpdateButton: "更新", + jobsFilterApplied: "已申请", + jobsAppliedTitle: "我的申请", + jobsAppliedBadge: "已申请", + jobsFilterFavs: "收藏", + jobsFavsTitle: "收藏", + jobsFilterNeeds: "需要帮助", + jobsNeedsTitle: "需要帮助", + jobsSearchLabel: "搜索", + jobsSearchPlaceholder: "搜索标题、标签、描述...", + jobsMinSalaryLabel: "最低薪资", + jobsMaxSalaryLabel: "最高薪资", + jobsSortLabel: "排序", + jobsSortRecent: "最近", + jobsSortSalary: "最高薪资", + jobsSortSubscribers: "最多申请者", + jobsSearchButton: "搜索", + jobsFavoriteButton: "收藏", + jobsUnfavoriteButton: "取消收藏", + jobsMessageAuthorButton: "私信", + jobsApplicants: "申请者", + jobsUpdatedAt: "已更新", + jobSetOpen: "设为开放", + jobNewBadge: "新", + jobsTagsLabel: "标签", + jobsTagsPlaceholder: "标签1, 标签2, 标签3", + noJobsMatch: "没有匹配的工作。", + projectsTitle: "项目", + projectsDescription: "创建、资助和关注你网络中的社区驱动项目。", + projectCreateProject: "创建项目", + projectCreateButton: "创建项目", + projectUpdateButton: "更新", + projectDeleteButton: "删除", + projectNoProjectsFound: "未找到项目。", + projectFilterAll: "全部", + projectFilterMine: "我的", + projectFilterActive: "活跃", + projectFilterPaused: "已暂停", + projectFilterCompleted: "已完成", + projectFilterFollowing: "已关注", + projectFilterRecent: "最近", + projectFilterTop: "热门", + projectAllTitle: "项目", + projectMineTitle: "你的项目", + projectActiveTitle: "活跃项目", + projectPausedTitle: "已暂停项目", + projectCompletedTitle: "已完成项目", + projectFollowingTitle: "已关注项目", + projectRecentTitle: "最近的项目", + projectTopTitle: "最高筹资", + projectTitlePlaceholder: "项目名称", + projectImage: "上传媒体(最大:50MB)", + projectDescription: "描述", + projectDescriptionPlaceholder: "讲述故事和目标…", + projectGoal: "目标 (ECO)", + projectGoalPlaceholder: "50000", + projectDeadline: "截止日期", + projectProgress: "起始进度 (%)", + projectStatus: "状态", + projectFunding: "筹资", + projectPledged: "已质押", + projectSetStatus: "设置状态", + projectSetProgress: "更新进度", + projectFollowButton: "关注", + projectUnfollowButton: "取消关注", + projectStatusACTIVE: "活跃", + projectStatusPAUSED: "已暂停", + projectStatusCOMPLETED: "已完成", + projectStatusCANCELLED: "已取消", + projectPledgeTitle: "支持此项目", + projectPledgePlaceholder: "ECO 金额", + projectBounties: "悬赏", + projectBountiesInputLabel: "悬赏(每行一个:标题|金额 [ECO]|描述)", + projectBountiesPlaceholder: "修复 UI 漏洞|100|问题链接\n编写文档|250|列出使用示例", + projectNoBounties: "未找到悬赏。", + projectTitle: "标题", + projectAddBountyTitle: "新悬赏", + projectBountyTitle: "悬赏标题", + projectBountyAmount: "金额 (ECO)", + projectBountyDescription: "描述", + projectMilestoneSelect: "选择里程碑", + projectBountyCreateButton: "创建悬赏", + projectBountyStatus: "悬赏状态", + projectBountyOpen: "开放", + projectBountyClaimed: "已认领", + projectBountyDone: "已完成", + projectBountyClaimedBy: "认领者", + projectBountyClaimButton: "认领", + projectBountyCompleteButton: "标记为已完成", + projectMilestones: "里程碑", + projectAddMilestoneTitle: "新里程碑", + projectMilestoneTitle: "里程碑标题", + projectMilestoneTargetPercent: "百分比 (%)", + projectMilestoneDueDate: "日期", + projectMilestoneCreateButton: "创建里程碑", + projectMilestoneStatus: "里程碑状态", + projectMilestoneOpen: "开放", + projectMilestoneDone: "已完成", + projectMilestoneDue: "截止", + projectNoMilestones: "未找到里程碑。", + projectMilestoneMarkDone: "标记为已完成", + projectMilestoneTitlePlaceholder: "输入里程碑标题", + projectMilestoneDescriptionPlaceholder: "输入此里程碑的描述", + projectMilestoneDescription: "里程碑描述", + projectBudgetGoal: "预算(目标)", + projectBudgetAssigned: "已分配至悬赏", + projectBudgetRemaining: "剩余", + projectBudgetOver: "⚠ 超出预算:分配金额超过目标", + projectFollowers: "关注者", + projectFollowersTitle: "关注者", + projectFollowersNone: "还没有关注者。", + projectMore: "更多", + projectYouFollowHint: "你关注了此项目", + projectBackers: "支持者", + projectBackersTitle: "支持者", + projectBackersTotal: "支持者总数", + projectBackersTotalPledged: "质押总额", + projectBackersYourPledge: "你的质押", + projectBackersNone: "还没有质押。", + projectNoRemainingBudget: "没有剩余预算。", + projectFilterBackers: "支持者", + projectFilterApplied: "已申请", + projectAppliedTitle: "已申请", + projectBackersLeaderboardTitle: "顶级支持者", + projectNoBackersFound: "未找到支持者。", + projectBackerAmount: "总贡献", + projectBackerPledges: "质押", + projectBackerProjects: "项目", + projectPledgeAmount: "金额", + projectSelectMilestoneOrBounty: "选择里程碑或悬赏", + projectPledgeButton: "质押", + footerLicense: "GPLv3", + footerPackage: "软件包", + footerVersion: "版本", + modulesModuleName: "名称", + modulesModuleDescription: "描述", + modulesModuleStatus: "状态", + modulesTotalModulesLabel: "已加载模块", + modulesEnabledModulesLabel: "已启用", + modulesDisabledModulesLabel: "已禁用", + modulesPopularLabel: "精选", + modulesPopularDescription: "接收热门、最多浏览或最多评论帖子的模块。", + modulesTopicsLabel: "话题", + modulesTopicsDescription: "接收基于共同兴趣的讨论类别的模块。", + modulesSummariesLabel: "摘要", + modulesSummariesDescription: "接收长讨论或帖子摘要的模块。", + modulesLatestLabel: "最新", + modulesLatestDescription: "接收最新帖子和讨论的模块。", + modulesThreadsLabel: "讨论串", + modulesThreadsDescription: "接收按话题或问题分组的对话的模块。", + modulesMultiverseLabel: "多元宇宙", + modulesMultiverseDescription: "接收来自其他联邦节点内容的模块。", + modulesInvitesLabel: "邀请", + modulesInvitesDescription: "管理和应用邀请码的模块。", + modulesWalletLabel: "钱包", + modulesWalletDescription: "管理你的数字资产 (ECOin) 的模块。", + modulesLegacyLabel: "密钥", + modulesLegacyDescription: "快速安全地管理你的私钥的模块。", + modulesCipherLabel: "加密器", + modulesCipherDescription: "使用共享密码对文本进行对称加密和解密的模块。", + modulesBookmarksLabel: "书签", + modulesBookmarksDescription: "发现和管理书签的模块。", + modulesVideosLabel: "视频", + modulesVideosDescription: "发现和管理视频的模块。", + modulesDocsLabel: "文档", + modulesDocsDescription: "发现和管理文档的模块。", + modulesAudiosLabel: "音频", + modulesAudiosDescription: "发现和管理音频的模块。", + modulesTagsLabel: "标签", + modulesTagsDescription: "发现和探索分类模式(标签)的模块。", + modulesImagesLabel: "图片", + modulesImagesDescription: "发现和管理图片的模块。", + modulesTrendingLabel: "热门", + modulesTrendingDescription: "探索最受欢迎内容的模块。", + modulesEventsLabel: "活动", + modulesEventsDescription: "发现和管理活动的模块。", + modulesTasksLabel: "任务", + modulesTasksDescription: "发现和管理任务的模块。", + modulesMarketLabel: "市场", + modulesMarketDescription: "交换商品或服务的模块。", + modulesShopsLabel: "商店", + modulesShopsDescription: "管理和发现商店的模块。", + modulesTribesLabel: "部落", + modulesTribesDescription: "探索或创建部落(群组)的模块。", + modulesVotationsLabel: "投票", + modulesVotationsDescription: "发现和管理投票的模块。", + modulesReportsLabel: "报告", + modulesReportsDescription: "管理和追踪与问题、漏洞、滥用和内容警告相关报告的模块。", + modulesOpinionsLabel: "观点", + modulesOpinionsDescription: "发现和为观点投票的模块。", + modulesTransfersLabel: "转账", + modulesTransfersDescription: "发现和管理智能合约(转账)的模块。", + modulesFediverseLabel: "联邦宇宙", + modulesFediverseDescription: "管理你的其他联邦宇宙账户,包括发送和接收内容。", + modulesFeedLabel: "动态", + modulesFeedDescription: "发现和分享短文(动态)的模块。", + modulesParliamentLabel: "议会", + modulesParliamentDescription: "选举政府和投票法律的模块。", + modulesCourtsLabel: "法庭", + modulesCourtsDescription: "解决冲突和发布裁决的模块。", + modulesPixeliaLabel: "Pixelia", + modulesPixeliaDescription: "在协作网格上绘画的模块。", + modulesMelodyLabel: "Melody", + modulesMelodyDescription: "用于生成并分享你区块链\"声音\"的模块。", + modulesAgendaLabel: "日程", + modulesAgendaDescription: "管理所有分配给你的项目的模块。", + modulesAILabel: "AI", + modulesAIDescription: "与名为 '42' 的 LLM 对话的模块。", + modulesForumLabel: "论坛", + modulesForumDescription: "发现和管理论坛的模块。", + modulesJobsLabel: "工作", + modulesJobsDescription: "发现和管理工作的模块。", + modulesProjectsLabel: "项目", + modulesProjectsDescription: "探索、众筹和管理项目的模块。", + modulesBankingLabel: "银行", + modulesBankingDescription: "确定 ECOIN 真实价值并使用公共金库分配 UBI 的模块。", + modulesFavoritesLabel: "收藏", + modulesFavoritesDescription: "管理你收藏内容的模块。", + fileTooLargeTitle: "文件过大", + fileTooLargeMessage: "文件超过了允许的最大大小(50 MB)。请选择较小的文件。", + goBack: "返回", + errorPageTitle: "出现了问题", + visibilityLabel: "可见性", + visibilityPublic: "公开", + visibilityHidden: "隐藏", + visibilityMakePublic: "设为公开", + visibilityMakeHidden: "设为隐藏", + invitesInhabitantsTitle: "居民", + invitesInhabitantsFollow: "关注", + aiNavPlaceholder: "你想去哪里?", + aiNavDisabled: "AI 导航已停用。", + aiNavResultsTitle: "AI 导航建议", + aiNavQueryLabel: "查询", + aiNavResultsDescription: "选择最符合你需求的路径。", + aiNavResultPath: "路径", + aiNavResultDescription: "你可以做什么", + aiNavResultMatch: "匹配度", + aiNavResultsEmpty: "无匹配路径,请尝试 /search。", + aiNavSearchInstead: "改为搜索", + modulesAINavLabel: "AINav", + modulesAINavDescription: "用于以自然语言查询网络内容的模块。", + directConnect: "直接连接", + directConnectDescription: "通过输入对方的 IP 地址、端口和公钥直接连接到节点。该节点将被添加为已关注的连接。", + peerHost: "IP", + peerPort: "端口(默认:8008)", + peerPublicKey: "公钥 (@...ed25519)", + connectAndFollow: "连接", + deviceSourceLabel: "设备来源", + modulesPresetTitle: "常用配置", + modulesPreset_minimal: "极简", + modulesPreset_basic: "基础", + modulesPreset_social: "社交", + modulesPreset_economy: "经济", + modulesPreset_full: "完整", + statsCarbonFootprintTitle: "碳足迹", + statsCarbonFootprintNetwork: "网络碳足迹", + statsCarbonFootprintYours: "你的碳足迹", + statsCarbonTombstone: "墓碑碳足迹", + feedSuccessMsg: "动态发布成功!", + dominantOpinionLabel: "主流观点", + uploadMedia: "上传媒体(最大:50MB)", + feedOpenDiscussion: "公开讨论", + feedDetailTitle: "动态", + feedPostComment: "发表评论", + courtsRespondentInvalid: "必须是有效的 SSB ID (@...ed25519)", + noComments: "还没有评论", + mapsLabel: "地图", + mapTitle: "地图", + mapDescription: "在您的网络中探索和管理离线地图。", + mapMineSectionTitle: "我的地图", + mapCreateSectionTitle: "创建地图", + mapUpdateSectionTitle: "更新地图", + mapAllSectionTitle: "地图", + mapRecentSectionTitle: "最近的地图", + mapFavoritesSectionTitle: "收藏", + mapFilterAll: "全部", + mapFilterMine: "我的", + mapFilterRecent: "最近", + mapFilterFavorites: "收藏", + mapUploadButton: "创建地图", + mapCreateButton: "创建地图", + mapUpdateButton: "更新", + mapDeleteButton: "删除", + mapAddFavoriteButton: "添加收藏", + mapRemoveFavoriteButton: "取消收藏", + mapLatLabel: "纬度", + mapLatPlaceholder: "例如 39.9042", + mapLngLabel: "经度", + mapLngPlaceholder: "例如 116.4074", + mapDescriptionLabel: "描述", + mapDescriptionPlaceholder: "描述地图或位置...", + mapTypeLabel: "地图类型", + mapTypeSingle: "单一(仅初始位置)", + mapTypeOpen: "开放(任何人可添加标记)", + mapTypeClosed: "封闭(仅创建者可添加标记)", + mapTagsLabel: "标签", + mapTagsPlaceholder: "输入以逗号分隔的标签", + mapUrlLabel: "地图链接", + mapPickCoordLabel: "或在网格上选择位置:", + mapMarkersLabel: "标记", + mapMarkersTitle: "标记", + mapMarkerDefault: "标记", + mapMarkerLatLabel: "标记纬度", + mapMarkerLngLabel: "标记经度", + mapMarkerLabelField: "标记名称", + mapMarkerLabelPlaceholder: "描述此标记...", + markerImageLabel: "标记图片", + mapAddMarkerTitle: "添加标记", + mapAddMarkerButton: "添加标记", + mapCleanMarkerButton: "Clean Marker", + mapApplyZoom: "应用缩放", + mapSearchPlaceholder: "搜索描述、标签、作者...", + mapSearchButton: "搜索", + mapUpdatedAt: "已更新", + mapNoMatch: "没有匹配的地图。", + noMaps: "没有可用的地图。", + mapLocationTitle: "位置", + mapVisitLabel: "访问地图", + typeMap: "地图", + typeMapMarker: "地图标记", + modulesMapLabel: "地图", + modulesMapDescription: "管理和分享离线地图的模块。", + padsTitle: "协作板", + padTitle: "协作板", + modulesPadsLabel: "协作板", + modulesPadsDescription: "管理协作文本编辑器的模块。", + padFilterAll: "全部", + padFilterMine: "我的", + padFilterRecent: "最近", + padFilterOpen: "开放", + padFilterClosed: "关闭", + padCreate: "创建协作板", + padUpdate: "更新协作板", + padDelete: "删除协作板", + padTitleLabel: "标题", + padTitlePlaceholder: "输入协作板标题...", + padStatusLabel: "状态", + padStatusOpen: "开放", + padStatusInviteOnly: "仅限受邀者", + padStatusClosed: "关闭", + padDeadlineLabel: "截止日期", + padTagsLabel: "标签", + padTagsPlaceholder: "标签1, 标签2, ...", + padMembersLabel: "成员", + padVisitPad: "访问协作板", + padShareUrl: "分享链接", + padCreated: "创建时间", + padAuthor: "作者", + padGenerateCode: "生成邀请码", + padInviteCodeLabel: "邀请码", + padInviteCodePlaceholder: "输入邀请码...", + padValidateInvite: "验证", + padStartEditing: "开始编辑!", + padEditorPlaceholder: "开始写作...", + padSubmitEntry: "提交", + padNoEntries: "暂无内容。", + padAllSectionTitle: "全部协作板", + padMineSectionTitle: "我的协作板", + padsDescription: "在您的网络中管理协作加密的文本编辑器。", + padRecentSectionTitle: "最近协作板", + padOpenSectionTitle: "开放协作板", + padClosedSectionTitle: "关闭协作板", + padCreateSectionTitle: "创建新协作板", + padUpdateSectionTitle: "更新协作板", + padInviteGenerated: "邀请码已生成", + typePad: "协作板", + padNew: "新建", + padAddFavorite: "添加到收藏", + padRemoveFavorite: "从收藏中移除", + padClose: "关闭记事本", + padBackToEditor: "返回编辑器", + padSearchPlaceholder: "搜索共享文本...", + + modulesChatsLabel: "聊天", + modulesChatsDescription: "发现和管理加密聊天室的模块。", + typeChat: "聊天", + typeChatMessage: "聊天消息", + chatLabel: "聊天", + chatMessageLabel: "聊天消息", + chatsTitle: "聊天", + chatMineSectionTitle: "Your Chats", + chatRecentTitle: "Recent Chats", + chatFavoritesTitle: "收藏", + chatOpenTitle: "Open Chats", + chatClosedTitle: "Closed Chats", + chatDescription: "描述", + chatCategory: "类别", + chatStatus: "状态", + chatFilterAll: "全部", + chatFilterMine: "我的", + chatFilterRecent: "最近", + chatFilterFavorites: "收藏", + chatFilterOpen: "开放", + chatFilterClosed: "已关闭", + chatCreate: "创建聊天", + chatUpdate: "更新聊天", + chatDelete: "删除聊天", + chatClose: "关闭聊天", + chatVisitChat: "访问聊天", + chatUntitled: "无标题聊天", + chatNoItems: "未找到聊天。", + chatParticipants: "参与者", + chatStartChatting: "开始聊天!", + chatGenerateCode: "生成代码", + chatShareUrl: "分享链接", + chatCreatedAt: "创建时间", + chatSearchPlaceholder: "搜索聊天...", + chatStatusOpen: "开放", + chatStatusInviteOnly: "仅限邀请", + chatStatusClosed: "已关闭", + chatSendMessage: "发送", + chatMessagePlaceholder: "输入您的消息...", + chatNoMessages: "暂无消息。", + chatLeave: "离开聊天", + chatInviteCodeLabel: "输入邀请码", + chatJoinByInvite: "加入", + chatTitlePlaceholder: "聊天标题", + chatDescriptionPlaceholder: "聊天描述", + chatTagsPlaceholder: "标签1, 标签2", + chatImageLabel: "选择图片文件 (.jpeg, .jpg, .png, .gif)", + chatInviteCode: "邀请码", + chatAuthor: "作者", + chatCreated: "创建", + chatAddFavorite: "添加到收藏", + chatRemoveFavorite: "从收藏中移除", + chatPM: "私信", + chatStatusLabel: "状态", + chatCategoryLabel: "类别", + chatParticipantsLabel: "参与者", + gamesTitle: "游戏", + gamesDescription: "发现并玩一些游戏。", + gamesFilterAll: "全部", + gamesPlayButton: "开始游戏!", + gamesBackToGames: "返回游戏", + modulesGamesLabel: "游戏", + modulesGamesDescription: "用于发现和玩游戏的模块。", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "将网络作为节点交互式地图浏览的模块。", + gamesCocolandTitle: "Cocoland", + gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树,收集ECOins。", + gamesTheFlowTitle: "ECOinflow", + gamesTheFlowDesc: "通过验证者、商店和累积器将PUB连接到居民。抵御CBDC威胁!", + gamesNeonInfiltratorTitle: "Neon Infiltrator", + gamesNeonInfiltratorDesc: "渗透网格,收集机密数据,辺开安全无人机并逃脱。能通过多少层关?", + gamesSpaceInvadersTitle: "Space Invaders", + gamesSpaceInvadersDesc: "阻止外星人入侵!在入侵者抵达地面之前击落它们。", + gamesArkanoidTitle: "Arkanoid", + gamesArkanoidDesc: "用你的球拍和球打破所有砖块。经典街机挑战。", + gamesPingPongTitle: "PingPong", + gamesPingPongDesc: "与AI对战的经典乒乓球。先得5分者获胜。", + gamesOutrunTitle: "Outrun", + gamesOutrunDesc: "与时间赛跑!躲避交通障碍,在时间用完前到达终点。", + gamesAsteroidsTitle: "Asteroids", + gamesAsteroidsDesc: "驾驶飞船穿越小行星带。在它们撞上你之前将其摧毁。", + gamesRockPaperScissorsTitle: "石头剪刀布", + gamesRockPaperScissorsDesc: "与AI对战石头、剪刀、布。三局两胜获胜。", + gamesTikTakToeTitle: "TikTakToe", + gamesTikTakToeDesc: "与AI对战经典井字游戏。连成三个即可获胜。", + gamesFlipFlopTitle: "FlipFlop", + gamesFlipFlopDesc: "抛硬币,猜正面还是反面。你有多幸运?", + games8BallTitle: "8Ball Pool", + games8BallDesc: "Top-down pool. Click to aim, hold to charge power. Pot all balls in the fewest shots.", + gamesArtilleryTitle: "Artillery", + gamesArtilleryDesc: "Aim your cannon, factor in the wind, and hit the target. 5 rounds, fewest shots wins.", + gamesLabyrinthTitle: "Labyrinth", + gamesLabyrinthDesc: "Escape the maze before your moves run out. Each level gets bigger and harder.", + gamesCocomanTitle: "Cocoman", + gamesCocomanDesc: "Eat all the dots, avoid the ghosts. Turn-based Pac-Man — every key press counts.", + gamesTetrisTitle: "Tetris", + gamesAudioPendulumTitle: "Audio Pendulum", + gamesAudioPendulumDesc: "Chaotic physics simulator with real-time audio synthesis. Angular velocities become frequencies, peaks become drum hits. No two simulations sound alike.", + gamesTetrisDesc: "Classic falling blocks. Clear lines to score. How long can you last?", + gamesQuakeTitle: "Quake Arena", + gamesQuakeDesc: "First-person raycasting arena. Move and shoot your way through waves of enemies.", + gamesFilterScoring: "SCORING", + gamesHallOfFame: "Hall of Fame", + gamesHallPlayer: "Player", + gamesHallScore: "Score", + gamesNoScores: "No scores yet.", + calendarsTitle: "日历", + calendarTitle: "日历", + modulesCalendarsLabel: "日历", + modulesCalendarsDescription: "用于发现和管理日历的模块。", + typeCalendar: "日历", + calendarFilterAll: "全部", + calendarFilterMine: "我的", + calendarFilterOpen: "开放", + calendarFilterClosed: "关闭", + calendarFilterRecent: "最近", + calendarFilterFavorites: "收藏", + calendarCreate: "创建日历", + calendarUpdate: "更新", + calendarDelete: "删除", + calendarTitleLabel: "标题", + calendarTitlePlaceholder: "日历标题...", + calendarStatusLabel: "状态", + calendarStatusOpen: "开放", + calendarStatusClosed: "关闭", + calendarDeadlineLabel: "Deadline", + calendarTagsLabel: "标签", + calendarTagsPlaceholder: "标签1, 标签2...", + calendarParticipantsLabel: "参与者", + calendarParticipantsCount: "参与者", + calendarVisitCalendar: "访问日历", + calendarCreated: "已创建", + calendarAuthor: "作者", + calendarJoin: "加入", + calendarGenerateInvite: "生成邀请", + calendarInviteCodePlaceholder: "输入邀请码...", + calendarValidateInvite: "验证邀请码", + calendarJoined: "已加入", + calendarAddDate: "添加日期", + calendarAddNote: "添加笔记", + calendarDateLabel: "日期", + calendarDatePlaceholder: "描述这个日期...", + calendarNoteLabel: "笔记", + calendarNotePlaceholder: "添加笔记...", + calendarFirstDateLabel: "日期", + calendarFirstNoteLabel: "备注", + calendarIntervalLabel: "Interval", + calendarIntervalWeekly: "Weekly", + calendarIntervalMonthly: "Monthly", + calendarIntervalYearly: "Yearly", + calendarFormDescription: "描述", + calendarNoDates: "尚未添加日期。", + calendarNoNotes: "暂无笔记。", + calendarsNoItems: "未找到日历。", + calendarsDescription: "在您的网络中发现和管理日历。", + calendarMonthPrev: "← 上一月", + calendarMonthNext: "下一月 →", + calendarMonthLabel: "日期", + calendarsShareUrl: "分享链接", + calendarAllSectionTitle: "日历", + calendarRecentSectionTitle: "最近的日历", + calendarFavoritesSectionTitle: "收藏", + calendarMineSectionTitle: "你的日历", + calendarOpenSectionTitle: "开放日历", + calendarClosedSectionTitle: "关闭日历", + calendarCreateSectionTitle: "创建新日历", + calendarUpdateSectionTitle: "更新日历", + calendarAddFavorite: "添加到收藏", + calendarDeleteNote: "Delete", + calendarRemoveFavorite: "从收藏移除", + calendarSearchPlaceholder: "搜索日历...", + calendarAddEntry: "添加条目", + calendarLeave: "离开日历", + statsCalendar: "日历", + statsCalendarDate: "日历日期", + statsCalendarNote: "日历笔记", + chatAccessDenied: "您没有访问此聊天室的权限。请申请邀请以访问内容。", + padAccessDenied: "您没有访问此协作板的权限。请申请邀请以访问内容。", + contentAccessDenied: "您没有访问此内容的权限。", + blockAccessRestricted: "访问受限", + tribeContentAccessDenied: "访问被拒绝", + tribeContentAccessDeniedMsg: "此内容属于一个部落。您必须是成员才能访问它。", + tribeViewTribes: "查看部落", + torrentsTitle: "种子", + torrentsDescription: "浏览和管理网络中的种子。", + torrentAllSectionTitle: "种子", + torrentMineSectionTitle: "我的种子", + torrentRecentSectionTitle: "最近的种子", + torrentTopSectionTitle: "热门种子", + torrentFavoritesSectionTitle: "收藏", + torrentFilterAll: "全部", + torrentFilterMine: "我的", + torrentFilterRecent: "最近", + torrentFilterTop: "热门", + torrentFilterFavorites: "收藏", + torrentCreateSectionTitle: "上传种子", + torrentUpdateSectionTitle: "更新种子", + torrentCreateButton: "上传种子", + torrentUpdateButton: "更新", + torrentDeleteButton: "删除", + torrentAddFavoriteButton: "添加到收藏", + torrentRemoveFavoriteButton: "从收藏移除", + torrentFileLabel: "选择种子文件 (.torrent)", + torrentTitleLabel: "标题", + torrentTitlePlaceholder: "标题", + torrentDescriptionLabel: "描述", + torrentDescriptionPlaceholder: "描述", + torrentTagsLabel: "标签", + torrentTagsPlaceholder: "输入以逗号分隔的标签", + torrentSizeLabel: "大小", + torrentDownloadButton: "DOWNLOAD IT!", + torrentNoFile: "没有种子文件", + noTorrents: "未找到种子。", + torrentSearchPlaceholder: "搜索标题、标签、作者...", + torrentSearchButton: "搜索", + torrentSortRecent: "最新", + torrentSortOldest: "最旧", + torrentSortTop: "最多投票", + torrentNoMatch: "没有匹配的种子。", + torrentMessageAuthorButton: "给作者发消息", + torrentUpdatedAt: "已更新", + statsTorrent: "种子", + typeTorrent: "种子", + modulesTorrentsLabel: "种子", + modulesTorrentsDescription: "发现和管理种子的模块。", + favoritesFilterTorrents: "种子", + favoritesFilterMarket: "市场", + favoritesFilterShopProducts: "商店商品", + tribeSectionTorrents: "种子", + tribeCreateTorrent: "上传种子", + tribeMediaTypeTorrent: "种子", + settingsWishTitle: "愿望", + settingsWishDesc: "配置您的头像愿望。", + settingsWishWhole: "Multiverse", + settingsWishMutuals: "Only mutual-support", + settingsWishOnlyLan: "Only LAN", + settingsPmVisibilityTitle: "私信", + settingsPmVisibilityDesc: "配置您希望在网络中的私信曝光级别。", + settingsPmVisibilityWhole: "Multiverse", + settingsPmVisibilityMutuals: "Only mutual-support", + pmMutualNotice: "出站是 UX 保护。入站是关注过滤器。", + pmBlockedNonMutual: "只能向相互支持的居民发送私信。", + inhabitantsPendingFollowsTitle: "待处理的支持请求", + inhabitantsPendingAccept: "接受", + inhabitantsPendingReject: "拒绝", + bxEncrypted: "已加密", + bxEncryptedHexLabel: "密文(预览)", + tribeSectionGovernance: "治理", + tribeSubStatusPublic: "公开", + tribeSubStatusPrivate: "私密", + tribeSubInheritedPrivate: "私密(继承自主部落)", + tribePadInviteRequired: "您无权访问 pad。请求邀请。", + tribeChatInviteRequired: "您无权访问聊天。请求邀请。", + tribeGovernanceDesc: "此部落的内部治理。", + tribeGovernanceNoGov: "无活跃政府", + tribeGovernanceNoGovDesc: "此部落尚未选举政府。", + tribeGovCardTitle: "当前政府", + tribeGovCycleSince: "周期开始", + tribeGovCycleEnd: "周期结束", + tribeGovTimeRemaining: "剩余时间", + tribeGovPopulation: "人口", + tribeGovMethod: "方法", + tribeGovVotesReceived: "收到的投票", + tribeGovLeader: "领导者", + tribeGovFilterGovernment: "政府", + tribeGovFilterCandidatures: "候选", + tribeGovFilterLaws: "法律", + tribeGovCandidatureId: "候选", + tribeGovCandidatureMethod: "方法", + tribeGovCandidatureProposeBtn: "发布候选", + tribeGovRuleTitle: "规则标题", + tribeGovRuleBody: "规则内容", + tribeGovProposals: "提案", + tribeGovRevocations: "撤销", + tribeGovHistorical: "历史", + tribeGovRules: "规则", + tribeGovernanceAlreadyPublished: "此部落在当前周期已有候选人。", + tribeGovernanceProposeInternal: "提议内部候选人", + tribeGovernanceInternalCandidatures: "内部候选人", + tribeGovernanceNoCandidatures: "无开放候选人。", + tribeGovernanceAddRule: "添加规则", + tribeGovernanceNoRules: "暂无规则。", + tribeGovernanceNoLeaders: "尚无领导人被选出。", + tribeGovernanceComingSoon: "治理模块即将推出。", + tribeGovNoProposals: "暂无任何提案", + tribeGovNoLaws: "暂无任何法律", + tribeGovNoRevocations: "暂无任何撤销", + tribeGovNoHistorical: "暂无任何记录", + logsTitle: "日志", + logsDescription: "记录您在网络中的体验。", + logsReadMore: "阅读更多", + logsViewTitle: "日志", + logsColumnType: "类型", + logsView: "查看", + logsManualPrompt: "撰写您的日志", + logsUpdateButton: "更新", + logsEditTitle: "编辑条目", + logsCreateDescription: "记录您的体验...", + logsCreateTitle: "创建条目", + logsWriteButton: "撰写", + logsTextPlaceholder: "描述您的体验...", + logsTextField: "文本", + logsLabelPlaceholder: "标签...", + logsLabelField: "撰写您的日志(标签)", + logsAiDisabledWarn: "在 /modules 中启用 AI 模块以使用 AI 撰写的日志。", + logsAiContextValue: "区块链日", + logsAiContext: "上下文", + logsAiModStatus: "AImod", + logsModeApply: "应用!", + logsModeAIWritten: "AI-Assistant", + logsModeManual: "手动", + logsModeAI: "AI", + logsColumnDelete: "删除", + logsColumnEdit: "编辑", + logsColumnLog: "日志", + logsColumnDate: "日期", + logsDelete: "删除", + logsEdit: "编辑", + logsFilterAlways: "始终", + logsFilterYear: "去年", + logsFilterMonth: "上月", + logsFilterWeek: "上周", + logsFilterToday: "今天", + logsFilterRecent: "最近", + logsFilterAll: "全部", + logsCreate: "创建条目", + logsExport: "导出日志", + logsExportOne: "导出", + logsViewDetails: "查看详情", + logsGenerateButton: "生成文本", + logsSearchText: "在日志中搜索...", + logsSearchAnyType: "任意类型", + logsSearchButton: "搜索", + logsEmpty: "暂无条目。", + modulesLogsLabel: "日志", + modulesLogsDescription: "用于(通过 AI 助手)记录体验的模块。", + statsLogsTitle: "日志", + statsLogsEntries: "条目", + typeLog: "日志", + blockchainCycle: "周期", + + larpTitle: "L.A.R.P.", + larpDescription: "用于协作实验的实时角色扮演层。", + larpAllHouses: "家族:", + larpFilterRuling: "执政", + larpFilterHouses: "家族", + larpFilterRules: "FAQ", + larpRulesTitle: "L.A.R.P. FAQ", + larpRulesEntryTitle: "起始家族", + larpRulesEntryText: "每位居民默认从 ACADEMIA 开始。在 ACADEMIA 中,从“加入家族”面板选择一个家族:这将打开其入会测试。回答 10 道问题并提交。若达到阈值(7/10),你将被自动接纳进入该家族;否则将被拒绝,留在 ACADEMIA 中。无论如何,系统会记录你的 OasisID 和尝试周期,并在 30 个周期的冷却期结束前阻止再次尝试。", + larpRulesLeaveText: "任何家族的成员都可以随时从其家族页面返回 ACADEMIA;这样会重置其成员身份,但不会取消测试的冷却时间。", + larpRulesGovernanceTitle: "执政墙", + larpRulesGovernanceText: "在其执政周期内,执政家族佩戴“执政”徽章,是唯一其墙面在“执政”标签下公开显示的家族。", + larpRulesSignTitle: "L.A.R.P. 徽章", + larpRulesSignText: "居民可在 /profile/edit > 传感器 中启用,将当前家族的图像作为徽记显示在个人资料、作者卡和居民卡上。徽记链接到家族页面。", + larpPostsTitle: "墙", + larpPostsEmpty: "暂无发布。", + larpPostLabel: "新发布", + larpPostPlaceholder: "这个家族想说什么?", + larpPostPublic: "公开(所有人可见,否则仅限成员)", + larpPostSubmit: "发布", + larpPostMembersOnly: "仅成员", + larpCycleLabel: "周期:", + audioBackToBcs: "Back to BCS", + audioFilterBcs: "BCS", + audioTranscodeButton: "TRANSCODE", + audioTranscodeCompositionEmpty: "This audio does not include a stored blockchain composition.", + audioTranscodeDetailDescription: "Decode the embedded payload and the original blockchain composition map.", + audioTranscodeDetailTitle: "Transcode", + audioTranscodeStegoEmpty: "(none)", + audioTranscodeStegoMessage: "TEXT", + audioTranscodeStegoNotFound: "No steganographic payload could be decoded from this audio.", + audioTranscodeStegoOasisId: "By", + audioTranscodeStegoTimestamp: "Generated at", + audioTranscodeStegoUnknown: "—", + bankExchangeEcoTaxAnnual: "ECOin Taxes (annual)", + bankExchangeEcoTaxMonthly: "ECOin Taxes (monthly)", + bankOverviewYourTaxes: "Your taxes", + bankRulesAlpha: "Alpha (max share of pub balance per epoch)", + bankRulesArchTaxFormula: "ARCH Tax(user) = max(0, (newestBlockTs − userFirstBlockTs) / 86400000) × ecoinPerDayOfHistory", + bankRulesArchTaxNote: "ARCH Tax reflects the long-term cost of growing and maintaining the network archive: the older your data lives in the network, the larger your share of the maintenance cost.", + bankRulesArchTaxRate: "Rate", + bankRulesArchTaxTitle: "ARCH Tax", + bankRulesCapPerEpoch: "Cap per epoch (absolute)", + bankRulesCapUser: "Cap per user per epoch", + bankRulesCarbonFactor: "Carbon factor", + bankRulesChipFormula: "band = ratio / reducer, where ratio = sizeBytes / maxBlockBytes, reducer = 1 + log10(inhabitants). high ≥ 0.66, mid ≥ 0.33, otherwise low.", + bankRulesChipNote: "Each item's chip is colored relative to the largest message ever observed in the network and softened by the number of active inhabitants — a larger network distributes the load and pushes more items into the green band.", + bankRulesChipTitle: "ECO Tax chip color band", + bankRulesEcoTaxFormula: "ECO Tax(user) = (userBytes / 1 MiB) × 0.095 × ecoinPerGramCO2", + bankRulesEcoTaxNote: "ECO Tax is deducted from the gross UBI when the epoch allocation is computed. The deducted ECO are not redistributed in this epoch — they remain in the pub balance and feed the next epoch's pool through reserveMin / alpha caps.", + bankRulesEcoTaxRate: "Rate", + bankRulesEcoTaxTitle: "ECO Tax", + bankRulesEpochKind: "Epoch granularity", + bankRulesFloor: "Floor per user (gross)", + bankRulesGraceDays: "Claim grace period", + bankRulesKarmaFormula: "karma = max(0, round(scoreFromActions − carbonGramsForUser))", + bankRulesKarmaNote: "Karma is derived from your actions (posts, votes, parliament/courts participation, etc.) with a time-decay factor, minus the carbon grams generated by your feed.", + bankRulesKarmaTitle: "Karma (user engagement score)", + bankRulesNetFormula: "net_UBI(user) = max(0, gross_UBI − ECO Tax)", + bankRulesPoolFormula: "Pool = min(pubBal − reserveMin, capPerEpoch, alpha × pubBal)", + bankRulesPoolTitle: "Monthly pool", + bankRulesReserveMin: "Reserve minimum (kept in pub balance)", + bankRulesShareFormula: "gross_UBI(user) = clamp(Pool × w / Σw, floor_user, cap_user_epoch)", + bankRulesShareTitle: "Per-user share", + bankRulesWeightFormula: "w = clamp(1 + karma/100, w_min, w_max)", + bankRulesWMax: "Maximum weight (w_max)", + bankRulesWMin: "Minimum weight (w_min)", + bankTaxes: "Taxes", + bankTaxesAnnualEcoin: "ECO Tax (annual)", + bankTaxesArchTaxAnnual: "ARCH Tax (annual)", + bankTaxesArchTaxFirstBlock: "Your first block age", + bankTaxesArchTaxFootprintNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost.", + bankTaxesArchTaxLifetime: "ARCH Tax (lifetime)", + bankTaxesArchTaxMonthly: "ARCH Tax (monthly)", + bankTaxesArchTaxNoteBold: "reducing the ARCH tax directly", + bankTaxesArchTaxNoteIntro: "Your ARCH tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Newer inhabitants and those specifically dedicated to archival, replication and maintenance tasks will likely have some task dedicated to keeping the network archive healthy on your behalf. Or they may dedicate themselves to ", + bankTaxesArchTaxNoteOutro: " through pruning agreements, deduplication of redundant data, and shared maintenance infrastructure, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxRate: "Rate", + bankTaxesArchTaxTitle: "ARCH Tax", + bankTaxesArchTaxUserAmount: "Your ARCH tax (price to return)", + bankTaxesEcoTaxLifetime: "ECO Tax (lifetime)", + bankTaxesEcoTaxTitle: "ECO Tax", + bankTaxesLookupAuthor: "Author", + bankTaxesLookupButton: "Inspect", + bankTaxesLookupCarbon: "Carbon footprint", + bankTaxesLookupEcoin: "ECO Tax", + bankTaxesLookupNotFound: "No block found in your local feed with that ID.", + bankTaxesLookupPlaceholder: "Block ID (e.g. %abc...=.sha256)", + bankTaxesLookupSize: "Size", + bankTaxesLookupTitle: "Inspect a block", + bankTaxesLookupType: "Type", + bankTaxesMonthlyEcoin: "ECO Tax (monthly)", + bankTaxesRate: "Rate", + bankTaxesSpan: "Sampling span", + bankTaxesTotalAnnual: "Total (annual)", + bankTaxesTotalBlocks: "Total blocks", + bankTaxesTotalBytes: "Total bytes", + bankTaxesTotalCarbon: "Total carbon", + bankTaxesTotalEcoin: "Total ECO tax (lifetime)", + bankTaxesTotalLifetime: "Total (lifetime)", + bankTaxesTotalMonthly: "Total (monthly)", + bankTaxesTypesApply: "Set my taxes", + bankTaxesTypesLabel: "Select which taxes you want to pay", + bankTaxesUserAmount: "Your ECO tax (price to return)", + bankTaxesUserNoteChips: "The ECO Tax chip shown next to each item changes color (green / yellow / red) depending on the item size relative to the largest block ever seen on the network, softened by the number of active inhabitants — a larger solar-punk network distributes the load and pushes more items into the green band.", + bankTaxesUserNoteOtherParams: "ECO Tax is currently derived from the carbon footprint of each block, but it may incorporate other parameters over time — energy spent on replication, redundant storage across peers, blob bandwidth, computational cost of decryption, mining footprint of associated transactions, or any other measurable load the network agrees to value.", + blockchainBackToBlockexplorer: "Back to blockexplorer", + blockchainBlockNotAvailable: "This block is not available in your local feed. It may belong to a peer you are not yet replicating from.", + ecoTaxLabel: "ECO Tax", + gamesHallDate: "Date", + gamesNewRecord: "New Record", + indexingEditProfileLink: "Set up my avatar", + indexingMessage: "Oasis is trying to syncronize a huge network of inhabitants. Just wait!", + indexingRefreshNote: "This page refreshes every 10 seconds.", + indexingTitle: "Synchronizing", + melodyAllEmpty: "No BCS compositions from other inhabitants yet.", + melodyByLabel: "By", + melodyNoteUnavailable: "block not in your local feed", + melodyUploadBcsButton: "Publish", + melodyUploadBcsNameNote: "The audio will be published with the auto-generated name", + melodyUploadBcsTitle: "Publish", + melodyUploadStegoLabel: "Hidden message (steganography)", + melodyUploadStegoMaxLabel: "max 280 characters", + melodyUploadStegoPlaceholder: "Optional.", + padsNoItems: "No pads found.", + padVersionHistory: "Version History", + padVersionView: "View", + statsChat: "Chats", + statsChatMessage: "Chat messages", + statsGameScore: "Gaming", + statsPad: "Pads", + statsPadEntry: "Pad entries", + statsTombstoneEmpty: "No tombstones in the network yet.", + typeGameScore: "GAME SCORE", + bankTaxesLookupNote: "输入区块 ID 在本地数据流中查询。", + bankTaxesUserNoteChipsClick: "点击任何标签以在 blockexplorer 中检查其区块。", + mapUrlPlaceholder: "/maps/MAP_ID", + larpRulesInviteEntry: "邀请码", + larpRulesInviteEntryText: "家族成员可以生成邀请码,提供对家族的直接访问。", + larpRulesOneHouseText: "你在任何时刻最多只能属于一个家族。如果你不属于任何家族,你就在 ACADEMIA。每个非 ACADEMIA 的家族页面都向其成员显示一个\"离开家族\"按钮,将成员身份重置为 ACADEMIA。离开并不会取消测试冷却。", + larpRulesOneHouseTitle: "一次一个家族", + larpRulesTestEntry: "WILL 测试", + larpRulesTestEntryText: "每个选项对一个或多个家族加权;提交时,系统统计得分并自动将你纳入得分最高的家族。", + larpWillTestHint: "完成后,你将被分配到与你的回答最匹配的家族。你的单个回答在本地处理,从不存储 — 只有最终的家族分配会发布到你的信息流。", + larpWillTestTitle: "WILL 测试", + settingsLanDesc: "定期向同一局域网中的其他 Oasis 实例广播此节点。禁用以停止 UDP 广播。", + settingsLanEnable: "启用局域网广播", + settingsLanTitle: "局域网广播", + settingsReplicationTitle: "复制", + settingsReplicationDesc: "配置在复制内容时,你的节点从自己的信息流出发跟随的跳数。", + settingsReplicationHopsLabel: "跳数", + aiApproveTagsLabel: "标签(逗号分隔)", + aiApproveTagsPlaceholder: "例如 oasis、治理、生态", + aiApproveRatingLabel: "评分", + aiExchangeLang: "语言", + aiExchangeTags: "标签", + aiExchangeRating: "评分", + aiExchangeHelpful: "有帮助", + aiExchangeMarkHelpful: "+1 有帮助", + larpCyclesUntilRuling: "下一执政周期", + larpVisitTribe: "访问部落", + larpGoverning: "执政:", + larpMyHouse: "我的家族:", + larpMembersCount: "成员", + larpMembersTitle: "成员", + larpNoMembers: "暂无成员。", + larpRolesLabel: "角色", + larpFunctionLabel: "功能", + larpMonthLabel: "执政周期", + larpLeaveToAcademia: "返回 ACADEMIA", + larpAcademiaJoinTitle: "加入家族", + larpAcademiaJoinHint: "进行心理画像测试以被分配到最适合你的家族,或使用家族成员分享的邀请码。", + larpTakeTestButton: "进行画像测试", + larpInviteRedeem: "加入家族", + larpInviteCreate: "生成邀请码", + larpInvitePlaceholder: "邀请码", + larpInviteBannerTitle: "新邀请码", + larpInviteBannerHint: "将此代码分享给 ACADEMIA 中的某人。30 个周期后过期,或使用一次后失效。", + larpTestAssignedHouse: "已分配家族", + larpTestRankingTitle: "各家族分数", + invitesHousesTitle: "L.A.R.P.", + invitesHouseInviteCodePlaceholder: "家族邀请码", + invitesHouseJoinButton: "加入家族", + larpLastAttemptTitle: "你最近的尝试", + larpLastAttemptHouse: "家族", + larpLastAttemptResult: "结果", + larpLastAttemptWhen: "时间", + larpLastAttemptCooldown: "下次尝试还需", + larpTestTitle: "入会测试", + larpTestIntro: "回答 10 道问题。至少需要 7 道正确才能被录取。无论结果如何,每 30 个周期可以尝试一次测试。", + larpTestSubmit: "加入家族", + larpTestCooldownActive: "下次测试可用还需", + larpTestCooldownDays: "周期", + larpTestResultTitle: "测试结果", + larpTestPassed: "测试通过 — 欢迎!", + larpTestFailed: "测试未通过", + larpTestScore: "得分", + larpTestNextAttempt: "你可以在 30 个周期后再次尝试测试。", + larpGoToHouse: "前往你的家族", + larpBackToAcademia: "返回 ACADEMIA", + larpBackToHouses: "◀ 家族", + larpBadgeYou: "你", + larpBadgeRuling: "执政", + modulesLarpLabel: "L.A.R.P.", + modulesLarpDescription: "Oasis 实时角色扮演层(9 个家族)的模块。", + melodyUploadTitlePlaceholder: "Composition title", + melodyUploadDescPlaceholder: "Optional description", + ecoinTaxLabel: "ECOin Tax", + bankTaxesNetworkTitle: "Network Taxes", + bankTaxesUserTitle: "Your taxes", + bankTaxesUserNote: "Your ECO tax is deducted from the surplus the network generates on top of your UBI; the base UBI value is set as an immovable minimum. The tax is never deducted from the fixed amount that corresponds to each inhabitant as UBI. The deducted funds feed the wealth-redistribution algorithm and are channeled back to other inhabitants via their UBI claims. Those other inhabitants who receive more UBI for their projects will likely have some task dedicated to helping you reduce your ECO tax. Or they may dedicate themselves to reducing the ECO tax directly, and their ongoing contribution may be required by networking consensus.", + bankTaxesArchTaxNote: "ARCH Tax reflects the cost of growing and maintaining the network archive. It is computed from the time gap between your first published block and the newest block in the network — the longer your history has lived in the archive, the larger your share of the maintenance cost. Like ECO Tax, ARCH Tax is deducted from the surplus the network adds on top of your UBI; the base UBI is never reduced by it.", + bankTaxesExample: "Example", + bankTaxesSummaryNote: "Aggregated total across every tax type the network applies. Currently only ECO Tax (carbon footprint) is enabled; future tax types will appear as additional rows and contribute to the total.", + statsEcoTaxTitle: "ECO Tax", + audioTranscodeTitle: "BCS Transcode", + audioTranscodeDescription: "Interpretation of the audio: the original blockchain composition that produced it, plus the OasisID, timestamp and hidden message embedded into the waveform via steganography.", + audioTranscodeStegoTitle: "Embedded in the waveform", + audioBackToAudio: "Back to audio", + audioAuthorLabel: "Author", + mapZoomIn: "Zoom +", + mapZoomOut: "Zoom −", + mapClickToCreate: "Click on the map to select a location", + mapClickToAddMarker: "Click on the map to add a marker", + gameScoreLabel: "GAME SCORES", + larpProfileQ1: "面对复杂问题时,你首先做什么?", + larpProfileQ1O1: "与他人交谈以达成共识", + larpProfileQ1O2: "构建原型进行测试", + larpProfileQ1O3: "研究相关文献", + larpProfileQ1O4: "颠覆产生它的系统", + larpProfileQ2: "什么赋予你日常工作意义?", + larpProfileQ2O1: "保护我所爱的人", + larpProfileQ2O2: "创造有形之物", + larpProfileQ2O3: "疗愈或滋养生命", + larpProfileQ2O4: "雕琢文字与想法", + larpProfileQ2O5: "逗他人开心", + larpProfileQ3: "当你所在的小组出现冲突时,你…", + larpProfileQ3O1: "主导对话", + larpProfileQ3O2: "选择立场并坚持", + larpProfileQ3O3: "用玩笑缓解紧张", + larpProfileQ3O4: "质疑冲突是否真实", + larpProfileQ3O5: "寻找生态根源", + larpProfileQ4: "你最喜欢的长期项目是…", + larpProfileQ4O1: "建造一座城市", + larpProfileQ4O2: "恢复一片森林", + larpProfileQ4O3: "起草一部宪法", + larpProfileQ4O4: "组织一场节日", + larpProfileQ4O5: "建立防御网络", + larpProfileQ4O6: "设计一台新机器", + larpProfileQ4O7: "整理一座档案", + larpProfileQ4O8: "颠覆不公的秩序", + larpProfileQ5: "什么造就好的领导者?", + larpProfileQ5O1: "倾听与调解者", + larpProfileQ5O2: "能战斗和保护者", + larpProfileQ5O3: "了解历史者", + larpProfileQ5O4: "让你微笑者", + larpProfileQ6: "你与规则的关系:", + larpProfileQ6O1: "规则源自对话和法律", + larpProfileQ6O2: "规则应严格遵守", + larpProfileQ6O3: "规则应经常被打破", + larpProfileQ6O4: "规则应服务于生命", + larpProfileQ6O5: "规则构建坚实的基础设施", + larpProfileQ7: "你如何处理信息?", + larpProfileQ7O1: "我整理和保存它", + larpProfileQ7O2: "我通过故事分享", + larpProfileQ7O3: "我质疑其来源", + larpProfileQ7O4: "我提取有用部分", + larpProfileQ7O5: "我用它来疗愈", + larpProfileQ8: "你对成功的理解是…", + larpProfileQ8O1: "一台运转的机器", + larpProfileQ8O2: "一个和平的社区", + larpProfileQ8O3: "一场热闹的节日", + larpProfileQ8O4: "一个安全的家庭", + larpProfileQ8O5: "一份完整的档案", + larpProfileQ8O6: "一个被打破的教条", + larpProfileQ8O7: "一次丰收", + larpProfileQ8O8: "一座建成的建筑", + larpProfileQ9: "醒来时,你想…", + larpProfileQ9O1: "锻炼身体", + larpProfileQ9O2: "阅读或写作", + larpProfileQ9O3: "园艺或烹饪", + larpProfileQ9O4: "摆弄些什么", + larpProfileQ9O5: "质疑权威", + larpProfileQ9O6: "规划项目", + larpProfileQ9O7: "和朋友交谈", + larpProfileQ9O8: "创作艺术", + larpProfileQ10: "你的弱点可能是:", + larpProfileQ10O1: "话太多", + larpProfileQ10O2: "过于务实", + larpProfileQ10O3: "过于理想化", + larpProfileQ10O4: "过于具有破坏性", + larpProfileQ10O5: "过于刻板", + larpProfileQ10O6: "过于轻浮", + larpProfileQ10O7: "过于谨慎", + larpProfileQ10O8: "过于雄心勃勃", + uxModeTitle: "UX", + uxModeDescription: "选择你想要的 UX 导航模式。", + uxModeMenus: "块", + uxModeAINav: "AI", + + } +}; diff --git a/src/client/cli-cmd-aliases.js b/src/client/cli-cmd-aliases.js new file mode 100644 index 0000000..8f9e148 --- /dev/null +++ b/src/client/cli-cmd-aliases.js @@ -0,0 +1,10 @@ +module.exports = { + feed: 'createFeedStream', + history: 'createHistoryStream', + hist: 'createHistoryStream', + public: 'getPublicKey', + pub: 'getPublicKey', + log: 'createLogStream', + logt: 'messagesByType', + conf: 'config', +}; diff --git a/src/client/gui.js b/src/client/gui.js new file mode 100644 index 0000000..dfe02ae --- /dev/null +++ b/src/client/gui.js @@ -0,0 +1,124 @@ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const debug = require('../server/node_modules/debug')('oasis'); +const lodash = require('../server/node_modules/lodash'); +const ssbClient = require('../server/node_modules/ssb-client'); +const ssbConfig = require('../server/node_modules/ssb-config'); +const ssbKeys = require('../server/node_modules/ssb-keys'); +const { printMetadata } = require('../server/ssb_metadata'); +const updateFlagPath = path.join(__dirname, "../server/.update_required"); + +let internalSSB = null; +try { + const { server } = require('../server/SSB_server'); + internalSSB = server; +} catch {} + +if (process.env.OASIS_TEST) { + ssbConfig.path = fs.mkdtempSync(path.join(os.tmpdir(), "oasis-")); + ssbConfig.keys = ssbKeys.generate(); +} + +const socketPath = path.join(ssbConfig.path, "socket"); +const publicInteger = ssbConfig.keys.public.replace(".ed25519", ""); +const remote = `unix:${socketPath}~noauth:${publicInteger}`; + +const connect = (options) => + new Promise((resolve, reject) => { + ssbClient(process.env.OASIS_TEST ? ssbConfig.keys : null, options) + .then(resolve) + .catch(reject); + }); + +let closing = false; +let clientHandle; + +const attemptConnectionWithBackoff = (attempt = 1) => { + const maxAttempts = 5; + const delay = Math.min(1000 * Math.pow(2, attempt), 10000); + + return new Promise((resolve, reject) => { + connect({ remote }) + .then(resolve) + .catch((error) => { + if (attempt >= maxAttempts) { + return reject(new Error("Failed to connect after multiple attempts")); + } + setTimeout(() => { + attemptConnectionWithBackoff(attempt + 1).then(resolve).catch(reject); + }, delay); + }); + }); +}; + +let pendingConnection = null; + +const ensureConnection = (customConfig) => { + if (pendingConnection === null) { + pendingConnection = new Promise((resolve) => { + setTimeout(() => { + attemptConnectionWithBackoff() + .then(resolve) + .catch(() => { + resolve(null); + }); + }); + }); + + const cancel = () => (pendingConnection = null); + pendingConnection.then(cancel, cancel); + } + + return pendingConnection; +}; + +module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }) => { + const customConfig = JSON.parse(JSON.stringify(ssbConfig)); + if (offline === true) { + lodash.set(customConfig, "conn.autostart", false); + } + lodash.set( + customConfig, + "conn.hops", + lodash.get(ssbConfig, "conn.hops", lodash.get(ssbConfig.friends, "hops", 0)) + ); + + const cooler = { + open() { + return new Promise((resolve, reject) => { + if (internalSSB) { + const { printMetadata, colors } = require('../server/ssb_metadata'); + printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic); + return resolve(internalSSB); + } + + if (clientHandle && clientHandle.closed === false) { + return resolve(clientHandle); + } + + ensureConnection(customConfig).then((ssb) => { + if (!ssb) return reject(new Error("No SSB server available")); + clientHandle = ssb; + if (closing) { + cooler.close(); + reject(new Error("Closing Oasis")); + } else { + const { printMetadata, colors } = require('../server/ssb_metadata'); + printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic); + resolve(ssb); + } + }).catch(reject); + }); + }, + + close() { + closing = true; + if (clientHandle && clientHandle.closed === false) { + clientHandle.close(); + } + }, + }; + + return cooler; +}; diff --git a/src/client/middleware.js b/src/client/middleware.js new file mode 100644 index 0000000..2801b1c --- /dev/null +++ b/src/client/middleware.js @@ -0,0 +1,210 @@ +const path = require("path"); +const os = require("os"); +const Koa = require(path.join(__dirname, "../server/node_modules/koa")); +const koaStatic = require(path.join(__dirname, "../server/node_modules/koa-static")); +const { join } = require("path"); +const mount = require(path.join(__dirname, "../server/node_modules/koa-mount")); + +function obfuscateClearnetHtml(html) { + if (typeof html !== 'string' || html.length === 0) return html; + const preserve = []; + const stash = (re) => { + html = html.replace(re, (m) => { + preserve.push(m); + return `${preserve.length - 1}`; + }); + }; + stash(//gi); + stash(//gi); + stash(//gi); + html = html.replace(//g, ''); + html = html.replace(/>[\s\n\r\t]+<'); + html = html.replace(/[ \t]{2,}/g, ' '); + html = html.replace(/[\r\n]+/g, ''); + html = html.replace(/(\d+)/g, (_, i) => preserve[Number(i)] || ''); + return html; +} + +const collectLocalIPs = () => { + const out = []; + try { + const ifaces = os.networkInterfaces(); + for (const name of Object.keys(ifaces)) { + for (const info of (ifaces[name] || [])) { + if (info && !info.internal && (info.family === 'IPv4' || info.family === 4)) { + out.push(info.address); + } + } + } + } catch (_) {} + return out; +}; + +module.exports = ({ host, port, middleware, allowHost }) => { + const assets = new Koa() + assets.use(koaStatic(join(__dirname, "..", "client", "assets"))); + + const app = new Koa(); + const validHosts = []; + + const isClearnetPath = (request) => { + const url = String(request.url || ''); + return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?'); + }; + + const isValidRequest = (request) => { + if (isClearnetPath(request)) return request.method === 'GET'; + if (validHosts.includes(request.hostname) !== true) { + return false; + } + if (request.method !== "GET") { + if (request.header.referer == null) { + return false; + } + + try { + const refererUrl = new URL(request.header.referer); + if (validHosts.includes(refererUrl.hostname) !== true) { + return false; + } + + if (refererUrl.pathname.startsWith("/blob/")) { + return false; + } + } catch (e) { + return false; + } + } + + return true; + }; + + app.on("error", (err, ctx) => { + if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) { + return; + } + if (err && (err.name === 'BadRequestError' || err.status === 400)) { + console.error(`[400] ${err.message}`); + return null; + } + console.error(err); + if (ctx && isValidRequest(ctx.request)) { + err.message = err.message || 'Internal server error'; + err.expose = true; + } + return null; + }); + + app.use(mount("/assets", assets)); + + const maptiles = new Koa(); + maptiles.use(koaStatic(join(__dirname, "..", "maps", "tiles"))); + app.use(mount("/maptiles", maptiles)); + + const mapcache = new Koa(); + mapcache.use(koaStatic(join(__dirname, "..", "maps", "cache"))); + app.use(mount("/mapcache", mapcache)); + + + const gamesStatic = new Koa(); + gamesStatic.use(koaStatic(join(__dirname, "..", "games"))); + app.use(mount("/game-assets", gamesStatic)); + + app.use(mount("/js", koaStatic(path.join(__dirname, 'public/js')))); + app.use(koaStatic(path.join(__dirname, 'public'))); + + app.use(async (ctx, next) => { + + //console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests + + const isClearnet = isClearnetPath(ctx.request); + const csp = isClearnet + ? [ + "default-src 'self'", + "script-src 'none'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "media-src 'self' blob:", + "connect-src 'self'", + "form-action 'self'", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'" + ].join("; ") + : [ + "default-src 'self'", + "script-src 'self' http://localhost:3000/js", + "style-src 'self'", + "img-src 'self'", + "media-src 'self' blob:", + "worker-src 'self' blob:", + "frame-src 'self'", + "form-action 'self'", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'" + ].join("; "); + + ctx.set("Content-Security-Policy", csp); + ctx.set("X-Frame-Options", "SAMEORIGIN"); + + ctx.set("X-Content-Type-Options", "nosniff"); + + ctx.set("Referrer-Policy", "same-origin"); + ctx.set("Permissions-Policy", "speaker=(self)"); + + const validHostsString = validHosts.join(" or "); + + ctx.assert( + isValidRequest(ctx.request), + 400, + `Request must be addressed to ${validHostsString} and non-GET requests must contain non-blob referer.` + ); + + await next(); + + if (isClearnet && typeof ctx.body === 'string') { + const type = String(ctx.response.type || ctx.response.get('Content-Type') || '').toLowerCase(); + if (type.includes('html')) { + ctx.body = obfuscateClearnetHtml(ctx.body); + } + } + }); + + // pdf viewer + const pdfjsPath = path.join(__dirname, '../server/node_modules/pdfjs-dist/build/pdf.min.js'); + app.use(koaStatic(pdfjsPath)); + + middleware.forEach((m) => app.use(m)); + + const server = app.listen({ host, port }); + + server.on("listening", () => { + const address = server.address(); + + if (typeof address === "string") { + throw new Error("HTTP server should never bind to Unix socket"); + } + + if (allowHost !== null) { + validHosts.push(allowHost); + } + + validHosts.push(address.address); + + if (validHosts.includes(host) === false) { + validHosts.push(host); + } + + for (const ip of collectLocalIPs()) { + if (validHosts.includes(ip) === false) validHosts.push(ip); + } + + for (const loopback of ['localhost', '127.0.0.1']) { + if (validHosts.includes(loopback) === false) validHosts.push(loopback); + } + }); + + return server; +}; + diff --git a/src/client/oasis_client.js b/src/client/oasis_client.js new file mode 100644 index 0000000..951bdfa --- /dev/null +++ b/src/client/oasis_client.js @@ -0,0 +1,70 @@ +"use strict"; + +const path = require('path'); +const yargs = require(path.join(__dirname, '../server/node_modules/yargs')); +const { hideBin } = require(path.join(__dirname, '../server/node_modules/yargs/helpers')); +const _ = require(path.join(__dirname, '../server/node_modules/lodash')); + +const moduleAlias = require(path.join(__dirname, '../server/node_modules/module-alias')); +moduleAlias.addAlias('punycode', 'punycode/'); + +const HELP_TEXT = `Usage: sh oasis.sh [mode] [options] + +Modes: + gui Launch the Oasis web GUI (default if no mode given). + server, pub Launch only the Oasis Sbot (headless, PUB mode). + help, -h, --help Show this help message. + +PUB admin commands (require the Oasis Sbot to be running): + whoami Print this Oasis ID. + invite [N] Create an invite code. N = number of uses (default 1). + name Set this Oasis display name. + announce [port] Publish a pub address (default port 8008). + follow Follow another Oasis ID / feed. + status Show peer / replication status. + gossip List known gossip peers. + +GUI options (forwarded to the backend): + --host= Hostname / IP the web UI listens on (default: localhost). + Use 0.0.0.0 to expose on a VPS. + --port= Port for the web UI (default: 3000). + --allow-host= Extra hostname allowed when behind a reverse proxy. + --public Public-hosting mode: disables POST and redacts content + from people who haven't opted in to public hosting. + --offline Don't connect to Oasis peers or pubs. + --no-open Don't auto-open a browser tab on launch (useful on VPS). + --debug Verbose logging. + +Examples: + sh oasis.sh + sh oasis.sh server + sh oasis.sh invite 100 + sh oasis.sh name "My PUB" + sh oasis.sh announce mypub.example.com + sh oasis.sh --host=0.0.0.0 --port=8080 --no-open +`; + +const cli = (presets /*, defaultConfigFile */) => { + const argv = process.argv.slice(2); + if (argv.includes('-h') || argv.includes('--help') || argv.includes('-help') || argv.includes('help')) { + process.stdout.write(HELP_TEXT); + process.exit(0); + } + return yargs(hideBin(process.argv)) + .scriptName("oasis") + .env("OASIS") + .help(false) + .version(false) + .options("open", { default: _.get(presets, "open", true), type: "boolean" }) + .options("offline", { default: _.get(presets, "offline", false), type: "boolean" }) + .options("host", { default: _.get(presets, "host", "localhost"), type: "string" }) + .options("allow-host", { default: _.get(presets, "allow-host", null), type: "string" }) + .options("port", { default: _.get(presets, "port", 3000), type: "number" }) + .options("public", { default: _.get(presets, "public", false), type: "boolean" }) + .options("debug", { default: _.get(presets, "debug", false), type: "boolean" }) + .parserConfiguration({ "strip-aliased": true }) + .argv; +}; + +module.exports = { cli }; + diff --git a/src/client/public/docs/ecoin.md b/src/client/public/docs/ecoin.md new file mode 100644 index 0000000..6449f16 --- /dev/null +++ b/src/client/public/docs/ecoin.md @@ -0,0 +1,115 @@ + +ECOin - Copyright (c) - 2014/2025 - GPLv3 - epsylon@riseup.net (https://ecoin.03c8.net) + +=========================================== +# SOURCES for P2P Crypto-Currency (ECOin) # +=========================================== + +Testing machine is: Debian GNU/Linux 12 (bookworm) (x86_64). + +All of the commands should be executed in a shell. + +------------------------------ + +(0.) Clone the github tree to get the source code: + + + Official: + + git clone http://code.03c8.net/epsylon/ecoin + + + Mirror: + + git clone https://github.com/epsylon/ecoin + +------------------------------ + +=================================================== +# SERVER -ecoind- for P2P Crypto-Currency (ECOin) # +=================================================== + +(0.) Version libraries: + + - Libboost -> source code 1.68 provided at: src/boost_1_68_0 + +(1.) Install dependencies: + + sudo apt-get install build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev + + + Optionally install qrencode (and set USE_QRCODE=1): + + sudo apt-get install libqrencode-dev + +(2.) Now you should be able to build ecoind: + + cd src/ + make -f makefile.linux USE_UPNP=- USE_IPV6=- + strip ecoind + + An executable named 'ecoind' will be built. + + Now you can launch: ./ecoind to run your ECOin server. + +------------------------------ + +============================================ +# WALLET for P2P Crypto-Currency (ECOin) # +============================================ + +(0.) Version libraries: + + - Libboost -> source code 1.68 provided at: src/boost_1_68_0 + +(1.) First, make sure that the required packages for Qt5 development (an the others required for building the daemon) are installed: + + sudo apt-get install qt5-qmake qtbase5-dev build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev + + + Optionally install qrencode (and set USE_QRCODE=1): + + sudo apt-get install libqrencode-dev + +(2.) Then execute the following: + + qmake USE_UPNP=- USE_IPV6=- + make + + An executable named 'ecoin-qt' will be built. + + Now you can launch: ./ecoin-qt to run your ECOin wallet/GUI. + +------------------------------ +======================================== ++ CPU MINER for ECOin (Unix/GNU-Linux) # +======================================== + +See doc/MINING.txt for detailed instructions on running /ecoin-miner/ on different platforms. + + + GNU/Linux: + + cd miner/ + sh build.sh + + An executable named 'cpuminer' will be built. + + Now you can launch: ./cpuminer to run your ECOin PoW miner. + +====================================== + +For a list of command-line options: + + ./ecoind --help + +To start the ECOin daemon: + + ./ecoind -daemon + +For ECOin-QT Wallet + + ./ecoin-qt + +For debugging: + + tail -f /home/$USER/.ecoin/debug.log + +====================================== + + diff --git a/src/configs/AI-history.json b/src/configs/AI-history.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/src/configs/AI-history.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/src/configs/agenda-config.json b/src/configs/agenda-config.json new file mode 100644 index 0000000..89bea0a --- /dev/null +++ b/src/configs/agenda-config.json @@ -0,0 +1,3 @@ +{ + "discardedItems": [] +} \ No newline at end of file diff --git a/src/configs/banking-allocations.json b/src/configs/banking-allocations.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/src/configs/banking-allocations.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/src/configs/banking-eco-history.json b/src/configs/banking-eco-history.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/src/configs/banking-eco-history.json @@ -0,0 +1 @@ +[] diff --git a/src/configs/banking-epochs.json b/src/configs/banking-epochs.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/src/configs/banking-epochs.json @@ -0,0 +1 @@ +[] diff --git a/src/configs/config-manager.js b/src/configs/config-manager.js new file mode 100644 index 0000000..71f3574 --- /dev/null +++ b/src/configs/config-manager.js @@ -0,0 +1,109 @@ +const fs = require('fs'); +const path = require('path'); + +const configFilePath = path.join(__dirname, 'oasis-config.json'); + +if (!fs.existsSync(configFilePath)) { + const defaultConfig = { + "themes": { + "current": "Dark-SNH" + }, + "ux": { + "current": "blocks" + }, + "modules": { + "popularMod": "on", + "topicsMod": "on", + "summariesMod": "on", + "latestMod": "on", + "threadsMod": "on", + "multiverseMod": "on", + "fediverseMod": "off", + "invitesMod": "on", + "walletMod": "on", + "legacyMod": "on", + "cipherMod": "on", + "bookmarksMod": "on", + "videosMod": "on", + "docsMod": "on", + "audiosMod": "on", + "tagsMod": "on", + "imagesMod": "on", + "trendingMod": "on", + "eventsMod": "on", + "tasksMod": "on", + "marketMod": "on", + "votesMod": "on", + "tribesMod": "on", + "reportsMod": "on", + "opinionsMod": "on", + "padsMod": "on", + "calendarsMod": "on", + "transfersMod": "on", + "feedMod": "on", + "pixeliaMod": "on", + "melodyMod": "on", + "agendaMod": "on", + "aiMod": "on", + "aiNavMod": "on", + "forumMod": "on", + "gamesMod": "on", + "jobsMod": "on", + "shopsMod": "on", + "projectsMod": "on", + "bankingMod": "on", + "parliamentMod": "on", + "courtsMod": "on", + "favoritesMod": "on", + "logsMod": "on", + "mapsMod": "on", + "chatsMod": "on", + "torrentsMod": "on", + "graphosMod": "on", + "larpMod": "on" + }, + "wallet": { + "url": "http://localhost:7474", + "user": "", + "pass": "", + "fee": "5" + }, + "walletPub": { + "pubId": "" + }, + "ai": { + "prompt": "Provide an informative and precise response." + }, + "ssbLogStream": { + "limit": 2000 + }, + "homePage": "activity", + "language": "en", + "wish": "whole", + "pmVisibility": "whole", + "lanBroadcasting": true + }; + fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2)); +} + +const getConfig = () => { + const configData = fs.readFileSync(configFilePath); + const cfg = JSON.parse(configData); + if (!['whole', 'mutuals', 'only-lan'].includes(cfg.wish)) cfg.wish = 'whole'; + if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole'; + if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux }; + if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' }; + if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks'; + if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav') cfg.ux.current = 'blocks'; + if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks'; + return cfg; +}; + +const saveConfig = (newConfig) => { + fs.writeFileSync(configFilePath, JSON.stringify(newConfig, null, 2)); +}; + +module.exports = { + getConfig, + saveConfig, +}; diff --git a/src/configs/fediverse-accounts.json b/src/configs/fediverse-accounts.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/configs/fediverse-accounts.json @@ -0,0 +1 @@ +{} diff --git a/src/configs/follow_state.json b/src/configs/follow_state.json new file mode 100644 index 0000000..e66a46e --- /dev/null +++ b/src/configs/follow_state.json @@ -0,0 +1 @@ +{"pending":[],"accepted":[],"lastAcceptMs":0} diff --git a/src/configs/media-favorites.json b/src/configs/media-favorites.json new file mode 100644 index 0000000..8c9d7c2 --- /dev/null +++ b/src/configs/media-favorites.json @@ -0,0 +1,13 @@ +{ + "audios": [], + "bookmarks": [], + "calendars": [], + "chats": [], + "documents": [], + "images": [], + "maps": [], + "pads": [], + "shops": [], + "torrents": [], + "videos": [] +} diff --git a/src/configs/oasis-config.json b/src/configs/oasis-config.json new file mode 100644 index 0000000..4e835c6 --- /dev/null +++ b/src/configs/oasis-config.json @@ -0,0 +1,79 @@ +{ + "themes": { + "current": "OasisMobile" + }, + "ux": { + "current": "blocks" + }, + "modules": { + "popularMod": "on", + "topicsMod": "on", + "summariesMod": "on", + "latestMod": "on", + "threadsMod": "on", + "multiverseMod": "on", + "fediverseMod": "on", + "invitesMod": "on", + "walletMod": "off", + "legacyMod": "off", + "cipherMod": "on", + "bookmarksMod": "on", + "videosMod": "on", + "docsMod": "off", + "audiosMod": "on", + "tagsMod": "on", + "imagesMod": "on", + "trendingMod": "on", + "eventsMod": "on", + "tasksMod": "on", + "marketMod": "on", + "votesMod": "on", + "tribesMod": "on", + "reportsMod": "on", + "opinionsMod": "on", + "padsMod": "on", + "calendarsMod": "on", + "transfersMod": "off", + "feedMod": "on", + "pixeliaMod": "on", + "melodyMod": "on", + "agendaMod": "on", + "aiMod": "off", + "aiNavMod": "off", + "forumMod": "on", + "gamesMod": "off", + "jobsMod": "on", + "shopsMod": "on", + "projectsMod": "on", + "bankingMod": "off", + "parliamentMod": "on", + "courtsMod": "off", + "favoritesMod": "on", + "logsMod": "off", + "mapsMod": "off", + "chatsMod": "on", + "torrentsMod": "off", + "graphosMod": "on", + "larpMod": "on" + }, + "wallet": { + "url": "http://localhost:7474", + "user": "", + "pass": "", + "fee": "5" + }, + "walletPub": { + "pubId": "" + }, + "ai": { + "prompt": "Provide an informative and precise response." + }, + "ssbLogStream": { + "limit": 2000 + }, + "homePage": "activity", + "language": "en", + "wish": "whole", + "pmVisibility": "whole", + "lanBroadcasting": true +} \ No newline at end of file diff --git a/src/configs/server-config.json b/src/configs/server-config.json new file mode 100644 index 0000000..7779f6f --- /dev/null +++ b/src/configs/server-config.json @@ -0,0 +1,61 @@ +{ + "logging": { + "level": "notice" + }, + "caps": { + "shs": "H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s=" + }, + "pub": false, + "local": true, + "friends": { + "dunbar": 300, + "hops": 2 + }, + "gossip": { + "connections": 20, + "local": true, + "friends": true, + "seed": true, + "global": true + }, + "replicationScheduler": { + "autostart": true, + "partialReplication": null + }, + "autofollow": { + "enabled": false, + "feeds": [] + }, + "connections": { + "seeds": [], + "incoming": { + "net": [ + { + "scope": ["device", "local"], + "transform": "shs", + "port": 8008 + } + ], + "unix": [ + { + "scope": [ + "device", + "local", + "private" + ], + "transform": "noauth" + } + ] + }, + "outgoing": { + "net": [ + { + "transform": "shs" + } + ], + "tunnel": [], + "onion": [], + "ws": [] + } + } +} diff --git a/src/configs/shared-state.js b/src/configs/shared-state.js new file mode 100644 index 0000000..dfed74b --- /dev/null +++ b/src/configs/shared-state.js @@ -0,0 +1,35 @@ +let _inboxCount = 0; +let _carbonHcT = 0; +let _carbonHcH = 0; +let _lastRefresh = 0; +let _onlinePeers = null; +let _inboxUnread = null; +let _lastSyncTs = null; +let _ecoValue = null; +let _lastActivity = null; +let _maxBlockBytes = 0; +let _inhabitantCount = 0; +module.exports = { + getInboxCount: () => _inboxCount, + setInboxCount: (n) => { _inboxCount = n; }, + getCarbonHcT: () => _carbonHcT, + setCarbonHcT: (n) => { _carbonHcT = n; }, + getCarbonHcH: () => _carbonHcH, + setCarbonHcH: (n) => { _carbonHcH = n; }, + getLastRefresh: () => _lastRefresh, + setLastRefresh: (t) => { _lastRefresh = t; }, + getOnlinePeerCount: () => _onlinePeers, + setOnlinePeerCount: (n) => { _onlinePeers = n; }, + getInboxUnreadCount: () => _inboxUnread, + setInboxUnreadCount: (n) => { _inboxUnread = n; }, + getLastSyncTs: () => _lastSyncTs, + setLastSyncTs: (t) => { _lastSyncTs = t; }, + getEcoValue: () => _ecoValue, + setEcoValue: (v) => { _ecoValue = v; }, + getLastActivity: () => _lastActivity, + setLastActivity: (a) => { _lastActivity = a; }, + getMaxBlockBytes: () => _maxBlockBytes, + setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); }, + getInhabitantCount: () => _inhabitantCount, + setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); } +}; diff --git a/src/configs/snh-invite-code.json b/src/configs/snh-invite-code.json new file mode 100644 index 0000000..545d3b1 --- /dev/null +++ b/src/configs/snh-invite-code.json @@ -0,0 +1,7 @@ +{ + "name": "SNH \"La Plaza\"", + "description": "A shared place to begin a utopia ...", + "url": "https://pub.solarnethub.com", + "code": "pub.solarnethub.com:8008:@0qSCyK3xyL71X4qKkmf84Cb2riP6OeUqxCvbP2Z6HWs=.ed25519~7mC1c1gBJe8pmnWI9eIUYpFtEPUOiuHAxbzfxP/f0Ak=", + "createdAt": "2026-05-24T00:00:00.000Z" +} diff --git a/src/configs/wallet-addresses.json b/src/configs/wallet-addresses.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/configs/wallet-addresses.json @@ -0,0 +1 @@ +{} diff --git a/src/games/8ball/index.html b/src/games/8ball/index.html new file mode 100644 index 0000000..120b790 --- /dev/null +++ b/src/games/8ball/index.html @@ -0,0 +1,285 @@ + + + + + +8Ball Pool + + + +