This commit is contained in:
Mateusz Gruszczyński committed 2026-10-16 22:49:22 +02:00
1 parent 4da41a1404
commit 5cb6960b04
31 files changed
+1611 -2123

No files matched your search

Generated
+1 -1
View File
@@ -2674,7 +2674,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.90" version = "0.2.91"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.90" version = "0.2.91"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
-29
View File
@@ -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
View File
@@ -938,6 +938,9 @@
"workspace.noteDeletedTitle": "Note deleted", "workspace.noteDeletedTitle": "Note deleted",
"workspace.noteName.placeholder": "Note name", "workspace.noteName.placeholder": "Note name",
"workspace.noteProtect": "Protect this note from deletion", "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.notes.copy": "Select a note or create a new one.",
"workspace.notesView": "Notes view", "workspace.notesView": "Notes view",
"workspace.pagination": "Notes pagination", "workspace.pagination": "Notes pagination",
@@ -1013,6 +1016,8 @@
"files.less": "Less", "files.less": "Less",
"files.noMatchesHelp": "Try a different file name or type.", "files.noMatchesHelp": "Try a different file name or type.",
"files.link": "Link", "files.link": "Link",
"files.markdown": "Markdown" "files.markdown": "Markdown",
"home.account.loading": "Checking your account...",
"home.account.retry": "Reload page"
} }
} }
+6 -1
View File
@@ -938,6 +938,9 @@
"workspace.noteDeletedTitle": "Notatka usunięta", "workspace.noteDeletedTitle": "Notatka usunięta",
"workspace.noteName.placeholder": "Nazwa notatki", "workspace.noteName.placeholder": "Nazwa notatki",
"workspace.noteProtect": "Chroń tę notatkę przed usunięciem", "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.notes.copy": "Wybierz notatkę albo utwórz nową.",
"workspace.notesView": "Widok notatek", "workspace.notesView": "Widok notatek",
"workspace.pagination": "Paginacja notatek", "workspace.pagination": "Paginacja notatek",
@@ -1013,6 +1016,8 @@
"files.less": "Mniej", "files.less": "Mniej",
"files.noMatchesHelp": "Spróbuj innej nazwy pliku lub typu.", "files.noMatchesHelp": "Spróbuj innej nazwy pliku lub typu.",
"files.link": "Link", "files.link": "Link",
"files.markdown": "Markdown" "files.markdown": "Markdown",
"home.account.loading": "Sprawdzanie konta...",
"home.account.retry": "Odśwież stronę"
} }
} }
+50 -3
View File
@@ -48,6 +48,7 @@ const MODULES: &[&str] = &[
"theme", "theme",
"url-state", "url-state",
"vendor-libs", "vendor-libs",
"virtual-editor-layers",
"security", "security",
"select", "select",
]; ];
@@ -73,8 +74,18 @@ pub fn render_html(
urls.stylesheet("styles"), urls.stylesheet("styles"),
urls.stylesheet_path("libs/rustpad-player/player.css"), urls.stylesheet_path("libs/rustpad-player/player.css"),
); );
let show_note_loader = matches!(entrypoint, "pad" | "note");
let html = template 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_STYLESHEET__", &app_stylesheets)
.replace("__APP_IMPORT_MAP__", &urls.import_map()) .replace("__APP_IMPORT_MAP__", &urls.import_map())
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint)) .replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
@@ -101,6 +112,39 @@ pub fn render_html(
response 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 { 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>"# 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 { 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 { fn entrypoint(&self, name: &str) -> String {
let entrypoint = self.url(&format!("js/{name}.js")); let entrypoint = self.url(&format!("js/{name}.js"));
format!( 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) escape_js_string(&entrypoint)
) )
} }
+2 -5
View File
@@ -174,7 +174,7 @@ impl Config {
"GUEST_UPLOAD_MAX_SIZE_MB", "GUEST_UPLOAD_MAX_SIZE_MB",
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)?, http_compression: values.bool("HTTP_COMPRESSION", false)?,
asset_cache_max_age_seconds: values asset_cache_max_age_seconds: values
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, .nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
@@ -241,10 +241,7 @@ fn multipart_body_limit_bytes(
file_limit.saturating_add(1024 * 1024) file_limit.saturating_add(1024 * 1024)
} }
fn megabytes_to_bytes( fn megabytes_to_bytes(name: &str, megabytes: u64) -> Result<usize, Box<dyn std::error::Error>> {
name: &str,
megabytes: u64,
) -> Result<usize, Box<dyn std::error::Error>> {
let bytes = megabytes let bytes = megabytes
.checked_mul(1024 * 1024) .checked_mul(1024 * 1024)
.ok_or_else(|| format!("{name} is too large"))?; .ok_or_else(|| format!("{name} is too large"))?;
+240
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -13,6 +13,7 @@
</head> </head>
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__"> <body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
__APP_LOADING_OVERLAY__
<header class="app-header"> <header class="app-header">
<div class="app-header__main"> <div class="app-header__main">
<div class="resource-brand" aria-label="__RESOURCE_ARIA_LABEL__"> <div class="resource-brand" aria-label="__RESOURCE_ARIA_LABEL__">
+40 -15
View File
@@ -13,7 +13,9 @@
</head> </head>
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__"> <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"> <main class="home-layout home-layout--wide">
<section class="home-intro"> <section class="home-intro">
<h1>Write. Share. Collaborate.</h1> <h1>Write. Share. Collaborate.</h1>
@@ -73,18 +75,27 @@
<footer class="home-footer"> <footer class="home-footer">
<div class="home-footer__inner"> <div class="home-footer__inner">
<div id="footer-account-guest" class="home-footer__account"> <div id="footer-account-slot" class="home-footer__account-slot" data-state="loading" aria-busy="true">
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button> <div id="footer-account-pending" class="home-footer__account-pending" role="status">
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button> <span class="home-footer__loading-line" aria-hidden="true"></span>
</div> <span class="home-footer__loading-action" aria-hidden="true"></span>
<div id="footer-account-user" class="home-footer__account" hidden> <span class="sr-only" data-i18n="home.account.loading">Checking your account...</span>
<span id="footer-user-label" class="home-footer__user"> </div>
<span class="home-footer__user-primary" data-account-primary></span> <a id="footer-account-retry" href="/" class="footer-action" data-i18n="home.account.retry" hidden>Reload
<small class="home-footer__user-secondary" data-account-secondary hidden></small> page</a>
</span> <div id="footer-account-guest" class="home-footer__account" hidden>
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button> <button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
<button id="footer-profile" class="footer-action" type="button">Profile</button> <button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
<button id="footer-logout" class="footer-action" type="button">Log out</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> </div>
<button id="footer-about" class="footer-action home-footer__about" type="button">About</button> <button id="footer-about" class="footer-action home-footer__about" type="button">About</button>
</div> </div>
@@ -107,7 +118,8 @@
<p>Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl</p> <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 <p>This software is available under GNU GPLv3, which also permits commercial use provided that its terms are
followed.</p> 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 part of a proprietary (closed-source) product without the obligations imposed by GPLv3, contact us about a
separate commercial license. <br>Contact: linuxiarz&lt;at&gt;linuxiarz.pl</p> separate commercial license. <br>Contact: linuxiarz&lt;at&gt;linuxiarz.pl</p>
</div> </div>
@@ -161,7 +173,8 @@
<section class="resources-category resources-category--favorites"> <section class="resources-category resources-category--favorites">
<header class="resources-category__header"> <header class="resources-category__header">
<h3>Favorites</h3> <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> </header>
<div id="favorites-list" class="resources-list resources-list--favorites"></div> <div id="favorites-list" class="resources-list resources-list--favorites"></div>
</section> </section>
@@ -239,6 +252,18 @@
<p id="profile-message" class="form-message" role="status"></p> <p id="profile-message" class="form-message" role="status"></p>
</form> </form>
</dialog> </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 id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals">
</div> </div>
+68 -7
View File
@@ -60,7 +60,61 @@ export function parseAuthorship(content, raw) {
} }
export function serializeAuthorship(model, contentLength) { 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) { export function applyAuthorshipEdit(model, previousText, nextText, owner) {
@@ -79,6 +133,8 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) {
const removedLength = oldSuffix - prefix; const removedLength = oldSuffix - prefix;
const insertedLength = newSuffix - prefix; const insertedLength = newSuffix - prefix;
const delta = insertedLength - removedLength; const delta = insertedLength - removedLength;
const canonical = canonicalSpans(model, previousText.length);
if (canonical) return fastAuthorshipEdit(canonical, prefix, oldSuffix, insertedLength, delta, owner);
const updated = []; const updated = [];
for (const source of normalize(model?.spans, previousText.length)) { 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) { 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 { return {
version: VERSION, version: VERSION,
spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength), spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength),
}; };
} }
export function authorshipOwners(model) { export function authorshipOwners(model) {
const owners = new Map(); const owners = new Map();
for (const span of model?.spans || []) { for (const span of model?.spans || []) {
@@ -120,13 +185,11 @@ export function lineAuthors(content, model) {
const starts = [0]; const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1); 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; let canonical = true;
for (let i = 0, previousEnd = 0; i < spans.length; i++) { for (let i = 0, previousEnd = 0; i < spans.length; i++) {
const span = spans[i]; const span = spans[i];
if (!Number.isFinite(span?.start) || !Number.isFinite(span?.end) 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; canonical = false;
break; break;
} }
@@ -187,8 +250,6 @@ export function syncAuthorshipLayer(layer, editor) {
const canvas = layer.querySelector(".authorship-canvas"); const canvas = layer.querySelector(".authorship-canvas");
if (!canvas) return; if (!canvas) return;
canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`; 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.scrollTop = 0;
layer.scrollLeft = 0; layer.scrollLeft = 0;
} }
+129
View File
@@ -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);
}
});
})();
+7 -2
View File
@@ -105,10 +105,15 @@ export class CollaborationSession {
const replacements = mergeOwnerReplacements([], ownerReplacements); const replacements = mergeOwnerReplacements([], ownerReplacements);
if (!hasTextEffect(operation) && !replacements.length) return null; 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) { if (operationBaseLength(operation) !== localLength) {
throw new Error("Local operation base length does not match the collaborative document"); throw new Error("Local operation base length does not match the collaborative document");
} }
const targetLength = operationTargetLength(operation);
if (!this.buffer) { if (!this.buffer) {
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements); this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
@@ -117,7 +122,7 @@ export class CollaborationSession {
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements); 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"); throw new Error("Buffered operation target length does not match the collaborative document");
} }
return this.buffer; return this.buffer;
+20 -3
View File
@@ -100,6 +100,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken() || await handleShareInvitationToken(); await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken() || await handleShareInvitationToken();
const identityDialog = document.querySelector("#identity-dialog"); const identityDialog = document.querySelector("#identity-dialog");
const accountSlot = document.querySelector("#footer-account-slot");
const guestAccount = document.querySelector("#footer-account-guest"); const guestAccount = document.querySelector("#footer-account-guest");
const userAccount = document.querySelector("#footer-account-user"); const userAccount = document.querySelector("#footer-account-user");
const userLabel = document.querySelector("#footer-user-label"); const userLabel = document.querySelector("#footer-user-label");
@@ -121,6 +122,7 @@ const aboutDialog = document.querySelector("#about-dialog");
const profileForm = document.querySelector("#profile-form"); const profileForm = document.querySelector("#profile-form");
let currentSession = null; let currentSession = null;
let accountStateRevision = 0;
function authHeaders() { return {}; } function authHeaders() { return {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; } 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) { function renderAccount(session) {
accountStateRevision += 1;
currentSession = session; currentSession = session;
guestAccount.hidden = Boolean(session); guestAccount.hidden = Boolean(session);
userAccount.hidden = !session; userAccount.hidden = !session;
@@ -473,6 +476,13 @@ function renderAccount(session) {
userLabelSecondary.hidden = true; userLabelSecondary.hidden = true;
} }
registerLink.hidden = !registrationEnabled; 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()); document.querySelector("#footer-about")?.addEventListener("click", () => aboutDialog?.showModal());
@@ -523,7 +533,7 @@ if (identityDialog) {
profileDialog.showModal(); profileDialog.showModal();
}); });
document.addEventListener("rustpad:languagechange", () => { document.addEventListener("rustpad:languagechange", () => {
renderAccount(currentSession); if (accountSlot?.dataset.state === "ready") renderAccount(currentSession);
if (!profileDialog?.open) return; if (!profileDialog?.open) return;
populateLanguageSelect(document.querySelector("#profile-language"), getLanguage()); populateLanguageSelect(document.querySelector("#profile-language"), getLanguage());
}); });
@@ -601,6 +611,13 @@ if (identityDialog) {
}); });
window.addEventListener("rustpad:session-expired", () => renderAccount(null)); window.addEventListener("rustpad:session-expired", () => renderAccount(null));
renderAccount(null); const lookupRevision = accountStateRevision;
validateCurrentSession().then(renderAccount); 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
View File
@@ -16,6 +16,7 @@ import { identityOperation, operationFromEdit } from "@rustpad/collaboration";
import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session"; import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links"; 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 { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
import { createMentionAutocomplete } from "@rustpad/mention-autocomplete"; import { createMentionAutocomplete } from "@rustpad/mention-autocomplete";
import { createMentionNavigator } from "@rustpad/mention-navigation"; import { createMentionNavigator } from "@rustpad/mention-navigation";
@@ -52,6 +53,18 @@ export function startNoteEditor(adapter) {
? crypto.randomUUID().replaceAll("-", "") ? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId); 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 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 editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
let pendingPreviewViewport = null; let pendingPreviewViewport = null;
@@ -549,6 +562,7 @@ export function startNoteEditor(adapter) {
} }
function syncEditorLayers() { function syncEditorLayers() {
virtualLayers.sync();
gutter.scrollTop = editor.scrollTop; gutter.scrollTop = editor.scrollTop;
syncAuthorshipLayer(authorshipLayer, editor); syncAuthorshipLayer(authorshipLayer, editor);
syncOwnerLabels(); syncOwnerLabels();
@@ -557,19 +571,34 @@ export function startNoteEditor(adapter) {
let gutterLineCount = 0; let gutterLineCount = 0;
let gutterLineHeight = 0; let gutterLineHeight = 0;
let highlightedGutterLine = null; 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() { function renderGutter() {
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked); document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked); document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked);
gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked)); gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked));
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const content = editor.value;
const lines = Array.from({ length: lineCount }); cachedLineText = content;
cachedLineStarts = buildLineStarts(content);
const lineCount = cachedLineStarts.length;
const owners = authorshipOwners(authorship); const owners = authorshipOwners(authorship);
renderedAuthorshipOwners = owners; renderedAuthorshipOwners = owners;
const showAuthorship = authorshipColorsEnabled && owners.length > 0; const showAuthorship = authorshipColorsEnabled && owners.length > 0;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
const full = authorshipMode === "full"; const full = authorshipMode === "full";
const authorsByLine = showAuthorship && full ? lineAuthors(content, authorship) : [];
authorshipLayer.hidden = !showAuthorship; authorshipLayer.hidden = !showAuthorship;
ownerLabels.hidden = !full || !showAuthorship; ownerLabels.hidden = !full || !showAuthorship;
renderParticipantBadges(authorshipColorsEnabled ? owners : []); renderParticipantBadges(authorshipColorsEnabled ? owners : []);
@@ -577,8 +606,26 @@ export function startNoteEditor(adapter) {
editorWorkspace.dataset.authorshipMode = authorshipMode; 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; 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`; 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. ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
// Reuse them across character edits and add/remove only the changed tail. 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) { if (gutterLineHeight !== lineHeight) {
for (const row of gutter.children) row.style.height = `${lineHeight}px`; for (const row of gutter.children) row.style.height = `${lineHeight}px`;
gutterLineHeight = lineHeight; gutterLineHeight = lineHeight;
@@ -593,8 +640,6 @@ export function startNoteEditor(adapter) {
for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove(); for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove();
} }
gutterLineCount = lineCount; gutterLineCount = lineCount;
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
if (full) { if (full) {
let previousAuthorSignature = null; let previousAuthorSignature = null;
ownerLabels.innerHTML = `<div class="owner-labels-canvas">${lines.map((_, i) => { ownerLabels.innerHTML = `<div class="owner-labels-canvas">${lines.map((_, i) => {
@@ -610,12 +655,7 @@ export function startNoteEditor(adapter) {
} else ownerLabels.replaceChildren(); } else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren(); else authorshipLayer.replaceChildren();
const linkedLine = lineFromHash(location.hash, lineCount); updateLinkedLineHighlight();
if (highlightedGutterLine !== linkedLine) {
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
highlightedGutterLine = linkedLine;
}
syncEditorLayers(); syncEditorLayers();
} }
@@ -644,10 +684,12 @@ export function startNoteEditor(adapter) {
function revealLinkedLine() { function revealLinkedLine() {
if (!location.hash || location.hash === lastRevealedLineHash) return; 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); const line = lineFromHash(location.hash, lineCount);
if (!line) return; if (!line) return;
const offset = lineStartOffset(editor.value, line); const offset = hasCurrentIndex ? cachedLineStarts[line - 1] : lineStartOffset(editor.value, line);
if (offset == null) return; if (offset == null) return;
const style = getComputedStyle(editor); const style = getComputedStyle(editor);
@@ -1135,8 +1177,6 @@ export function startNoteEditor(adapter) {
let cachedPreviewHeight = -1; let cachedPreviewHeight = -1;
let cachedPreviewWidth = -1; let cachedPreviewWidth = -1;
function invalidatePreviewPositions() { previewPositionCache = null; } 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") { if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(invalidatePreviewPositions).observe(preview); new ResizeObserver(invalidatePreviewPositions).observe(preview);
} }
@@ -1149,10 +1189,11 @@ export function startNoteEditor(adapter) {
preview.addEventListener("toggle", invalidatePreviewPositions, true); preview.addEventListener("toggle", invalidatePreviewPositions, true);
preview.addEventListener("load", invalidatePreviewPositions, true); preview.addEventListener("load", invalidatePreviewPositions, true);
window.addEventListener("resize", invalidatePreviewPositions); window.addEventListener("resize", invalidatePreviewPositions);
window.addEventListener("resize", () => {
if (virtualLayers.active) virtualLayers.sync();
});
document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions); document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions);
function previewLinePositions() { 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; const height = preview.scrollHeight, width = preview.clientWidth;
if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache; if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache;
const previewRect = preview.getBoundingClientRect(); const previewRect = preview.getBoundingClientRect();
@@ -1312,11 +1353,9 @@ export function startNoteEditor(adapter) {
let previewStale = true; let previewStale = true;
let forcePreviewRender = false; let forcePreviewRender = false;
let initialPreviewEnhancements = Promise.resolve();
function renderNow() { function renderNow() {
const previewViewport = pendingPreviewViewport; 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 const headingRevealPending = uiState.mode === "markdown" && location.hash
&& !/^#L[1-9]\d*$/i.test(location.hash) && !/^#L[1-9]\d*$/i.test(location.hash)
&& lastRevealedHeadingHash !== location.hash; && lastRevealedHeadingHash !== location.hash;
@@ -1337,9 +1376,7 @@ export function startNoteEditor(adapter) {
scheduleAliasFileRefresh(editor.value); scheduleAliasFileRefresh(editor.value);
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)"; document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
if (activeView() !== "edit" || previewNeededForPdf) { if (activeView() !== "edit" || previewNeededForPdf) {
renderMermaid(); initialPreviewEnhancements = Promise.allSettled([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]);
renderCodeHighlight();
renderMediaPlayers();
} }
} else { } else {
preview.classList.add("preview--raw"); preview.classList.add("preview--raw");
@@ -1360,16 +1397,35 @@ export function startNoteEditor(adapter) {
revealHeadingAnchor(); 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); 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 deferredRenderTimer = null;
let firstDeferredRenderAt = 0; let firstDeferredRenderAt = 0;
let lastLocalInputAt = -Infinity;
function scheduleDocumentRender() { function scheduleDocumentRender() {
const now = performance.now(); const now = performance.now();
if (deferredRenderTimer === null) firstDeferredRenderAt = now; if (deferredRenderTimer === null) firstDeferredRenderAt = now;
else clearTimeout(deferredRenderTimer); 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 = setTimeout(() => {
deferredRenderTimer = null; deferredRenderTimer = null;
firstDeferredRenderAt = 0; firstDeferredRenderAt = 0;
@@ -1607,7 +1663,7 @@ export function startNoteEditor(adapter) {
throw new Error("Server snapshot does not match the collaborative operation"); throw new Error("Server snapshot does not match the collaborative operation");
} }
if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map); 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; return result;
} }
@@ -1665,8 +1721,7 @@ export function startNoteEditor(adapter) {
} }
} }
const { loadFiles, initialFilesReady } = bindNoteFiles({
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken, editor, toast, getAccessToken: () => accessToken,
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0, getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
canDelete: () => Boolean(info?.can_delete_files), canDelete: () => Boolean(info?.can_delete_files),
@@ -1753,6 +1808,8 @@ export function startNoteEditor(adapter) {
applyCollaborativeView({ resetHistory: true }); applyCollaborativeView({ resetHistory: true });
flushRequested = false; flushRequested = false;
saveState.textContent = "Read only"; saveState.textContent = "Read only";
bootDocumentReady = true;
void finishInitialEditorBoot();
return; return;
} }
handleCollaborativeAuthentication(message); handleCollaborativeAuthentication(message);
@@ -1760,6 +1817,8 @@ export function startNoteEditor(adapter) {
else if (collaboration.buffer) flushCollaborativeUpdate(); else if (collaboration.buffer) flushCollaborativeUpdate();
else saveState.textContent = "Changes are saved automatically"; else saveState.textContent = "Changes are saved automatically";
editor.focus(); editor.focus();
bootDocumentReady = true;
void finishInitialEditorBoot();
}, },
onDocument: handleCollaborativeDocument, onDocument: handleCollaborativeDocument,
onResync: message => recoverCollaborativeSnapshot(message, "resync"), 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 }); toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
if (!passwordDialog.open) passwordDialog.showModal(); if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus(); document.querySelector("#open-password")?.focus();
releaseBootForAccessDialog();
try { try {
await loadNoteInfo(); await loadNoteInfo();
adapter.configureView?.(info); adapter.configureView?.(info);
@@ -1803,6 +1863,7 @@ export function startNoteEditor(adapter) {
if (saveState.textContent === "Saving…") saveState.textContent = "Save failed"; if (saveState.textContent === "Saving…") saveState.textContent = "Save failed";
if (/nickname|session|account/i.test(message)) { if (/nickname|session|account/i.test(message)) {
if (!identityDialog.open) identityDialog.showModal(); if (!identityDialog.open) identityDialog.showModal();
releaseBootForAccessDialog();
return; return;
} }
if (info?.protected && isResourceAccessError(message)) { if (info?.protected && isResourceAccessError(message)) {
@@ -1811,6 +1872,7 @@ export function startNoteEditor(adapter) {
document.querySelector("#password-error").textContent = friendly; document.querySelector("#password-error").textContent = friendly;
if (!passwordDialog.open) passwordDialog.showModal(); if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus(); document.querySelector("#open-password")?.focus();
releaseBootForAccessDialog();
return; return;
} }
toast.danger(friendly, { title: "Editor connection error" }); toast.danger(friendly, { title: "Editor connection error" });
@@ -1842,23 +1904,35 @@ export function startNoteEditor(adapter) {
nickname = accountSession?.nickname || getNickname(); nickname = accountSession?.nickname || getNickname();
if (!nickname) { if (!nickname) {
if (!identityDialog.open) identityDialog.showModal(); if (!identityDialog.open) identityDialog.showModal();
releaseBootForAccessDialog();
return; return;
} }
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key);
await loadNoteInfo(); await loadNoteInfo();
bootLoading?.complete("access");
document.title = adapter.title(info); 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(); 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); adapter.configureView?.(info);
applyUi({ write: true, replace: true }); applyUi({ write: true, replace: true });
updateCurrentUser(); updateCurrentUser();
if (info.protected && info.access_level === "none") passwordDialog.showModal(); if (info.protected && info.access_level === "none") {
else { loadFiles(); connect(); } passwordDialog.showModal();
releaseBootForAccessDialog();
} else {
void loadFiles();
void initialFilesReady.then(() => {
bootFilesReady = true;
void finishInitialEditorBoot();
});
connect();
}
} catch (e) { } catch (e) {
if (e.status === 403 || e.status === 404) { if (e.status === 403 || e.status === 404) {
await showSystemNotFound(); await showSystemNotFound();
return; 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>`; 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("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 => { preview.addEventListener("click", event => {
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null; const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
if (!link) return; if (!link) return;
@@ -2626,6 +2700,7 @@ export function startNoteEditor(adapter) {
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner()); authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
previousContent = nextContent; previousContent = nextContent;
if (!applyingRemote && !applyingHistory) lastLocalInputAt = performance.now();
scheduleDocumentRender(); scheduleDocumentRender();
if (applyingRemote || applyingHistory) return; if (applyingRemote || applyingHistory) return;
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship)); queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
+5 -1
View File
@@ -182,6 +182,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
let currentFiles = []; let currentFiles = [];
let filesLoaded = false; let filesLoaded = false;
let loadSequence = 0; let loadSequence = 0;
let finishInitialFiles;
const initialFilesReady = new Promise(resolve => { finishInitialFiles = resolve; });
function summaryText(files, shown = files.length) { function summaryText(files, shown = files.length) {
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); 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)}`); footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
renderFiles(); renderFiles();
onFilesChanged(currentFiles); onFilesChanged(currentFiles);
finishInitialFiles();
} catch (error) { } catch (error) {
if (sequence !== loadSequence) return; if (sequence !== loadSequence) return;
renderLoadError(error); renderLoadError(error);
finishInitialFiles();
if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") }); 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
View File
@@ -128,7 +128,7 @@ function settleInitialPublicAnchor(hash) {
window.addEventListener("load", scheduleCorrection, { once: true }); window.addEventListener("load", scheduleCorrection, { once: true });
cleanups.push(() => window.removeEventListener("load", scheduleCorrection)); cleanups.push(() => window.removeEventListener("load", scheduleCorrection));
} }
document.fonts?.ready?.then(scheduleCorrection).catch(() => {}); document.fonts?.ready?.then(scheduleCorrection).catch(() => { });
if (typeof ResizeObserver === "function") { if (typeof ResizeObserver === "function") {
observer = new ResizeObserver(scheduleCorrection); observer = new ResizeObserver(scheduleCorrection);
@@ -183,6 +183,7 @@ function restoreFullWidth() {
async function initialize() { async function initialize() {
try { try {
const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() });
window.__RUSTPAD_LOADING__?.complete("access");
if (passwordDialog.open) passwordDialog.close(); if (passwordDialog.open) passwordDialog.close();
passwordError.textContent = ""; passwordError.textContent = "";
publicTitle.textContent = page.title; publicTitle.textContent = page.title;
@@ -197,12 +198,17 @@ async function initialize() {
installPublicPermalinks(); installPublicPermalinks();
alignPreviewLineNumbers(content); alignPreviewLineNumbers(content);
settleInitialPublicAnchor(location.hash); settleInitialPublicAnchor(location.hash);
window.__RUSTPAD_LOADING__?.complete("files");
window.__RUSTPAD_LOADING__?.ready();
} catch (error) { } catch (error) {
window.__RUSTPAD_LOADING__?.complete("access");
publicAnchorSettleCleanup?.(); publicAnchorSettleCleanup?.();
if (error.status === 401 || error.status === 403) { 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."); 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(); if (!passwordDialog.open) passwordDialog.showModal();
passwordInput.focus(); passwordInput.focus();
window.__RUSTPAD_LOADING__?.skip("files");
window.__RUSTPAD_LOADING__?.ready();
return; return;
} }
content.replaceChildren(); content.replaceChildren();
@@ -211,6 +217,8 @@ async function initialize() {
message.textContent = String(error.message); message.textContent = String(error.message);
content.append(message); content.append(message);
toast.danger(error.message, { title: "Could not load published page" }); toast.danger(error.message, { title: "Could not load published page" });
window.__RUSTPAD_LOADING__?.skip("files");
window.__RUSTPAD_LOADING__?.ready();
} }
} }
content.addEventListener("click", async event => { content.addEventListener("click", async event => {
+263
View File
@@ -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
View File
@@ -489,7 +489,17 @@ workspaceSettingsForm.addEventListener("submit", async event => {
submit.disabled = false; 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("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
document.querySelector("#note-form").addEventListener("submit", async e => { document.querySelector("#note-form").addEventListener("submit", async e => {
e.preventDefault(); 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" }); } catch (e) { toast.danger(e.message, { title: "Could not copy workspace link" }); }
}); });
async function startAuthorizedWorkspace() { async function startAuthorizedWorkspace() {
const session = await validateCurrentSession(); const loading = window.__RUSTPAD_LOADING__;
accountSession = session; document.querySelector("#workspace-startup-error")?.setAttribute("hidden", "");
nickname = session?.nickname || getNickname(); 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; workspaceContent.hidden = true;
if (!identityDialog.open) identityDialog.showModal(); const panel = document.querySelector("#workspace-startup-error");
return; 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({ bindIdentityDialog({
dialog: identityDialog, dialog: identityDialog,
onIdentity: async (value, session) => { onIdentity: async (value, session) => {
+27 -8
View File
@@ -27,6 +27,11 @@
</div> </div>
<div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div> <div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
</header> </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> <main id="workspace-content" class="workspace-page" hidden>
<section class="workspace-top"> <section class="workspace-top">
<div> <div>
@@ -34,8 +39,10 @@
<p>Select a note or create a new one.</p> <p>Select a note or create a new one.</p>
</div> </div>
<div class="workspace-top__actions"> <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="archived-notes-button" class="secondary-button inline-button" type="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> 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> <button id="new-note-button" class="primary-button inline-button" type="button">New note</button>
</div> </div>
</section> </section>
@@ -50,9 +57,12 @@
<form id="workspace-settings-form" class="workspace-settings-card" hidden> <form id="workspace-settings-form" class="workspace-settings-card" hidden>
<div class="workspace-settings-card__copy"> <div class="workspace-settings-card__copy">
<strong data-i18n="workspace.settings.deletedNotes">Deleted notes</strong> <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> </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> <button class="secondary-button" type="submit">Save</button>
<small id="workspace-settings-status" class="form-message" role="status"></small> <small id="workspace-settings-status" class="form-message" role="status"></small>
</form> </form>
@@ -78,7 +88,8 @@
<div class="archived-notes-panel__header"> <div class="archived-notes-panel__header">
<div> <div>
<h2 data-i18n="workspace.archive.title">Archived notes</h2> <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> </div>
<button id="close-archived-notes" class="icon-button" type="button" aria-label="Close dialog">×</button> <button id="close-archived-notes" class="icon-button" type="button" aria-label="Close dialog">×</button>
</div> </div>
@@ -127,14 +138,22 @@
<dialog id="note-dialog"> <dialog id="note-dialog">
<form id="note-form" class="dialog-panel"> <form id="note-form" class="dialog-panel">
<h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label <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 class="dialog-check"><input id="note-protect" type="checkbox"
deletion</label> 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> <p id="note-error" class="form-message error"></p>
<div class="dialog-actions"><button type="button" id="cancel-note" <div class="dialog-actions"><button type="button" id="cancel-note"
class="secondary-button">Cancel</button><button class="primary-button">Create</button></div> class="secondary-button">Cancel</button><button class="primary-button">Create</button></div>
</form> </form>
</dialog> </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> </body>
</html> </html>
-35
View File
@@ -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');
-27
View File
@@ -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`);
}
-61
View File
@@ -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');
-157
View File
@@ -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()
-74
View File
@@ -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`);
-114
View File
@@ -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()
-35
View File
@@ -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');
-179
View File
@@ -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()
-37
View File
@@ -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');
-829
View File
@@ -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())