From da00d149bd806962814cf21683622008b72774d2 Mon Sep 17 00:00:00 2001 From: mae Date: Sat, 18 Apr 2026 02:07:52 -0500 Subject: [PATCH] cmd/pkgserver: finish search implementation --- cmd/pkgserver/api.go | 6 +- cmd/pkgserver/main.go | 4 +- cmd/pkgserver/search.go | 12 +- cmd/pkgserver/ui.go | 7 +- cmd/pkgserver/ui/index.html | 58 +++-- cmd/pkgserver/ui/index.ts | 327 ++++++++++++++++++++++++++ cmd/pkgserver/ui/static/_common.scss | 0 cmd/pkgserver/ui/static/dark.scss | 6 - cmd/pkgserver/ui/static/index.ts | 155 ------------ cmd/pkgserver/ui/static/light.scss | 6 - cmd/pkgserver/ui/static/style.css | 21 ++ cmd/pkgserver/ui/static/tsconfig.json | 5 - cmd/pkgserver/ui/tsconfig.json | 8 + cmd/pkgserver/ui_full.go | 2 +- 14 files changed, 411 insertions(+), 206 deletions(-) create mode 100644 cmd/pkgserver/ui/index.ts delete mode 100644 cmd/pkgserver/ui/static/_common.scss delete mode 100644 cmd/pkgserver/ui/static/dark.scss delete mode 100644 cmd/pkgserver/ui/static/index.ts delete mode 100644 cmd/pkgserver/ui/static/light.scss create mode 100644 cmd/pkgserver/ui/static/style.css delete mode 100644 cmd/pkgserver/ui/static/tsconfig.json create mode 100644 cmd/pkgserver/ui/tsconfig.json diff --git a/cmd/pkgserver/api.go b/cmd/pkgserver/api.go index 914479c3..fb41f126 100644 --- a/cmd/pkgserver/api.go +++ b/cmd/pkgserver/api.go @@ -127,7 +127,7 @@ func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request) ) return } - search, err := url.PathUnescape(q.Get("search")) + search, err := url.QueryUnescape(q.Get("search")) if len(search) > 100 || err != nil { http.Error( w, "search must be a string between 0 and 100 characters long", @@ -141,8 +141,8 @@ func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) } writeAPIPayload(w, &struct { - Count int `json:"count"` - Results []searchResult `json:"results"` + Count int `json:"count"` + Values []searchResult `json:"values"` }{n, res}) } diff --git a/cmd/pkgserver/main.go b/cmd/pkgserver/main.go index af59ff2e..a811075d 100644 --- a/cmd/pkgserver/main.go +++ b/cmd/pkgserver/main.go @@ -10,8 +10,8 @@ import ( "syscall" "time" + "hakurei.app/check" "hakurei.app/command" - "hakurei.app/container/check" "hakurei.app/internal/pkg" "hakurei.app/internal/rosa" "hakurei.app/message" @@ -47,7 +47,7 @@ func main() { return err } - cache, err = pkg.Open(ctx, msg, 0, baseDir) + cache, err = pkg.Open(ctx, msg, 0, 0, baseDir) if err != nil { return err } diff --git a/cmd/pkgserver/search.go b/cmd/pkgserver/search.go index ba41628b..4ce1e256 100644 --- a/cmd/pkgserver/search.go +++ b/cmd/pkgserver/search.go @@ -22,9 +22,13 @@ type searchCacheEntry struct { } func (index *packageIndex) performSearchQuery(limit int, i int, search string, desc bool) (int, []searchResult, error) { - entry, ok := index.search[search] - if ok { - return len(entry.results), entry.results[i:min(i+limit, len(entry.results))], nil + query := search + if desc { + query += ";withDesc" + } + entry, ok := index.search[query] + if ok && len(entry.results) > 0 { + return len(entry.results), entry.results[min(i, len(entry.results)-1):min(i+limit, len(entry.results))], nil } regex, err := regexp.Compile(search) @@ -59,7 +63,7 @@ func (index *packageIndex) performSearchQuery(limit int, i int, search string, d results: res, expiry: expiry, } - index.search[search] = entry + index.search[query] = entry return len(res), res[i:min(i+limit, len(entry.results))], nil } diff --git a/cmd/pkgserver/ui.go b/cmd/pkgserver/ui.go index e4c42832..54ab2696 100644 --- a/cmd/pkgserver/ui.go +++ b/cmd/pkgserver/ui.go @@ -15,12 +15,7 @@ func serveWebUI(w http.ResponseWriter, r *http.Request) { func serveStaticContent(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/static/style.css": - darkTheme := r.CookiesNamed("dark_theme") - if len(darkTheme) > 0 && darkTheme[0].Value == "true" { - http.ServeFileFS(w, r, content, "ui/static/dark.css") - } else { - http.ServeFileFS(w, r, content, "ui/static/light.css") - } + http.ServeFileFS(w, r, content, "ui/static/style.css") case "/favicon.ico": http.ServeFileFS(w, r, content, "ui/static/favicon.ico") case "/static/index.js": diff --git a/cmd/pkgserver/ui/index.html b/cmd/pkgserver/ui/index.html index ec21a5f4..0be0ecc7 100644 --- a/cmd/pkgserver/ui/index.html +++ b/cmd/pkgserver/ui/index.html @@ -2,34 +2,56 @@ + Hakurei PkgServer

Hakurei PkgServer

- +
+

Showing entries .

+ + + + + + + +
+
+
+ +
« Previous Next »
Loading...
-

Showing entries .

-« Previous 1 Next » - - - +
« Previous Next »
+ + \ No newline at end of file diff --git a/cmd/pkgserver/ui/index.ts b/cmd/pkgserver/ui/index.ts new file mode 100644 index 00000000..779c29fe --- /dev/null +++ b/cmd/pkgserver/ui/index.ts @@ -0,0 +1,327 @@ +interface PackageIndexEntry { + name: string + size?: number + description?: string + website?: string + version?: string + report?: boolean +} + +function entryToHTML(entry: PackageIndexEntry | SearchResult): HTMLTableRowElement { + let v = entry.version != null ? `${escapeHtml(entry.version)}` : "" + let s = entry.size != null && entry.size > 0 ? `

Size: ${toByteSizeString(entry.size)} (${entry.size})

` : "" + let n: string + let d: string + if ('name_matches' in entry) { + n = `

${nameMatches(entry as SearchResult)} ${v}

` + } else { + n = `

${escapeHtml(entry.name)} ${v}

` + } + if ('desc_matches' in entry && STATE.getIncludeDescriptions()) { + d = descMatches(entry as SearchResult) + } else { + d = (entry as PackageIndexEntry).description != null ? `

${escapeHtml((entry as PackageIndexEntry).description)}

` : "" + } + let w = entry.website != null ? `Website` : "" + let r = entry.report ? `Log (View | Download)` : "" + let row = (document.createElement('tr')) + row.innerHTML = ` + ${n} + ${d} + ${s} + ${w} + ${r} + ` + return row +} + +function nameMatches(sr: SearchResult): string { + return markMatches(sr.name, sr.name_matches) +} + +function descMatches(sr: SearchResult): string { + return markMatches(sr.description!, sr.desc_matches) +} + +function markMatches(str: string, indices: [number, number][]): string { + if (indices == null) { + return str + } + let out: string = "" + let j = 0 + for (let i = 0; i < str.length; i++) { + if (j < indices.length) { + if (i === indices[j][0]) { + out += `${escapeHtmlChar(str[i])}` + continue + } + if (i === indices[j][1]) { + out += `${escapeHtmlChar(str[i])}` + j++ + continue + } + } + out += escapeHtmlChar(str[i]) + } + if (indices[j] !== undefined) { + out += "" + } + return out +} + +function toByteSizeString(bytes: number): string { + if (bytes == null) return `unspecified` + if (bytes < 1024) return `${bytes}B` + if (bytes < Math.pow(1024, 2)) return `${(bytes / 1024).toFixed(2)}kiB` + if (bytes < Math.pow(1024, 3)) return `${(bytes / Math.pow(1024, 2)).toFixed(2)}MiB` + if (bytes < Math.pow(1024, 4)) return `${(bytes / Math.pow(1024, 3)).toFixed(2)}GiB` + if (bytes < Math.pow(1024, 5)) return `${(bytes / Math.pow(1024, 4)).toFixed(2)}TiB` + return "not only is it big, it's large" +} + +const API_VERSION = 1 +const ENDPOINT = `/api/v${API_VERSION}` + +interface InfoPayload { + count?: number + hakurei_version?: string +} + +async function infoRequest(): Promise { + const res = await fetch(`${ENDPOINT}/info`) + const payload = await res.json() + return payload as InfoPayload +} + +interface GetPayload { + values?: PackageIndexEntry[] +} + +enum SortOrders { + DeclarationAscending, + DeclarationDescending, + NameAscending, + NameDescending +} + +async function getRequest(limit: number, index: number, sort: SortOrders): Promise { + const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`) + const payload = await res.json() + return payload as GetPayload +} + +interface SearchResult extends PackageIndexEntry { + name_matches: [number, number][] + desc_matches: [number, number][] + score: number +} + +interface SearchPayload { + count?: number + values?: SearchResult[] +} + +async function searchRequest(limit: number, index: number, search: string, desc: boolean): Promise { + const res = await fetch(`${ENDPOINT}/search?limit=${limit}&index=${index}&search=${encodeURIComponent(search)}&desc=${desc}`) + if (!res.ok) { + alert("invalid search query!") + exitSearch() + return Promise.reject(res.statusText) + } + const payload = await res.json() + return payload as SearchPayload +} + +class State { + entriesPerPage: number = 10 + entryIndex: number = 0 + maxTotal: number = 0 + maxEntries: number = 0 + sort: SortOrders = SortOrders.DeclarationAscending + search: boolean = false + + getEntriesPerPage(): number { + return this.entriesPerPage + } + + setEntriesPerPage(entriesPerPage: number) { + this.entriesPerPage = entriesPerPage + this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage) + } + + getEntryIndex(): number { + return this.entryIndex + } + + setEntryIndex(entryIndex: number) { + this.entryIndex = entryIndex + this.updatePage() + this.updateRange() + this.updateListings() + } + + getMaxTotal(): number { + return this.maxTotal + } + + setMaxTotal(max: number) { + this.maxTotal = max + } + + getSortOrder(): SortOrders { + return this.sort + } + + setSortOrder(sortOrder: SortOrders) { + this.sort = sortOrder + this.setEntryIndex(0) + } + + updatePage() { + let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage()) + for (let e of document.getElementsByClassName("page-number")) { + (e as HTMLInputElement).value = String(page) + } + } + + updateRange() { + let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxTotal()) + document.getElementById("entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxTotal()}` + if (this.search) { + document.getElementById("search-entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.maxTotal}/${this.maxEntries}` + document.getElementById("search-query")!.innerHTML = `${escapeHtml(this.getSearchQuery())}` + } + } + + getSearchQuery(): string { + let queryString = document.getElementById("search")!; + return (queryString as HTMLInputElement).value + } + + getIncludeDescriptions(): boolean { + let includeDesc = document.getElementById("include-desc")!; + return (includeDesc as HTMLInputElement).checked + } + + updateListings() { + if (this.search) { + searchRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSearchQuery(), this.getIncludeDescriptions()) + .then(res => { + let table = document.getElementById("pkg-list")! + table.innerHTML = '' + for (let row of res.values!) { + table.appendChild(entryToHTML(row)) + } + STATE.maxTotal = res.count! + STATE.updateRange() + }) + } else { + getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder()) + .then(res => { + let table = document.getElementById("pkg-list")! + table.innerHTML = '' + for (let row of res.values!) { + table.appendChild(entryToHTML(row)) + } + }) + } + } +} + +let STATE: State + + +function lastPageIndex(): number { + return Math.floor(STATE.getMaxTotal() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage() +} + +function setPage(page: number) { + STATE.setEntryIndex(Math.max(0, Math.min(STATE.getEntriesPerPage() * (page - 1), lastPageIndex()))) +} + + +function escapeHtml(str?: string): string { + let out: string = '' + if (str == undefined) return "" + for (let i = 0; i < str.length; i++) { + out += escapeHtmlChar(str[i]) + } + return out +} + +function escapeHtmlChar(char: string): string { + if (char.length != 1) return char + switch (char[0]) { + case '&': + return "&" + case '<': + return "<" + case '>': + return ">" + case '"': + return """ + case "'": + return "'" + default: + return char + } +} + +function firstPage() { + STATE.setEntryIndex(0) +} + +function prevPage() { + let index = STATE.getEntryIndex() + STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage())) +} + +function lastPage() { + STATE.setEntryIndex(lastPageIndex()) +} + +function nextPage() { + let index = STATE.getEntryIndex() + STATE.setEntryIndex(Math.min(lastPageIndex(), index + STATE.getEntriesPerPage())) +} + +function doSearch() { + document.getElementById("top-controls-regular")!.toggleAttribute("hidden"); + document.getElementById("search-top-controls")!.toggleAttribute("hidden"); + STATE.search = true; + STATE.setEntryIndex(0); +} + +function exitSearch() { + document.getElementById("top-controls-regular")!.toggleAttribute("hidden"); + document.getElementById("search-top-controls")!.toggleAttribute("hidden"); + STATE.search = false; + STATE.setMaxTotal(STATE.maxEntries) + STATE.setEntryIndex(0) +} + +function main() { + STATE = new State() + infoRequest() + .then(res => { + STATE.maxEntries = res.count! + STATE.setMaxTotal(STATE.maxEntries) + document.getElementById("hakurei-version")!.textContent = res.hakurei_version! + STATE.updateRange() + STATE.updateListings() + }) + for (let e of document.getElementsByClassName("page-number")) { + e.addEventListener("change", (_) => { + setPage(parseInt((e as HTMLInputElement).value)) + }) + } + document.getElementById("count")?.addEventListener("change", (event) => { + STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value)) + }) + document.getElementById("sort")?.addEventListener("change", (event) => { + STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value)) + }) + document.getElementById("search")?.addEventListener("keyup", (event) => { + if (event.key === 'Enter') doSearch() + }) +} \ No newline at end of file diff --git a/cmd/pkgserver/ui/static/_common.scss b/cmd/pkgserver/ui/static/_common.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/cmd/pkgserver/ui/static/dark.scss b/cmd/pkgserver/ui/static/dark.scss deleted file mode 100644 index 8d7ea847..00000000 --- a/cmd/pkgserver/ui/static/dark.scss +++ /dev/null @@ -1,6 +0,0 @@ -@use 'common'; - -html { - background-color: #2c2c2c; - color: ghostwhite; -} \ No newline at end of file diff --git a/cmd/pkgserver/ui/static/index.ts b/cmd/pkgserver/ui/static/index.ts deleted file mode 100644 index ab1a8315..00000000 --- a/cmd/pkgserver/ui/static/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -class PackageIndexEntry { - name: string - size: number | null - description: string | null - website: string | null - version: string | null - report: boolean -} -function toHTML(entry: PackageIndexEntry): HTMLTableRowElement { - let v = entry.version != null ? `${escapeHtml(entry.version)}` : "" - let s = entry.size != null ? `

Size: ${toByteSizeString(entry.size)} (${entry.size})

` : "" - let d = entry.description != null ? `

${escapeHtml(entry.description)}

` : "" - let w = entry.website != null ? `Website` : "" - let r = entry.report ? `Log (View | Download)` : "" - let row = (document.createElement('tr')) - row.innerHTML = ` -

${escapeHtml(entry.name)} ${v}

- ${d} - ${s} - ${w} - ${r} - ` - return row -} - -function toByteSizeString(bytes: number): string { - if(bytes == null || bytes < 1024) return `${bytes}B` - if(bytes < Math.pow(1024, 2)) return `${(bytes/1024).toFixed(2)}kiB` - if(bytes < Math.pow(1024, 3)) return `${(bytes/Math.pow(1024, 2)).toFixed(2)}MiB` - if(bytes < Math.pow(1024, 4)) return `${(bytes/Math.pow(1024, 3)).toFixed(2)}GiB` - if(bytes < Math.pow(1024, 5)) return `${(bytes/Math.pow(1024, 4)).toFixed(2)}TiB` - return "not only is it big, it's large" -} - -const API_VERSION = 1 -const ENDPOINT = `/api/v${API_VERSION}` -class InfoPayload { - count: number - hakurei_version: string -} - -async function infoRequest(): Promise { - const res = await fetch(`${ENDPOINT}/info`) - const payload = await res.json() - return payload as InfoPayload -} -class GetPayload { - values: PackageIndexEntry[] -} - -enum SortOrders { - DeclarationAscending, - DeclarationDescending, - NameAscending, - NameDescending -} -async function getRequest(limit: number, index: number, sort: SortOrders): Promise { - const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`) - const payload = await res.json() - return payload as GetPayload -} -class State { - entriesPerPage: number = 10 - entryIndex: number = 0 - maxEntries: number = 0 - sort: SortOrders = SortOrders.DeclarationAscending - - getEntriesPerPage(): number { - return this.entriesPerPage - } - setEntriesPerPage(entriesPerPage: number) { - this.entriesPerPage = entriesPerPage - this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage) - } - getEntryIndex(): number { - return this.entryIndex - } - setEntryIndex(entryIndex: number) { - this.entryIndex = entryIndex - this.updatePage() - this.updateRange() - this.updateListings() - } - getMaxEntries(): number { - return this.maxEntries - } - setMaxEntries(max: number) { - this.maxEntries = max - } - getSortOrder(): SortOrders { - return this.sort - } - setSortOrder(sortOrder: SortOrders) { - this.sort = sortOrder - this.setEntryIndex(0) - } - updatePage() { - let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage()) - document.getElementById("page-number").innerText = String(page) - } - updateRange() { - let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxEntries()) - document.getElementById("entry-counter").innerText = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxEntries()}` - } - updateListings() { - getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder()) - .then(res => { - let table = document.getElementById("pkg-list") - table.innerHTML = '' - res.values.forEach((row) => { - table.appendChild(toHTML(row)) - }) - }) - } - -} - -let STATE: State - -function prevPage() { - let index = STATE.getEntryIndex() - STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage())) -} -function nextPage() { - let index = STATE.getEntryIndex() - STATE.setEntryIndex(Math.min((Math.ceil(STATE.getMaxEntries() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()) - STATE.getEntriesPerPage(), index + STATE.getEntriesPerPage())) -} - -function escapeHtml(str: string): string { - if(str === undefined) return "" - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -document.addEventListener("DOMContentLoaded", () => { - STATE = new State() - infoRequest() - .then(res => { - STATE.setMaxEntries(res.count) - document.getElementById("hakurei-version").innerText = res.hakurei_version - STATE.updateRange() - STATE.updateListings() - }) - - document.getElementById("count").addEventListener("change", (event) => { - STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value)) - }) - document.getElementById("sort").addEventListener("change", (event) => { - STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value)) - }) -}) \ No newline at end of file diff --git a/cmd/pkgserver/ui/static/light.scss b/cmd/pkgserver/ui/static/light.scss deleted file mode 100644 index 437cb87a..00000000 --- a/cmd/pkgserver/ui/static/light.scss +++ /dev/null @@ -1,6 +0,0 @@ -@use 'common'; - -html { - background-color: #d3d3d3; - color: black; -} \ No newline at end of file diff --git a/cmd/pkgserver/ui/static/style.css b/cmd/pkgserver/ui/static/style.css new file mode 100644 index 00000000..b4f281ac --- /dev/null +++ b/cmd/pkgserver/ui/static/style.css @@ -0,0 +1,21 @@ +.page-number { + width: 2em; + text-align: center; +} +.page-number { + width: 2em; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + html { + background-color: #2c2c2c; + color: ghostwhite; + } +} +@media (prefers-color-scheme: light) { + html { + background-color: #d3d3d3; + color: black; + } +} \ No newline at end of file diff --git a/cmd/pkgserver/ui/static/tsconfig.json b/cmd/pkgserver/ui/static/tsconfig.json deleted file mode 100644 index 8589a396..00000000 --- a/cmd/pkgserver/ui/static/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024" - } -} \ No newline at end of file diff --git a/cmd/pkgserver/ui/tsconfig.json b/cmd/pkgserver/ui/tsconfig.json new file mode 100644 index 00000000..24df4936 --- /dev/null +++ b/cmd/pkgserver/ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2024", + "strict": true, + "alwaysStrict": true, + "outDir": "static" + } +} \ No newline at end of file diff --git a/cmd/pkgserver/ui_full.go b/cmd/pkgserver/ui_full.go index f9ca8816..f7f730c6 100644 --- a/cmd/pkgserver/ui_full.go +++ b/cmd/pkgserver/ui_full.go @@ -4,6 +4,6 @@ package main import "embed" -//go:generate sh -c "sass ui/static/dark.scss ui/static/dark.css && sass ui/static/light.scss ui/static/light.css && tsc -p ui/static" +//go:generate tsc -p ui //go:embed ui/* var content embed.FS