using Invercargill; using Invercargill.DataStructures; using InvercargillJson; using Statum; using Usm; namespace UsmWeb { /** One package row in the overview and single-repo tables. */ public class PackageRow : Object { public string name { get; set; default = ""; } public string version { get; set; default = ""; } public string summary { get; set; default = ""; } public string size { get; set; default = ""; } public string file { get; set; default = ""; } public string repo { get; set; default = ""; } } /** * One artifact row in a DCBA repository's browse table: the listing * entry's name/version plus the page links the row renders — the * detail page ({@link href}) and the raw `.dcba` download * ({@link download_href}). */ public class ArtifactRow : Object { public string name { get; set; default = ""; } public string version { get; set; default = ""; } public string size { get; set; default = ""; } public string file { get; set; default = ""; } public string href { get; set; default = ""; } public string download_href { get; set; default = ""; } } /** * One repository summary in the overview: the stats card fields plus the * per-repository add-repository instruction fields ({@link base_uri}, * {@link usmr_href}, {@link repos_d_snippet}). * * Both repository kinds render a card; {@link kind} (the * {@link RepositoryKind.to_string} wire name) picks which instruction * block and stats the card shows — a USM card carries its package count * and byte total, a DCBA card its artifact count and artifact byte * total — that total sums the listing's `size` fields, so artifacts * from listings written before the field existed (size 0) contribute * nothing while still counting toward {@link artifact_count}. The * `*_href`/`*_snippet` members are named for the USM card; * a DCBA summary fills them with the `repo.dcbar` / `usm dcba * add-repo` counterparts so the card markup stays one block with a * kind branch. */ public class RepoSummary : Object { public string name { get; set; default = ""; } public string summary { get; set; default = ""; } public string kind { get; set; default = "usm"; } public int package_count { get; set; } public string total_size { get; set; default = ""; } public string href { get; set; default = ""; } public string key_fingerprint { get; set; default = ""; } public string base_uri { get; set; default = ""; } public string usmr_href { get; set; default = ""; } public string repos_d_snippet { get; set; default = ""; } public int artifact_count { get; set; } public string artifact_total_size { get; set; default = ""; } } /** * Authors the `packages` PAGE-slot state shared by the overview and * single-repo entrypoints and the search action: repository summaries, * the add-repository / install-USM instruction blocks, and the (possibly * query-filtered) package table. The lists are authored as JSON arrays * because collection-typed GObject properties are write-side only. */ public class PackagesState : Object { public const string SLOT_TYPE = "packages"; /** * Builds the slot state: a repo-scoped view when `repo_name` names a * repository (its overview card, instruction block and table rows), * otherwise the all-repositories overview (summary cards with * per-repository add instructions, and one combined table). Rows are * filtered by the case-insensitive `query` substring over name and * summary (an empty query keeps every entry). * * The overview lists repositories of **both** kinds as summary * cards, in discovery order, but only the USM source repositories * feed the package table and the package counts and byte totals — * a DCBA entry contributes a card (its artifact count and artifact * byte total from the `ARTIFACTS.usml` sizes) and nothing else, so * pre-built artifacts surface in the detail views, never in the * home package table. * * A repo-scoped build for a DCBA repository renders that * repository's browse view instead: the same overview-card * fields (carrying its artifact count and artifact byte total), * the `usm dcba add-repo` instruction and one * {@link ArtifactRow} per `ARTIFACTS.usml` entry. The query * never filters artifacts (search is source-package-only), so * those rows ignore `query` entirely and the page omits the * search form on the DCBA branch. */ public static State build(RepositoryService repositories, UsmWebConfig config, ActionRegistry action_registry, string base_uri, string query, string? repo_name = null) throws Error { var repo_rows = new Vector(); var package_rows = new Vector(); var dcba_artifact_rows = new Vector(); int64 total_bytes = 0; uint total_count = 0; uint matched = 0; foreach (var entry in repositories.entries()) { var repo = entry.repo(); if (repo_name != null && repo.name != repo_name) { continue; } if (entry.kind == RepositoryKind.DCBA) { int64 artifact_bytes = 0; uint artifact_count = 0; var artifact_rows = new Vector(); if (repo_name != null) { foreach (var artifact_entry in entry.dcba_listing_entries()) { var file = Path.get_basename(artifact_entry.path); artifact_bytes += (int64) artifact_entry.size; artifact_count++; artifact_rows.add(new ArtifactRow() { name = artifact_entry.name, version = artifact_entry.version, size = artifact_entry.size > 0 ? format_size((int64) artifact_entry.size) : "?", file = file, href = "/repo/" + repo.name + "/artifact/" + file, download_href = "/repo/" + repo.name + "/" + file }); } dcba_artifact_rows.add_all(artifact_rows); } else { foreach (var artifact_entry in entry.dcba_listing_entries()) { artifact_bytes += (int64) artifact_entry.size; artifact_count++; } } repo_rows.add(new RepoSummary() { name = repo.name, summary = repo.summary, kind = RepositoryKind.DCBA.to_string(), artifact_count = (int) artifact_count, artifact_total_size = format_size(artifact_bytes), href = "/repo/" + repo.name, key_fingerprint = fingerprint(repo.key.to_hex()), base_uri = base_uri + "repo/" + repo.name + "/", usmr_href = base_uri + "repo/" + repo.name + "/repo.dcbar", repos_d_snippet = "sudo usm dcba add-repo %srepo/%s/repo.dcbar" .printf(base_uri, repo.name) }); continue; } int64 repo_bytes = 0; uint repo_count = 0; var rows = new Vector(); foreach (var listing_entry in entry.listing_entries()) { var file = Path.get_basename(listing_entry.path); int64 size = file_size(entry.package_location(file)); repo_bytes += size; repo_count++; if (!matches(listing_entry, query)) { continue; } rows.add(new PackageRow() { name = listing_entry.manifest.name, version = listing_entry.manifest.version.to_string(), summary = listing_entry.manifest.summary, size = format_size(size), file = file, repo = repo.name }); } matched += rows.length; package_rows.add_all(rows); repo_rows.add(new RepoSummary() { name = repo.name, summary = repo.summary, kind = RepositoryKind.USM.to_string(), package_count = (int) repo_count, total_size = format_size(repo_bytes), href = "/repo/" + repo.name, key_fingerprint = fingerprint(repo.key.to_hex()), base_uri = base_uri + "repo/" + repo.name + "/", usmr_href = base_uri + "repo/" + repo.name + "/repo.usmr", repos_d_snippet = "sudo usm add-repo %srepo/%s/repo.usmr" .printf(base_uri, repo.name) }); total_bytes += repo_bytes; total_count += repo_count; } if (repo_name != null && repo_rows.length == 0) { throw new UsmWebError.INVALID_CONFIGURATION(@"No repository named \"$repo_name\""); } var dict = new PropertyDictionary(); if (repo_name == null) { dict.set_native("site_name", config.display_name ?? "Repositories"); dict.set_native("repo_count", (int) repo_rows.length); dict.set_native("package_count", (int) total_count); dict.set_native("total_size", format_size(total_bytes)); dict["repos"] = repo_list(repo_rows); } else { var summary = repo_rows.first(); dict.set_native("repo_name", summary.name); dict.set_native("repo_summary", summary.summary); dict.set_native("repo_kind", summary.kind); dict.set_native("key_fingerprint", summary.key_fingerprint); dict.set_native("package_count", summary.package_count); dict.set_native("total_size", summary.total_size); dict.set_native("base_uri", summary.base_uri); dict.set_native("usmr_href", summary.usmr_href); dict.set_native("repos_d_snippet", summary.repos_d_snippet); dict.set_native("artifact_count", summary.artifact_count); dict.set_native("artifact_total_size", summary.artifact_total_size); } dict["artifacts"] = artifact_list(dcba_artifact_rows); dict.set_native("query", query); dict.set_native("result_note", result_note(query, matched, total_count)); dict.set_native("installer_mode", config.installer.to_string()); dict.set_native("installer_href", installer_href(config, base_uri)); dict.set_native("installer_command", installer_command(config, base_uri)); dict["search"] = action_registry.author().to_element(); dict["packages"] = list(package_rows); return new State() { type_name = SLOT_TYPE, public_data = dict, private_data = new PropertyDictionary() }; } /** Case-insensitive substring match over name and summary. */ public static bool matches(RepositoryListingEntry entry, string query) { if (query.length == 0) { return true; } var needle = query.casefold(); return entry.manifest.name.casefold().contains(needle) || entry.manifest.summary.casefold().contains(needle); } /** Builds the package rows' JSON array (the {@link StateJson.list} pattern). */ public static JsonElement list(Lot rows) throws Error { var arr = new JsonArray(); foreach (var row in rows) { arr.add(new JsonElement.from_properties(GObjectMapping.to_properties(row))); } return new JsonElement.from_elements(arr); } /** Builds the artifact rows' JSON array (the same pattern as {@link list}). */ public static JsonElement artifact_list(Lot rows) throws Error { var arr = new JsonArray(); foreach (var row in rows) { arr.add(new JsonElement.from_properties(GObjectMapping.to_properties(row))); } return new JsonElement.from_elements(arr); } /** Builds the repository summaries' JSON array. */ public static JsonElement repo_list(Lot rows) throws Error { var arr = new JsonArray(); foreach (var row in rows) { arr.add(new JsonElement.from_properties(GObjectMapping.to_properties(row))); } return new JsonElement.from_elements(arr); } /** Groups a hex digest into space-separated 16-character chunks. */ public static string fingerprint(string hex) { var builder = new StringBuilder(); for (int i = 0; i < hex.length; i += 16) { if (i > 0) { builder.append_c(' '); } builder.append(hex[i:int.min(i + 16, hex.length)]); } return builder.str; } /** Human-readable byte size (KiB/MiB/GiB). */ public static string format_size(int64 bytes) { if (bytes >= 1073741824) { return "%.2f GiB".printf(bytes / 1073741824.0); } if (bytes >= 1048576) { return "%.1f MiB".printf(bytes / 1048576.0); } if (bytes >= 1024) { return "%.1f KiB".printf(bytes / 1024.0); } return "%lld B".printf(bytes); } /** The link the install card points at (external URL or this app). */ private static string installer_href(UsmWebConfig config, string base_uri) { if (config.installer == InstallerSource.URL) { return (!)config.installer_url; } return base_uri + "install-usm.sh"; } /** The copy-paste one-liner the install card shows. */ private static string installer_command(UsmWebConfig config, string base_uri) { return "curl -fsSL %s | sh".printf(installer_href(config, base_uri)); } private static string result_note(string query, uint matched, uint total) { if (query.length == 0) { return ""; } if (matched == 0) { return "No packages match \"%s\".".printf(query); } return "Showing %u of %u packages.".printf(matched, total); } private static int64 file_size(string path) { try { var info = File.new_for_path(path).query_info( FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE); return info.get_size(); } catch (Error e) { return 0; } } } }