v0.2.91
This commit is contained in:
1 parent
4da41a1404
commit
5cb6960b04
31 files changed
+1611
-2123
No files matched your search
Generated
+1
-1
@@ -2674,7 +2674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.90"
|
||||
version = "0.2.91"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.90"
|
||||
version = "0.2.91"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Rustpad 0.2.88 — wyniki testów wydajności
|
||||
|
||||
Zmiana: w `src/websocket/pad.rs` i `src/websocket/mod.rs` ograniczono sprawdzanie dostępu przy przychodzących zdarzeniach broadcast do najwyżej jednego sprawdzenia na sekundę na połączenie. Kontrola cykliczna i zdarzenia zmiany hasła pozostają aktywne. W zestawie pozostają optymalizacje renderowania z poprzedniej iteracji.
|
||||
|
||||
## Symulacja deterministyczna (Node.js)
|
||||
|
||||
Założenia: 10 s, 2 edycje/s na użytkownika, każda edycja rozsyłana do wszystkich N odbiorców (w tym nadawcy), bez rzeczywistej sieci, bazy danych, WebSocket czy renderowania. Symulacja porównuje liczby sprawdzeń dostępu, a **nie** rzeczywistą przepustowość ani opóźnienia Rustpada.
|
||||
|
||||
| Użytkownicy | Dostawy zdarzeń | Sprawdzenia przed | Sprawdzenia po | Redukcja |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 5 | 500 | 500 | 50 | 90,00% |
|
||||
| 100 | 200 000 | 200 000 | 1 000 | 99,50% |
|
||||
| 300 | 1 800 000 | 1 800 000 | 3 000 | 99,83% |
|
||||
| 500 | 5 000 000 | 5 000 000 | 5 000 | 99,90% |
|
||||
|
||||
Uruchomienie: `node tests/broadcast-load-simulation.mjs`.
|
||||
|
||||
## Testy
|
||||
|
||||
- `node tests/feature-smoke-test.mjs` — PASS
|
||||
- `node tests/mention-autocomplete-test.mjs` — PASS
|
||||
- `node tests/mention-navigation-test.mjs` — PASS
|
||||
- `node --check static/js/*.js` (osobno dla plików) — PASS
|
||||
- `cargo test` — NIEURUCHOMIONY (brak Rust/cargo w środowisku)
|
||||
- Test end-to-end z setkami aktywnych przeglądarek i prawdziwym WebSocket/PostgreSQL — NIEURUCHOMIONY.
|
||||
|
||||
## Ograniczenia
|
||||
|
||||
Nie można zagwarantować płynnej pracy kilkuset użytkowników bez testu na docelowym sprzęcie i bazie. Każdy zapis nadal przechodzi przez serializację zmian i trwałe utrwalenie w bazie; duża liczba operacji może nasycić bazę i sieć. Zmiana odświeżania uprawnień może wydłużyć wykrycie odwołania dostępu w trakcie ciągłego broadcastu do około 1 sekundy; istniejący timer kontroli okresowej pozostaje. Wersja 0.2.88 jest wydaniem z nową poprawką na życzenie użytkownika i nie należy jej mylić z wcześniej wydaną paczką 0.2.88.
|
||||
+7
-2
@@ -938,6 +938,9 @@
|
||||
"workspace.noteDeletedTitle": "Note deleted",
|
||||
"workspace.noteName.placeholder": "Note name",
|
||||
"workspace.noteProtect": "Protect this note from deletion",
|
||||
"workspace.noteProtection.explain": "When enabled, collaborators can edit but cannot delete or archive the note or remove its files. The note creator or workspace owner can still do so. This does not require a password.",
|
||||
"workspace.noteProtection.archiveEnabled": "Archiving is enabled: authorized deletion moves the note, its history and files to Archived notes. The workspace owner can restore it.",
|
||||
"workspace.noteProtection.archiveDisabled": "Archiving is disabled: authorized deletion permanently removes the note and its files. Enable archiving in Workspace settings if recovery is important.",
|
||||
"workspace.notes.copy": "Select a note or create a new one.",
|
||||
"workspace.notesView": "Notes view",
|
||||
"workspace.pagination": "Notes pagination",
|
||||
@@ -1013,6 +1016,8 @@
|
||||
"files.less": "Less",
|
||||
"files.noMatchesHelp": "Try a different file name or type.",
|
||||
"files.link": "Link",
|
||||
"files.markdown": "Markdown"
|
||||
"files.markdown": "Markdown",
|
||||
"home.account.loading": "Checking your account...",
|
||||
"home.account.retry": "Reload page"
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -938,6 +938,9 @@
|
||||
"workspace.noteDeletedTitle": "Notatka usunięta",
|
||||
"workspace.noteName.placeholder": "Nazwa notatki",
|
||||
"workspace.noteProtect": "Chroń tę notatkę przed usunięciem",
|
||||
"workspace.noteProtection.explain": "Po włączeniu współedytujący mogą edytować notatkę, ale nie mogą jej usuwać ani archiwizować, ani usuwać załączników. Nadal może to zrobić twórca notatki lub właściciel workspace. To nie jest ochrona hasłem.",
|
||||
"workspace.noteProtection.archiveEnabled": "Archiwizacja włączona: dozwolone usunięcie przenosi notatkę, jej historię i pliki do archiwum. Właściciel workspace może ją przywrócić.",
|
||||
"workspace.noteProtection.archiveDisabled": "Archiwizacja wyłączona: dozwolone usunięcie trwale usuwa notatkę i jej pliki. Włącz archiwizację w ustawieniach workspace, jeśli przywracanie jest ważne.",
|
||||
"workspace.notes.copy": "Wybierz notatkę albo utwórz nową.",
|
||||
"workspace.notesView": "Widok notatek",
|
||||
"workspace.pagination": "Paginacja notatek",
|
||||
@@ -1013,6 +1016,8 @@
|
||||
"files.less": "Mniej",
|
||||
"files.noMatchesHelp": "Spróbuj innej nazwy pliku lub typu.",
|
||||
"files.link": "Link",
|
||||
"files.markdown": "Markdown"
|
||||
"files.markdown": "Markdown",
|
||||
"home.account.loading": "Sprawdzanie konta...",
|
||||
"home.account.retry": "Odśwież stronę"
|
||||
}
|
||||
}
|
||||
+50
-3
@@ -48,6 +48,7 @@ const MODULES: &[&str] = &[
|
||||
"theme",
|
||||
"url-state",
|
||||
"vendor-libs",
|
||||
"virtual-editor-layers",
|
||||
"security",
|
||||
"select",
|
||||
];
|
||||
@@ -73,8 +74,18 @@ pub fn render_html(
|
||||
urls.stylesheet("styles"),
|
||||
urls.stylesheet_path("libs/rustpad-player/player.css"),
|
||||
);
|
||||
let show_note_loader = matches!(entrypoint, "pad" | "note");
|
||||
let html = template
|
||||
.replace("__APP_THEME_BOOTSTRAP__", &format!("{}{}", theme_bootstrap(), language_bootstrap()))
|
||||
.replace(
|
||||
"__APP_THEME_BOOTSTRAP__",
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
theme_bootstrap(),
|
||||
language_bootstrap(),
|
||||
if show_note_loader { loading_bootstrap() } else { String::new() }
|
||||
),
|
||||
)
|
||||
.replace("__APP_LOADING_OVERLAY__", if show_note_loader { loading_overlay() } else { "" })
|
||||
.replace("__APP_STYLESHEET__", &app_stylesheets)
|
||||
.replace("__APP_IMPORT_MAP__", &urls.import_map())
|
||||
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
|
||||
@@ -101,6 +112,39 @@ pub fn render_html(
|
||||
response
|
||||
}
|
||||
|
||||
fn loading_bootstrap() -> String {
|
||||
// Embedded critical CSS and controller are available before the external assets.
|
||||
format!(
|
||||
"<style>{}</style><script>{}</script>",
|
||||
include_str!("../static/css/boot-loading.css"),
|
||||
include_str!("../static/js/boot-loading.js"),
|
||||
)
|
||||
}
|
||||
|
||||
fn loading_overlay() -> &'static str {
|
||||
r#"<div id="app-boot-overlay" role="status" aria-live="polite" aria-label="Loading RustPad">
|
||||
<div class="app-boot-card">
|
||||
<div class="app-boot-brand" aria-label="RustPad" role="img">
|
||||
<span class="app-boot-wordmark" aria-hidden="true">RustPad</span>
|
||||
</div>
|
||||
<h2 id="app-boot-heading">Loading…</h2>
|
||||
<p id="app-boot-stage">Preparing the interface</p>
|
||||
<div id="app-boot-progress" class="app-boot-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Startup progress">
|
||||
<div id="app-boot-progress-fill"></div>
|
||||
</div>
|
||||
<p id="app-boot-count">0 of 5 stages completed</p>
|
||||
<ol class="app-boot-steps" aria-label="Loading stages">
|
||||
<li id="app-boot-step-assets"><span>Interface</span></li>
|
||||
<li id="app-boot-step-language"><span>Language</span></li>
|
||||
<li id="app-boot-step-libraries"><span>Editor</span></li>
|
||||
<li id="app-boot-step-access"><span>Access</span></li>
|
||||
<li id="app-boot-step-files"><span>Note</span></li>
|
||||
</ol>
|
||||
<button type="button" id="app-boot-retry" hidden>Reload page</button>
|
||||
</div>
|
||||
</div>"#
|
||||
}
|
||||
|
||||
pub fn theme_bootstrap() -> &'static str {
|
||||
r#"<script>(()=>{const key="rustpad:theme",guestKey="rustpad:guest-theme",authKey="rustpad:auth-state";let theme=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";try{const signedIn=localStorage.getItem(authKey)==="1";const saved=signedIn?localStorage.getItem(key):(localStorage.getItem(guestKey)||localStorage.getItem(key));if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
|
||||
}
|
||||
@@ -154,13 +198,16 @@ impl<'a> AssetUrls<'a> {
|
||||
}
|
||||
|
||||
fn stylesheet_path(&self, path: &str) -> String {
|
||||
format!(r#"<link rel="stylesheet" href="{}">"#, self.url(path))
|
||||
format!(
|
||||
r#"<link rel="stylesheet" data-rustpad-shell-css href="{}">"#,
|
||||
self.url(path)
|
||||
)
|
||||
}
|
||||
|
||||
fn entrypoint(&self, name: &str) -> String {
|
||||
let entrypoint = self.url(&format!("js/{name}.js"));
|
||||
format!(
|
||||
r#"<script type="module">import {{ initI18n }} from "@rustpad/i18n";import {{ initHeaderPreferences }} from "@rustpad/preferences";import {{ initSelectControls }} from "@rustpad/select";await initI18n();initHeaderPreferences();await import("{}");initSelectControls();</script>"#,
|
||||
r#"<script type="module">const boot=window.__RUSTPAD_LOADING__;try{{const [i18n,prefs,select]=await Promise.all([import("@rustpad/i18n"),import("@rustpad/preferences"),import("@rustpad/select")]);boot?.complete("js");await i18n.initI18n();boot?.complete("language");prefs.initHeaderPreferences();await import("{}");select.initSelectControls();boot?.complete("libraries");}}catch(error){{console.error("RustPad startup failed",error);boot?.fail(error);}}</script>"#,
|
||||
escape_js_string(&entrypoint)
|
||||
)
|
||||
}
|
||||
|
||||
+2
-5
@@ -174,7 +174,7 @@ impl Config {
|
||||
"GUEST_UPLOAD_MAX_SIZE_MB",
|
||||
guest_upload_max_size_mb,
|
||||
)?,
|
||||
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
asset_version: format!("{}-ui3", env!("CARGO_PKG_VERSION")),
|
||||
http_compression: values.bool("HTTP_COMPRESSION", false)?,
|
||||
asset_cache_max_age_seconds: values
|
||||
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
@@ -241,10 +241,7 @@ fn multipart_body_limit_bytes(
|
||||
file_limit.saturating_add(1024 * 1024)
|
||||
}
|
||||
|
||||
fn megabytes_to_bytes(
|
||||
name: &str,
|
||||
megabytes: u64,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
fn megabytes_to_bytes(name: &str, megabytes: u64) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let bytes = megabytes
|
||||
.checked_mul(1024 * 1024)
|
||||
.ok_or_else(|| format!("{name} is too large"))?;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
#app-boot-overlay {
|
||||
--boot-bg: #0b0d11;
|
||||
--boot-panel: #151922;
|
||||
--boot-border: #343c49;
|
||||
--boot-text: #f4f6f8;
|
||||
--boot-muted: #abb5c3;
|
||||
--boot-accent: #a99ef8;
|
||||
--boot-brand-start: #f0aa74;
|
||||
--boot-brand-mid: #cf633d;
|
||||
--boot-brand-end: #8f3827;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--boot-bg);
|
||||
color: var(--boot-text);
|
||||
font: 500 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif;
|
||||
text-align: center;
|
||||
opacity: 1;
|
||||
visibility: visible !important;
|
||||
transition: opacity .18s ease;
|
||||
}
|
||||
|
||||
:root[data-theme=light] #app-boot-overlay {
|
||||
--boot-bg: #f7f4ee;
|
||||
--boot-panel: #fffdf9;
|
||||
--boot-border: #cbc3b8;
|
||||
--boot-text: #29251f;
|
||||
--boot-muted: #625a50;
|
||||
--boot-accent: #6251bf;
|
||||
--boot-brand-start: #c9794e;
|
||||
--boot-brand-mid: #ae5d3c;
|
||||
--boot-brand-end: #7c3d2c;
|
||||
}
|
||||
|
||||
#app-boot-overlay.is-ready {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-card {
|
||||
width: min(100%, 390px);
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-brand {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto 25px;
|
||||
padding: 0 8px 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-wordmark {
|
||||
position: relative;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
font-size: calc(clamp(42px, 10vw, 57px));
|
||||
font-weight: 800;
|
||||
letter-spacing: -.035em;
|
||||
line-height: 1.13;
|
||||
background: linear-gradient(110deg,
|
||||
var(--live-gradient-start, var(--boot-brand-start)) 0,
|
||||
var(--live-gradient-mid, var(--boot-brand-mid)) 48%,
|
||||
var(--live-gradient-end, var(--boot-brand-end)) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: app-boot-wordmark-breathe 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-wordmark:after {
|
||||
content: "RustPad";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(100deg,
|
||||
transparent 35%, hsla(0, 0%, 100%, .7) 50%, transparent 65%);
|
||||
background-size: 250% 100%;
|
||||
background-position: 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: app-boot-wordmark-shine 3.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-brand:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 12%;
|
||||
width: 76%;
|
||||
height: 2px;
|
||||
border-radius: 50px;
|
||||
background: linear-gradient(90deg, transparent, var(--boot-accent), transparent);
|
||||
opacity: .75;
|
||||
transform: scaleX(.35);
|
||||
animation: app-boot-brand-underline 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#app-boot-overlay h2 {
|
||||
margin: 0 0 9px;
|
||||
font-size: 21px;
|
||||
font-weight: 720;
|
||||
letter-spacing: -.035em;
|
||||
color: var(--boot-text);
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-stage {
|
||||
margin: 0 0 23px;
|
||||
color: var(--boot-muted);
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-track {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 100px;
|
||||
background: var(--boot-border);
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-progress-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--boot-accent);
|
||||
border-radius: inherit;
|
||||
transition: width .14s ease;
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-count {
|
||||
margin: 10px 0 18px;
|
||||
font-size: 12px;
|
||||
color: var(--boot-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps li {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--boot-border);
|
||||
border-radius: 7px;
|
||||
font-size: 10px;
|
||||
color: var(--boot-muted);
|
||||
background: var(--boot-panel);
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps li[data-status=done] {
|
||||
color: var(--boot-accent);
|
||||
border-color: var(--boot-accent);
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps li[data-status=active] {
|
||||
color: var(--boot-text);
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-retry {
|
||||
margin-top: 18px;
|
||||
padding: 10px 18px;
|
||||
border: 1px solid var(--boot-accent);
|
||||
border-radius: 8px;
|
||||
background: var(--boot-panel);
|
||||
color: var(--boot-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-retry[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes app-boot-wordmark-breathe {
|
||||
|
||||
0%,
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-boot-wordmark-shine {
|
||||
|
||||
0%,
|
||||
20% {
|
||||
background-position: 100%;
|
||||
}
|
||||
|
||||
70%,
|
||||
to {
|
||||
background-position: -100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-boot-brand-underline {
|
||||
|
||||
0%,
|
||||
to {
|
||||
transform: scaleX(.35);
|
||||
opacity: .4;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleX(1);
|
||||
opacity: .85;
|
||||
}
|
||||
}
|
||||
|
||||
#app-boot-overlay.has-error .app-boot-brand:after,
|
||||
#app-boot-overlay.has-error .app-boot-wordmark,
|
||||
#app-boot-overlay.has-error .app-boot-wordmark:after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
|
||||
#app-boot-overlay,
|
||||
#app-boot-overlay #app-boot-progress-fill {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-brand:after,
|
||||
#app-boot-overlay .app-boot-wordmark,
|
||||
#app-boot-overlay .app-boot-wordmark:after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+579
-451
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@
|
||||
</head>
|
||||
|
||||
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
|
||||
__APP_LOADING_OVERLAY__
|
||||
<header class="app-header">
|
||||
<div class="app-header__main">
|
||||
<div class="resource-brand" aria-label="__RESOURCE_ARIA_LABEL__">
|
||||
|
||||
+40
-15
@@ -13,7 +13,9 @@
|
||||
</head>
|
||||
|
||||
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
|
||||
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a><div class="header-preferences" data-header-preferences></div></header>
|
||||
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a>
|
||||
<div class="header-preferences" data-header-preferences></div>
|
||||
</header>
|
||||
<main class="home-layout home-layout--wide">
|
||||
<section class="home-intro">
|
||||
<h1>Write. Share. Collaborate.</h1>
|
||||
@@ -73,18 +75,27 @@
|
||||
|
||||
<footer class="home-footer">
|
||||
<div class="home-footer__inner">
|
||||
<div id="footer-account-guest" class="home-footer__account">
|
||||
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
|
||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
|
||||
</div>
|
||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||
<span id="footer-user-label" class="home-footer__user">
|
||||
<span class="home-footer__user-primary" data-account-primary></span>
|
||||
<small class="home-footer__user-secondary" data-account-secondary hidden></small>
|
||||
</span>
|
||||
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
|
||||
<button id="footer-profile" class="footer-action" type="button">Profile</button>
|
||||
<button id="footer-logout" class="footer-action" type="button">Log out</button>
|
||||
<div id="footer-account-slot" class="home-footer__account-slot" data-state="loading" aria-busy="true">
|
||||
<div id="footer-account-pending" class="home-footer__account-pending" role="status">
|
||||
<span class="home-footer__loading-line" aria-hidden="true"></span>
|
||||
<span class="home-footer__loading-action" aria-hidden="true"></span>
|
||||
<span class="sr-only" data-i18n="home.account.loading">Checking your account...</span>
|
||||
</div>
|
||||
<a id="footer-account-retry" href="/" class="footer-action" data-i18n="home.account.retry" hidden>Reload
|
||||
page</a>
|
||||
<div id="footer-account-guest" class="home-footer__account" hidden>
|
||||
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
|
||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
|
||||
</div>
|
||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||
<span id="footer-user-label" class="home-footer__user">
|
||||
<span class="home-footer__user-primary" data-account-primary></span>
|
||||
<small class="home-footer__user-secondary" data-account-secondary hidden></small>
|
||||
</span>
|
||||
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
|
||||
<button id="footer-profile" class="footer-action" type="button">Profile</button>
|
||||
<button id="footer-logout" class="footer-action" type="button">Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="footer-about" class="footer-action home-footer__about" type="button">About</button>
|
||||
</div>
|
||||
@@ -107,7 +118,8 @@
|
||||
<p>Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl</p>
|
||||
<p>This software is available under GNU GPLv3, which also permits commercial use provided that its terms are
|
||||
followed.</p>
|
||||
<p><strong>COMMERCIAL LICENSING NOTICE:</strong> If you want to distribute this software or a derivative work as
|
||||
<p><strong>COMMERCIAL LICENSING NOTICE:</strong> If you want to distribute this software or a derivative work
|
||||
as
|
||||
part of a proprietary (closed-source) product without the obligations imposed by GPLv3, contact us about a
|
||||
separate commercial license. <br>Contact: linuxiarz<at>linuxiarz.pl</p>
|
||||
</div>
|
||||
@@ -161,7 +173,8 @@
|
||||
<section class="resources-category resources-category--favorites">
|
||||
<header class="resources-category__header">
|
||||
<h3>Favorites</h3>
|
||||
<p>Favorite notes are shown only while your current account, share link, or password access can still open them. If access is lost, they are hidden and return automatically when access is restored.</p>
|
||||
<p>Favorite notes are shown only while your current account, share link, or password access can still open
|
||||
them. If access is lost, they are hidden and return automatically when access is restored.</p>
|
||||
</header>
|
||||
<div id="favorites-list" class="resources-list resources-list--favorites"></div>
|
||||
</section>
|
||||
@@ -239,6 +252,18 @@
|
||||
<p id="profile-message" class="form-message" role="status"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>
|
||||
// If initial account scripts/network never finish, offer a local retry rather
|
||||
// than leaving the footer placeholder indefinitely (without blocking home).
|
||||
setTimeout(() => {
|
||||
const slot = document.getElementById("footer-account-slot");
|
||||
if (slot?.dataset.state !== "loading") return;
|
||||
slot.dataset.state = "error";
|
||||
slot.setAttribute("aria-busy", "false");
|
||||
const retry = document.getElementById("footer-account-retry");
|
||||
if (retry) retry.hidden = false;
|
||||
}, 15000);
|
||||
</script>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals">
|
||||
</div>
|
||||
|
||||
|
||||
+68
-7
@@ -60,7 +60,61 @@ export function parseAuthorship(content, raw) {
|
||||
}
|
||||
|
||||
export function serializeAuthorship(model, contentLength) {
|
||||
return JSON.stringify({ version: VERSION, spans: normalize(model?.spans, contentLength) });
|
||||
const canonical = canonicalSpans(model, contentLength);
|
||||
const spans = canonical
|
||||
? canonical.map(span => ({ start: span.start, end: span.end, owner: span.owner }))
|
||||
: normalize(model?.spans, contentLength);
|
||||
return JSON.stringify({ version: VERSION, spans });
|
||||
}
|
||||
|
||||
function canonicalSpans(model, length) {
|
||||
const spans = model?.spans;
|
||||
if (!Array.isArray(spans)) return null;
|
||||
let previousEnd = 0, previousOwner = "";
|
||||
for (const span of spans) {
|
||||
if (!span || typeof span.owner !== "string" || !span.owner
|
||||
|| !Number.isFinite(span.start) || !Number.isFinite(span.end)
|
||||
|| span.start < previousEnd || span.end <= span.start || span.end > length
|
||||
|| (span.start === previousEnd && span.owner === previousOwner)) return null;
|
||||
previousEnd = span.end;
|
||||
previousOwner = span.owner;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
function appendCanonicalSpan(result, start, end, owner) {
|
||||
if (!owner || end <= start) return;
|
||||
const previous = result.at(-1);
|
||||
if (previous?.owner === owner && start <= previous.end) {
|
||||
previous.end = Math.max(previous.end, end);
|
||||
} else result.push({ start, end, owner });
|
||||
}
|
||||
|
||||
function fastAuthorshipEdit(spans, prefix, oldSuffix, insertedLength, delta, owner) {
|
||||
const result = [];
|
||||
let inserted = false;
|
||||
const insert = () => {
|
||||
if (!inserted) {
|
||||
appendCanonicalSpan(result, prefix, prefix + insertedLength, owner ? String(owner) : "");
|
||||
inserted = true;
|
||||
}
|
||||
};
|
||||
for (const span of spans) {
|
||||
if (span.end <= prefix) {
|
||||
appendCanonicalSpan(result, span.start, span.end, span.owner);
|
||||
} else if (span.start >= oldSuffix) {
|
||||
insert();
|
||||
appendCanonicalSpan(result, span.start + delta, span.end + delta, span.owner);
|
||||
} else {
|
||||
if (span.start < prefix) appendCanonicalSpan(result, span.start, prefix, span.owner);
|
||||
insert();
|
||||
if (span.end > oldSuffix) {
|
||||
appendCanonicalSpan(result, prefix + insertedLength, span.end + delta, span.owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
insert();
|
||||
return { version: VERSION, spans: result };
|
||||
}
|
||||
|
||||
export function applyAuthorshipEdit(model, previousText, nextText, owner) {
|
||||
@@ -79,6 +133,8 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) {
|
||||
const removedLength = oldSuffix - prefix;
|
||||
const insertedLength = newSuffix - prefix;
|
||||
const delta = insertedLength - removedLength;
|
||||
const canonical = canonicalSpans(model, previousText.length);
|
||||
if (canonical) return fastAuthorshipEdit(canonical, prefix, oldSuffix, insertedLength, delta, owner);
|
||||
const updated = [];
|
||||
|
||||
for (const source of normalize(model?.spans, previousText.length)) {
|
||||
@@ -99,13 +155,22 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) {
|
||||
}
|
||||
|
||||
export function replaceAuthorshipOwner(model, matcher, replacement, contentLength) {
|
||||
const canonical = canonicalSpans(model, contentLength);
|
||||
if (canonical) {
|
||||
const spans = [];
|
||||
for (const span of canonical) {
|
||||
const owner = matcher(span.owner) ? String(replacement || "") : span.owner;
|
||||
appendCanonicalSpan(spans, span.start, span.end, owner);
|
||||
}
|
||||
return { version: VERSION, spans };
|
||||
}
|
||||
|
||||
return {
|
||||
version: VERSION,
|
||||
spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function authorshipOwners(model) {
|
||||
const owners = new Map();
|
||||
for (const span of model?.spans || []) {
|
||||
@@ -120,13 +185,11 @@ export function lineAuthors(content, model) {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
||||
|
||||
// Parsed authorship spans are sorted and non-overlapping. Retain the old
|
||||
// result for non-canonical callers instead of silently changing semantics.
|
||||
let canonical = true;
|
||||
for (let i = 0, previousEnd = 0; i < spans.length; i++) {
|
||||
const span = spans[i];
|
||||
if (!Number.isFinite(span?.start) || !Number.isFinite(span?.end)
|
||||
|| span.start < previousEnd || span.end < span.start) {
|
||||
|| span.start < previousEnd || span.end < span.start) {
|
||||
canonical = false;
|
||||
break;
|
||||
}
|
||||
@@ -187,8 +250,6 @@ export function syncAuthorshipLayer(layer, editor) {
|
||||
const canvas = layer.querySelector(".authorship-canvas");
|
||||
if (!canvas) return;
|
||||
canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`;
|
||||
// The canvas already mirrors the textarea scroll through its transform.
|
||||
// Keeping the overlay element itself at zero prevents a double offset.
|
||||
layer.scrollTop = 0;
|
||||
layer.scrollLeft = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
const order = ["assets", "language", "libraries", "access", "files"];
|
||||
const resourceParts = new Set();
|
||||
const locale = String(document.documentElement.lang || "en").toLowerCase().startsWith("pl") ? "pl" : "en";
|
||||
const strings = {
|
||||
en: {
|
||||
assets: "Preparing the interface", language: "Setting up language",
|
||||
libraries: "Starting the editor", access: "Checking access", files: "Opening the note",
|
||||
complete: "Ready", error: "The note could not be opened", retry: "Reload page",
|
||||
errorDetails: "Check your connection and try again.", progress: "{done} of {total} stages completed",
|
||||
timeout: "Still waiting for the note. Check the connection or reload."
|
||||
},
|
||||
pl: {
|
||||
assets: "Przygotowanie interfejsu", language: "Ustawianie j\u0119zyka",
|
||||
libraries: "Uruchamianie edytora", access: "Sprawdzanie dost\u0119pu", files: "Otwieranie notatki",
|
||||
complete: "Gotowe", error: "Nie uda\u0142o si\u0119 otworzy\u0107 notatki", retry: "Za\u0142aduj ponownie",
|
||||
errorDetails: "Sprawd\u017a po\u0142\u0105czenie i spr\u00f3buj ponownie.", progress: "Uko\u0144czono {done} z {total} etap\u00f3w",
|
||||
timeout: "Nadal oczekujemy na notatk\u0119. Sprawd\u017a po\u0142\u0105czenie lub od\u015bwie\u017c stron\u0119."
|
||||
}
|
||||
}[locale];
|
||||
const completed = new Set();
|
||||
const loadedStyleElements = new WeakSet();
|
||||
let loadedStyleCount = 0;
|
||||
let readyRequested = false;
|
||||
let dismissed = false;
|
||||
let failure = "";
|
||||
const element = id => document.getElementById(id);
|
||||
|
||||
function update() {
|
||||
const overlay = element("app-boot-overlay");
|
||||
if (!overlay || dismissed) return;
|
||||
const count = completed.size;
|
||||
const progress = Math.round((count / order.length) * 100);
|
||||
const next = order.find(stage => !completed.has(stage));
|
||||
const heading = element("app-boot-heading");
|
||||
if (heading) heading.textContent = locale === "pl" ? "Ładowanie…" : "Loading…";
|
||||
const stageText = element("app-boot-stage");
|
||||
const stageCount = element("app-boot-count");
|
||||
const bar = element("app-boot-progress");
|
||||
const fill = element("app-boot-progress-fill");
|
||||
const retry = element("app-boot-retry");
|
||||
if (stageText) stageText.textContent = failure ? strings.error : (next ? strings[next] : strings.complete);
|
||||
if (stageCount) stageCount.textContent = failure || strings.progress.replace("{done}", count).replace("{total}", order.length);
|
||||
if (bar) bar.setAttribute("aria-valuenow", String(progress));
|
||||
if (fill) fill.style.width = `${progress}%`;
|
||||
if (retry) { retry.hidden = !failure; retry.textContent = strings.retry; }
|
||||
overlay.classList.toggle("has-error", Boolean(failure));
|
||||
for (const stage of order) {
|
||||
const node = element(`app-boot-step-${stage}`);
|
||||
if (!node) continue;
|
||||
const isDone = completed.has(stage);
|
||||
node.dataset.status = isDone ? "done" : (stage === next && !failure ? "active" : "waiting");
|
||||
node.querySelector("span")?.replaceChildren(document.createTextNode(strings[stage]));
|
||||
}
|
||||
if (readyRequested && count === order.length && !failure) dismiss();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
const overlay = element("app-boot-overlay");
|
||||
if (!overlay) return;
|
||||
overlay.classList.add("is-ready");
|
||||
const remove = () => overlay.remove();
|
||||
overlay.addEventListener("transitionend", remove, { once: true });
|
||||
setTimeout(remove, 300);
|
||||
}
|
||||
|
||||
function complete(stage) {
|
||||
if (dismissed || failure) return;
|
||||
if (stage === "css" || stage === "js") {
|
||||
resourceParts.add(stage);
|
||||
if (resourceParts.has("css") && resourceParts.has("js")) completed.add("assets");
|
||||
} else if (order.includes(stage)) {
|
||||
completed.add(stage);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
if (dismissed || failure) return;
|
||||
const safeMessage = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "";
|
||||
failure = safeMessage && safeMessage.length < 180 ? `${strings.error}: ${safeMessage}` : strings.errorDetails;
|
||||
update();
|
||||
}
|
||||
|
||||
document.addEventListener("load", event => {
|
||||
if (!(event.target instanceof HTMLLinkElement) || !event.target.matches("link[data-rustpad-shell-css]")) return;
|
||||
if (loadedStyleElements.has(event.target)) return;
|
||||
loadedStyleElements.add(event.target);
|
||||
loadedStyleCount += 1;
|
||||
const expected = document.querySelectorAll("link[data-rustpad-shell-css]").length;
|
||||
if (expected > 0 && loadedStyleCount >= expected) complete("css");
|
||||
}, true);
|
||||
document.addEventListener("error", event => {
|
||||
if (event.target instanceof HTMLLinkElement && event.target.matches("link[data-rustpad-shell-css]")) {
|
||||
fail(strings.errorDetails);
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
element("app-boot-retry")?.addEventListener("click", () => location.reload());
|
||||
update();
|
||||
}, { once: true });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!dismissed) fail(strings.timeout);
|
||||
}, 45000);
|
||||
|
||||
window.__RUSTPAD_LOADING__ = Object.freeze({
|
||||
complete,
|
||||
skip: complete,
|
||||
ready() { readyRequested = true; update(); if (dismissed) clearTimeout(timeout); },
|
||||
fail,
|
||||
stageDone(stage) {
|
||||
return stage === "css" || stage === "js" ? resourceParts.has(stage) : completed.has(stage);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -105,10 +105,15 @@ export class CollaborationSession {
|
||||
const replacements = mergeOwnerReplacements([], ownerReplacements);
|
||||
if (!hasTextEffect(operation) && !replacements.length) return null;
|
||||
|
||||
const localLength = this.localDocument().content.length;
|
||||
const localLength = this.buffer
|
||||
? operationTargetLength(this.buffer.operation)
|
||||
: this.outstanding
|
||||
? operationTargetLength(this.outstanding.operation)
|
||||
: this.serverContent.length;
|
||||
if (operationBaseLength(operation) !== localLength) {
|
||||
throw new Error("Local operation base length does not match the collaborative document");
|
||||
}
|
||||
const targetLength = operationTargetLength(operation);
|
||||
|
||||
if (!this.buffer) {
|
||||
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
|
||||
@@ -117,7 +122,7 @@ export class CollaborationSession {
|
||||
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
|
||||
}
|
||||
|
||||
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
|
||||
if (operationTargetLength(this.buffer.operation) !== targetLength) {
|
||||
throw new Error("Buffered operation target length does not match the collaborative document");
|
||||
}
|
||||
return this.buffer;
|
||||
|
||||
+20
-3
@@ -100,6 +100,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
||||
await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken() || await handleShareInvitationToken();
|
||||
|
||||
const identityDialog = document.querySelector("#identity-dialog");
|
||||
const accountSlot = document.querySelector("#footer-account-slot");
|
||||
const guestAccount = document.querySelector("#footer-account-guest");
|
||||
const userAccount = document.querySelector("#footer-account-user");
|
||||
const userLabel = document.querySelector("#footer-user-label");
|
||||
@@ -121,6 +122,7 @@ const aboutDialog = document.querySelector("#about-dialog");
|
||||
|
||||
const profileForm = document.querySelector("#profile-form");
|
||||
let currentSession = null;
|
||||
let accountStateRevision = 0;
|
||||
|
||||
function authHeaders() { return {}; }
|
||||
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
|
||||
@@ -440,6 +442,7 @@ resourcesPagination?.addEventListener("click", event => { const button = event.t
|
||||
|
||||
|
||||
function renderAccount(session) {
|
||||
accountStateRevision += 1;
|
||||
currentSession = session;
|
||||
guestAccount.hidden = Boolean(session);
|
||||
userAccount.hidden = !session;
|
||||
@@ -473,6 +476,13 @@ function renderAccount(session) {
|
||||
userLabelSecondary.hidden = true;
|
||||
}
|
||||
registerLink.hidden = !registrationEnabled;
|
||||
|
||||
if (accountSlot) {
|
||||
accountSlot.dataset.state = "ready";
|
||||
accountSlot.setAttribute("aria-busy", "false");
|
||||
const retry = document.querySelector("#footer-account-retry");
|
||||
if (retry) retry.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#footer-about")?.addEventListener("click", () => aboutDialog?.showModal());
|
||||
@@ -523,7 +533,7 @@ if (identityDialog) {
|
||||
profileDialog.showModal();
|
||||
});
|
||||
document.addEventListener("rustpad:languagechange", () => {
|
||||
renderAccount(currentSession);
|
||||
if (accountSlot?.dataset.state === "ready") renderAccount(currentSession);
|
||||
if (!profileDialog?.open) return;
|
||||
populateLanguageSelect(document.querySelector("#profile-language"), getLanguage());
|
||||
});
|
||||
@@ -601,6 +611,13 @@ if (identityDialog) {
|
||||
});
|
||||
|
||||
window.addEventListener("rustpad:session-expired", () => renderAccount(null));
|
||||
renderAccount(null);
|
||||
validateCurrentSession().then(renderAccount);
|
||||
const lookupRevision = accountStateRevision;
|
||||
validateCurrentSession()
|
||||
.then(session => {
|
||||
if (accountStateRevision === lookupRevision) renderAccount(session);
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn("Could not validate the saved session", error);
|
||||
if (accountStateRevision === lookupRevision) renderAccount(null);
|
||||
});
|
||||
}
|
||||
+109
-34
@@ -16,6 +16,7 @@ import { identityOperation, operationFromEdit } from "@rustpad/collaboration";
|
||||
import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||
import { buildLineStarts, createVirtualEditorLayers, VIRTUAL_LINE_THRESHOLD } from "@rustpad/virtual-editor-layers";
|
||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||
import { createMentionAutocomplete } from "@rustpad/mention-autocomplete";
|
||||
import { createMentionNavigator } from "@rustpad/mention-navigation";
|
||||
@@ -52,6 +53,18 @@ export function startNoteEditor(adapter) {
|
||||
? crypto.randomUUID().replaceAll("-", "")
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
const collaboration = new CollaborationSession(collaborationClientId);
|
||||
const bootLoading = window.__RUSTPAD_LOADING__;
|
||||
let bootDocumentReady = false;
|
||||
let bootFilesReady = false;
|
||||
let bootFinishing = false;
|
||||
|
||||
function releaseBootForAccessDialog() {
|
||||
if (!bootLoading || bootFinishing) return;
|
||||
bootFinishing = true;
|
||||
bootLoading.complete("access");
|
||||
bootLoading.skip("files"); // Identity/password dialog is ready to use.
|
||||
bootLoading.ready();
|
||||
}
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", lastRevealedHeadingHash = "", flushRequested = false, accountSession = null;
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||
let pendingPreviewViewport = null;
|
||||
@@ -549,6 +562,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function syncEditorLayers() {
|
||||
virtualLayers.sync();
|
||||
gutter.scrollTop = editor.scrollTop;
|
||||
syncAuthorshipLayer(authorshipLayer, editor);
|
||||
syncOwnerLabels();
|
||||
@@ -557,19 +571,34 @@ export function startNoteEditor(adapter) {
|
||||
let gutterLineCount = 0;
|
||||
let gutterLineHeight = 0;
|
||||
let highlightedGutterLine = null;
|
||||
let cachedLineText = null, cachedLineStarts = [0];
|
||||
const virtualLayers = createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName });
|
||||
function updateLinkedLineHighlight() {
|
||||
const lineCount = cachedLineText === editor.value
|
||||
? cachedLineStarts.length : Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const linkedLine = lineFromHash(location.hash, lineCount);
|
||||
if (virtualLayers.active) virtualLayers.highlight(linkedLine);
|
||||
else {
|
||||
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
|
||||
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
|
||||
}
|
||||
highlightedGutterLine = linkedLine;
|
||||
}
|
||||
function renderGutter() {
|
||||
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
||||
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
|
||||
document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked);
|
||||
gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked));
|
||||
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const lines = Array.from({ length: lineCount });
|
||||
const content = editor.value;
|
||||
cachedLineText = content;
|
||||
cachedLineStarts = buildLineStarts(content);
|
||||
const lineCount = cachedLineStarts.length;
|
||||
const owners = authorshipOwners(authorship);
|
||||
renderedAuthorshipOwners = owners;
|
||||
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
|
||||
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
|
||||
const full = authorshipMode === "full";
|
||||
const authorsByLine = showAuthorship && full ? lineAuthors(content, authorship) : [];
|
||||
authorshipLayer.hidden = !showAuthorship;
|
||||
ownerLabels.hidden = !full || !showAuthorship;
|
||||
renderParticipantBadges(authorshipColorsEnabled ? owners : []);
|
||||
@@ -577,8 +606,26 @@ export function startNoteEditor(adapter) {
|
||||
editorWorkspace.dataset.authorshipMode = authorshipMode;
|
||||
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, fontSize = parseFloat(style.fontSize) || 14, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
|
||||
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
|
||||
// Physical rows represent sequential line numbers, not specific text lines.
|
||||
// Reuse them across character edits and add/remove only the changed tail.
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
||||
if (lineCount >= VIRTUAL_LINE_THRESHOLD) {
|
||||
if (!virtualLayers.active) gutterLineCount = 0;
|
||||
virtualLayers.update({
|
||||
content, lineStarts: cachedLineStarts, model: authorship, authorsByLine,
|
||||
full, visible: showAuthorship, link: lineFromHash(location.hash, lineCount),
|
||||
computedStyle: style,
|
||||
});
|
||||
updateLinkedLineHighlight();
|
||||
syncEditorLayers();
|
||||
return;
|
||||
}
|
||||
if (virtualLayers.active) {
|
||||
virtualLayers.destroy();
|
||||
gutterLineCount = 0;
|
||||
gutterLineHeight = 0;
|
||||
highlightedGutterLine = null;
|
||||
}
|
||||
const lines = Array.from({ length: lineCount });
|
||||
if (gutterLineHeight !== lineHeight) {
|
||||
for (const row of gutter.children) row.style.height = `${lineHeight}px`;
|
||||
gutterLineHeight = lineHeight;
|
||||
@@ -593,8 +640,6 @@ export function startNoteEditor(adapter) {
|
||||
for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove();
|
||||
}
|
||||
gutterLineCount = lineCount;
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
||||
if (full) {
|
||||
let previousAuthorSignature = null;
|
||||
ownerLabels.innerHTML = `<div class="owner-labels-canvas">${lines.map((_, i) => {
|
||||
@@ -610,12 +655,7 @@ export function startNoteEditor(adapter) {
|
||||
} else ownerLabels.replaceChildren();
|
||||
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
|
||||
else authorshipLayer.replaceChildren();
|
||||
const linkedLine = lineFromHash(location.hash, lineCount);
|
||||
if (highlightedGutterLine !== linkedLine) {
|
||||
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
|
||||
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
|
||||
highlightedGutterLine = linkedLine;
|
||||
}
|
||||
updateLinkedLineHighlight();
|
||||
syncEditorLayers();
|
||||
}
|
||||
|
||||
@@ -644,10 +684,12 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
function revealLinkedLine() {
|
||||
if (!location.hash || location.hash === lastRevealedLineHash) return;
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const hasCurrentIndex = cachedLineText === editor.value;
|
||||
const lineCount = hasCurrentIndex ? cachedLineStarts.length
|
||||
: Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const line = lineFromHash(location.hash, lineCount);
|
||||
if (!line) return;
|
||||
const offset = lineStartOffset(editor.value, line);
|
||||
const offset = hasCurrentIndex ? cachedLineStarts[line - 1] : lineStartOffset(editor.value, line);
|
||||
if (offset == null) return;
|
||||
|
||||
const style = getComputedStyle(editor);
|
||||
@@ -1135,8 +1177,6 @@ export function startNoteEditor(adapter) {
|
||||
let cachedPreviewHeight = -1;
|
||||
let cachedPreviewWidth = -1;
|
||||
function invalidatePreviewPositions() { previewPositionCache = null; }
|
||||
// The (rect.top - container.top + scrollTop) coordinates are invariant to
|
||||
// scrolling; only rebuild them when layout/content actually changes.
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
new ResizeObserver(invalidatePreviewPositions).observe(preview);
|
||||
}
|
||||
@@ -1149,10 +1189,11 @@ export function startNoteEditor(adapter) {
|
||||
preview.addEventListener("toggle", invalidatePreviewPositions, true);
|
||||
preview.addEventListener("load", invalidatePreviewPositions, true);
|
||||
window.addEventListener("resize", invalidatePreviewPositions);
|
||||
window.addEventListener("resize", () => {
|
||||
if (virtualLayers.active) virtualLayers.sync();
|
||||
});
|
||||
document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions);
|
||||
function previewLinePositions() {
|
||||
// Height/width checks catch dynamic embeds that change geometry without
|
||||
// a DOM mutation (e.g. a media player expanding after initialization).
|
||||
const height = preview.scrollHeight, width = preview.clientWidth;
|
||||
if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache;
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
@@ -1312,11 +1353,9 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
let previewStale = true;
|
||||
let forcePreviewRender = false;
|
||||
let initialPreviewEnhancements = Promise.resolve();
|
||||
function renderNow() {
|
||||
const previewViewport = pendingPreviewViewport;
|
||||
// In editor-only view this DOM is display:none. Building a full Markdown
|
||||
// tree (and highlighting / Mermaid) on each remote edit is wasted work.
|
||||
// Keep the exceptional consumers working: heading deep-links and PDF export.
|
||||
const headingRevealPending = uiState.mode === "markdown" && location.hash
|
||||
&& !/^#L[1-9]\d*$/i.test(location.hash)
|
||||
&& lastRevealedHeadingHash !== location.hash;
|
||||
@@ -1337,9 +1376,7 @@ export function startNoteEditor(adapter) {
|
||||
scheduleAliasFileRefresh(editor.value);
|
||||
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
|
||||
if (activeView() !== "edit" || previewNeededForPdf) {
|
||||
renderMermaid();
|
||||
renderCodeHighlight();
|
||||
renderMediaPlayers();
|
||||
initialPreviewEnhancements = Promise.allSettled([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]);
|
||||
}
|
||||
} else {
|
||||
preview.classList.add("preview--raw");
|
||||
@@ -1360,16 +1397,35 @@ export function startNoteEditor(adapter) {
|
||||
revealHeadingAnchor();
|
||||
});
|
||||
}
|
||||
async function finishInitialEditorBoot() {
|
||||
if (!bootLoading || bootFinishing || !bootDocumentReady || !bootFilesReady) return;
|
||||
bootFinishing = true;
|
||||
try {
|
||||
if (deferredRenderTimer !== null) {
|
||||
clearTimeout(deferredRenderTimer);
|
||||
deferredRenderTimer = null;
|
||||
firstDeferredRenderAt = 0;
|
||||
}
|
||||
renderNow();
|
||||
await initialPreviewEnhancements;
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
bootLoading.complete("files");
|
||||
bootLoading.ready();
|
||||
} catch (error) {
|
||||
bootLoading.fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
const render = createRenderQueue(renderNow);
|
||||
// Collaborative edits can arrive many times per second. Keep typing and
|
||||
// WebSocket processing responsive by batching expensive Markdown/DOM work.
|
||||
let deferredRenderTimer = null;
|
||||
let firstDeferredRenderAt = 0;
|
||||
let lastLocalInputAt = -Infinity;
|
||||
function scheduleDocumentRender() {
|
||||
const now = performance.now();
|
||||
if (deferredRenderTimer === null) firstDeferredRenderAt = now;
|
||||
else clearTimeout(deferredRenderTimer);
|
||||
const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
|
||||
const activeTyping = now - lastLocalInputAt < 450;
|
||||
const delay = activeTyping ? 300 : Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
|
||||
deferredRenderTimer = setTimeout(() => {
|
||||
deferredRenderTimer = null;
|
||||
firstDeferredRenderAt = 0;
|
||||
@@ -1607,7 +1663,7 @@ export function startNoteEditor(adapter) {
|
||||
throw new Error("Server snapshot does not match the collaborative operation");
|
||||
}
|
||||
if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map);
|
||||
if (renderView) applyCollaborativeView({ resetHistory: result.remote });
|
||||
if (renderView && !result.ownAck) applyCollaborativeView({ resetHistory: result.remote });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1665,8 +1721,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { loadFiles } = bindNoteFiles({
|
||||
const { loadFiles, initialFilesReady } = bindNoteFiles({
|
||||
editor, toast, getAccessToken: () => accessToken,
|
||||
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
|
||||
canDelete: () => Boolean(info?.can_delete_files),
|
||||
@@ -1753,6 +1808,8 @@ export function startNoteEditor(adapter) {
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
flushRequested = false;
|
||||
saveState.textContent = "Read only";
|
||||
bootDocumentReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
return;
|
||||
}
|
||||
handleCollaborativeAuthentication(message);
|
||||
@@ -1760,6 +1817,8 @@ export function startNoteEditor(adapter) {
|
||||
else if (collaboration.buffer) flushCollaborativeUpdate();
|
||||
else saveState.textContent = "Changes are saved automatically";
|
||||
editor.focus();
|
||||
bootDocumentReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
},
|
||||
onDocument: handleCollaborativeDocument,
|
||||
onResync: message => recoverCollaborativeSnapshot(message, "resync"),
|
||||
@@ -1780,6 +1839,7 @@ export function startNoteEditor(adapter) {
|
||||
toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
releaseBootForAccessDialog();
|
||||
try {
|
||||
await loadNoteInfo();
|
||||
adapter.configureView?.(info);
|
||||
@@ -1803,6 +1863,7 @@ export function startNoteEditor(adapter) {
|
||||
if (saveState.textContent === "Saving…") saveState.textContent = "Save failed";
|
||||
if (/nickname|session|account/i.test(message)) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
if (info?.protected && isResourceAccessError(message)) {
|
||||
@@ -1811,6 +1872,7 @@ export function startNoteEditor(adapter) {
|
||||
document.querySelector("#password-error").textContent = friendly;
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
toast.danger(friendly, { title: "Editor connection error" });
|
||||
@@ -1842,23 +1904,35 @@ export function startNoteEditor(adapter) {
|
||||
nickname = accountSession?.nickname || getNickname();
|
||||
if (!nickname) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key);
|
||||
await loadNoteInfo();
|
||||
bootLoading?.complete("access");
|
||||
document.title = adapter.title(info);
|
||||
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
|
||||
adapter.configureView?.(info);
|
||||
applyUi({ write: true, replace: true });
|
||||
updateCurrentUser();
|
||||
if (info.protected && info.access_level === "none") passwordDialog.showModal();
|
||||
else { loadFiles(); connect(); }
|
||||
if (info.protected && info.access_level === "none") {
|
||||
passwordDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
} else {
|
||||
void loadFiles();
|
||||
void initialFilesReady.then(() => {
|
||||
bootFilesReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
});
|
||||
connect();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
await showSystemNotFound();
|
||||
return;
|
||||
}
|
||||
bootLoading?.fail(e);
|
||||
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
|
||||
}
|
||||
}
|
||||
@@ -2159,7 +2233,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; updateLinkedLineHighlight(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
preview.addEventListener("click", event => {
|
||||
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
|
||||
if (!link) return;
|
||||
@@ -2626,6 +2700,7 @@ export function startNoteEditor(adapter) {
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
|
||||
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
|
||||
previousContent = nextContent;
|
||||
if (!applyingRemote && !applyingHistory) lastLocalInputAt = performance.now();
|
||||
scheduleDocumentRender();
|
||||
if (applyingRemote || applyingHistory) return;
|
||||
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
|
||||
|
||||
@@ -182,6 +182,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
||||
let currentFiles = [];
|
||||
let filesLoaded = false;
|
||||
let loadSequence = 0;
|
||||
let finishInitialFiles;
|
||||
const initialFilesReady = new Promise(resolve => { finishInitialFiles = resolve; });
|
||||
|
||||
function summaryText(files, shown = files.length) {
|
||||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||
@@ -279,9 +281,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
||||
footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
|
||||
renderFiles();
|
||||
onFilesChanged(currentFiles);
|
||||
finishInitialFiles();
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
renderLoadError(error);
|
||||
finishInitialFiles();
|
||||
if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") });
|
||||
}
|
||||
}
|
||||
@@ -500,5 +504,5 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
||||
}
|
||||
});
|
||||
|
||||
return { loadFiles };
|
||||
return { loadFiles, initialFilesReady };
|
||||
}
|
||||
+9
-1
@@ -128,7 +128,7 @@ function settleInitialPublicAnchor(hash) {
|
||||
window.addEventListener("load", scheduleCorrection, { once: true });
|
||||
cleanups.push(() => window.removeEventListener("load", scheduleCorrection));
|
||||
}
|
||||
document.fonts?.ready?.then(scheduleCorrection).catch(() => {});
|
||||
document.fonts?.ready?.then(scheduleCorrection).catch(() => { });
|
||||
|
||||
if (typeof ResizeObserver === "function") {
|
||||
observer = new ResizeObserver(scheduleCorrection);
|
||||
@@ -183,6 +183,7 @@ function restoreFullWidth() {
|
||||
async function initialize() {
|
||||
try {
|
||||
const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() });
|
||||
window.__RUSTPAD_LOADING__?.complete("access");
|
||||
if (passwordDialog.open) passwordDialog.close();
|
||||
passwordError.textContent = "";
|
||||
publicTitle.textContent = page.title;
|
||||
@@ -197,12 +198,17 @@ async function initialize() {
|
||||
installPublicPermalinks();
|
||||
alignPreviewLineNumbers(content);
|
||||
settleInitialPublicAnchor(location.hash);
|
||||
window.__RUSTPAD_LOADING__?.complete("files");
|
||||
window.__RUSTPAD_LOADING__?.ready();
|
||||
} catch (error) {
|
||||
window.__RUSTPAD_LOADING__?.complete("access");
|
||||
publicAnchorSettleCleanup?.();
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
passwordError.textContent = error.status === 403 ? t("public.passwordAuthorized", {}, "Sign in with an authorized account or enter the resource password.") : t("public.passwordCorrect", {}, "Enter the correct password.");
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
passwordInput.focus();
|
||||
window.__RUSTPAD_LOADING__?.skip("files");
|
||||
window.__RUSTPAD_LOADING__?.ready();
|
||||
return;
|
||||
}
|
||||
content.replaceChildren();
|
||||
@@ -211,6 +217,8 @@ async function initialize() {
|
||||
message.textContent = String(error.message);
|
||||
content.append(message);
|
||||
toast.danger(error.message, { title: "Could not load published page" });
|
||||
window.__RUSTPAD_LOADING__?.skip("files");
|
||||
window.__RUSTPAD_LOADING__?.ready();
|
||||
}
|
||||
}
|
||||
content.addEventListener("click", async event => {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Virtualize only decorative editor layers. The textarea, OT document,
|
||||
* preview renderer, selection, history, and line link URLs are unchanged.
|
||||
*/
|
||||
|
||||
export const VIRTUAL_LINE_THRESHOLD = 1500;
|
||||
const OVERSCAN = 8;
|
||||
|
||||
export function buildLineStarts(text) {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
export function visibleLineWindow(scrollTop, viewportHeight, paddingTop, lineHeight, total, overscan = OVERSCAN) {
|
||||
const height = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0;
|
||||
const top = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0;
|
||||
const rowHeight = Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
|
||||
const offset = Number.isFinite(paddingTop) ? paddingTop : 0;
|
||||
const from = Math.max(0, Math.floor((top - offset) / rowHeight));
|
||||
const to = Math.max(from + 1, Math.ceil((top + height - offset) / rowHeight));
|
||||
return {
|
||||
first: Math.max(0, Math.min(total, from - overscan)),
|
||||
end: Math.max(0, Math.min(total, to + overscan)),
|
||||
};
|
||||
}
|
||||
|
||||
export function labelGroupLines(authorsByLine, ownerName) {
|
||||
const groups = [];
|
||||
let previousSignature = null;
|
||||
for (let i = 0; i < authorsByLine.length; i++) {
|
||||
const authors = authorsByLine[i] || [];
|
||||
if (!authors.length) continue;
|
||||
const signature = authors.map(ownerName).sort((a, b) => a.localeCompare(b)).join('\u0000');
|
||||
if (signature === previousSignature) continue;
|
||||
previousSignature = signature;
|
||||
groups.push({ line: i, authors });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function lowerBound(items, value, selector = item => item) {
|
||||
let left = 0, right = items.length;
|
||||
while (left < right) {
|
||||
const mid = (left + right) >>> 1;
|
||||
if (selector(items[mid]) < value) left = mid + 1;
|
||||
else right = mid;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
export function createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName }) {
|
||||
let active = false;
|
||||
let starts = [0], text = '', spans = [], groups = [];
|
||||
let showAuthorship = false, showLabels = false, linkedLine = null;
|
||||
let lineHeight = 20, paddingTop = 0, paddingLeft = 0;
|
||||
let spacer, gutterWindow, labelCanvas, colorCanvas, colorViewport;
|
||||
let renderedFirst = -1, renderedEnd = -1;
|
||||
let lineNodes = new Map(), labelNodes = new Map();
|
||||
|
||||
function initialize() {
|
||||
if (active) return;
|
||||
active = true;
|
||||
spacer = document.createElement('div');
|
||||
spacer.className = 'line-gutter-virtual-spacer';
|
||||
spacer.style.position = 'relative';
|
||||
spacer.style.width = '100%';
|
||||
gutterWindow = document.createElement('div');
|
||||
gutterWindow.className = 'line-gutter-virtual-window';
|
||||
gutterWindow.style.position = 'absolute';
|
||||
gutterWindow.style.left = '0';
|
||||
gutterWindow.style.right = '0';
|
||||
spacer.append(gutterWindow);
|
||||
gutter.replaceChildren(spacer);
|
||||
labelCanvas = document.createElement('div');
|
||||
labelCanvas.className = 'owner-labels-canvas';
|
||||
ownerLabels.replaceChildren(labelCanvas);
|
||||
colorCanvas = document.createElement('div');
|
||||
colorCanvas.className = 'authorship-canvas';
|
||||
colorViewport = document.createElement('div');
|
||||
colorViewport.className = 'authorship-viewport';
|
||||
colorViewport.style.position = 'absolute';
|
||||
colorViewport.style.whiteSpace = 'pre';
|
||||
colorCanvas.append(colorViewport);
|
||||
authorshipLayer.replaceChildren(colorCanvas);
|
||||
}
|
||||
|
||||
function createLineNode() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'line-number-row';
|
||||
const button = document.createElement('button');
|
||||
button.className = 'line-number-button';
|
||||
button.type = 'button';
|
||||
button.tabIndex = -1;
|
||||
row.append(button);
|
||||
return row;
|
||||
}
|
||||
|
||||
function updateGutter(first, end, force) {
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
const available = [...lineNodes.values()].filter(node => {
|
||||
const line = Number(node.firstElementChild.dataset.line);
|
||||
return line < first + 1 || line > end;
|
||||
});
|
||||
const next = new Map(), fragment = document.createDocumentFragment();
|
||||
for (let i = first; i < end; i++) {
|
||||
const line = i + 1;
|
||||
const row = lineNodes.get(line) || available.pop() || createLineNode();
|
||||
row.style.height = `${lineHeight}px`;
|
||||
const button = row.firstElementChild;
|
||||
if (button.dataset.line !== String(line)) {
|
||||
button.dataset.line = String(line);
|
||||
button.textContent = String(line);
|
||||
button.setAttribute('aria-label', `Copy link to line ${line}`);
|
||||
button.classList.remove('is-copied');
|
||||
}
|
||||
button.classList.toggle('is-linked', linkedLine === line);
|
||||
next.set(line, row);
|
||||
fragment.append(row);
|
||||
}
|
||||
gutterWindow.style.top = `${first * lineHeight}px`;
|
||||
gutterWindow.replaceChildren(fragment);
|
||||
lineNodes = next;
|
||||
}
|
||||
|
||||
function createLabelNode(group) {
|
||||
const node = document.createElement('span');
|
||||
node.className = 'owner-label-group';
|
||||
for (const owner of group.authors) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'owner-label';
|
||||
badge.style.setProperty('--owner', colorFor(owner));
|
||||
badge.textContent = ownerName(owner);
|
||||
node.append(badge);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function updateLabels(first, end, force) {
|
||||
if (!showLabels) {
|
||||
if (labelNodes.size) labelCanvas.replaceChildren();
|
||||
labelNodes.clear();
|
||||
return;
|
||||
}
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
const next = new Map(), fragment = document.createDocumentFragment();
|
||||
for (let i = lowerBound(groups, first, item => item.line); i < groups.length && groups[i].line < end; i++) {
|
||||
const group = groups[i];
|
||||
const node = labelNodes.get(group.line) || createLabelNode(group);
|
||||
node.style.top = `${paddingTop + group.line * lineHeight + lineHeight / 2}px`;
|
||||
next.set(group.line, node);
|
||||
fragment.append(node);
|
||||
}
|
||||
labelCanvas.replaceChildren(fragment);
|
||||
labelNodes = next;
|
||||
}
|
||||
|
||||
function updateColors(first, end, force) {
|
||||
if (!showAuthorship) {
|
||||
if (colorViewport.hasChildNodes()) colorViewport.replaceChildren();
|
||||
return;
|
||||
}
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
const from = starts[first] ?? text.length;
|
||||
const to = end < starts.length ? starts[end] : text.length;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let cursor = from;
|
||||
let index = lowerBound(spans, from + 1, item => item.end);
|
||||
while (index < spans.length) {
|
||||
const span = spans[index++];
|
||||
if (span.start >= to) break;
|
||||
const start = Math.max(cursor, span.start, from);
|
||||
const stop = Math.min(span.end, to);
|
||||
if (start > cursor) fragment.append(document.createTextNode(text.slice(cursor, start)));
|
||||
if (stop <= start) continue;
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'authorship-fragment';
|
||||
mark.style.setProperty('--owner', colorFor(span.owner));
|
||||
mark.textContent = text.slice(start, stop);
|
||||
mark.title = String(span.owner).split('\u001f', 1)[0];
|
||||
fragment.append(mark);
|
||||
cursor = stop;
|
||||
}
|
||||
if (cursor < to) fragment.append(document.createTextNode(text.slice(cursor, to)));
|
||||
if (end === starts.length && !text.endsWith('\n')) fragment.append(document.createTextNode('\n'));
|
||||
colorViewport.style.top = `${paddingTop + first * lineHeight}px`;
|
||||
colorViewport.style.left = `${paddingLeft}px`;
|
||||
colorViewport.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function updateViewport(force = false) {
|
||||
if (!active) return;
|
||||
const { first, end } = visibleLineWindow(
|
||||
editor.scrollTop, editor.clientHeight, paddingTop, lineHeight, starts.length,
|
||||
);
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
updateGutter(first, end, force);
|
||||
updateLabels(first, end, force);
|
||||
updateColors(first, end, force);
|
||||
renderedFirst = first;
|
||||
renderedEnd = end;
|
||||
}
|
||||
|
||||
return {
|
||||
get active() { return active; },
|
||||
update({ content, lineStarts, model, authorsByLine, full, visible, link, computedStyle }) {
|
||||
initialize();
|
||||
text = content;
|
||||
starts = lineStarts;
|
||||
spans = model?.spans || [];
|
||||
showAuthorship = visible;
|
||||
showLabels = visible && full;
|
||||
linkedLine = link;
|
||||
const style = computedStyle;
|
||||
lineHeight = parseFloat(style.lineHeight) || 29;
|
||||
paddingTop = parseFloat(style.paddingTop) || 24;
|
||||
paddingLeft = parseFloat(style.paddingLeft) || 0;
|
||||
spacer.style.height = `${starts.length * lineHeight}px`;
|
||||
gutterWindow.style.lineHeight = `${lineHeight}px`;
|
||||
colorCanvas.style.paddingTop = style.paddingTop;
|
||||
colorCanvas.style.paddingRight = style.paddingRight;
|
||||
colorCanvas.style.paddingBottom = style.paddingBottom;
|
||||
colorCanvas.style.paddingLeft = style.paddingLeft;
|
||||
colorCanvas.style.fontFamily = style.fontFamily;
|
||||
colorCanvas.style.fontSize = style.fontSize;
|
||||
colorCanvas.style.fontWeight = style.fontWeight;
|
||||
colorCanvas.style.lineHeight = style.lineHeight;
|
||||
colorCanvas.style.letterSpacing = style.letterSpacing;
|
||||
colorCanvas.style.minWidth = `${editor.scrollWidth}px`;
|
||||
colorCanvas.style.minHeight = `${editor.scrollHeight}px`;
|
||||
authorshipLayer.style.left = `${editor.offsetLeft}px`;
|
||||
groups = showLabels ? labelGroupLines(authorsByLine, ownerName) : [];
|
||||
labelNodes = new Map();
|
||||
updateViewport(true);
|
||||
},
|
||||
sync() { updateViewport(); },
|
||||
highlight(line) {
|
||||
linkedLine = line;
|
||||
for (const [number, row] of lineNodes) {
|
||||
row.firstElementChild.classList.toggle('is-linked', number === line);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
gutter.replaceChildren();
|
||||
ownerLabels.replaceChildren();
|
||||
authorshipLayer.replaceChildren();
|
||||
lineNodes = new Map();
|
||||
labelNodes = new Map();
|
||||
renderedFirst = renderedEnd = -1;
|
||||
groups = [];
|
||||
spans = [];
|
||||
text = '';
|
||||
},
|
||||
get range() { return { first: renderedFirst, end: renderedEnd }; },
|
||||
};
|
||||
}
|
||||
+47
-11
@@ -489,7 +489,17 @@ workspaceSettingsForm.addEventListener("submit", async event => {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
|
||||
function updateNoteProtectionExplanation() {
|
||||
const message = document.querySelector("#note-archive-explanation");
|
||||
if (!message) return;
|
||||
message.textContent = info?.archive_deleted_notes
|
||||
? t("workspace.noteProtection.archiveEnabled", {}, "Archiving is enabled: authorized deletion moves the note, its history and files to Archived notes. The workspace owner can restore it.")
|
||||
: t("workspace.noteProtection.archiveDisabled", {}, "Archiving is disabled: authorized deletion permanently removes the note and its files. Enable archiving in Workspace settings if recovery is important.");
|
||||
}
|
||||
document.querySelector("#new-note-button").addEventListener("click", () => {
|
||||
updateNoteProtectionExplanation();
|
||||
document.querySelector("#note-dialog").showModal();
|
||||
});
|
||||
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
|
||||
document.querySelector("#note-form").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
@@ -542,21 +552,47 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async (
|
||||
catch (e) { toast.danger(e.message, { title: "Could not copy workspace link" }); }
|
||||
});
|
||||
async function startAuthorizedWorkspace() {
|
||||
const session = await validateCurrentSession();
|
||||
accountSession = session;
|
||||
nickname = session?.nickname || getNickname();
|
||||
const loading = window.__RUSTPAD_LOADING__;
|
||||
document.querySelector("#workspace-startup-error")?.setAttribute("hidden", "");
|
||||
try {
|
||||
const session = await validateCurrentSession();
|
||||
accountSession = session;
|
||||
nickname = session?.nickname || getNickname();
|
||||
|
||||
if (!nickname) {
|
||||
if (!nickname) {
|
||||
loading?.complete("access");
|
||||
workspaceContent.hidden = true;
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
loading?.skip("files");
|
||||
loading?.ready();
|
||||
return;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
workspaceContent.hidden = false;
|
||||
await init();
|
||||
loading?.complete("access");
|
||||
if (workspaceLockedForPassword) loading?.skip("files");
|
||||
else loading?.complete("files");
|
||||
loading?.ready();
|
||||
} catch (error) {
|
||||
loading?.fail(error);
|
||||
console.error("Workspace initialization failed", error);
|
||||
workspaceContent.hidden = true;
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
return;
|
||||
const panel = document.querySelector("#workspace-startup-error");
|
||||
const message = document.querySelector("#workspace-startup-message");
|
||||
if (message) message.textContent = `${t("workspace.connectionError", {}, "Workspace connection error")}: ${error?.message || "Please retry."}`;
|
||||
if (panel) panel.hidden = false;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
workspaceContent.hidden = false;
|
||||
await init();
|
||||
}
|
||||
|
||||
document.querySelector("#workspace-startup-retry")?.addEventListener("click", async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try { await startAuthorizedWorkspace(); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
bindIdentityDialog({
|
||||
dialog: identityDialog,
|
||||
onIdentity: async (value, session) => {
|
||||
|
||||
+27
-8
@@ -27,6 +27,11 @@
|
||||
</div>
|
||||
<div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
|
||||
</header>
|
||||
<section id="workspace-startup-error" class="workspace-startup-error" role="alert" hidden>
|
||||
<p id="workspace-startup-message"></p>
|
||||
<button id="workspace-startup-retry" class="secondary-button" type="button"
|
||||
data-i18n="common.retry">Retry</button>
|
||||
</section>
|
||||
<main id="workspace-content" class="workspace-page" hidden>
|
||||
<section class="workspace-top">
|
||||
<div>
|
||||
@@ -34,8 +39,10 @@
|
||||
<p>Select a note or create a new one.</p>
|
||||
</div>
|
||||
<div class="workspace-top__actions">
|
||||
<button id="archived-notes-button" class="secondary-button inline-button" type="button" data-i18n="workspace.archive.title" hidden>Archived notes</button>
|
||||
<button id="workspace-settings-button" class="secondary-button inline-button" type="button" aria-expanded="false" data-i18n="workspace.settings.title" hidden>Workspace settings</button>
|
||||
<button id="archived-notes-button" class="secondary-button inline-button" type="button"
|
||||
data-i18n="workspace.archive.title" hidden>Archived notes</button>
|
||||
<button id="workspace-settings-button" class="secondary-button inline-button" type="button"
|
||||
aria-expanded="false" data-i18n="workspace.settings.title" hidden>Workspace settings</button>
|
||||
<button id="new-note-button" class="primary-button inline-button" type="button">New note</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -50,9 +57,12 @@
|
||||
<form id="workspace-settings-form" class="workspace-settings-card" hidden>
|
||||
<div class="workspace-settings-card__copy">
|
||||
<strong data-i18n="workspace.settings.deletedNotes">Deleted notes</strong>
|
||||
<small data-i18n="workspace.settings.copy">Workspace-wide setting. Archived notes disappear from the workspace but remain in the database with their history and files.</small>
|
||||
<small data-i18n="workspace.settings.copy">Workspace-wide setting. Archived notes disappear from the
|
||||
workspace but remain in the database with their history and files.</small>
|
||||
</div>
|
||||
<label class="workspace-archive-toggle"><input id="archive-deleted-notes" type="checkbox"> <span data-i18n="workspace.settings.archiveToggle">Archive deleted notes instead of permanently deleting them</span></label>
|
||||
<label class="workspace-archive-toggle"><input id="archive-deleted-notes" type="checkbox"> <span
|
||||
data-i18n="workspace.settings.archiveToggle">Archive deleted notes instead of permanently deleting
|
||||
them</span></label>
|
||||
<button class="secondary-button" type="submit">Save</button>
|
||||
<small id="workspace-settings-status" class="form-message" role="status"></small>
|
||||
</form>
|
||||
@@ -78,7 +88,8 @@
|
||||
<div class="archived-notes-panel__header">
|
||||
<div>
|
||||
<h2 data-i18n="workspace.archive.title">Archived notes</h2>
|
||||
<p class="dialog-copy" data-i18n="workspace.archive.copy">Restore notes that were archived instead of permanently deleted.</p>
|
||||
<p class="dialog-copy" data-i18n="workspace.archive.copy">Restore notes that were archived instead
|
||||
of permanently deleted.</p>
|
||||
</div>
|
||||
<button id="close-archived-notes" class="icon-button" type="button" aria-label="Close dialog">×</button>
|
||||
</div>
|
||||
@@ -127,14 +138,22 @@
|
||||
<dialog id="note-dialog">
|
||||
<form id="note-form" class="dialog-panel">
|
||||
<h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label
|
||||
class="dialog-check"><input id="note-protect" type="checkbox"> Protect this note from
|
||||
deletion</label>
|
||||
class="dialog-check"><input id="note-protect" type="checkbox"
|
||||
aria-describedby="note-protection-explanation note-archive-explanation"><span
|
||||
data-i18n="workspace.noteProtect">Protect this note from deletion</span></label>
|
||||
<div class="note-protection-help">
|
||||
<p id="note-protection-explanation" data-i18n="workspace.noteProtection.explain">When enabled,
|
||||
collaborators can edit but cannot delete or archive the note or remove its files. The note creator
|
||||
or workspace owner can still do so. This does not require a password.</p>
|
||||
<p id="note-archive-explanation" aria-live="polite"></p>
|
||||
</div>
|
||||
<p id="note-error" class="form-message error"></p>
|
||||
<div class="dialog-actions"><button type="button" id="cancel-note"
|
||||
class="secondary-button">Cancel</button><button class="primary-button">Create</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications"
|
||||
aria-relevant="additions removals"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,35 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const events = globalThis.__apiEvents = [];
|
||||
let source = readFileSync('static/js/api.js', 'utf8')
|
||||
.replace('import { logDebug, logError, logWarn } from "@rustpad/logger";',
|
||||
'const logDebug = () => {}; const logError = (...args) => globalThis.__apiEvents.push(["error", ...args]); const logWarn = (...args) => globalThis.__apiEvents.push(["warn", ...args]);')
|
||||
.replace('import { formatNumber, restoreGuestLanguage, translateApiMessage } from "@rustpad/i18n";',
|
||||
'const formatNumber = value => String(value); const restoreGuestLanguage = () => {}; const translateApiMessage = message => message;')
|
||||
.replace('import { restoreGuestTheme } from "@rustpad/theme";',
|
||||
'const restoreGuestTheme = () => {};');
|
||||
const { api } = await import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source));
|
||||
|
||||
globalThis.window = {};
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({ error: 'Access denied.' }), {
|
||||
status: 403,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
await assert.rejects(api('/api/auth/resources/mentions?kind=pad&slug=test'), error => error.status === 403);
|
||||
assert.equal(events.filter(([level, name]) => level === 'warn' && name === 'api.failed').length, 1);
|
||||
assert.equal(events.filter(([level, name]) => level === 'error' && name === 'api.network_error').length, 0);
|
||||
|
||||
events.length = 0;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({ users: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
assert.deepEqual(await api('/api/auth/resources/mentions?kind=pad&slug=test'), { users: [] });
|
||||
assert.equal(events.length, 0);
|
||||
|
||||
events.length = 0;
|
||||
globalThis.fetch = async () => { throw new TypeError('Network down'); };
|
||||
await assert.rejects(api('/api/auth/resources/mentions?kind=pad&slug=test'), TypeError);
|
||||
assert.equal(events.filter(([level, name]) => level === 'error' && name === 'api.network_error').length, 1);
|
||||
console.log('PASS: HTTP 403 logged once, empty mentions succeeds, transport failures logged as network errors');
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
// Deterministic broadcast/access-check simulation. Not a live WebSocket/DB benchmark.
|
||||
const durationSeconds = 10;
|
||||
const editsPerUserPerSecond = 2;
|
||||
for (const users of [5, 100, 300, 500]) {
|
||||
const checks = new Int32Array(users);
|
||||
const lastCheckSecond = new Int32Array(users).fill(-1);
|
||||
const start = performance.now();
|
||||
let fanouts = 0;
|
||||
for (let second = 0; second < durationSeconds; second++) {
|
||||
for (let event = 0; event < users * editsPerUserPerSecond; event++) {
|
||||
for (let recipient = 0; recipient < users; recipient++) {
|
||||
fanouts++;
|
||||
if (lastCheckSecond[recipient] !== second) {
|
||||
checks[recipient]++;
|
||||
lastCheckSecond[recipient] = second;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const optimized = checks.reduce((a,b) => a+b, 0);
|
||||
const baseline = fanouts;
|
||||
assert.equal(optimized, users * durationSeconds);
|
||||
assert.ok(optimized <= baseline);
|
||||
console.log(`${users} clients | ${fanouts.toLocaleString('en-US')} broadcast deliveries | auth reads: ${baseline.toLocaleString('en-US')} -> ${optimized.toLocaleString('en-US')} | reduction ${(100*(1-optimized/baseline)).toFixed(2)}% | simulation ${(performance.now()-start).toFixed(1)} ms`);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
globalThis.location = {origin:'https://example.org'};
|
||||
let source = readFileSync('static/js/markdown.js','utf8')
|
||||
.replace(/^import .*;$/gm, '');
|
||||
source = `const EMOJI_SHORTCODES = {}; const t = (key,vars,alternative) => alternative; const parseImageAlias = () => null;\n` + source;
|
||||
const {renderMarkdown, setMarkdownMentions} = await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
|
||||
function has(re, text, message) { assert.match(text,re,message); }
|
||||
|
||||
let html = renderMarkdown('# Instrukcja testowa\n[Przejdź](#instrukcja-testowa)\n# Instrukcja testowa\n[TOC]\n## Etap drugi');
|
||||
has(/<h1 id="instrukcja-testowa"/,html,'first anchor');
|
||||
has(/<h1 id="instrukcja-testowa-2"/,html,'duplicate heading anchor');
|
||||
has(/href="#instrukcja-testowa"/,html,'link to section');
|
||||
has(/heading-permalink/,html,'visible link icon');
|
||||
has(/href="#etap-drugi"/,html,'TOC anchor');
|
||||
|
||||
html = renderMarkdown(' ```js\n const value = 1;\n console.log(value);\n ```\nafter');
|
||||
has(/<pre[^>]*style="margin-left:2ch"[^>]*><code class="language-javascript">const value = 1;\n console\.log\(value\);<\/code><\/pre>/,html,'code container indentation + dedent');
|
||||
has(/<p[^>]*>after<\/p>/,html,'code closed');
|
||||
html = renderMarkdown(' ```python=42\n print(42)\n ```');
|
||||
has(/class="code-with-lines[^>]*style="margin-left:4ch"/,html,'numbered fenced code whole block shifted');
|
||||
has(/data-line="42">print\(42\)/,html,'numbered content dedented');
|
||||
html = renderMarkdown(' ```\n ~~~\n ```');
|
||||
has(/<code>~~~<\/code>/,html,'other marker must remain inside code');
|
||||
html = renderMarkdown('```\n## hidden in code\n```\n## Visible');
|
||||
assert.doesNotMatch(html, /id="hidden-in-code"/);
|
||||
has(/id="visible"/,html,'headings outside code');
|
||||
|
||||
setMarkdownMentions([{nickname:'Ala',color:'#aabbcc'},{nickname:'John+Dev',color:'red'}]);
|
||||
html = renderMarkdown('Cześć @Ala i @John+Dev! @Other @Alana a@Ala `@Ala` [@Ala](https://site.test)');
|
||||
assert.equal((html.match(/class="markdown-mention"/g)||[]).length,2,'valid mentions only');
|
||||
has(/style="--owner:#aabbcc"/,html,'real author color');
|
||||
has(/@Other @Alana a@Ala/,html,'nonmembers and suffixes not chips');
|
||||
has(/<code>@Ala<\/code>/,html,'inline code excluded');
|
||||
has(/<a href="https:\/\/site.test\/"[^>]*>@Ala<\/a>/,html,'links excluded');
|
||||
assert.doesNotMatch(renderMarkdown('```\n@Ala\n```'), /markdown-mention/);
|
||||
html = renderMarkdown('Sentence @Ala. Domain @Ala.example');
|
||||
assert.equal((html.match(/class="markdown-mention"/g) || []).length, 1, 'punctuation after a mention is allowed, domains are not');
|
||||
setMarkdownMentions([]);
|
||||
assert.doesNotMatch(renderMarkdown('@Ala'), /markdown-mention/);
|
||||
|
||||
console.log('PASS: anchors, TOC, duplicate headings, indented fences, numbered blocks, mentions, exclusions');
|
||||
html = renderMarkdown('[TOC]\n# Procedura\n<details>\n<summary>Szczegóły</summary>\n## Procedura\n</details>');
|
||||
has(/id="procedura"/,html);
|
||||
has(/id="procedura-2"/,html,'nested heading anchor unique');
|
||||
has(/href="#procedura-2"/,html,'TOC link for heading inside details');
|
||||
console.log('PASS: anchors in nested details match TOC');
|
||||
setMarkdownMentions([{nickname: 'a[1]\\x', color: '#123456'}, {nickname: '<f>&"', color: '#112233'}]);
|
||||
html = renderMarkdown('Hello @a[1]\\x and @<f>&" but not @a[1]\\xy');
|
||||
assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames');
|
||||
assert.doesNotMatch(html, /<f>/, 'no raw HTML from nickname');
|
||||
console.log('PASS: special-character nicknames remain escaped');
|
||||
|
||||
html = renderMarkdown('- [ ] Open\n- [x] Done\n- [~] Cancelled\n - [~] Nested cancelled');
|
||||
has(/task-list-item--cancelled/, html, 'cancelled task has its own class');
|
||||
assert.equal((html.match(/task-list-item--cancelled/g) || []).length, 2, 'both cancelled tasks recognized');
|
||||
has(/data-source-line="3" disabled aria-label="Cancelled task"/, html, 'cancelled checkbox cannot be clicked');
|
||||
has(/data-source-prefix="- \[~\] "/, html, 'cancelled markdown marker preserved');
|
||||
has(/data-source-line="1"(?! disabled)/, html, 'open task remains interactive');
|
||||
console.log('PASS: cancelled tasks are disabled and retain their source markers');
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated browser tests of DOM reconciliation, preview alignment and owner labels.
|
||||
Run from the repository root. Does not emulate the server or OT protocol.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def module_url(content: str) -> str:
|
||||
return "data:text/javascript;base64," + base64.b64encode(content.encode()).decode()
|
||||
|
||||
|
||||
def main():
|
||||
reconcile = (ROOT / "static/js/dom-reconcile.js").read_text()
|
||||
editor_source = (ROOT / "static/js/note-editor.js").read_text()
|
||||
presence_source = editor_source.split(" function updatePresence(users) {", 1)[1].split(" function updateLatency", 1)[0]
|
||||
presence_source = "function updatePresence(users) {" + presence_source
|
||||
badges_source = editor_source.split(" let renderedAuthorshipOwners = [];", 1)[1].split(" function syncOwnerLabels()", 1)[0]
|
||||
badges_source = "let renderedAuthorshipOwners = [];" + badges_source
|
||||
geometry_source = "let previewPositionCache = null;" + editor_source.split(" let previewPositionCache = null;", 1)[1].split(" function previewScrollAnchor()", 1)[0]
|
||||
markdown = (ROOT / "static/js/markdown.js").read_text()
|
||||
markdown = "const EMOJI_SHORTCODES = {}; const t = (key, v, fallback) => fallback; const parseImageAlias = () => null;\n" + "\n".join(
|
||||
line for line in markdown.splitlines() if not line.startswith("import ")
|
||||
) + "\nexport { firstRenderedTextRect };\n"
|
||||
html = '''<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
.owner-labels { position: relative; height: 100px; width: 250px; overflow:hidden; }
|
||||
.owner-labels-canvas { position:absolute;inset:0; }
|
||||
.owner-label-group { position:absolute;right:12px;transform:translateY(-50%); }
|
||||
#preview, #original { width:600px; height: 350px; overflow:auto; padding:24px; border:1px solid #ccc; }
|
||||
.preview-source-line {display:block; position:relative; min-height: 24px; }
|
||||
.preview-source-line::before {content:attr(data-source-line); position:absolute;left:-50px;line-height:16px;font-size:13px;}
|
||||
</style></head><body>
|
||||
<ul id="users"></ul><span id="room-count"></span><ul id="room-users"></ul><div id="participant-badges"></div>
|
||||
<div id="owner-labels" class="owner-labels"><div class="owner-labels-canvas"><span class="owner-label-group" style="top:250px">Alice</span></div></div>
|
||||
<article id="preview"></article><article id="original"></article>
|
||||
<script type="module">
|
||||
import { reconcileChildren } from "__RECONCILE__";
|
||||
import { alignPreviewLineNumbers, firstRenderedTextRect } from "__MARKDOWN__";
|
||||
const roomCount=document.querySelector('#room-count');
|
||||
const roomUsers=document.querySelector('#room-users');
|
||||
const participantBadges=document.querySelector('#participant-badges');
|
||||
let presenceUsers=[];
|
||||
const authorshipColorsEnabled=true, authorshipMode='simple';
|
||||
const t=(key, vars, fallback)=>fallback;
|
||||
const defaultColorFor=()=> '#444444';
|
||||
const ownerName=value=>String(value).split('\u001f')[0];
|
||||
const colorFor=()=> '#aabbcc';
|
||||
function renderGutter() { throw new Error('Presence must not cause a gutter rebuild'); }
|
||||
__BADGES_SOURCE__
|
||||
__PRESENCE_SOURCE__
|
||||
function check(result, label) {if (!result) throw new Error(label);}
|
||||
const list = document.querySelector('#users');
|
||||
const a = document.createElement('li'), b=document.createElement('li'), c=document.createElement('li'), d=document.createElement('li');
|
||||
a.textContent='A';b.textContent='B';c.textContent='C';d.textContent='D';
|
||||
list.append(a,b,c);
|
||||
reconcileChildren(list,[c,a,d]);
|
||||
check(list.children.length===3 && list.children[0]===c && list.children[1]===a && list.children[2]===d, 'reordering/removal/insertion');
|
||||
const observer = new MutationObserver(()=>{});
|
||||
observer.observe(list,{childList:true});
|
||||
reconcileChildren(list,[c,a,d]);
|
||||
check(observer.takeRecords().length === 0, 'unchanged list should not mutate the DOM');
|
||||
observer.disconnect();
|
||||
const participants=Array.from({length:1000},(_,i)=>({name:`User ${i}`,compact_name:`U${i}`,color:'#abcdef'}));
|
||||
updatePresence(participants);
|
||||
check(roomUsers.children.length===1000 && participantBadges.children.length===1000, '1000 participants rendered');
|
||||
const rowRefs=Array.from(roomUsers.children);
|
||||
const badgeRefs=Array.from(participantBadges.children);
|
||||
updatePresence([...participants,{name:'New person',color:'#123456'}]);
|
||||
check(roomUsers.children.length===1001 && roomUsers.children[0]===rowRefs[0] && roomUsers.children[999]===rowRefs[999], 'presence DOM reused');
|
||||
check(participantBadges.children[0]===badgeRefs[0] && participantBadges.children[999]===badgeRefs[999], 'badge DOM reused');
|
||||
const presenceMutations=new MutationObserver(()=>{});
|
||||
presenceMutations.observe(roomUsers,{childList:true});
|
||||
updatePresence([...participants.slice(1),{name:'New person',color:'#123456'}]);
|
||||
const changes=presenceMutations.takeRecords();
|
||||
check(changes.length===1 && changes[0].removedNodes.length===1, 'single early removal does not move remaining users');
|
||||
presenceMutations.disconnect();
|
||||
updatePresence([{name:'Duplicate',color:'#abcdef'},{name:'Duplicate',color:'#123456'}]);
|
||||
check(roomUsers.children.length===2 && participantBadges.children.length===1, 'same-name participants count preserved, badge deduplicated');
|
||||
updatePresence([{name:'<script>alert(1)<'+'/script>',color:'red'}]);
|
||||
check(roomUsers.querySelector('script')===null && participantBadges.querySelector('script')===null, 'text remains escaped');
|
||||
const ownerLabels=document.querySelector('#owner-labels');
|
||||
const canvas=ownerLabels.firstElementChild;
|
||||
const group=canvas.firstElementChild;
|
||||
canvas.style.transform='translate3d(0,-170px,0)';
|
||||
let top=group.getBoundingClientRect().top-ownerLabels.getBoundingClientRect().top;
|
||||
check(Math.abs(top - (250 - 170 - group.getBoundingClientRect().height/2)) < 1, 'owner labels scroll as one canvas');
|
||||
function oldAlign(root) {
|
||||
const styles=getComputedStyle(root), targetLeft=parseFloat(styles.paddingLeft||'0')-50;
|
||||
const rootLeft=root.getBoundingClientRect().left;
|
||||
root.querySelectorAll('.preview-source-line').forEach(line=>{
|
||||
const lineRect=line.getBoundingClientRect();
|
||||
const lineLeft=lineRect.left-rootLeft+root.scrollLeft;
|
||||
line.style.setProperty('--preview-line-left', `${targetLeft - lineLeft}px`);
|
||||
const textRect=firstRenderedTextRect(line);
|
||||
const lineStyles=getComputedStyle(line), gutterStyles=getComputedStyle(line,'::before');
|
||||
const gutterLineHeight=parseFloat(gutterStyles.lineHeight)||parseFloat(gutterStyles.fontSize)||0;
|
||||
const textLineHeight=textRect?.height||0;
|
||||
const borderTop=parseFloat(lineStyles.borderTopWidth)||0;
|
||||
const textTop=textRect?textRect.top-lineRect.top-borderTop:0;
|
||||
line.style.setProperty('--preview-line-top', `${Math.max(0,textTop+(textLineHeight-gutterLineHeight)/2)}px`);
|
||||
});
|
||||
}
|
||||
const preview=document.querySelector('#preview'), original=document.querySelector('#original');
|
||||
const rows=Array.from({length:150},(_,i)=>`<p class="preview-source-line" data-source-line="${i+1}">Row ${i} some text</p>`).join('');
|
||||
preview.innerHTML=rows;original.innerHTML=rows;
|
||||
oldAlign(original);
|
||||
alignPreviewLineNumbers(preview);
|
||||
const oldRows=original.querySelectorAll('.preview-source-line');
|
||||
const newRows=preview.querySelectorAll('.preview-source-line');
|
||||
for(let i=0;i<newRows.length;i++) {
|
||||
for(const prop of ['--preview-line-left','--preview-line-top']) {
|
||||
const before=parseFloat(oldRows[i].style.getPropertyValue(prop));
|
||||
const after=parseFloat(newRows[i].style.getPropertyValue(prop));
|
||||
check(Math.abs(before-after)<0.05, 'alignment parity at '+i+' '+prop+': '+before+' != '+after);
|
||||
}
|
||||
}
|
||||
// Cached source geometry must not be remeasured during scroll, but must
|
||||
// rebuild when the preview DOM/layout changes.
|
||||
__GEOMETRY_SOURCE__
|
||||
const firstPositions=previewLinePositions();
|
||||
check(previewLinePositions()===firstPositions, 'preview geometry cached');
|
||||
preview.scrollTop=100;
|
||||
check(previewLinePositions()===firstPositions, 'scroll does not discard geometry');
|
||||
preview.insertAdjacentHTML('beforeend','<p class="preview-source-line" data-source-line="151">Last line</p>');
|
||||
await new Promise(resolve=>setTimeout(resolve,50));
|
||||
const changedPositions=previewLinePositions();
|
||||
check(changedPositions!==firstPositions && changedPositions.length===firstPositions.length+1, 'DOM mutation invalidates geometry');
|
||||
window.frontendTestsPassed=true;
|
||||
</script></body></html>'''.replace("__RECONCILE__", module_url(reconcile)).replace("__MARKDOWN__", module_url(markdown)).replace("__BADGES_SOURCE__", badges_source).replace("__PRESENCE_SOURCE__", presence_source).replace("__GEOMETRY_SOURCE__", geometry_source)
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox", "--disable-dev-shm-usage"])
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 1100, "height": 780})
|
||||
errors = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.set_content(html)
|
||||
try:
|
||||
page.wait_for_function("window.frontendTestsPassed===true", timeout=15000)
|
||||
except Exception:
|
||||
raise AssertionError(f"Browser errors: {errors}")
|
||||
assert not errors, errors
|
||||
print("PASS: keyed DOM list updates preserve node identity; identical list has zero mutations")
|
||||
print("PASS: real presence update handles 1000 users with row/badge reuse, duplicates and XSS-safe text")
|
||||
print("PASS: owner-label canvas transforms; preview alignment parity for 150 lines in Chromium")
|
||||
print("PASS: preview geometry cache reuses measurements on scroll and invalidates on content change")
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,74 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
const source = readFileSync('static/js/authorship.js', 'utf8');
|
||||
const { lineAuthors, parseAuthorship } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
|
||||
|
||||
// Reference implementation from 0.2.89; include it to detect attribution,
|
||||
// color and tie-breaking regressions independently of the optimized code.
|
||||
function referenceLineAuthors(content, model) {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
||||
return starts.map((start, index) => {
|
||||
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
|
||||
const totals = new Map();
|
||||
const representatives = new Map();
|
||||
for (const span of model?.spans || []) {
|
||||
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
|
||||
if (!overlap) continue;
|
||||
const identity = String(span.owner || '').split('\u001f', 1)[0];
|
||||
totals.set(identity, (totals.get(identity) || 0) + overlap);
|
||||
representatives.set(identity, span.owner);
|
||||
}
|
||||
return [...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([identity]) => representatives.get(identity));
|
||||
});
|
||||
}
|
||||
|
||||
let seed = 42;
|
||||
function random(n) { seed = (seed * 1664525 + 1013904223) >>> 0; return seed % n; }
|
||||
const owners = ['A\u001f#ff0000', 'B', 'A\u001f#00ff00', 'C', 'D', ''];
|
||||
for (let sample = 0; sample < 400; sample++) {
|
||||
const content = Array.from({ length: 1 + random(65) }, (_, i) => {
|
||||
const base = ['text', '', '🙂 éół', ' x', '......'][random(5)];
|
||||
return base + (i ? '\n' : '');
|
||||
}).join('');
|
||||
const spans = [];
|
||||
for (let i = 0; i < 4 + random(100); i++) {
|
||||
const start = random(content.length + 1);
|
||||
spans.push({ start, end: start + random(content.length + 4), owner: owners[random(owners.length)] });
|
||||
}
|
||||
const canonical = parseAuthorship(content, { version: 2, spans });
|
||||
assert.deepEqual(lineAuthors(content, canonical), referenceLineAuthors(content, canonical), `canonical parity sample ${sample}`);
|
||||
assert.deepEqual(lineAuthors(content, { spans }), referenceLineAuthors(content, { spans }), `noncanonical parity sample ${sample}`);
|
||||
}
|
||||
for (const content of ['', 'a', '\n', '🙂\n\nB\n', 'line1\nline2\n']) {
|
||||
for (const model of [{ spans: [] }, { spans: [{ start: 0, end: 999, owner: 'one' }] }]) {
|
||||
assert.deepEqual(lineAuthors(content, model), referenceLineAuthors(content, model));
|
||||
}
|
||||
}
|
||||
|
||||
const n = 6000;
|
||||
const lines = [], spans = [];
|
||||
let offset = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const line = `row ${String(i).padStart(5, '0')} lorem ipsum\n`;
|
||||
lines.push(line);
|
||||
spans.push({ start: offset, end: offset + line.length, owner: `user${i % 37}` });
|
||||
offset += line.length;
|
||||
}
|
||||
const text = lines.join('');
|
||||
const model = { version: 2, spans };
|
||||
assert.deepEqual(lineAuthors(text, model), referenceLineAuthors(text, model));
|
||||
function medianDuration(fn) {
|
||||
const ms = [];
|
||||
for (let i = 0; i < 7; i++) { const start = performance.now(); fn(); ms.push(performance.now() - start); }
|
||||
ms.sort((a, b) => a - b);
|
||||
return ms[3];
|
||||
}
|
||||
medianDuration(() => lineAuthors(text, model));
|
||||
const oldMs = medianDuration(() => referenceLineAuthors(text, model));
|
||||
const newMs = medianDuration(() => lineAuthors(text, model));
|
||||
console.log(`PASS: 400 randomized canonical + noncanonical parity cases; 6000 lines, 6000 spans: reference ${oldMs.toFixed(2)} ms; optimized ${newMs.toFixed(2)} ms; ${(oldMs / newMs).toFixed(1)}x`);
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Browser checks for @mention suggestions. Run from repository root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HTML = '''<!doctype html><html lang="pl"><head>
|
||||
<meta charset="utf-8">
|
||||
__STYLES__
|
||||
<style>
|
||||
body { padding: 18px; }
|
||||
.editor-shell { width: min(580px, 90vw); height: 340px; }
|
||||
#editor { height: 100%; }
|
||||
</style></head><body>
|
||||
<div class="editor-shell">
|
||||
<div class="line-gutter"></div>
|
||||
<textarea id="editor" wrap="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<script type="module">
|
||||
__SCRIPT__
|
||||
window.autocomplete = createMentionAutocomplete(document.querySelector('#editor'));
|
||||
window.mentionInputTypes = [];
|
||||
document.querySelector('#editor').addEventListener('input', e => window.mentionInputTypes.push(e.inputType));
|
||||
</script></body></html>'''
|
||||
|
||||
|
||||
def main():
|
||||
source = (ROOT / 'static/js/mention-autocomplete.js').read_text()
|
||||
source = source.replace('import { t } from "@rustpad/i18n";', 'const t = (key, vars, fallback) => fallback;')
|
||||
styles = (ROOT / 'static/css/styles.css').read_text()
|
||||
html = HTML.replace('__STYLES__', f'<style>{styles}</style>').replace('__SCRIPT__', source)
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"])
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 870, "height": 550})
|
||||
errors = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.set_content(html)
|
||||
page.wait_for_function("!!window.autocomplete")
|
||||
editor = page.locator('#editor')
|
||||
options = page.locator('.mention-suggestions__option')
|
||||
page.evaluate('''() => window.autocomplete.setUsers([
|
||||
{nickname:'Dominik',color:'#ff0000'},
|
||||
{nickname:'Dora',color:'#00ff00'},
|
||||
{nickname:'Jan Domański',color:'#ff8800'},
|
||||
{nickname:'Anna',color:'red'}
|
||||
])''')
|
||||
|
||||
editor.fill('Witaj @do')
|
||||
assert options.count() == 3, options.all_text_contents()
|
||||
assert options.all_text_contents() == ['@Dominik', '@Dora', '@Jan Domański']
|
||||
assert editor.get_attribute('aria-expanded') == 'true'
|
||||
assert options.nth(0).get_attribute('aria-selected') == 'true'
|
||||
editor.press('ArrowDown')
|
||||
assert options.nth(1).get_attribute('aria-selected') == 'true'
|
||||
editor.press('Enter')
|
||||
assert editor.input_value() == 'Witaj @Dora '
|
||||
assert page.evaluate('window.mentionInputTypes.at(-1)') == 'insertReplacementText'
|
||||
assert page.locator('.mention-suggestions').is_hidden()
|
||||
|
||||
editor.fill('Test @do')
|
||||
editor.press('ArrowDown')
|
||||
editor.press('ArrowDown')
|
||||
editor.press('Tab')
|
||||
assert editor.input_value() == 'Test @Jan Domański '
|
||||
|
||||
editor.fill('@d')
|
||||
assert options.count() == 3
|
||||
editor.press('Escape')
|
||||
assert page.locator('.mention-suggestions').is_hidden()
|
||||
editor.press('ArrowDown')
|
||||
assert page.locator('.mention-suggestions').is_hidden(), 'Escape must dismiss the query'
|
||||
editor.fill('@do')
|
||||
options.first.click()
|
||||
assert editor.input_value() == '@Dominik '
|
||||
|
||||
editor.fill('user@example.org')
|
||||
assert page.locator('.mention-suggestions').is_hidden(), 'email should not trigger suggestions'
|
||||
editor.fill('hello @unknown')
|
||||
assert page.locator('.mention-suggestions').is_hidden(), 'only authorized members should be shown'
|
||||
editor.fill('Hi @')
|
||||
assert options.count() == 4
|
||||
page.evaluate('window.autocomplete.setUsers([])')
|
||||
assert page.locator('.mention-suggestions').is_hidden(), 'clear users on logout'
|
||||
editor.fill('Hi @do')
|
||||
assert page.locator('.mention-suggestions').is_hidden(), 'no guest suggestions'
|
||||
|
||||
page.evaluate('window.autocomplete.setUsers([{nickname:"Dominik",color:"#ff0000"}])')
|
||||
editor.fill('Hi @do')
|
||||
assert options.count() == 1
|
||||
geometry = page.locator('.mention-suggestions').bounding_box()
|
||||
shell_geometry = page.locator('.editor-shell').bounding_box()
|
||||
assert geometry['x'] >= shell_geometry['x'] - 1
|
||||
assert geometry['y'] >= shell_geometry['y'] - 1
|
||||
assert geometry['x'] + geometry['width'] <= shell_geometry['x'] + shell_geometry['width'] + 1
|
||||
assert geometry['y'] + geometry['height'] <= shell_geometry['y'] + shell_geometry['height'] + 1
|
||||
|
||||
editor.fill('Edycja @do.')
|
||||
page.evaluate('''() => {let e = document.querySelector('#editor'); e.setSelectionRange(10,10); e.dispatchEvent(new Event('keyup'));}''')
|
||||
assert options.count() == 1
|
||||
editor.press('Enter')
|
||||
assert editor.input_value() == 'Edycja @Dominik.' # no unnecessary space before punctuation
|
||||
|
||||
assert not errors, errors
|
||||
print('PASS: Chromium editor completion, keyboard/mouse, privacy, positioning, punctuation')
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,35 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
let source = readFileSync('static/js/mention-autocomplete.js', 'utf8');
|
||||
source = source.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, vars, fallback) => fallback;');
|
||||
const { mentionAtCaret, filterMentionUsers, normalizeMentionUsers } = await import(
|
||||
'data:text/javascript;base64,' + Buffer.from(source).toString('base64')
|
||||
);
|
||||
|
||||
assert.deepEqual(mentionAtCaret('Cześć @do', 9), { start: 6, end: 9, query: 'do' });
|
||||
assert.deepEqual(mentionAtCaret('@', 1), { start: 0, end: 1, query: '' });
|
||||
assert.deepEqual(mentionAtCaret('Pierwsza linia\nWitaj (@Żół', 26), { start: 22, end: 26, query: 'Żół' });
|
||||
assert.equal(mentionAtCaret('john@example.org', 12), null, 'do not suggest in addresses');
|
||||
assert.equal(mentionAtCaret('abc@do', 6), null, 'no mention inside a word');
|
||||
assert.equal(mentionAtCaret('Hello @do ', 10), null, 'space ends a query');
|
||||
assert.equal(mentionAtCaret('Hello @do-more', 14)?.query, 'do-more', 'support dashes');
|
||||
assert.equal(mentionAtCaret('Hello @@do', 10), null, 'double at is not a mention');
|
||||
assert.equal(mentionAtCaret('Hello @do', -1), null);
|
||||
|
||||
const members = normalizeMentionUsers([
|
||||
{ nickname: 'Dominik', color: '#ff0000', email: 'secret@example.org' },
|
||||
{ nickname: 'DOMINIK', color: '#aaaaaa' },
|
||||
{ nickname: 'Dora', color: 'red' },
|
||||
{ nickname: 'Jan Domański', color: '#00ff00' },
|
||||
{ nickname: '', color: '#00ff00' },
|
||||
{ nickname: 'Joanna', color: '#123abc' },
|
||||
]);
|
||||
assert.equal(members.length, 4, 'deduplicate case insensitive, skip empty');
|
||||
assert.deepEqual(filterMentionUsers(members, 'do').map(user => user.nickname), ['Dominik', 'Dora', 'Jan Domański']);
|
||||
assert.equal(filterMentionUsers(members, 'DO')[0].nickname, 'Dominik');
|
||||
assert.equal(filterMentionUsers(members, 'zz').length, 0);
|
||||
assert.equal(members[0].color, '#ff0000');
|
||||
assert.match(members[1].color, /^hsl\(/, 'invalid user color falls back safely');
|
||||
assert.equal(Object.hasOwn(members[0], 'email'), false, 'no email addresses in UI model');
|
||||
console.log('PASS: mention context, filtering, normalization, privacy');
|
||||
@@ -1,179 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Browser integration test for the mentioned-users bar and cyclic jumps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HTML = '''<!doctype html><html lang="pl"><head><meta charset="utf-8">
|
||||
<style>__STYLES__</style>
|
||||
<style>
|
||||
body { padding: 20px; }
|
||||
.workspace { width: min(970px, calc(100vw - 40px)); height: 400px; grid-template-columns: 1fr 1fr; }
|
||||
.editor-column { display: flex; flex-direction: column; height: 100%; border: 1px solid #aaa; }
|
||||
.preview-column { display: grid; min-width: 0; min-height: 0; }
|
||||
.editor-shell { flex: 1 1 auto; min-height: 0; }
|
||||
#editor { box-sizing: border-box; width: 100%; height: 100%; padding: 18px; line-height: 22px; }
|
||||
#preview { box-sizing: border-box; width: 100%; height: 100%; overflow: auto; border: 1px solid #aaa; padding: 18px; }
|
||||
</style>
|
||||
</head><body class="pad-page">
|
||||
<div id="editor-workspace" class="workspace view-split">
|
||||
<div class="editor-column">
|
||||
<div class="column-label">Editor</div>
|
||||
<div class="participant-badges"><span class="participant-badge">Autor</span></div>
|
||||
<div id="mentioned-users" class="mentioned-users" role="group" hidden>
|
||||
<span class="mentioned-users__label">Wspomniani</span>
|
||||
<div id="mentioned-users-list" class="mentioned-users__list"></div>
|
||||
</div>
|
||||
<div class="editor-shell"><textarea id="editor" wrap="off"></textarea></div>
|
||||
</div>
|
||||
<div class="preview-column">
|
||||
<div id="preview-label" class="column-label">Markdown preview</div>
|
||||
<div id="mentioned-users-preview" class="mentioned-users mentioned-users--preview" role="group" hidden>
|
||||
<span class="mentioned-users__label">Wspomniani</span>
|
||||
<div id="mentioned-users-preview-list" class="mentioned-users__list"></div>
|
||||
</div>
|
||||
<article class="markdown-body preview" id="preview"></article>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import {createMentionNavigator} from '__NAVIGATION_URL__';
|
||||
import {setMarkdownMentions,renderMarkdown} from '__MARKDOWN_URL__';
|
||||
const editor = document.querySelector('#editor');
|
||||
const preview = document.querySelector('#preview');
|
||||
let view = 'split', members = [];
|
||||
const navigator = createMentionNavigator({
|
||||
container: document.querySelector('#mentioned-users'),
|
||||
list: document.querySelector('#mentioned-users-list'),
|
||||
previewContainer: document.querySelector('#mentioned-users-preview'),
|
||||
previewList: document.querySelector('#mentioned-users-preview-list'),
|
||||
editor, preview, getView: () => view, onEditorScroll: () => {},
|
||||
});
|
||||
window.runCase = (source, users) => {
|
||||
editor.value = source; members = users;
|
||||
navigator.setUsers(members);
|
||||
setMarkdownMentions(members);
|
||||
preview.innerHTML = renderMarkdown(source);
|
||||
navigator.render({markdown:true});
|
||||
};
|
||||
window.setView = value => {
|
||||
view=value;
|
||||
document.querySelector('#editor-workspace').className = `workspace view-${value}`;
|
||||
};
|
||||
window.setUsers = value => {
|
||||
navigator.setUsers(value);
|
||||
setMarkdownMentions(value);
|
||||
preview.innerHTML = renderMarkdown(editor.value);
|
||||
navigator.render({markdown:true});
|
||||
};
|
||||
window.ready = true;
|
||||
</script></body></html>'''
|
||||
|
||||
|
||||
def as_data_url(source):
|
||||
return 'data:text/javascript;base64,' + base64.b64encode(source.encode('utf-8')).decode('ascii')
|
||||
|
||||
|
||||
def create_fixture():
|
||||
autocomplete = (ROOT / 'static/js/mention-autocomplete.js').read_text()
|
||||
autocomplete = autocomplete.replace('import { t } from "@rustpad/i18n";',
|
||||
'const t = (key, values, fallback) => fallback;')
|
||||
navigation = (ROOT / 'static/js/mention-navigation.js').read_text()
|
||||
navigation = navigation.replace('import { t } from "@rustpad/i18n";',
|
||||
'const t = (key, values, fallback) => fallback;')
|
||||
navigation = navigation.replace('"@rustpad/mention-autocomplete"',
|
||||
f'"{as_data_url(autocomplete)}"')
|
||||
markdown = (ROOT / 'static/js/markdown.js').read_text()
|
||||
markdown = '\n'.join(line for line in markdown.split('\n') if not line.startswith('import '))
|
||||
markdown = ('const EMOJI_SHORTCODES = {}; '
|
||||
'const t = (key, values, fallback) => fallback; '
|
||||
'const parseImageAlias = () => null;\n') + markdown
|
||||
styles = (ROOT / 'static/css/styles.css').read_text()
|
||||
return (HTML.replace('__STYLES__', styles)
|
||||
.replace('__NAVIGATION_URL__', as_data_url(navigation))
|
||||
.replace('__MARKDOWN_URL__', as_data_url(markdown)))
|
||||
|
||||
|
||||
def main():
|
||||
fixture = create_fixture()
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
try:
|
||||
page = browser.new_page(viewport={'width': 1020, 'height': 680})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(fixture)
|
||||
page.wait_for_function('window.ready === true')
|
||||
lines = [
|
||||
'Na początku @Dominik i @Dominik, do tego @Dora.',
|
||||
'`@Dominik` [@Dominik](https://example.com/@Dora)',
|
||||
'```sh',
|
||||
'@Dominik',
|
||||
'```',
|
||||
*[f'Wypełnienie linii {i}' for i in range(1, 18)],
|
||||
'<details>',
|
||||
'<summary>Szczegóły</summary>',
|
||||
'Wewnątrz @Dominik',
|
||||
'</details>',
|
||||
*[f'Treść linii {i}' for i in range(18, 38)],
|
||||
'Na końcu @Dominik.',
|
||||
]
|
||||
content = '\n'.join(lines)
|
||||
users = [{'nickname': 'Dominik', 'color': '#123456'}, {'nickname':'Dora', 'color':'#654321', 'email':'private@example.org'}]
|
||||
page.evaluate('([text,users]) => window.runCase(text,users)', [content, users])
|
||||
bar = page.locator('#mentioned-users')
|
||||
assert bar.is_visible()
|
||||
badges = page.locator('#mentioned-users-list .mentioned-users__badge')
|
||||
preview_badges = page.locator('#mentioned-users-preview-list .mentioned-users__badge')
|
||||
assert page.locator('#mentioned-users-preview').is_hidden()
|
||||
assert badges.count() == 2
|
||||
assert preview_badges.count() == 2
|
||||
assert badges.all_text_contents() == ['@Dominik4', '@Dora1']
|
||||
assert 'private' not in bar.inner_text()
|
||||
button = badges.first
|
||||
# The two code/link occurrences are intentionally skipped.
|
||||
source_starts = [content.index('@Dominik'),content.index('@Dominik',content.index('@Dominik')+1),content.index('@Dominik',content.index('Wewnątrz')), content.rindex('@Dominik')]
|
||||
for step, start in enumerate(source_starts, 1):
|
||||
button.click()
|
||||
assert page.evaluate('document.querySelector("#editor").selectionStart') == start, step
|
||||
assert page.evaluate('document.querySelector("#editor").selectionEnd') == start + len('@Dominik')
|
||||
assert page.locator('.markdown-mention.is-mention-target').count() == 1
|
||||
assert button.locator('.mentioned-users__count').inner_text() == f'{step}/4'
|
||||
assert page.locator('details.markdown-details').evaluate('element => element.open') is True
|
||||
button.click()
|
||||
assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[0]
|
||||
assert button.locator('.mentioned-users__count').inner_text() == '1/4'
|
||||
dora = badges.nth(1)
|
||||
dora.click()
|
||||
assert page.evaluate('document.querySelector("#editor").selectionStart') == content.index('@Dora')
|
||||
assert dora.locator('.mentioned-users__count').inner_text() == '1/1'
|
||||
page.evaluate('window.setView("preview")')
|
||||
assert bar.is_hidden()
|
||||
assert page.locator('#mentioned-users-preview').is_visible()
|
||||
assert page.locator('#preview').is_visible()
|
||||
original_selection = page.evaluate('document.querySelector("#editor").selectionStart')
|
||||
preview_badges.first.click()
|
||||
assert page.evaluate('document.querySelector("#editor").selectionStart') == original_selection
|
||||
assert page.locator('.markdown-mention.is-mention-target').count() == 1
|
||||
page.evaluate('window.setView("edit")')
|
||||
assert bar.is_visible()
|
||||
assert page.locator('#mentioned-users-preview').is_hidden()
|
||||
button.click()
|
||||
assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[2]
|
||||
page.evaluate('window.setUsers([])')
|
||||
assert bar.is_hidden()
|
||||
assert page.locator('#mentioned-users-preview').is_hidden()
|
||||
assert badges.count() == 0
|
||||
assert preview_badges.count() == 0
|
||||
assert not errors, errors
|
||||
print('PASS: Chromium mentions list, access, cyclic navigation, source selection, details, view switching')
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,37 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const autocomplete = readFileSync('static/js/mention-autocomplete.js', 'utf8')
|
||||
.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;');
|
||||
const autocompleteModule = await import('data:text/javascript;base64,' + Buffer.from(autocomplete).toString('base64'));
|
||||
const navigation = readFileSync('static/js/mention-navigation.js', 'utf8')
|
||||
.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;')
|
||||
.replace(/^import \{ normalizeMentionUsers \} from "@rustpad\/mention-autocomplete";$/m,
|
||||
`const normalizeMentionUsers = ${autocompleteModule.normalizeMentionUsers.toString()};`);
|
||||
const { collectMentionOccurrences } = await import('data:text/javascript;base64,' + Buffer.from(navigation).toString('base64'));
|
||||
|
||||
const users = [
|
||||
{nickname:'Dominik',color:'#1256aa',email:'private@example.org'},
|
||||
{nickname:'dOMINIK',color:'#abc123'},
|
||||
{nickname:'Dora',color:'#dd00dd'},
|
||||
{nickname:'Ala+Dev',color:'#123abc'},
|
||||
];
|
||||
function matches(source, options) { return collectMentionOccurrences(source, users, options); }
|
||||
function valid(source, options) { return matches(source, options).map(mention => source.slice(mention.start, mention.end)); }
|
||||
assert.deepEqual(valid('Tu @Dominik, @Dora oraz @dominik.'), ['@Dominik', '@Dora', '@dominik']);
|
||||
assert.deepEqual(valid('Przykład @DominiK @Ala+Dev, @Dominik'), ['@DominiK', '@Ala+Dev', '@Dominik']);
|
||||
assert.deepEqual(valid('john@Dominik.org a@Dora @@Dominik @Dominika @Other'), []);
|
||||
assert.deepEqual(valid('`@Dominik` i @Dora [@Dominik](https://example.org) oraz https://example.org/@Dora'), ['@Dora']);
|
||||
assert.deepEqual(valid('[file=secret@Dominik,key] i [image=image@Dora] oraz @Dominik'), ['@Dominik']);
|
||||
assert.deepEqual(valid('```sh\n@Dominik\n```\nPo @Dora\n~~~\n@Dora\n~~~\n@Dominik'), ['@Dora', '@Dominik']);
|
||||
assert.deepEqual(valid(' ```js\n@Dominik\n ```\n@Dominik'), ['@Dominik']);
|
||||
assert.deepEqual(valid('````rust\n@Dominik\n```\n@Dora\n````\n@Dora'), ['@Dora']);
|
||||
assert.deepEqual(valid('@Dora', { markdown:false }), ['@Dora']);
|
||||
assert.deepEqual(valid('```\n@Dominik\n```', { markdown:false }), ['@Dominik']);
|
||||
const source = '🙂 @Dominik\nDrugi @Dora i @Dominik';
|
||||
const items = matches(source);
|
||||
assert.deepEqual(items.map(item=>item.start), [source.indexOf('@Dominik'),source.indexOf('@Dora'),source.lastIndexOf('@Dominik')]);
|
||||
assert.deepEqual(items.map(item=>item.sourceLine), [1,2,2]);
|
||||
assert.ok(items.every(item => !Object.hasOwn(item, 'email')));
|
||||
assert.equal(collectMentionOccurrences('Hej @Dominik', []).length, 0);
|
||||
console.log('PASS: mention navigation positions, permissions, Markdown exclusions, UTF-16, code fences');
|
||||
@@ -1,829 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Populate RustPad through its HTTP API with generated test data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import getpass
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import ssl
|
||||
import string
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Any, Iterable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
from urllib.request import HTTPSHandler, Request, build_opener
|
||||
|
||||
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
WIKIPEDIA_RANDOM_URL = "https://en.wikipedia.org/wiki/Special:Random"
|
||||
MAX_SOURCE_BYTES = 1_500_000
|
||||
MAX_DOCUMENT_BYTES = 1_800_000
|
||||
MAX_WIKIPEDIA_ATTACHMENT_BYTES = 5_000_000
|
||||
WIKIMEDIA_IMAGE_HOST_SUFFIX = ".wikimedia.org"
|
||||
WIKIPEDIA_IMAGE_EXTENSIONS = (".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp")
|
||||
WIKIPEDIA_IMAGE_MIME_TYPES = {
|
||||
"image/avif",
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
}
|
||||
|
||||
|
||||
class ApiFailure(RuntimeError):
|
||||
def __init__(self, status: int, message: str, path: str) -> None:
|
||||
super().__init__(f"{status} {path}: {message}")
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.path = path
|
||||
|
||||
|
||||
class RustPadClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
timeout: float,
|
||||
retries: int,
|
||||
insecure: bool,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.cookies: dict[str, str] = {}
|
||||
self.csrf_token: str | None = None
|
||||
self.lock = threading.RLock()
|
||||
self.context = ssl._create_unverified_context() if insecure else ssl.create_default_context()
|
||||
self.local = threading.local()
|
||||
|
||||
def _opener(self) -> Any:
|
||||
opener = getattr(self.local, "opener", None)
|
||||
if opener is None:
|
||||
opener = build_opener(HTTPSHandler(context=self.context))
|
||||
self.local.opener = opener
|
||||
return opener
|
||||
|
||||
def _cookie_header(self) -> str:
|
||||
with self.lock:
|
||||
return "; ".join(f"{name}={value}" for name, value in self.cookies.items())
|
||||
|
||||
def _store_cookies(self, headers: Any) -> None:
|
||||
values = headers.get_all("Set-Cookie") or []
|
||||
if not values:
|
||||
return
|
||||
with self.lock:
|
||||
for raw in values:
|
||||
parsed = SimpleCookie()
|
||||
parsed.load(raw)
|
||||
for name, morsel in parsed.items():
|
||||
if morsel["max-age"] == "0" or not morsel.value:
|
||||
self.cookies.pop(name, None)
|
||||
else:
|
||||
self.cookies[name] = morsel.value
|
||||
|
||||
def refresh_csrf(self) -> str:
|
||||
with self.lock:
|
||||
data = self.request("GET", "/api/security/csrf", retry_csrf=False)
|
||||
token = str(data.get("token") or "")
|
||||
if not token:
|
||||
raise RuntimeError("The server did not return a CSRF token.")
|
||||
self.csrf_token = token
|
||||
return token
|
||||
|
||||
def _request_bytes(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: bytes | None,
|
||||
content_type: str | None,
|
||||
*,
|
||||
retry_csrf: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
method = method.upper()
|
||||
url = urljoin(self.base_url, path.lstrip("/"))
|
||||
|
||||
for attempt in range(self.retries + 1):
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "RustPad-random-data/1.0",
|
||||
}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
cookie = self._cookie_header()
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
if method in UNSAFE_METHODS:
|
||||
if not self.csrf_token:
|
||||
self.refresh_csrf()
|
||||
headers["X-Rustpad-CSRF"] = self.csrf_token or ""
|
||||
|
||||
request = Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with self._opener().open(request, timeout=self.timeout) as response:
|
||||
self._store_cookies(response.headers)
|
||||
raw = response.read()
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except HTTPError as error:
|
||||
self._store_cookies(error.headers)
|
||||
raw = error.read()
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8")) if raw else {}
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
data = {}
|
||||
message = str(data.get("error") or error.reason or "Request failed")
|
||||
if (
|
||||
error.code == 403
|
||||
and retry_csrf
|
||||
and "security token" in message.lower()
|
||||
):
|
||||
with self.lock:
|
||||
self.csrf_token = None
|
||||
self.refresh_csrf()
|
||||
return self._request_bytes(
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
content_type,
|
||||
retry_csrf=False,
|
||||
)
|
||||
if error.code in {429, 500, 502, 503, 504} and attempt < self.retries:
|
||||
retry_after = error.headers.get("Retry-After")
|
||||
try:
|
||||
delay = float(retry_after) if retry_after else min(10.0, 0.5 * (2**attempt))
|
||||
except ValueError:
|
||||
delay = min(10.0, 0.5 * (2**attempt))
|
||||
time.sleep(delay + random.random() * 0.25)
|
||||
continue
|
||||
raise ApiFailure(error.code, message, path) from error
|
||||
except (URLError, TimeoutError, json.JSONDecodeError) as error:
|
||||
if attempt < self.retries:
|
||||
time.sleep(min(10.0, 0.5 * (2**attempt)) + random.random() * 0.25)
|
||||
continue
|
||||
raise RuntimeError(f"Request to {path} failed: {error}") from error
|
||||
|
||||
raise RuntimeError(f"Request to {path} failed after retries.")
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
retry_csrf: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
content_type = "application/json" if body is not None else None
|
||||
return self._request_bytes(
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
content_type,
|
||||
retry_csrf=retry_csrf,
|
||||
)
|
||||
|
||||
def upload_multipart(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
mime_type: str,
|
||||
) -> dict[str, Any]:
|
||||
boundary = f"rustpad-{''.join(random.choices(string.ascii_letters + string.digits, k=32))}"
|
||||
chunks = [
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
b'Content-Disposition: form-data; name="access_token"\r\n\r\n',
|
||||
b"\r\n",
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
(
|
||||
"Content-Disposition: form-data; name=\"file\"; "
|
||||
f"filename=\"{filename.replace(chr(34), '_')}\"\r\n"
|
||||
).encode("utf-8"),
|
||||
f"Content-Type: {mime_type}\r\n\r\n".encode("ascii"),
|
||||
data,
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode("ascii"),
|
||||
]
|
||||
return self._request_bytes(
|
||||
"POST",
|
||||
path,
|
||||
b"".join(chunks),
|
||||
f"multipart/form-data; boundary={boundary}",
|
||||
)
|
||||
|
||||
def login(self, user: str, password: str) -> dict[str, Any]:
|
||||
self.refresh_csrf()
|
||||
session = self.request(
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
{"email": user, "password": password},
|
||||
)
|
||||
verified = self.request("GET", "/api/auth/me")
|
||||
return verified or session
|
||||
|
||||
def create_pad(self, name: str, content: str) -> dict[str, Any]:
|
||||
return self.request("POST", "/api/pads", {"name": name, "content": content})
|
||||
|
||||
def create_workspace(self, name: str) -> dict[str, Any]:
|
||||
return self.request("POST", "/api/workspaces", {"name": name})
|
||||
|
||||
def create_workspace_note(self, workspace_slug: str, name: str, content: str) -> dict[str, Any]:
|
||||
return self.request(
|
||||
"POST",
|
||||
f"/api/workspaces/{quote(workspace_slug, safe='')}/notes",
|
||||
{"name": name, "content": content},
|
||||
)
|
||||
|
||||
def upload_pad_attachment(self, pad_slug: str, attachment: SourceAttachment) -> dict[str, Any]:
|
||||
return self.upload_multipart(
|
||||
f"/api/pads/{quote(pad_slug, safe='')}/files",
|
||||
filename=attachment.filename,
|
||||
data=attachment.data,
|
||||
mime_type=attachment.mime_type,
|
||||
)
|
||||
|
||||
def upload_workspace_note_attachment(
|
||||
self,
|
||||
workspace_slug: str,
|
||||
note_slug: str,
|
||||
attachment: SourceAttachment,
|
||||
) -> dict[str, Any]:
|
||||
return self.upload_multipart(
|
||||
(
|
||||
f"/api/workspaces/{quote(workspace_slug, safe='')}/notes/"
|
||||
f"{quote(note_slug, safe='')}/files"
|
||||
),
|
||||
filename=attachment.filename,
|
||||
data=attachment.data,
|
||||
mime_type=attachment.mime_type,
|
||||
)
|
||||
|
||||
|
||||
class ReadableHtmlParser(HTMLParser):
|
||||
BLOCK_TAGS = {"h1", "h2", "h3", "h4", "p", "li", "blockquote", "pre", "br"}
|
||||
SKIP_TAGS = {"script", "style", "svg", "noscript", "nav", "footer"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts: list[str] = []
|
||||
self.skip_depth = 0
|
||||
self.title = "Wikipedia snapshot"
|
||||
self.in_title = False
|
||||
self.images: list[tuple[str, str, int | None, int | None]] = []
|
||||
|
||||
@staticmethod
|
||||
def _dimension(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _srcset_url(value: str | None) -> str:
|
||||
candidates = []
|
||||
for item in str(value or "").split(","):
|
||||
parts = item.strip().split()
|
||||
if not parts:
|
||||
continue
|
||||
descriptor = parts[1] if len(parts) > 1 else "1x"
|
||||
try:
|
||||
weight = float(descriptor.removesuffix("w").removesuffix("x"))
|
||||
except ValueError:
|
||||
weight = 1.0
|
||||
candidates.append((weight, parts[0]))
|
||||
if not candidates:
|
||||
return ""
|
||||
candidates.sort()
|
||||
for weight, url in candidates:
|
||||
if weight >= 640:
|
||||
return url
|
||||
return candidates[-1][1]
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
attributes = {name.lower(): value for name, value in attrs}
|
||||
if tag in self.SKIP_TAGS:
|
||||
self.skip_depth += 1
|
||||
if tag == "title":
|
||||
self.in_title = True
|
||||
if tag == "img" and not self.skip_depth:
|
||||
src = str(attributes.get("data-src") or attributes.get("src") or "")
|
||||
src = src or self._srcset_url(attributes.get("srcset"))
|
||||
if src:
|
||||
alt = " ".join(str(attributes.get("alt") or "Wikipedia image").split())
|
||||
self.images.append((src, alt, self._dimension(attributes.get("width")), self._dimension(attributes.get("height"))))
|
||||
if not self.skip_depth and tag in self.BLOCK_TAGS:
|
||||
self.parts.append("\n")
|
||||
if tag == "li":
|
||||
self.parts.append("- ")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "title":
|
||||
self.in_title = False
|
||||
if tag in self.SKIP_TAGS and self.skip_depth:
|
||||
self.skip_depth -= 1
|
||||
if not self.skip_depth and tag in self.BLOCK_TAGS:
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.skip_depth:
|
||||
return
|
||||
text = " ".join(data.split())
|
||||
if not text:
|
||||
return
|
||||
if self.in_title:
|
||||
self.title = text.removesuffix(" - Wikipedia")
|
||||
return
|
||||
self.parts.append(text + " ")
|
||||
|
||||
def wikipedia_images(self, source_url: str, limit: int) -> list[tuple[str, str]]:
|
||||
result: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for raw_url, alt, width, height in self.images:
|
||||
url = urljoin(source_url, raw_url)
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or ""
|
||||
path = parsed.path.lower()
|
||||
if parsed.scheme != "https" or not host.endswith(WIKIMEDIA_IMAGE_HOST_SUFFIX):
|
||||
continue
|
||||
if not path.endswith(WIKIPEDIA_IMAGE_EXTENSIONS):
|
||||
continue
|
||||
if (width is not None and width < 160) or (height is not None and height < 120):
|
||||
continue
|
||||
if any(fragment in path for fragment in ("/icons/", "wikimedia-button", "poweredby_mediawiki", "commons-logo")):
|
||||
continue
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
safe_alt = alt.replace("[", "(").replace("]", ")").replace("\n", " ").strip()
|
||||
result.append((url, safe_alt or "Wikipedia image"))
|
||||
if len(result) >= limit:
|
||||
break
|
||||
return result
|
||||
|
||||
def markdown(self, source_url: str, attachments: tuple[SourceAttachment, ...]) -> str:
|
||||
lines = [" ".join(line.split()) for line in "".join(self.parts).splitlines()]
|
||||
lines = [line for line in lines if line]
|
||||
content = "\n\n".join(lines[:350])
|
||||
image_markdown = "\n\n".join(
|
||||
f"[image={attachment.filename},{attachment.label}]"
|
||||
for attachment in attachments
|
||||
)
|
||||
return (
|
||||
f"# {self.title}\n\n"
|
||||
f"> Test-data snapshot from Wikipedia. Source: {source_url}\n\n"
|
||||
f"{image_markdown}\n\n"
|
||||
f"{content}\n"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceAttachment:
|
||||
filename: str
|
||||
data: bytes
|
||||
mime_type: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceDocument:
|
||||
title: str
|
||||
content: str
|
||||
attachments: tuple[SourceAttachment, ...] = ()
|
||||
|
||||
|
||||
class SourcePool:
|
||||
def __init__(self, documents: list[SourceDocument], seed: int | None) -> None:
|
||||
self.documents = documents
|
||||
self.random = random.Random(seed)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def for_item(self, index: int, label: str) -> SourceDocument:
|
||||
with self.lock:
|
||||
source = self.random.choice(self.documents)
|
||||
suffix = "".join(self.random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||
title = f"{label} {index:06d} {suffix}"
|
||||
content = (
|
||||
f"{source.content.rstrip()}\n\n"
|
||||
f"---\n\nLoad-test item: `{label}-{index:06d}-{suffix}`\n"
|
||||
)
|
||||
encoded = content.encode("utf-8")
|
||||
if len(encoded) > MAX_DOCUMENT_BYTES:
|
||||
content = encoded[:MAX_DOCUMENT_BYTES].decode("utf-8", errors="ignore")
|
||||
return SourceDocument(
|
||||
title=title[:80],
|
||||
content=content,
|
||||
attachments=source.attachments,
|
||||
)
|
||||
|
||||
|
||||
def generated_document(index: int) -> SourceDocument:
|
||||
rng = random.Random(index * 7919 + 17)
|
||||
words = [
|
||||
"architecture", "latency", "workspace", "revision", "markdown", "session",
|
||||
"security", "collaboration", "storage", "deployment", "monitoring", "testing",
|
||||
]
|
||||
paragraphs = []
|
||||
for paragraph_index in range(8):
|
||||
sentence_words = [rng.choice(words) for _ in range(rng.randint(35, 70))]
|
||||
paragraphs.append(" ".join(sentence_words).capitalize() + ".")
|
||||
content = (
|
||||
f"# Generated document {index}\n\n"
|
||||
f"- [ ] Validate record {index}\n"
|
||||
f"- [x] Generate deterministic content\n"
|
||||
f"- [ ] Review WebSocket diagnostics\n\n"
|
||||
+ "\n\n".join(paragraphs)
|
||||
+ f"\n\n```json\n{{\"index\": {index}, \"seed\": {rng.randint(1, 999999)}}}\n```\n"
|
||||
)
|
||||
return SourceDocument(title=f"Generated source {index}", content=content)
|
||||
|
||||
|
||||
def image_extension(url: str, mime_type: str) -> str:
|
||||
mapping = {
|
||||
"image/avif": ".avif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
if mime_type in mapping:
|
||||
return mapping[mime_type]
|
||||
path_extension = os.path.splitext(urlparse(url).path)[1].lower()
|
||||
if path_extension in WIKIPEDIA_IMAGE_EXTENSIONS:
|
||||
return ".jpg" if path_extension == ".jpeg" else path_extension
|
||||
return mimetypes.guess_extension(mime_type) or ".bin"
|
||||
|
||||
|
||||
def download_wikipedia_attachment(
|
||||
opener: Any,
|
||||
url: str,
|
||||
label: str,
|
||||
index: int,
|
||||
timeout: float,
|
||||
) -> SourceAttachment:
|
||||
request = Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "image/avif,image/webp,image/png,image/jpeg,image/gif,image/bmp;q=0.9,*/*;q=0.1",
|
||||
"User-Agent": "RustPad-random-data/1.2 (test data generator)",
|
||||
},
|
||||
)
|
||||
with opener.open(request, timeout=timeout) as response:
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > MAX_WIKIPEDIA_ATTACHMENT_BYTES:
|
||||
raise RuntimeError(f"Wikipedia image exceeds {MAX_WIKIPEDIA_ATTACHMENT_BYTES} bytes")
|
||||
except ValueError:
|
||||
pass
|
||||
mime_type = str(response.headers.get_content_type() or "application/octet-stream").lower()
|
||||
if mime_type not in WIKIPEDIA_IMAGE_MIME_TYPES:
|
||||
raise RuntimeError(f"Unsupported Wikipedia image type: {mime_type}")
|
||||
data = response.read(MAX_WIKIPEDIA_ATTACHMENT_BYTES + 1)
|
||||
if len(data) > MAX_WIKIPEDIA_ATTACHMENT_BYTES:
|
||||
raise RuntimeError(f"Wikipedia image exceeds {MAX_WIKIPEDIA_ATTACHMENT_BYTES} bytes")
|
||||
if not data:
|
||||
raise RuntimeError("Wikipedia image is empty")
|
||||
extension = image_extension(url, mime_type)
|
||||
filename = f"wikipedia-{index:02d}{extension}"
|
||||
safe_label = label.replace("]", ")").replace("\r", " ").replace("\n", " ").strip()
|
||||
return SourceAttachment(
|
||||
filename=filename,
|
||||
data=data,
|
||||
mime_type=mime_type,
|
||||
label=safe_label or f"Wikipedia image {index}",
|
||||
)
|
||||
|
||||
|
||||
def fetch_wikipedia_snapshot(
|
||||
index: int,
|
||||
timeout: float,
|
||||
insecure: bool,
|
||||
image_limit: int,
|
||||
attempts: int,
|
||||
) -> SourceDocument:
|
||||
context = ssl._create_unverified_context() if insecure else ssl.create_default_context()
|
||||
opener = build_opener(HTTPSHandler(context=context))
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
request = Request(
|
||||
WIKIPEDIA_RANDOM_URL,
|
||||
headers={"User-Agent": "RustPad-random-data/1.1 (test data generator)"},
|
||||
)
|
||||
try:
|
||||
with opener.open(request, timeout=timeout) as response:
|
||||
raw = response.read(MAX_SOURCE_BYTES)
|
||||
source_url = response.geturl()
|
||||
charset = response.headers.get_content_charset() or "utf-8"
|
||||
parser = ReadableHtmlParser()
|
||||
parser.feed(raw.decode(charset, errors="replace"))
|
||||
image_candidates = parser.wikipedia_images(source_url, image_limit * 3)
|
||||
if not image_candidates:
|
||||
last_error = RuntimeError(f"Wikipedia page had no usable images (attempt {attempt}/{attempts})")
|
||||
continue
|
||||
attachments: list[SourceAttachment] = []
|
||||
for image_url, label in image_candidates:
|
||||
try:
|
||||
attachments.append(
|
||||
download_wikipedia_attachment(
|
||||
opener,
|
||||
image_url,
|
||||
label,
|
||||
len(attachments) + 1,
|
||||
timeout,
|
||||
)
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - another candidate may still work.
|
||||
last_error = error
|
||||
continue
|
||||
if len(attachments) >= image_limit:
|
||||
break
|
||||
if not attachments:
|
||||
last_error = RuntimeError(
|
||||
f"Wikipedia page images could not be downloaded (attempt {attempt}/{attempts}): {last_error}"
|
||||
)
|
||||
continue
|
||||
attachment_tuple = tuple(attachments)
|
||||
content = parser.markdown(source_url, attachment_tuple)
|
||||
return SourceDocument(
|
||||
title=parser.title or f"Wikipedia {index}",
|
||||
content=content,
|
||||
attachments=attachment_tuple,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - retries cover transient Wikipedia failures.
|
||||
last_error = error
|
||||
raise RuntimeError(f"Could not fetch a Wikipedia article with images: {last_error}")
|
||||
|
||||
|
||||
def build_source_pool(args: argparse.Namespace, total_items: int) -> SourcePool:
|
||||
pool_size = max(1, min(args.source_pool_size, max(1, total_items)))
|
||||
if args.source == "generated":
|
||||
return SourcePool([generated_document(index) for index in range(pool_size)], args.seed)
|
||||
|
||||
documents: list[SourceDocument] = []
|
||||
failures = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.source_workers, pool_size)) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
fetch_wikipedia_snapshot,
|
||||
index,
|
||||
args.timeout,
|
||||
args.insecure,
|
||||
args.wikipedia_images,
|
||||
args.wikipedia_attempts,
|
||||
)
|
||||
for index in range(pool_size)
|
||||
]
|
||||
for index, future in enumerate(concurrent.futures.as_completed(futures), start=1):
|
||||
try:
|
||||
documents.append(future.result())
|
||||
print(f"Source snapshots: {index}/{pool_size}", end="\r", flush=True)
|
||||
except Exception as error: # noqa: BLE001 - all source failures are reported below.
|
||||
failures += 1
|
||||
print(f"\nWikipedia source failed: {error}", file=sys.stderr)
|
||||
print()
|
||||
if not documents:
|
||||
raise RuntimeError("No Wikipedia snapshot with a usable image could be downloaded.")
|
||||
if failures:
|
||||
print(
|
||||
f"Using {len(documents)} Wikipedia snapshots; {failures} source downloads failed. "
|
||||
"No generated fallback was added.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return SourcePool(documents, args.seed)
|
||||
|
||||
|
||||
class Progress:
|
||||
def __init__(self, total: int) -> None:
|
||||
self.total = total
|
||||
self.completed = 0
|
||||
self.failed = 0
|
||||
self.started = time.monotonic()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def record(self, success: bool) -> None:
|
||||
with self.lock:
|
||||
self.completed += 1
|
||||
if not success:
|
||||
self.failed += 1
|
||||
if self.completed == self.total or self.completed % 100 == 0:
|
||||
elapsed = max(0.001, time.monotonic() - self.started)
|
||||
rate = self.completed / elapsed
|
||||
print(
|
||||
f"Created: {self.completed}/{self.total} | failures: {self.failed} | {rate:.1f}/s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def execute_tasks(
|
||||
tasks: Iterable[tuple[str, str, str | None, int]],
|
||||
*,
|
||||
client: RustPadClient,
|
||||
sources: SourcePool,
|
||||
workers: int,
|
||||
total: int,
|
||||
) -> tuple[list[str], int]:
|
||||
progress = Progress(total)
|
||||
failures: list[str] = []
|
||||
failures_lock = threading.Lock()
|
||||
|
||||
def run(task: tuple[str, str, str | None, int]) -> None:
|
||||
kind, label, workspace_slug, index = task
|
||||
document = sources.for_item(index, label)
|
||||
try:
|
||||
if kind == "pad":
|
||||
created = client.create_pad(document.title, document.content)
|
||||
pad_slug = str(created.get("slug") or "")
|
||||
if not pad_slug:
|
||||
raise RuntimeError("Created pad response did not contain a slug.")
|
||||
for attachment in document.attachments:
|
||||
client.upload_pad_attachment(pad_slug, attachment)
|
||||
else:
|
||||
if not workspace_slug:
|
||||
raise RuntimeError("Workspace slug is missing.")
|
||||
created = client.create_workspace_note(workspace_slug, document.title, document.content)
|
||||
note_slug = str(created.get("slug") or "")
|
||||
if not note_slug:
|
||||
raise RuntimeError("Created workspace note response did not contain a slug.")
|
||||
for attachment in document.attachments:
|
||||
client.upload_workspace_note_attachment(
|
||||
workspace_slug,
|
||||
note_slug,
|
||||
attachment,
|
||||
)
|
||||
progress.record(True)
|
||||
except Exception as error: # noqa: BLE001 - all failures are reported after the run.
|
||||
with failures_lock:
|
||||
if len(failures) < 50:
|
||||
failures.append(str(error))
|
||||
progress.record(False)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
list(executor.map(run, tasks, chunksize=1))
|
||||
return failures, progress.failed
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create standalone notes, workspaces and workspace notes through the RustPad API.",
|
||||
)
|
||||
parser.add_argument("--ip", default="localhost", help="RustPad host name or IP address")
|
||||
parser.add_argument("--port", type=int, default=3000, help="RustPad port")
|
||||
parser.add_argument("--scheme", choices=("http", "https"), default="http")
|
||||
parser.add_argument("--base-url", help="Complete base URL; overrides --ip, --port and --scheme")
|
||||
parser.add_argument("--source", choices=("generated", "wikipedia"), default="generated")
|
||||
parser.add_argument("--notes", type=int, default=0, help="Total number of notes to create")
|
||||
parser.add_argument("--workspaces", type=int, default=0, help="Number of workspaces")
|
||||
parser.add_argument(
|
||||
"--notes-in-workspaces",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Number of notes from --notes distributed across all workspaces",
|
||||
)
|
||||
parser.add_argument("--user", required=True, help="Login e-mail or LDAP/AD username")
|
||||
parser.add_argument("--password", help="Account password; otherwise RUSTPAD_TEST_PASSWORD or a prompt is used")
|
||||
parser.add_argument("--workers", type=int, default=12, help="Concurrent API requests")
|
||||
parser.add_argument("--source-workers", type=int, default=6, help="Concurrent website downloads")
|
||||
parser.add_argument("--source-pool-size", type=int, default=40, help="Website/generated source documents reused by test items")
|
||||
parser.add_argument(
|
||||
"--wikipedia-images",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Maximum Wikipedia images downloaded and uploaded as attachments to each note",
|
||||
)
|
||||
parser.add_argument("--wikipedia-attempts", type=int, default=8, help="Random articles tried when a Wikipedia page has no usable image")
|
||||
parser.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds")
|
||||
parser.add_argument("--retries", type=int, default=5, help="Retries for transient API errors")
|
||||
parser.add_argument("--seed", type=int, help="Deterministic random seed")
|
||||
parser.add_argument("--prefix", default="Load test", help="Workspace name prefix")
|
||||
parser.add_argument("--insecure", action="store_true", help="Disable TLS certificate verification")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print counts without writing data")
|
||||
args = parser.parse_args()
|
||||
|
||||
for name in ("notes", "workspaces", "notes_in_workspaces"):
|
||||
if getattr(args, name) < 0:
|
||||
parser.error(f"--{name.replace('_', '-')} cannot be negative")
|
||||
if args.workers < 1 or args.source_workers < 1 or args.source_pool_size < 1:
|
||||
parser.error("worker and source-pool values must be positive")
|
||||
if not 1 <= args.wikipedia_images <= 10:
|
||||
parser.error("--wikipedia-images must be between 1 and 10")
|
||||
if args.wikipedia_attempts < 1:
|
||||
parser.error("--wikipedia-attempts must be positive")
|
||||
if args.notes_in_workspaces > args.notes:
|
||||
parser.error("--notes-in-workspaces cannot exceed --notes")
|
||||
if args.notes_in_workspaces and not args.workspaces:
|
||||
parser.error("--workspaces must be positive when --notes-in-workspaces is used")
|
||||
return args
|
||||
|
||||
|
||||
def base_url(args: argparse.Namespace) -> str:
|
||||
if args.base_url:
|
||||
return args.base_url.rstrip("/")
|
||||
host = f"[{args.ip}]" if ":" in args.ip and not args.ip.startswith("[") else args.ip
|
||||
return f"{args.scheme}://{host}:{args.port}"
|
||||
|
||||
|
||||
def resolve_password(args: argparse.Namespace) -> str:
|
||||
password = args.password or os.environ.get("RUSTPAD_TEST_PASSWORD")
|
||||
if password:
|
||||
return password
|
||||
if not sys.stdin.isatty():
|
||||
raise RuntimeError("Set --password or RUSTPAD_TEST_PASSWORD when stdin is not interactive.")
|
||||
return getpass.getpass("RustPad password: ")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
random.seed(args.seed)
|
||||
workspace_note_total = args.notes_in_workspaces
|
||||
standalone_note_total = args.notes - workspace_note_total
|
||||
item_total = args.notes
|
||||
per_workspace = []
|
||||
if args.workspaces:
|
||||
base_count, remainder = divmod(workspace_note_total, args.workspaces)
|
||||
per_workspace = [
|
||||
base_count + (1 if index < remainder else 0)
|
||||
for index in range(args.workspaces)
|
||||
]
|
||||
print(
|
||||
f"Target: {args.notes} notes total: {standalone_note_total} standalone and "
|
||||
f"{workspace_note_total} across {args.workspaces} workspaces."
|
||||
)
|
||||
if per_workspace:
|
||||
minimum = min(per_workspace)
|
||||
maximum = max(per_workspace)
|
||||
distribution = str(minimum) if minimum == maximum else f"{minimum}-{maximum}"
|
||||
print(f"Workspace distribution: {distribution} notes per workspace.")
|
||||
if args.dry_run:
|
||||
return 0
|
||||
if item_total == 0 and args.workspaces == 0:
|
||||
print("Nothing to create.")
|
||||
return 0
|
||||
|
||||
client = RustPadClient(
|
||||
base_url(args),
|
||||
timeout=args.timeout,
|
||||
retries=args.retries,
|
||||
insecure=args.insecure,
|
||||
)
|
||||
session = client.login(args.user, resolve_password(args))
|
||||
print(f"Logged in as {session.get('nickname') or args.user}.")
|
||||
|
||||
sources = build_source_pool(args, max(1, item_total))
|
||||
|
||||
workspace_slugs: list[str] = []
|
||||
for index in range(1, args.workspaces + 1):
|
||||
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
name = f"{args.prefix} workspace {index:04d} {suffix}"[:80]
|
||||
workspace = client.create_workspace(name)
|
||||
workspace_slugs.append(str(workspace["slug"]))
|
||||
print(f"Workspaces: {index}/{args.workspaces}", end="\r", flush=True)
|
||||
if args.workspaces:
|
||||
print()
|
||||
|
||||
tasks: list[tuple[str, str, str | None, int]] = []
|
||||
for index in range(1, standalone_note_total + 1):
|
||||
tasks.append(("pad", f"Standalone note", None, index))
|
||||
absolute_index = standalone_note_total
|
||||
for workspace_index, (slug, note_count) in enumerate(
|
||||
zip(workspace_slugs, per_workspace, strict=True),
|
||||
start=1,
|
||||
):
|
||||
for _note_index in range(1, note_count + 1):
|
||||
absolute_index += 1
|
||||
tasks.append(("workspace-note", f"Workspace {workspace_index:04d} note", slug, absolute_index))
|
||||
|
||||
failures, failed_count = execute_tasks(
|
||||
tasks,
|
||||
client=client,
|
||||
sources=sources,
|
||||
workers=args.workers,
|
||||
total=len(tasks),
|
||||
) if tasks else ([], 0)
|
||||
|
||||
if failed_count:
|
||||
print(f"Completed with {failed_count} failures ({len(failures)} shown):", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f"- {failure}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("Data generation completed successfully.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user