OXYGENHUB

A default bookmark must exist: settings refuse a missing one, the overview stands in main with a note, an unknown name is not a bookmark, tag or change; Bookmarks › New bookmark makes one on the web (closes #345) 351cf57e5608

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3a2338c9c706
When
· 2026-10-27 17:56 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
351cf57e5608e7486d534fae73a0bfe833c800684179121a555b643e9f55aae3
Depends on
046e3352cdfd 1553bfc9ae53 19bb31ff1f5c 3f4b0ea6eabf 83d5052bf76c a7e6e8e6094c b5668108487f ba0a55bb5b3c d23464c0ac60 e271a37b629a ffb67a81188c
Depended on by
217814c239f0
In bookmarks
main
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
17 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A default bookmark must exist, and bookmarks are made on the web (#345).** Settings refused nothing, so a default named before it existed (doomsdayone/tetriblox's `develop`) left the repository's pages saying `"develop" is not a change`. Settings now refuse a new default that is not a bookmark (an empty repository may name any, for its first push) and list those that are; a missing default leaves the overview showing main, or the first bookmark, with a note; an unknown name is "not a bookmark, tag or change of this repository". Bookmarks › New bookmark makes one from a bookmark, tag or change, as the user's delegate or, without a trust policy, unsigned as a push would be, so a proposal can then target it.
8 +
7 9
- **Iterations on project boards (#343).** A board can run an iteration: a name and its first and last days. The board shows it above the columns, day N of M and the cards done so far, with a burn-up of cards done per day. Closing it archives the cards in the Done column (the one that takes finished work, or else the last) under it; its page, linked from "past iterations", lists them, restorable, with its burn-up. One iteration is open at a time. Migration 0100.
8 10
9 11
- **`oh` on Haiku (#344).** `oh` builds and runs on Haiku R1/beta6 (x86_64); `oh-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.9.9, with its own `SHA256SUMS-haiku`, and the README says how to install it.
…
372 unchanged lines
modified crates/hub/src/repos/view.rs +28 −4
…
102 unchanged lines
103 103
    Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] })
104 104
}
105 105
106 +
/// The bookmark to show when the default bookmark does not exist: main,
107 +
/// else master, else the first by name; `None` without any.
108 +
fn stand_in(repo: &Repo) -> Result<Option<String>> {
109 +
    let view = repo.view()?;
110 +
    let names: Vec<&String> = view.bookmarks.keys().collect();
111 +
    Ok(["main", "master"]
112 +
        .iter()
113 +
        .find(|n| names.iter().any(|b| b == *n))
114 +
        .map(|n| n.to_string())
115 +
        .or_else(|| names.iter().min().map(|b| b.to_string())))
116 +
}
117 +
106 118
/// A change by full hash or unique hex prefix, searched over every
107 119
/// bookmark's state.
108 120
pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result<ChangeHash> {
109 121
    let spec = spec.trim_start_matches('#');
110 122
    if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) {
111 -
        return Err(fail(format!("{spec:?} is not a change")));
123 +
        return Err(fail(format!("{spec:?} is not a bookmark, tag or change of this repository")));
112 124
    }
113 125
    if let Some(h) = ChangeHash::from_hex(spec) {
114 126
        if repo.store().has(&h) {
…
800 unchanged lines
915 927
    pub policy: PolicyBadge,
916 928
    pub contributors: Vec<Contributor>,
917 929
    pub languages: Vec<LanguageShare>,
930 +
    /// The bookmark shown in the default's place when the repository has
931 +
    /// no bookmark of the default's name (a default set before its first
932 +
    /// push, or one deleted since).
933 +
    pub standing_in: Option<String>,
918 934
}
919 935
920 936
impl Overview {
…
271 unchanged lines
1192 1208
/// The overview of `shown` (a bookmark or tag; the default bookmark
1193 1209
/// unless the viewer chose another, oxygenhub#191).
1194 1210
pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str, shown: &str) -> Result<Overview> {
1195 -
    let target = match resolve(repo, cache, shown) {
1196 -
        Ok(t) => t,
1197 -
        Err(_) => RefTarget { label: shown.into(), kind: RefKind::Bookmark, heads: vec![] },
1211 +
    let missing = || RefTarget { label: shown.into(), kind: RefKind::Bookmark, heads: vec![] };
1212 +
    let (target, standing_in) = match resolve(repo, cache, shown) {
1213 +
        Ok(t) => (t, None),
1214 +
        // No bookmark of the default's name: show main, or else the first
1215 +
        // bookmark, and say so, rather than an empty repository.
1216 +
        Err(_) if shown == default_bookmark => match stand_in(repo)? {
1217 +
            Some(b) => (resolve(repo, cache, &b)?, Some(b)),
1218 +
            None => (missing(), None),
1219 +
        },
1220 +
        Err(_) => (missing(), None),
1198 1221
    };
1199 1222
    let heads = target.heads.len();
1200 1223
    // One load of the default state serves the counts, the latest change
…
26 unchanged lines
1227 1250
        policy: policy_badge(repo),
1228 1251
        contributors: contributors(repo, state),
1229 1252
        languages: languages(snapshot),
1253 +
        standing_in,
1230 1254
    })
1231 1255
}
1232 1256
…
320 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
245 unchanged lines
246 246
        .route("/{owner}/{name}/compare/{spec}", get(history::compare))
247 247
        .route("/{owner}/{name}/bookmarks", get(refs::bookmarks))
248 248
        .route("/{owner}/{name}/bookmarks/delete", post(refs::delete_bookmark))
249 +
        .route("/{owner}/{name}/bookmarks/create", post(refs::create_bookmark))
249 250
        .route("/{owner}/{name}/tags", get(refs::tags))
250 251
        .route("/{owner}/{name}/tags/create", post(refs::create_tag))
251 252
        .route("/{owner}/{name}/tags/withdraw", post(refs::withdraw_tag))
…
711 unchanged lines
modified crates/hub/src/web/refs.rs +108 −1
…
14 unchanged lines
15 15
    shell: Shell,
16 16
    repo: RepoShell,
17 17
    bookmarks: Vec<view::BookmarkView>,
18 -
    /// Whether the viewer may delete bookmarks (write access).
18 +
    /// Whether the viewer may create and delete bookmarks (write access).
19 19
    can_delete: bool,
20 20
    /// Whether the viewer may run the pipeline of a bookmark.
21 21
    can_run: bool,
…
119 unchanged lines
141 141
    .await
142 142
}
143 143
144 +
#[derive(Deserialize)]
145 +
pub struct CreateBookmarkForm {
146 +
    #[serde(rename = "_csrf")]
147 +
    csrf_token: String,
148 +
    name: String,
149 +
    /// A bookmark, tag or change; the default bookmark when empty.
150 +
    #[serde(default)]
151 +
    from: String,
152 +
}
153 +
154 +
/// Create a bookmark from the web at the heads of a bookmark, tag or change:
155 +
/// what pushing a new bookmark does, signed by the hub key as the user's
156 +
/// delegate, as the web's other changes to bookmarks are.
157 +
pub async fn create_bookmark(
158 +
    State(app): State<Shared>,
159 +
    viewer: Viewer,
160 +
    Path((owner, name)): Path<(String, String)>,
161 +
    Form(f): Form<CreateBookmarkForm>,
162 +
) -> Result<Page<BookmarksPage>, Error> {
163 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer);
164 +
    let me = require(&viewer, &shell)?;
165 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
166 +
    csrf(&viewer, &f.csrf_token, &shell)?;
167 +
    let bookmark = f.name.trim().to_string();
168 +
    let from = match f.from.trim() {
169 +
        "" => row.default_bookmark.clone(),
170 +
        s => s.to_string(),
171 +
    };
172 +
    if bookmark.is_empty() || bookmark.chars().any(char::is_whitespace) {
173 +
        return bookmarks_page(&app, shell, repo, row, role, None, Some("A bookmark's name has no spaces.".into())).await;
174 +
    }
175 +
    let (wanted, spec) = (bookmark.clone(), from.clone());
176 +
    let found = app
177 +
        .repos
178 +
        .read(&repo, move |r, cache| {
179 +
            if r.view()?.bookmarks.contains_key(&wanted) {
180 +
                return Ok(Err(format!("{wanted} is a bookmark already.")));
181 +
            }
182 +
            Ok(view::resolve(r, cache, &spec).map(|t| t.heads).map_err(|e| e.to_string()))
183 +
        })
184 +
        .await
185 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
186 +
    let heads = match found {
187 +
        Ok(heads) if !heads.is_empty() => heads,
188 +
        Ok(_) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("{from} holds no changes to start from."))).await,
189 +
        Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await,
190 +
    };
191 +
    // As the user's delegate when they have one; otherwise unsigned, which
192 +
    // a repository without a trust policy takes, as a push without one.
193 +
    let made = match super::edit::signer_for(&app, me.account.id).await {
194 +
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, bookmark.clone(), heads.clone(), signer)
195 +
            .await
196 +
            .map(|_| ())
197 +
            .map_err(|e| e.to_string()),
198 +
        Err(why) => {
199 +
            let (target, at) = (bookmark.clone(), heads.clone());
200 +
            let unsigned = app
201 +
                .repos
202 +
                .write(&repo, move |r, _| {
203 +
                    let update = r.host_ref_update(&target, &at, None)?;
204 +
                    let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?;
205 +
                    Ok(match report.results.first() {
206 +
                        Some(res) if res.ok => Ok(()),
207 +
                        Some(res) => Err(res.reason.clone()),
208 +
                        None => Err("nothing happened".to_string()),
209 +
                    })
210 +
                })
211 +
                .await
212 +
                .map_err(|e| e.to_string())
213 +
                .and_then(|r| r);
214 +
            unsigned.map_err(|refused| format!("{refused}. {why}"))
215 +
        }
216 +
    };
217 +
    if let Err(e) = made {
218 +
        return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not create {bookmark}: {e}"))).await;
219 +
    }
220 +
    let made = bookmark.clone();
221 +
    let new_state = app
222 +
        .repos
223 +
        .read(&repo, move |r, _| Ok(r.view()?.bookmarks.get(&made).map(|s| s.to_hex()).unwrap_or_default()))
224 +
        .await
225 +
        .unwrap_or_default();
226 +
    let result = db::repos::PushOutcome {
227 +
        reference: bookmark.clone(),
228 +
        ok: true,
229 +
        reason: format!("created on the web from {from}"),
230 +
        old_state: oxy_core::hash::ObjectRef::ZERO.to_hex(),
231 +
        new_state: new_state.clone(),
232 +
        heads: heads.len() as i64,
233 +
        changes: 0,
234 +
    };
235 +
    db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[result]).await.ok();
236 +
    crate::hooks::emit(
237 +
        &app,
238 +
        &row,
239 +
        "push",
240 +
        &me.account.username,
241 +
        serde_json::json!({ "updates": [{ "reference": bookmark, "old_state": oxy_core::hash::ObjectRef::ZERO.to_hex(), "new_state": new_state, "heads": heads.len(), "changes": 0, "created": true }], "via": "web" }),
242 +
    )
243 +
    .await;
244 +
    db::accounts::audit(&app.db, Some(&me.account), "bookmark.created", &format!("{owner}/{name}"), &format!("{bookmark} from {from}"))
245 +
        .await
246 +
        .ok();
247 +
    let notice = format!("Created {bookmark} from {from}. Propose changes into it, or make it the default in Settings.");
248 +
    bookmarks_page(&app, shell, repo, row, role, Some(notice), None).await
249 +
}
250 +
144 251
#[derive(Template)]
145 252
#[template(path = "tags.html")]
146 253
pub struct TagsPage {
…
178 unchanged lines
modified crates/hub/src/web/repo_settings.rs +19 −1
…
523 unchanged lines
524 524
    Form(f): Form<GeneralForm>,
525 525
) -> Result<Response, Error> {
526 526
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
527 -
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
527 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
528 528
    csrf(&viewer, &f.csrf_token, &shell)?;
529 529
    let e = |e| Error::from_db(shell.clone(), e);
530 +
    // A new default must be a bookmark the repository has, or every page
531 +
    // that opens it finds nothing; an empty repository may name any, for
532 +
    // its first push to create.
533 +
    let wanted = f.default_bookmark.trim().to_string();
534 +
    if wanted != row.default_bookmark {
535 +
        let names: Vec<String> = app
536 +
            .repos
537 +
            .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>()))
538 +
            .await
539 +
            .map_err(|err| Error::from_repo(shell.clone(), err))?;
540 +
        if !names.is_empty() && !names.contains(&wanted) {
541 +
            let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect();
542 +
            some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len()));
543 +
            let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() };
544 +
            let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", "));
545 +
            return back(&row, Section::General, None, Some(msg));
546 +
        }
547 +
    }
530 548
    let topics = match db::repos::parse_topics(&f.topics) {
531 549
        Ok(t) => t,
532 550
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
…
1113 unchanged lines
modified crates/hub/tests/releases.rs +94 −0
…
481 unchanged lines
482 482
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.2")).await;
483 483
    assert!(r.text().contains("withdrawn"), "{}", r.text());
484 484
}
485 +
486 +
/// A bookmark is made on the web from another (doomsdayone/tetriblox could
487 +
/// not make develop), a default must be a bookmark the repository has, and
488 +
/// one that is missing (set before) leaves the overview showing main with a
489 +
/// note rather than an empty repository or a 404 saying "not a change".
490 +
#[tokio::test(flavor = "multi_thread")]
491 +
async fn bookmarks_are_made_on_the_web_and_a_default_must_exist() {
492 +
    hermetic();
493 +
    let dir = tempfile::tempdir().unwrap();
494 +
    let mut config = Config::load(dir.path()).unwrap();
495 +
    config.registration.setup_code = "test-setup".into();
496 +
    config.auth.push_tokens = vec!["site".into()];
497 +
    config.registration.mode = "open".into();
498 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
499 +
    let port = listener.local_addr().unwrap().port();
500 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
501 +
    let app = oxygenhub::build(config).await.unwrap();
502 +
    let router = oxygenhub::router(app.clone());
503 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
504 +
    let base = format!("http://127.0.0.1:{port}");
505 +
    let app = &app;
506 +
    let andy = send(
507 +
        app,
508 +
        "POST",
509 +
        "/register",
510 +
        None,
511 +
        None,
512 +
        Some("application/x-www-form-urlencoded"),
513 +
        b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(),
514 +
    )
515 +
    .await
516 +
    .cookie
517 +
    .unwrap();
518 +
    let repo = RepoName::parse("andy/demo").unwrap();
519 +
    app.repos.create(&repo, None).unwrap();
520 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
521 +
    let work = tempfile::tempdir().unwrap();
522 +
    let mut client = Repo::init(work.path()).unwrap();
523 +
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
524 +
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
525 +
    client.config.set("remote.origin.token", "site").unwrap();
526 +
    client.save_config().unwrap();
527 +
    write(work.path(), "a.txt", b"one\n");
528 +
    client.snapshot("t").unwrap();
529 +
    let c1 = client.commit("First", &[]).unwrap();
530 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
531 +
    let host = || app.repos.open(&repo).unwrap();
532 +
    let heads_of = |b: &str| {
533 +
        let r = host();
534 +
        let st = r.view().unwrap().bookmarks.get(b).copied();
535 +
        st.map(|st| r.store().get_state(&st).unwrap().heads)
536 +
    };
537 +
    let page = send(app, "GET", "/andy/demo/bookmarks", Some(&andy), None, None, vec![]).await.text();
538 +
    assert!(page.contains("New bookmark") && page.contains("value=\"main\""), "{page}");
539 +
    let csrf = csrf_of(&page);
540 +
541 +
    // develop from the default; then not twice, nor from nothing.
542 +
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=develop&from=")).await;
543 +
    assert!(r.text().contains("Created develop from main."), "{}", r.text());
544 +
    assert_eq!(heads_of("develop"), Some(vec![c1]));
545 +
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=develop&from=main")).await;
546 +
    assert!(r.text().contains("develop is a bookmark already."), "{}", r.text());
547 +
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=feature&from=nowhere")).await;
548 +
    assert!(r.text().contains("&#34;nowhere&#34; is not a bookmark, tag or change of this repository"), "{}", r.text());
549 +
    assert_eq!(heads_of("feature"), None);
550 +
    let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=two+words&from=main")).await;
551 +
    assert!(r.text().contains("no spaces"), "{}", r.text());
552 +
553 +
    // The default: an existing bookmark, never a missing one.
554 +
    let settings = |b: &str| format!("_csrf={csrf}&description=&visibility=public&default_bookmark={b}");
555 +
    let r = form(app, "/andy/demo/settings", &andy, &settings("release")).await;
556 +
    let to = r.location.unwrap_or_default();
557 +
    assert!(to.contains("error=") && to.contains("release"), "{to}");
558 +
    let row = || async { db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() };
559 +
    assert_eq!(row().await.default_bookmark, "main");
560 +
    form(app, "/andy/demo/settings", &andy, &settings("develop")).await;
561 +
    assert_eq!(row().await.default_bookmark, "develop");
562 +
563 +
    // One set before it existed (or deleted since): main, with a note.
564 +
    sqlx::query("UPDATE repositories SET default_bookmark = 'gone' WHERE name = 'demo'").execute(&app.db).await.unwrap();
565 +
    let page = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await;
566 +
    assert_eq!(page.status, StatusCode::OK);
567 +
    let page = page.text();
568 +
    assert!(
569 +
        page.contains("This repository has no bookmark <strong>gone</strong>, its default, so this is <strong>main</strong>."),
570 +
        "{page}"
571 +
    );
572 +
    assert!(page.contains("a.txt"), "main's files: {page}");
573 +
    // Saving other settings keeps a missing default rather than refusing.
574 +
    let r =
575 +
        form(app, "/andy/demo/settings", &andy, &format!("_csrf={csrf}&description=Demo&visibility=public&default_bookmark=gone")).await;
576 +
    assert!(!r.location.unwrap_or_default().contains("error="));
577 +
    assert_eq!(row().await.description, "Demo");
578 +
}
modified templates/bookmarks.html +8 −0
…
6 unchanged lines
7 7
<div class="head"><h1>Bookmarks <small>{{ bookmarks.len() }}</small></h1></div>
8 8
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
9 9
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
10 +
{% if can_delete %}<details class="panel"{% if bookmarks.is_empty() %} open{% endif %}><summary>New bookmark</summary>
11 +
<form class="card" method="post" action="{{ repo.url }}/bookmarks/create"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
12 +
  <label for="bm-name">Name</label><input type="text" id="bm-name" name="name" maxlength="255" placeholder="develop" required>
13 +
  <label for="bm-from">From</label><input type="text" id="bm-from" name="from" list="bm-list" value="{% for b in bookmarks %}{% if b.default %}{{ b.name }}{% endif %}{% endfor %}" placeholder="a bookmark, tag or change">
14 +
  <datalist id="bm-list">{% for b in bookmarks %}<option value="{{ b.name }}">{% endfor %}</datalist>
15 +
  <p class="small mute">It starts at the same changes as what it is made from; nothing is copied.</p>
16 +
  <button type="submit">CREATE</button>
17 +
</form></details>{% endif %}
10 18
<div class="table"><table>
11 19
<tr><th>Bookmark</th><th>Heads</th><th>Changes</th><th>Latest</th><th></th></tr>
12 20
{% for b in bookmarks %}
…
11 unchanged lines
modified templates/overview.html +1 −0
…
9 unchanged lines
10 10
  <div>
11 11
    {% if o.heads > 1 %}<div class="strip warn">{% if at.len() > 24 %}<span class="hash-tip" tabindex="0" data-full="{{ at }}" aria-label="{{ at }}">{{ at|ends }}</span>{% else %}{{ at }}{% endif %} has {{ o.heads }} heads; the tree below is their union. <a href="{{ repo.url }}/bookmarks">Bookmarks</a></div>{% endif %}
12 12
    {% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %}
13 +
    {% if let Some(b) = o.standing_in %}<div class="strip warn">This repository has no bookmark <strong>{{ repo.default_bookmark }}</strong>, its default, so this is <strong>{{ b }}</strong>. Push {{ repo.default_bookmark }}, or choose an existing default in <a href="{{ repo.url }}/settings">settings</a>.</div>{% endif %}
13 14
    <div class="bar">
14 15
      <details class="ref-pick">
15 16
        <summary class="go small" aria-haspopup="true" title="Choose a bookmark or tag"><span aria-hidden="true">{% if o.tree.target.kind == crate::repos::view::RefKind::Tag %}🏷{% else %}◆{% endif %}</span> {% if at.len() > 24 %}<span class="hash-tip" tabindex="0" data-full="{{ at }}" aria-label="{{ at }}">{{ at|ends }}</span>{% else %}{{ at }}{% endif %} <span aria-hidden="true">▾</span></summary>
…
103 unchanged lines