results.astro (22365B)
1 --- 2 import StartLayout from "../layouts/StartLayout.astro"; 3 import { MeiliSearch, type SearchResponse } from "meilisearch"; 4 import Pagination from "../components/Pagination.astro"; 5 import Preview from "../components/Preview.astro"; 6 import Checkbox from "../components/Checkbox.astro"; 7 8 interface FilterJson { 9 client_server?: boolean; 10 server_server?: boolean; 11 application_services?: boolean; 12 e2e?: boolean; 13 kind_core?: boolean; 14 kind_feature?: boolean; 15 kind_maintenance?: boolean; 16 needs_implementation?: boolean; 17 proposal?: boolean; 18 abandoned?: boolean; 19 blocked?: boolean; 20 state?: { 21 merged?: boolean; 22 closed?: boolean; 23 other?: boolean; 24 }; 25 start_opened_date?: number; 26 end_opened_date?: number; 27 start_merged_closed_date?: number; 28 end_merged_closed_date?: number; 29 [key: string]: number | boolean | { 30 merged?: boolean; 31 closed?: boolean; 32 other?: boolean; 33 } | undefined; 34 } 35 36 const parsed_url = new URL(Astro.request.url); 37 const search_params = parsed_url.searchParams; 38 const query = search_params.get("q"); 39 const filter = search_params.get("filter"); 40 const current_page = search_params.get("page"); 41 const filter_parsed: FilterJson = JSON.parse(filter || "{}"); 42 let results: SearchResponse | undefined = undefined; 43 if (Astro.request.method === "POST") { 44 try { 45 const data = await Astro.request.formData(); 46 47 const entries = Object.fromEntries(data.entries()); 48 const filter_json: FilterJson = {}; 49 for (const key in entries) { 50 if (entries.hasOwnProperty(key)) { 51 if (entries[key] === "on") { 52 if (key === "merged" || key === "closed" || key === "other") { 53 filter_json["state"] = { 54 ...filter_json["state"], 55 [key]: true, 56 }; 57 } else{ 58 filter_json[key] = true; 59 } 60 } else if ( 61 key === "start-opened-date" || 62 key === "end-opened-date" || 63 key === "start-merged-closed-date" || 64 key === "end-merged-closed-date" 65 ) { 66 const fixed_key = key.replaceAll("-", "_"); 67 if (entries[key] !== "") { 68 const milis = Date.parse(entries[key] as string); 69 filter_json[fixed_key] = milis / 1000; 70 } else { 71 filter_json[fixed_key] = undefined; 72 } 73 } 74 } 75 } 76 77 const searchQuery = data.get("search"); 78 const json = JSON.stringify(filter_json); 79 const search_params = new URLSearchParams({ 80 q: searchQuery as string, 81 filter: json, 82 }); 83 return Astro.redirect(`/results?${search_params.toString()}`); 84 } catch (error) { 85 if (error instanceof Error) { 86 console.error(error.message); 87 } 88 } 89 } else { 90 const has_query = search_params.has("q"); 91 if (has_query) { 92 const INDEX = import.meta.env.INDEX; 93 const client = new MeiliSearch({ 94 host: import.meta.env.MEILI_HOST ?? "http://127.0.0.1:7700", 95 apiKey: import.meta.env.API_KEY, 96 requestConfig: { 97 headers: { 98 Authorization: `Bearer ${import.meta.env.API_KEY}` ?? "", 99 }, 100 }, 101 }); 102 results = await client.index(INDEX).search(query, { 103 filter: filter_to_string(filter_parsed), 104 page: parseInt(current_page ?? "1"), 105 }); 106 console.log(results); 107 } 108 } 109 110 function format_date(time: number | undefined): string | undefined { 111 if (time === undefined || time === null) { 112 return undefined; 113 } 114 const date = new Date(time * 1000); 115 return date.toISOString().split("T")[0]; 116 } 117 118 /** This function converts the filter json to a meilisearch filter string 119 * We must make sure to not have a stray AND at the end. 120 * If we compare labels we need to have an OR relation between them. 121 * If we check state we need to check if it is merged, closed or other by comapring the state field to the string in uppercase. 122 * Other means that it is not merged or closed. 123 * It also can never be AND for a state as it can only be either merged, closed or other. 124 * 125 * 126 * @param filter The filter json 127 * @returns The filter string 128 * @example 129 * Example: 130 * 131 * ```json 132 * { 133 * client_server: true, 134 * server_server: true, 135 * state: { 136 * merged: true, 137 * closed: true, 138 * } 139 * } 140 * ``` 141 * 142 * should be converted to 143 * 144 * ```text 145 * (labels.name = "client_server" OR labels.name = "server_server") AND (state = "MERGED" OR state = "CLOSED") 146 * ``` 147 */ 148 149 function filter_to_string(filter: FilterJson): string { 150 let filter_string = ""; 151 152 // Copy the filter to not mutate the original 153 const filter_copy = { ...filter }; 154 155 // Split the state filter from the rest 156 const state_filter = filter_copy.state; 157 delete filter_copy.state; 158 159 // Convert the state filter to a string 160 if (state_filter !== undefined) { 161 if (state_filter.merged) { 162 filter_string += `(state = "MERGED"`; 163 } 164 if (state_filter.closed) { 165 if (filter_string === "") { 166 filter_string += `(state = "CLOSED"`; 167 } else { 168 filter_string += ` OR state = "CLOSED"`; 169 } 170 } 171 if (state_filter.other) { 172 if (filter_string === "") { 173 filter_string += `((state != "MERGED" AND state != "CLOSED")`; 174 } else { 175 filter_string += ` OR (state != "MERGED" AND state != "CLOSED")`; 176 } 177 } 178 // Close the state filter 179 if (filter_string !== "") { 180 filter_string += ")"; 181 } 182 } 183 184 // Split the date filters from the rest 185 const start_opened_date = filter_copy.start_opened_date; 186 const end_opened_date = filter_copy.end_opened_date; 187 const start_merged_closed_date = filter_copy.start_merged_closed_date; 188 const end_merged_closed_date = filter_copy.end_merged_closed_date; 189 delete filter_copy.start_opened_date; 190 delete filter_copy.end_opened_date; 191 delete filter_copy.start_merged_closed_date; 192 delete filter_copy.end_merged_closed_date; 193 194 // Convert the date filters to a string 195 let date_filter = "("; 196 if (start_opened_date !== undefined) { 197 if (date_filter === "(") { 198 date_filter += `created_at >= ${start_opened_date}`; 199 } else { 200 date_filter += ` AND created_at >= ${start_opened_date}`; 201 } 202 } 203 204 if (end_opened_date !== undefined) { 205 if (date_filter === "(") { 206 date_filter += `created_at <= ${end_opened_date}`; 207 } else { 208 date_filter += ` AND created_at <= ${end_opened_date}`; 209 } 210 } 211 212 if (start_merged_closed_date !== undefined) { 213 if (date_filter === "(") { 214 date_filter += `(merged_at >= ${start_merged_closed_date} AND merged_at != null) OR (closed_at >= ${start_merged_closed_date} AND closed_at != null)`; 215 } else { 216 date_filter += ` AND (merged_at >= ${start_merged_closed_date} AND merged_at != null) OR (closed_at >= ${start_merged_closed_date} AND closed_at != null)`; 217 } 218 } 219 220 if (end_merged_closed_date !== undefined) { 221 if (date_filter === "(") { 222 date_filter += `(merged_at <= ${end_merged_closed_date} AND merged_at != null) OR (closed_at <= ${end_merged_closed_date} AND closed_at != null)`; 223 } else { 224 date_filter += ` AND (merged_at <= ${end_merged_closed_date} AND merged_at != null) OR (closed_at <= ${end_merged_closed_date} AND closed_at != null)`; 225 } 226 } 227 228 // Close the date filter 229 if (date_filter !== "") { 230 date_filter += ")"; 231 } 232 233 if (filter_string !== "" && date_filter !== "()") { 234 filter_string += ` AND ${date_filter}`; 235 } else if (filter_string === "" && date_filter !== "()") { 236 filter_string += date_filter; 237 } 238 239 // Convert the rest of the filter to a string 240 let label_filter = "("; 241 for (const key in filter_copy) { 242 if (filter.hasOwnProperty(key)) { 243 const value = filter[key]; 244 if (value === undefined) { 245 continue; 246 } 247 if (typeof value === "boolean") { 248 if (value) { 249 // Add filter but make sure we don't have a stray OR at the end 250 if (label_filter === "(") { 251 label_filter += `labels.name = "${key}"`; 252 } else { 253 label_filter += ` OR labels.name = "${key}"`; 254 } 255 } 256 } 257 } 258 } 259 // Close the label filter 260 if (label_filter !== "") { 261 label_filter += ")"; 262 } 263 if (filter_string !== "" && label_filter !== "()") { 264 filter_string += ` AND ${label_filter}`; 265 } else if (filter_string === "" && label_filter !== "()") { 266 filter_string += label_filter; 267 } 268 269 console.log(filter_string) 270 return filter_string; 271 } 272 --- 273 274 <StartLayout> 275 <form method="POST"> 276 <main> 277 <header> 278 <a href="/"><h1>Matrix Spec Changes</h1></a> 279 <div id="search"> 280 <input type="search" name="search" aria-label="Search" value={query} required /> 281 <button type="submit" > 282 <svg id="search-icon" 283 width="24" 284 height="24" 285 viewBox="0 0 48 48" 286 fill="none" 287 aria-label="Search" 288 xmlns="http://www.w3.org/2000/svg" 289 > 290 <g clip-path="url(#clip0_2_39)"> 291 <path 292 d="M31 28H29.42L28.86 27.46C30.82 25.18 32 22.22 32 19C32 11.82 26.18 6 19 6C11.82 6 6 11.82 6 19C6 26.18 11.82 32 19 32C22.22 32 25.18 30.82 27.46 28.86L28 29.42V31L38 40.98L40.98 38L31 28ZM19 28C14.02 28 10 23.98 10 19C10 14.02 14.02 10 19 10C23.98 10 28 14.02 28 19C28 23.98 23.98 28 19 28Z" 293 fill="#334155"></path> 294 </g> 295 <defs> 296 <clipPath id="clip0_2_39"> 297 <rect width="48" height="48" fill="white" 298 ></rect> 299 </clipPath> 300 </defs> 301 </svg> 302 </button> 303 </div> 304 </header> 305 <div id="content"> 306 <div id="results"> 307 {results?.hits.map((hit) => ( 308 <Preview 309 title={hit.title} 310 author={hit.author} 311 content={hit.body} 312 id={hit.uid} 313 /> 314 ))} 315 </div> 316 <aside id="filters"> 317 <div class="filter-section"> 318 <h2>Labels</h2> 319 <div class="filter-options"> 320 <Checkbox name="client_server" label="Client-Server" checked={filter_parsed.client_server} /> 321 <Checkbox name="server_server" label="Server-Server" checked={filter_parsed.server_server} /> 322 <Checkbox name="application_services" label="Application Services" checked={filter_parsed.application_services} /> 323 <Checkbox name="e2e" label="E2EE" checked={filter_parsed.e2e} /> 324 <Checkbox name="kind_core" label="Kind: Core" checked={filter_parsed.kind_core} /> 325 <Checkbox name="kind_feature" label="Kind: Feature" checked={filter_parsed.kind_feature} /> 326 <Checkbox name="kind_maintenance" label="Kind: Maintenance" checked={filter_parsed.kind_maintenance} /> 327 <Checkbox name="needs_implementation" label="Needs Implementation" checked={filter_parsed.needs_implementation} /> 328 <Checkbox name="proposal" label="Proposal" checked={filter_parsed.proposal} /> 329 <Checkbox name="abandoned" label="Abandoned" checked={filter_parsed.abandoned} /> 330 <Checkbox name="blocked" label="Blocked" checked={filter_parsed.blocked} /> 331 </div> 332 </div> 333 <div class="filter-section"> 334 <h2>State</h2> 335 <div class="filter-options"> 336 <Checkbox name="merged" label="Merged" checked={filter_parsed.state?.merged} /> 337 <Checkbox name="closed" label="Closed" checked={filter_parsed.state?.closed} /> 338 <Checkbox name="other" label="Other" checked={filter_parsed.state?.other} /> 339 </div> 340 </div> 341 342 <div class="filter-section"> 343 <h2>Opened Between</h2> 344 <div class="filter-options"> 345 <div class="filter-wrapper date"> 346 <label for="start-opened-date">Start</label> 347 <input 348 type="date" 349 name="start-opened-date" 350 id="start-opened-date" 351 value={format_date( 352 filter_parsed.start_opened_date, 353 )} 354 /> 355 </div> 356 <div class="filter-wrapper date"> 357 <label for="end-opened-date">End</label> 358 <input 359 type="date" 360 name="end-opened-date" 361 id="end-opened-date" 362 value={format_date( 363 filter_parsed.end_opened_date, 364 )} 365 /> 366 </div> 367 </div> 368 </div> 369 370 <div class="filter-section"> 371 <h2>Merged/closed Between</h2> 372 <div class="filter-options"> 373 <div class="filter-wrapper date"> 374 <label for="start-merged-closed-date" 375 >Start</label 376 > 377 <input 378 type="date" 379 name="start-merged-closed-date" 380 id="start-merged-closed-date" 381 value={filter_parsed.start_merged_closed_date} 382 /> 383 </div> 384 <div class="filter-wrapper date"> 385 <label for="end-merged-closed-date">End</label> 386 <input 387 type="date" 388 name="end-merged-closed-date" 389 id="end-merged-closed-date" 390 value={filter_parsed.end_merged_closed_date} 391 /> 392 </div> 393 </div> 394 </div> 395 396 <button type="submit">Apply Filters</button> 397 </aside> 398 </div> 399 <footer> 400 {results ? <span id="infos">Aprox. results: {results.totalHits} ({results.processingTimeMs} ms)</span>:<></>} 401 <Pagination 402 currentPage={parseInt(current_page ?? "1")} 403 pages={results?.totalPages ?? 1} 404 /> 405 </footer> 406 </main> 407 </form> 408 </StartLayout> 409 410 <style lang="scss"> 411 main { 412 display: flex; 413 width: 100%; 414 height: 100%; 415 padding: var(--spacing-7, 28px) var(--spacing-6, 24px); 416 flex-direction: column; 417 align-items: center; 418 gap: var(--spacing-12, 48px); 419 420 header { 421 display: flex; 422 align-items: center; 423 gap: var(--spacing-12, 48px); 424 align-self: stretch; 425 426 h1 { 427 color: var(--colors-base-white, #fff); 428 text-align: center; 429 font-size: 24px; 430 font-style: normal; 431 font-weight: 700; 432 } 433 } 434 435 #search { 436 width: 730px; 437 height: 50px; 438 display: flex; 439 border-radius: var(--border-radius-lg, 8px); 440 border: 2px solid var(--colors-slate-400, #94a3b8); 441 background: var(--colors-neutral-100, #f5f5f5); 442 443 /* shadow/lg */ 444 box-shadow: 445 0px 4px 6px -2px rgba(0, 0, 0, 0.05), 446 0px 10px 15px -3px rgba(0, 0, 0, 0.1); 447 448 align-items: center; 449 450 input[type="search"] { 451 width: 100%; 452 height: 100%; 453 border-top-left-radius: var(--border-radius-lg, 8px); 454 border-bottom-left-radius: var(--border-radius-lg, 8px); 455 background: var(--colors-neutral-100, #f5f5f5); 456 padding-left: var(--spacing-2, 8px); 457 458 color: #000; 459 font-size: 24px; 460 font-style: normal; 461 font-weight: 400; 462 } 463 464 #search-icon { 465 margin-right: var(--spacing-2, 8px); 466 margin-left: var(--spacing-2, 8px); 467 min-width: 24px; 468 height: 24px; 469 vertical-align: middle; 470 } 471 } 472 473 #content { 474 display: flex; 475 align-items: flex-start; 476 gap: var(--spacing-2, 8px); 477 align-self: stretch; 478 min-height: 100%; 479 480 #results { 481 display: flex; 482 padding: 8px 0px 8px 8px; 483 flex-direction: column; 484 align-items: flex-start; 485 gap: var(--spacing-4, 16px); 486 flex: 1 0 0; 487 min-height: 100%; 488 } 489 490 #filters { 491 display: flex; 492 min-width: 350px; 493 padding: 8px 0px 8px 8px; 494 flex-direction: column; 495 align-items: center; 496 gap: 8px; 497 align-self: stretch; 498 border-left-width: 1px; 499 border-left-color: var(--colors-slate-500, #64748b); 500 min-height: 100%; 501 502 button[type="submit"]:hover { 503 background: var(--colors-neutral-200, #e5e5e5); 504 } 505 button[type="submit"] { 506 border-radius: 25px 25px 25px 25px; 507 background: var(--colors-neutral-100, #f5f5f5); 508 padding: 8px; 509 } 510 511 .filter-section { 512 display: flex; 513 min-width: 100%; 514 flex-direction: column; 515 align-items: center; 516 gap: 8px; 517 align-self: stretch; 518 color: var(--colors-base-white, #fff); 519 font-size: 24px; 520 font-style: normal; 521 font-weight: 700; 522 523 .filter-options { 524 display: flex; 525 padding: var(--spacing-2, 8px); 526 flex-direction: column; 527 align-items: flex-start; 528 gap: var(--spacing-1, 4px); 529 align-self: stretch; 530 531 .filter-wrapper { 532 color: var(--colors-base-white, #fff); 533 font-size: 18px; 534 font-style: normal; 535 font-weight: 400; 536 537 display: flex; 538 align-items: center; 539 gap: 8px; 540 } 541 .date { 542 display: flex; 543 flex-direction: column; 544 justify-content: center; 545 align-items: flex-start; 546 gap: 8px; 547 align-self: stretch; 548 549 input { 550 background-color: var( 551 --colors-slate-700, 552 #334155 553 ); 554 border-radius: var( 555 --border-radius-default, 556 4px 557 ); 558 border: 1px solid 559 var(--colors-slate-400, #94a3b8); 560 height: 40px; 561 min-width: 100%; 562 } 563 } 564 } 565 } 566 } 567 } 568 footer { 569 display: flex; 570 flex-direction: column; 571 align-items: center; 572 gap: var(--spacing-2, 8px); 573 align-self: stretch; 574 575 #infos { 576 color: var(--colors-gray-300, #d1d5db); 577 text-align: center; 578 font-size: 16px; 579 font-style: normal; 580 font-weight: 400; 581 } 582 } 583 } 584 </style>