Skip to main content

us_excel_template_data/
catalog.rs

1//! Query API over the embedded catalog CSV (1,008 rows).
2//!
3//! The raw file is available in the crate sources (`data/`) and mirrors the CC BY 4.0
4//! dataset published on [Zenodo](https://doi.org/10.5281/zenodo.21416251) by
5//! [TableTemplates](https://tabletemplates.com/).
6
7const RAW: &str = include_str!("../data/excel-template-search-demand-us-2026.csv");
8
9/// One keyword of the catalog.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Row {
12    /// Template-related search query (US, English).
13    pub keyword: &'static str,
14    /// Estimated monthly US search volume (2026).
15    pub monthly_search_volume_us: u32,
16    /// One of the 16 practical categories (kebab-case slug).
17    pub category: &'static str,
18    /// URL of a free, no-signup implementation in the
19    /// [free template library](https://tabletemplates.com/free/), when one exists.
20    pub free_template_url: Option<&'static str>,
21}
22
23/// Aggregated demand for one category.
24#[derive(Debug, Clone, PartialEq)]
25pub struct CategoryStats {
26    /// Category slug (e.g. `project-management`).
27    pub category: &'static str,
28    /// Number of keywords in the category.
29    pub keywords: usize,
30    /// Sum of the monthly US volumes of those keywords.
31    pub combined_monthly_volume_us: u64,
32}
33
34/// Every row of the catalog, in file order.
35pub fn all() -> Vec<Row> {
36    RAW.lines()
37        .skip(1)
38        .filter(|l| !l.trim().is_empty())
39        .map(|line| {
40            let mut it = line.splitn(5, ',');
41            let keyword = it.next().unwrap_or_default();
42            let volume = it.next().unwrap_or("0").parse().unwrap_or(0);
43            let category = it.next().unwrap_or_default();
44            let _has_free = it.next();
45            let url = it.next().unwrap_or_default().trim();
46            Row {
47                keyword,
48                monthly_search_volume_us: volume,
49                category,
50                free_template_url: if url.is_empty() { None } else { Some(url) },
51            }
52        })
53        .collect()
54}
55
56/// The `n` highest-volume keywords.
57pub fn top(n: usize) -> Vec<Row> {
58    let mut rows = all();
59    rows.sort_by(|a, b| b.monthly_search_volume_us.cmp(&a.monthly_search_volume_us));
60    rows.truncate(n);
61    rows
62}
63
64/// Rows whose keyword contains `needle` (case-insensitive), ordered by volume.
65pub fn search(needle: &str) -> Vec<Row> {
66    let q = needle.to_lowercase();
67    let mut rows: Vec<Row> = all()
68        .into_iter()
69        .filter(|r| r.keyword.to_lowercase().contains(&q))
70        .collect();
71    rows.sort_by(|a, b| b.monthly_search_volume_us.cmp(&a.monthly_search_volume_us));
72    rows
73}
74
75/// Rows of one category (slug, e.g. `bookkeeping-accounting`), ordered by volume.
76pub fn by_category(slug: &str) -> Vec<Row> {
77    let mut rows: Vec<Row> = all().into_iter().filter(|r| r.category == slug).collect();
78    rows.sort_by(|a, b| b.monthly_search_volume_us.cmp(&a.monthly_search_volume_us));
79    rows
80}
81
82/// The 16 categories with aggregated stats, ordered by combined volume (descending).
83pub fn categories() -> Vec<CategoryStats> {
84    let mut cats: Vec<CategoryStats> = Vec::new();
85    for row in all() {
86        match cats.iter_mut().find(|c| c.category == row.category) {
87            Some(c) => {
88                c.keywords += 1;
89                c.combined_monthly_volume_us += u64::from(row.monthly_search_volume_us);
90            }
91            None => cats.push(CategoryStats {
92                category: row.category,
93                keywords: 1,
94                combined_monthly_volume_us: u64::from(row.monthly_search_volume_us),
95            }),
96        }
97    }
98    cats.sort_by(|a, b| b.combined_monthly_volume_us.cmp(&a.combined_monthly_volume_us));
99    cats
100}
101
102/// Rows that have a free, no-signup implementation, ordered by volume.
103pub fn with_free_template() -> Vec<Row> {
104    let mut rows: Vec<Row> = all().into_iter().filter(|r| r.free_template_url.is_some()).collect();
105    rows.sort_by(|a, b| b.monthly_search_volume_us.cmp(&a.monthly_search_volume_us));
106    rows
107}