Add support for reverse ordering in link queries (Issue #1) #1

closed
opened by maxh.site targeting main from maxh.site/microcosm-rs: order_query

The following adds support for reverse-chronological ordering when fetching back-links.

We add a non-required "reverse" query parameter that allows reversing the default order of returned back-links.

Supports both current storage backends (mem_store and rocks_store).

+12 -3
constellation/src/server/mod.rs
··· 239 /// Set the max number of links to return per page of results 240 #[serde(default = "get_default_cursor_limit")] 241 limit: u64, 242 } 243 #[derive(Serialize)] 244 struct OtherSubjectCount { ··· 301 collection, 302 &path, 303 &path_to_other, 304 limit, 305 cursor_key, 306 &filter_dids, ··· 409 /// Set the max number of links to return per page of results 410 #[serde(default = "get_default_cursor_limit")] 411 limit: u64, 412 - // TODO: allow reverse (er, forward) order as well 413 } 414 #[derive(Template, Serialize)] 415 #[template(path = "get-backlinks.html.j2")] ··· 460 &query.subject, 461 collection, 462 &path, 463 limit, 464 until, 465 &filter_dids, ··· 508 from_dids: Option<String>, // comma separated: gross 509 #[serde(default = "get_default_cursor_limit")] 510 limit: u64, 511 - // TODO: allow reverse (er, forward) order as well 512 } 513 #[derive(Template, Serialize)] 514 #[template(path = "links.html.j2")] ··· 562 &query.target, 563 &query.collection, 564 &query.path, 565 limit, 566 until, 567 &filter_dids, ··· 594 path: String, 595 cursor: Option<OpaqueApiCursor>, 596 limit: Option<u64>, 597 - // TODO: allow reverse (er, forward) order as well 598 } 599 #[derive(Template, Serialize)] 600 #[template(path = "dids.html.j2")]
··· 239 /// Set the max number of links to return per page of results 240 #[serde(default = "get_default_cursor_limit")] 241 limit: u64, 242 + /// Allow returning links in reverse order (default: false) 243 + #[serde(default)] 244 + reverse: bool, 245 } 246 #[derive(Serialize)] 247 struct OtherSubjectCount { ··· 304 collection, 305 &path, 306 &path_to_other, 307 + query.reverse, 308 limit, 309 cursor_key, 310 &filter_dids, ··· 413 /// Set the max number of links to return per page of results 414 #[serde(default = "get_default_cursor_limit")] 415 limit: u64, 416 + /// Allow returning links in reverse order (default: false) 417 + #[serde(default)] 418 + reverse: bool, 419 } 420 #[derive(Template, Serialize)] 421 #[template(path = "get-backlinks.html.j2")] ··· 466 &query.subject, 467 collection, 468 &path, 469 + query.reverse, 470 limit, 471 until, 472 &filter_dids, ··· 515 from_dids: Option<String>, // comma separated: gross 516 #[serde(default = "get_default_cursor_limit")] 517 limit: u64, 518 + /// Allow returning links in reverse order (default: false) 519 + #[serde(default)] 520 + reverse: bool, 521 } 522 #[derive(Template, Serialize)] 523 #[template(path = "links.html.j2")] ··· 571 &query.target, 572 &query.collection, 573 &query.path, 574 + query.reverse, 575 limit, 576 until, 577 &filter_dids, ··· 604 path: String, 605 cursor: Option<OpaqueApiCursor>, 606 limit: Option<u64>, 607 } 608 #[derive(Template, Serialize)] 609 #[template(path = "dids.html.j2")]
+22 -6
constellation/src/storage/mem_store.rs
··· 140 collection: &str, 141 path: &str, 142 path_to_other: &str, 143 limit: u64, 144 after: Option<String>, 145 filter_dids: &HashSet<Did>, ··· 157 let filter_to_targets: HashSet<Target> = 158 HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); 159 160 - let mut grouped_counts: HashMap<Target, (u64, HashSet<Did>)> = HashMap::new(); 161 - for (did, rkey) in linkers.iter().flatten().cloned() { 162 if !filter_dids.is_empty() && !filter_dids.contains(&did) { 163 continue; 164 } ··· 184 .take(1) 185 .next() 186 { 187 - let e = grouped_counts.entry(fwd_target.clone()).or_default(); 188 e.0 += 1; 189 e.1.insert(did.clone()); 190 } 191 } 192 let mut items: Vec<(String, u64, u64)> = grouped_counts 193 .iter() 194 - .map(|(k, (n, u))| (k.0.clone(), *n, u.len() as u64)) 195 .collect(); 196 - items.sort(); 197 items = items 198 .into_iter() 199 .skip_while(|(t, _, _)| after.as_ref().map(|a| t <= a).unwrap_or(false)) ··· 239 target: &str, 240 collection: &str, 241 path: &str, 242 limit: u64, 243 until: Option<u64>, 244 filter_dids: &HashSet<Did>, ··· 261 }); 262 }; 263 264 - let did_rkeys: Vec<_> = if !filter_dids.is_empty() { 265 did_rkeys 266 .iter() 267 .filter(|m| { ··· 285 let alive = did_rkeys.iter().flatten().count(); 286 let gone = total - alive; 287 288 let items: Vec<_> = did_rkeys[begin..end] 289 .iter() 290 .rev()
··· 140 collection: &str, 141 path: &str, 142 path_to_other: &str, 143 + reverse: bool, 144 limit: u64, 145 after: Option<String>, 146 filter_dids: &HashSet<Did>, ··· 158 let filter_to_targets: HashSet<Target> = 159 HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); 160 161 + // the last type field here acts as an index to allow keeping track of the order in which 162 + // we encountred single elements 163 + let mut grouped_counts: HashMap<Target, (u64, HashSet<Did>, usize)> = HashMap::new(); 164 + for (idx, (did, rkey)) in linkers.iter().flatten().cloned().enumerate() { 165 if !filter_dids.is_empty() && !filter_dids.contains(&did) { 166 continue; 167 } ··· 187 .take(1) 188 .next() 189 { 190 + let e = 191 + grouped_counts 192 + .entry(fwd_target.clone()) 193 + .or_insert((0, HashSet::new(), idx)); 194 e.0 += 1; 195 e.1.insert(did.clone()); 196 } 197 } 198 let mut items: Vec<(String, u64, u64)> = grouped_counts 199 .iter() 200 + .map(|(k, (n, u, _))| (k.0.clone(), *n, u.len() as u64)) 201 .collect(); 202 + // sort in reverse order to show entries from oldest to newest 203 + if reverse { 204 + items.sort_by(|a, b| b.cmp(a)); 205 + } else { 206 + items.sort(); 207 + } 208 items = items 209 .into_iter() 210 .skip_while(|(t, _, _)| after.as_ref().map(|a| t <= a).unwrap_or(false)) ··· 250 target: &str, 251 collection: &str, 252 path: &str, 253 + reverse: bool, 254 limit: u64, 255 until: Option<u64>, 256 filter_dids: &HashSet<Did>, ··· 273 }); 274 }; 275 276 + let mut did_rkeys: Vec<_> = if !filter_dids.is_empty() { 277 did_rkeys 278 .iter() 279 .filter(|m| { ··· 297 let alive = did_rkeys.iter().flatten().count(); 298 let gone = total - alive; 299 300 + if reverse { 301 + did_rkeys.reverse(); 302 + } 303 + 304 let items: Vec<_> = did_rkeys[begin..end] 305 .iter() 306 .rev()
+2
constellation/src/storage/mod.rs
··· 72 collection: &str, 73 path: &str, 74 path_to_other: &str, 75 limit: u64, 76 after: Option<String>, 77 filter_dids: &HashSet<Did>, ··· 87 target: &str, 88 collection: &str, 89 path: &str, 90 limit: u64, 91 until: Option<u64>, 92 filter_dids: &HashSet<Did>,
··· 72 collection: &str, 73 path: &str, 74 path_to_other: &str, 75 + reverse: bool, 76 limit: u64, 77 after: Option<String>, 78 filter_dids: &HashSet<Did>, ··· 88 target: &str, 89 collection: &str, 90 path: &str, 91 + reverse: bool, 92 limit: u64, 93 until: Option<u64>, 94 filter_dids: &HashSet<Did>,
+15 -1
constellation/src/storage/rocks_store.rs
··· 941 collection: &str, 942 path: &str, 943 path_to_other: &str, 944 limit: u64, 945 after: Option<String>, 946 filter_dids: &HashSet<Did>, ··· 1071 } 1072 1073 let mut items: Vec<(String, u64, u64)> = Vec::with_capacity(grouped_counts.len()); 1074 for (target_id, (n, dids)) in &grouped_counts { 1075 let Some(target) = self 1076 .target_id_table ··· 1082 items.push((target.0 .0, *n, dids.len() as u64)); 1083 } 1084 1085 let next = if grouped_counts.len() as u64 >= limit { 1086 // yeah.... it's a number saved as a string......sorry 1087 grouped_counts ··· 1127 target: &str, 1128 collection: &str, 1129 path: &str, 1130 limit: u64, 1131 until: Option<u64>, 1132 filter_dids: &HashSet<Did>, ··· 1171 let begin = end.saturating_sub(limit as usize); 1172 let next = if begin == 0 { None } else { Some(begin as u64) }; 1173 1174 - let did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1175 1176 let mut items = Vec::with_capacity(did_id_rkeys.len()); 1177 // TODO: use get-many (or multi-get or whatever it's called)
··· 941 collection: &str, 942 path: &str, 943 path_to_other: &str, 944 + reverse: bool, 945 limit: u64, 946 after: Option<String>, 947 filter_dids: &HashSet<Did>, ··· 1072 } 1073 1074 let mut items: Vec<(String, u64, u64)> = Vec::with_capacity(grouped_counts.len()); 1075 + 1076 for (target_id, (n, dids)) in &grouped_counts { 1077 let Some(target) = self 1078 .target_id_table ··· 1084 items.push((target.0 .0, *n, dids.len() as u64)); 1085 } 1086 1087 + // Sort in desired direction 1088 + if reverse { 1089 + items.sort_by(|a, b| b.cmp(a)); // descending 1090 + } else { 1091 + items.sort(); // ascending 1092 + } 1093 + 1094 let next = if grouped_counts.len() as u64 >= limit { 1095 // yeah.... it's a number saved as a string......sorry 1096 grouped_counts ··· 1136 target: &str, 1137 collection: &str, 1138 path: &str, 1139 + reverse: bool, 1140 limit: u64, 1141 until: Option<u64>, 1142 filter_dids: &HashSet<Did>, ··· 1181 let begin = end.saturating_sub(limit as usize); 1182 let next = if begin == 0 { None } else { Some(begin as u64) }; 1183 1184 + let mut did_id_rkeys = linkers.0[begin..end].iter().rev().collect::<Vec<_>>(); 1185 + 1186 + if reverse { 1187 + did_id_rkeys.reverse(); 1188 + } 1189 1190 let mut items = Vec::with_capacity(did_id_rkeys.len()); 1191 // TODO: use get-many (or multi-get or whatever it's called)
+4
constellation/templates/base.html.j2
··· 40 padding: 0.5em 0.3em; 41 max-width: 100%; 42 } 43 .stat { 44 color: #f90; 45 font-size: 1.618rem;
··· 40 padding: 0.5em 0.3em; 41 max-width: 100%; 42 } 43 + pre.code input { 44 + margin: 0; 45 + padding: 0; 46 + } 47 .stat { 48 color: #f90; 49 font-size: 1.618rem;
+2 -1
constellation/templates/get-backlinks.html.j2
··· 6 7 {% block content %} 8 9 - {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit) %} 10 11 <h2> 12 Links to <code>{{ query.subject }}</code> ··· 40 <input type="hidden" name="did" value="{{ did }}" /> 41 {% endfor %} 42 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 43 <button type="submit">next page&hellip;</button> 44 </form> 45 {% else %}
··· 6 7 {% block content %} 8 9 + {% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit, query.reverse) %} 10 11 <h2> 12 Links to <code>{{ query.subject }}</code> ··· 40 <input type="hidden" name="did" value="{{ did }}" /> 41 {% endfor %} 42 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 43 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 44 <button type="submit">next page&hellip;</button> 45 </form> 46 {% else %}
+2
constellation/templates/get-many-to-many-counts.html.j2
··· 13 query.did, 14 query.other_subject, 15 query.limit, 16 ) %} 17 18 <h2> ··· 53 {% endfor %} 54 <input type="hidden" name="limit" value="{{ query.limit }}" /> 55 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 56 <button type="submit">next page&hellip;</button> 57 </form> 58 {% else %}
··· 13 query.did, 14 query.other_subject, 15 query.limit, 16 + query.reverse, 17 ) %} 18 19 <h2> ··· 54 {% endfor %} 55 <input type="hidden" name="limit" value="{{ query.limit }}" /> 56 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 57 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 58 <button type="submit">next page&hellip;</button> 59 </form> 60 {% else %}
+7 -2
constellation/templates/hello.html.j2
··· 49 <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 50 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 51 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 52 </ul> 53 54 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 55 - {% call try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16) %} 56 57 58 <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToManyCounts</code></h3> ··· 68 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 69 <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple users. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 70 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 71 </ul> 72 73 <p style="margin-bottom: 0"><strong>Try it:</strong></p> ··· 78 [""], 79 [""], 80 25, 81 ) %} 82 83 ··· 96 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 97 <li><p><code>from_dids</code> [deprecated]: optional. Use <code>did</code> instead. Example: <code>from_dids=did:plc:vc7f4oafdgxsihk4cry2xpze,did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 98 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 99 </ul> 100 101 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 102 - {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16) %} 103 104 105 <h3 class="route"><code>GET /links/distinct-dids</code></h3>
··· 49 <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 50 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 51 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 52 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 53 </ul> 54 55 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 56 + {% call 57 + try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16, false) %} 58 59 60 <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToManyCounts</code></h3> ··· 70 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 71 <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple users. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 72 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 73 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 74 </ul> 75 76 <p style="margin-bottom: 0"><strong>Try it:</strong></p> ··· 81 [""], 82 [""], 83 25, 84 + false, 85 ) %} 86 87 ··· 100 <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 101 <li><p><code>from_dids</code> [deprecated]: optional. Use <code>did</code> instead. Example: <code>from_dids=did:plc:vc7f4oafdgxsihk4cry2xpze,did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 102 <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 103 + <li><p><code>reverse</code>: optional, return links in reverse order. Default: <code>false</code></p></li> 104 </ul> 105 106 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 107 + {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16, false) %} 108 109 110 <h3 class="route"><code>GET /links/distinct-dids</code></h3>
+2 -1
constellation/templates/links.html.j2
··· 6 7 {% block content %} 8 9 - {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit) %} 10 11 <h2> 12 Links to <code>{{ query.target }}</code> ··· 37 <input type="hidden" name="collection" value="{{ query.collection }}" /> 38 <input type="hidden" name="path" value="{{ query.path }}" /> 39 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 40 <button type="submit">next page&hellip;</button> 41 </form> 42 {% else %}
··· 6 7 {% block content %} 8 9 + {% call try_it::links(query.target, query.collection, query.path, query.did, query.limit, query.reverse) %} 10 11 <h2> 12 Links to <code>{{ query.target }}</code> ··· 37 <input type="hidden" name="collection" value="{{ query.collection }}" /> 38 <input type="hidden" name="path" value="{{ query.path }}" /> 39 <input type="hidden" name="cursor" value={{ c|json|safe }} /> 40 + <input type="hidden" name="reverse" value="{{ query.reverse }}"> 41 <button type="submit">next page&hellip;</button> 42 </form> 43 {% else %}
+10 -6
constellation/templates/try-it-macros.html.j2
··· 1 - {% macro get_backlinks(subject, source, dids, limit) %} 2 <form method="get" action="/xrpc/blue.microcosm.links.getBacklinks"> 3 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getBacklinks 4 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 6 {%- for did in dids %}{% if !did.is_empty() %} 7 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 8 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 9 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 10 </form> 11 <script> 12 const addDidButton = document.getElementById('add-did'); ··· 24 </script> 25 {% endmacro %} 26 27 - {% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit) %} 28 <form method="get" action="/xrpc/blue.microcosm.links.getManyToManyCounts"> 29 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getManyToManyCounts 30 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 36 {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %} 37 &otherSubject= <input type="text" name="did" value="{{ otherSubject }}" placeholder="at-uri, did, uri..." />{% endif %}{% endfor %} 38 <span id="m2m-did-placeholder"></span> <button id="m2m-add-did">+ did filter</button> 39 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 40 </form> 41 <script> 42 const m2mAddDidButton = document.getElementById('m2m-add-did'); ··· 66 </script> 67 {% endmacro %} 68 69 - {% macro links(target, collection, path, dids, limit) %} 70 <form method="get" action="/links"> 71 <pre class="code"><strong>GET</strong> /links 72 ?target= <input type="text" name="target" value="{{ target }}" placeholder="target" /> ··· 75 {%- for did in dids %}{% if !did.is_empty() %} 76 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 77 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 78 - &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre> 79 </form> 80 <script> 81 const addDidButton = document.getElementById('add-did');
··· 1 + {% macro get_backlinks(subject, source, dids, limit, reverse) %} 2 <form method="get" action="/xrpc/blue.microcosm.links.getBacklinks"> 3 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getBacklinks 4 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 6 {%- for did in dids %}{% if !did.is_empty() %} 7 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 8 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 9 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 10 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"><button type="submit">get links</button></pre> 11 </form> 12 <script> 13 const addDidButton = document.getElementById('add-did'); ··· 25 </script> 26 {% endmacro %} 27 28 + {% macro get_many_to_many_counts(subject, source, pathToOther, dids, otherSubjects, limit, reverse) %} 29 <form method="get" action="/xrpc/blue.microcosm.links.getManyToManyCounts"> 30 <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getManyToManyCounts 31 ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> ··· 37 {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %} 38 &otherSubject= <input type="text" name="did" value="{{ otherSubject }}" placeholder="at-uri, did, uri..." />{% endif %}{% endfor %} 39 <span id="m2m-did-placeholder"></span> <button id="m2m-add-did">+ did filter</button> 40 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 41 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"><button type="submit">get links</button></pre> 42 </form> 43 <script> 44 const m2mAddDidButton = document.getElementById('m2m-add-did'); ··· 68 </script> 69 {% endmacro %} 70 71 + {% macro links(target, collection, path, dids, limit, reverse) %} 72 <form method="get" action="/links"> 73 <pre class="code"><strong>GET</strong> /links 74 ?target= <input type="text" name="target" value="{{ target }}" placeholder="target" /> ··· 77 {%- for did in dids %}{% if !did.is_empty() %} 78 &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 79 <span id="did-placeholder"></span> <button id="add-did">+ did filter</button> 80 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> 81 + &reverse= <input type="checkbox" name="reverse" value="true" checked="false"> 82 + <button type="submit">get links</button></pre> 83 </form> 84 <script> 85 const addDidButton = document.getElementById('add-did');