Extract bos-settings to its own repo; add breadhelp; JSON-driven Hyprland config
bos-settings moves to git.breadway.dev/Breadway/bos-settings (full history preserved via git-filter-repo) so its release cadence is decoupled from BOS's own. breadhelp takes its place as this repo's workspace member: a GTK4 onboarding/help center replacing the old bos-welcome/bos-keybinds bash scripts with searchable guides, an interactive keybind viewer (sourced from the new keybinds.toml, not parsed out of hyprland.lua or hardcoded), a troubleshooting wizard with one-click fixes, and a proper first-run tour. bos-netcheck extracts bos-welcome's network-check half, which still needs to run every login independent of breadhelp's own first-run gating. hyprland.lua's keybinds/settings/monitors/autostart are now JSON-driven (binds.json/settings.json/monitors.json/autostart.json) with every loader pcall-wrapped and falling back to hardcoded defaults per field on bad or missing config, so bread* apps (bos-settings' new editors, and breadhelp's keybind viewer) can read/write this config without ever being able to leave the compositor unable to start. CI's package.yml now builds breadhelp instead of bos-settings on tag push; bos-settings needs its own equivalent workflow in its new repo (not yet set up).
This commit is contained in:
commit
fea8f83204
50 changed files with 2403 additions and 0 deletions
145
src/content/mod.rs
Normal file
145
src/content/mod.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! Guide content: `/usr/share/breadhelp/content/` (system, shipped by the
|
||||
//! package) merged with `~/.local/share/breadhelp/content/` (user overrides/
|
||||
//! additions). A guide directory name colliding between the two roots means
|
||||
//! the user copy wins wholesale — no field-level merge, same "replace the
|
||||
//! whole doc" discipline `bos-settings/src/config/mod.rs` uses for TOML, just
|
||||
//! at the file-tree level.
|
||||
|
||||
pub mod keybinds;
|
||||
pub mod markdown;
|
||||
pub mod meta;
|
||||
pub mod troubleshoot;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use meta::GuideMeta;
|
||||
|
||||
const SYSTEM_ROOT: &str = "/usr/share/breadhelp/content";
|
||||
|
||||
/// Categories are just top-level content directories — this only fixes the
|
||||
/// *display order* of ones we know about; unrecognized directories still
|
||||
/// show up (sorted alphabetically after these) instead of being hidden.
|
||||
const CATEGORY_ORDER: &[&str] = &[
|
||||
"getting-started",
|
||||
"daily-use",
|
||||
"customization",
|
||||
"apps",
|
||||
"troubleshooting",
|
||||
"advanced",
|
||||
];
|
||||
|
||||
pub struct Guide {
|
||||
pub id: String,
|
||||
pub category: String,
|
||||
pub meta: GuideMeta,
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
pub struct ContentStore {
|
||||
guides: Vec<Guide>,
|
||||
}
|
||||
|
||||
fn user_content_root() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
let p = PathBuf::from(xdg);
|
||||
if p.is_absolute() {
|
||||
return p.join("breadhelp/content");
|
||||
}
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
PathBuf::from(home).join(".local/share/breadhelp/content")
|
||||
}
|
||||
|
||||
fn scan_root(root: &Path, out: &mut BTreeMap<(String, String), Guide>) {
|
||||
let Ok(categories) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for cat_entry in categories.flatten() {
|
||||
let cat_path = cat_entry.path();
|
||||
if !cat_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let category = cat_entry.file_name().to_string_lossy().into_owned();
|
||||
let Ok(guide_dirs) = std::fs::read_dir(&cat_path) else {
|
||||
continue;
|
||||
};
|
||||
for guide_entry in guide_dirs.flatten() {
|
||||
let guide_dir = guide_entry.path();
|
||||
if !guide_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let id = guide_entry.file_name().to_string_lossy().into_owned();
|
||||
// troubleshooting/_symptoms holds wizard data, not guides.
|
||||
if id == "_symptoms" {
|
||||
continue;
|
||||
}
|
||||
let meta_path = guide_dir.join("meta.toml");
|
||||
let Ok(meta_text) = std::fs::read_to_string(&meta_path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(meta) = toml::from_str::<GuideMeta>(&meta_text) else {
|
||||
eprintln!("breadhelp: {} failed to parse, skipping", meta_path.display());
|
||||
continue;
|
||||
};
|
||||
let effective_category = meta.category.clone().unwrap_or_else(|| category.clone());
|
||||
out.insert(
|
||||
(effective_category.clone(), id.clone()),
|
||||
Guide { id, category: effective_category, meta, dir: guide_dir },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn category_rank(category: &str) -> usize {
|
||||
CATEGORY_ORDER.iter().position(|c| *c == category).unwrap_or(CATEGORY_ORDER.len())
|
||||
}
|
||||
|
||||
impl ContentStore {
|
||||
pub fn load() -> Self {
|
||||
let mut by_key = BTreeMap::new();
|
||||
scan_root(Path::new(SYSTEM_ROOT), &mut by_key);
|
||||
scan_root(&user_content_root(), &mut by_key);
|
||||
|
||||
let mut guides: Vec<Guide> = by_key.into_values().collect();
|
||||
guides.sort_by(|a, b| {
|
||||
(category_rank(&a.category), &a.category, &a.id).cmp(&(category_rank(&b.category), &b.category, &b.id))
|
||||
});
|
||||
Self { guides }
|
||||
}
|
||||
|
||||
pub fn categories(&self) -> Vec<String> {
|
||||
let mut seen = Vec::new();
|
||||
for g in &self.guides {
|
||||
if !seen.contains(&g.category) {
|
||||
seen.push(g.category.clone());
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
pub fn guides_in(&self, category: &str) -> Vec<&Guide> {
|
||||
self.guides.iter().filter(|g| g.category == category).collect()
|
||||
}
|
||||
|
||||
pub fn guide(&self, category: &str, id: &str) -> Option<&Guide> {
|
||||
self.guides.iter().find(|g| g.category == category && g.id == id)
|
||||
}
|
||||
|
||||
pub fn all(&self) -> &[Guide] {
|
||||
&self.guides
|
||||
}
|
||||
|
||||
/// The guide body for the given mode — `content.beginner.md` when in a
|
||||
/// simplified mode and present, otherwise `content.md`. Never errors: a
|
||||
/// missing file just renders as an empty guide rather than crashing.
|
||||
pub fn body(&self, guide: &Guide, simplified: bool) -> String {
|
||||
if simplified {
|
||||
let beginner = guide.dir.join("content.beginner.md");
|
||||
if let Ok(text) = std::fs::read_to_string(&beginner) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
std::fs::read_to_string(guide.dir.join("content.md")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue