feat: add custom tag icons and colors

This commit is contained in:
Blake Boone
2026-09-21 09:08:33 +00:00
committed by Yuri Karamian
parent 2983e3db29
commit 245cdacb68
18 changed files with 1289 additions and 93 deletions
+30 -6
View File
@@ -1588,6 +1588,28 @@ pub fn set_notebook_icon(
operations::set_notebook_icon(vault_path, &notebook_relative, icon_relative.as_deref())
}
// ── Tag Styles ──
#[tauri::command]
pub fn get_tag_styles(
state: State<'_, AppState>,
) -> Result<std::collections::HashMap<String, crate::types::TagStyle>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::load_tag_styles(vault_path)
}
#[tauri::command]
pub fn set_tag_style(
state: State<'_, AppState>,
tag: String,
style: Option<crate::types::TagStyle>,
) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::set_tag_style(vault_path, &tag, style)
}
// ── General Settings ──
#[tauri::command]
@@ -1803,7 +1825,7 @@ pub struct OrphanAttachment {
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files)
// plus notebook_icons.json, and matches each filename against both the raw text and a
// plus notebook_icons.json and tag_styles.json, and matches each filename against both the raw text and a
// percent-decoded copy (so a URL-encoded path like `my%20file.png` still counts as a reference).
// When in doubt a file is KEPT: a leftover orphan is harmless, a wrong deletion is not.
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
@@ -1841,11 +1863,13 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
}
}
}
// Folder icons live in attachments but are referenced here, not in notes. (#157)
let icons_path = operations::helixnotes_dir(vault).join("notebook_icons.json");
if let Ok(content) = std::fs::read_to_string(&icons_path) {
haystack.push_str(&content);
haystack.push('\n');
// Folder and tag icons live in attachments but are referenced here, not in notes. (#157)
for mapping in ["notebook_icons.json", "tag_styles.json"] {
let mapping_path = operations::helixnotes_dir(vault).join(mapping);
if let Ok(content) = std::fs::read_to_string(&mapping_path) {
haystack.push_str(&content);
haystack.push('\n');
}
}
let decoded = percent_decode(&haystack);
let orphans = files
+2
View File
@@ -216,6 +216,8 @@ pub fn run() {
commands::save_attachment,
commands::get_notebook_icons,
commands::set_notebook_icon,
commands::get_tag_styles,
commands::set_tag_style,
commands::set_general_settings,
commands::get_quick_access,
commands::add_quick_access,
+9 -5
View File
@@ -6,9 +6,9 @@
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
// and the manifest itself remain local-only.
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`,
// `.helixnotes/notebook_icons.json`, and `.helixnotes/tag_styles.json`. Search indexes,
// trash, history, other metadata, and the manifest itself remain local-only.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
@@ -123,11 +123,13 @@ struct LocalFile {
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
/// `.helixnotes/attachments/`, plus notebook icon and tag style mappings. Applied to BOTH local
/// and remote so pointing at a folder with unrelated files never imports them.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
rel.starts_with(".helixnotes/attachments/")
|| rel == ".helixnotes/notebook_icons.json"
|| rel == ".helixnotes/tag_styles.json"
} else {
rel.ends_with(".md")
}
@@ -758,6 +760,7 @@ mod tests {
"Notes/plan.md",
".helixnotes/attachments/notebook-icon.png",
".helixnotes/notebook_icons.json",
".helixnotes/tag_styles.json",
] {
assert!(is_synced_relpath(path), "expected {path} to be synced");
}
@@ -766,6 +769,7 @@ mod tests {
"Notes/image.png",
".helixnotes/sync_state.json",
".helixnotes/notebook_icons.json.bak",
".helixnotes/tag_styles.json.bak",
".helixnotes/attachments-old/icon.png",
] {
assert!(!is_synced_relpath(path), "expected {path} to stay local");
+22
View File
@@ -42,6 +42,28 @@ pub struct NotebookEntry {
pub note_count: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TagStyle {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl TagStyle {
pub fn is_empty(&self) -> bool {
self.icon
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
&& self
.color
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteContent {
pub path: String,
+236 -3
View File
@@ -1,5 +1,5 @@
use crate::types::{
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TagStyle, TrashContents,
TrashNotebookEntry, VaultState,
};
use crate::vault::frontmatter;
@@ -1448,6 +1448,100 @@ pub fn set_notebook_icon(
Ok(())
}
fn tag_styles_path(vault_path: &str) -> PathBuf {
helixnotes_dir(vault_path).join("tag_styles.json")
}
fn normalize_tag_style_key(tag: &str) -> String {
tag.trim().to_string()
}
fn tag_style_key_fold(tag: &str) -> String {
tag.to_ascii_lowercase()
}
fn remove_matching_tag_styles(styles: &mut std::collections::HashMap<String, TagStyle>, tag: &str) {
let fold = tag_style_key_fold(tag);
styles.retain(|existing, _| tag_style_key_fold(existing) != fold);
}
fn normalize_tag_color(color: Option<&str>) -> Result<Option<String>, String> {
let Some(color) = color.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let hex = color
.strip_prefix('#')
.ok_or_else(|| "Tag color must be a #hex value".to_string())?;
if !matches!(hex.len(), 3 | 6) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Tag color must be #RGB or #RRGGBB".to_string());
}
Ok(Some(format!("#{}", hex.to_ascii_lowercase())))
}
fn normalize_tag_icon(icon: Option<&str>) -> Option<String> {
icon.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.replace('\\', "/"))
}
pub fn load_tag_styles(
vault_path: &str,
) -> Result<std::collections::HashMap<String, TagStyle>, String> {
let styles_path = tag_styles_path(vault_path);
if !styles_path.exists() {
return Ok(std::collections::HashMap::new());
}
let data = fs::read_to_string(&styles_path).map_err(|e| e.to_string())?;
let styles: std::collections::HashMap<String, TagStyle> =
serde_json::from_str(&data).map_err(|e| e.to_string())?;
let mut collapsed = std::collections::HashMap::new();
for (tag, style) in styles {
let key = normalize_tag_style_key(&tag);
if key.is_empty() || style.is_empty() {
continue;
}
remove_matching_tag_styles(&mut collapsed, &key);
collapsed.insert(key, style);
}
Ok(collapsed)
}
pub fn set_tag_style(vault_path: &str, tag: &str, style: Option<TagStyle>) -> Result<(), String> {
let key = normalize_tag_style_key(tag);
if key.is_empty() {
return Err("Tag name is required".to_string());
}
let mut styles = load_tag_styles(vault_path)?;
let stored_key = styles
.keys()
.find(|existing| tag_style_key_fold(existing) == tag_style_key_fold(&key))
.cloned()
.unwrap_or_else(|| key.clone());
match style {
Some(style) => {
let next = TagStyle {
icon: normalize_tag_icon(style.icon.as_deref()),
color: normalize_tag_color(style.color.as_deref())?,
};
remove_matching_tag_styles(&mut styles, &key);
if !next.is_empty() {
styles.insert(stored_key, next);
}
}
None => {
remove_matching_tag_styles(&mut styles, &key);
}
}
let dir = helixnotes_dir(vault_path);
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let data = serde_json::to_string_pretty(&styles).map_err(|e| e.to_string())?;
fs::write(tag_styles_path(vault_path), data).map_err(|e| e.to_string())?;
Ok(())
}
pub fn load_quick_access(vault_path: &str) -> Result<Vec<String>, String> {
let qa_path = helixnotes_dir(vault_path).join("quick_access.json");
if qa_path.exists() {
@@ -1566,9 +1660,10 @@ pub fn sanitize_filename(name: &str) -> String {
mod tests {
use super::{
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
scan_notebooks, set_notebook_icon,
helixnotes_dir, load_notebook_icons, load_tag_styles, permanent_delete, read_note,
restore_notebook, scan_notebooks, set_notebook_icon, set_tag_style,
};
use crate::types::TagStyle;
use std::fs;
use uuid::Uuid;
@@ -1660,6 +1755,144 @@ mod tests {
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn persists_and_removes_tag_styles() {
let vault =
std::env::temp_dir().join(format!("helixnotes-tag-style-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
set_tag_style(
&vault_path,
" Work ",
Some(TagStyle {
icon: Some("builtin:briefcase".into()),
color: Some("#E11D48".into()),
}),
)
.unwrap();
let stored: std::collections::HashMap<String, TagStyle> = serde_json::from_str(
&fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(),
)
.unwrap();
assert!(stored.contains_key("Work"));
assert!(!stored.contains_key(" Work "));
let styles = load_tag_styles(&vault_path).unwrap();
let work = styles.get("Work").expect("work tag style");
assert_eq!(work.icon.as_deref(), Some("builtin:briefcase"));
assert_eq!(work.color.as_deref(), Some("#e11d48"));
set_tag_style(&vault_path, "Work", None).unwrap();
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn resetting_tag_style_is_case_insensitive() {
let vault =
std::env::temp_dir().join(format!("helixnotes-tag-style-case-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
set_tag_style(
&vault_path,
"Work",
Some(TagStyle {
icon: Some("builtin:briefcase".into()),
color: Some("#e11d48".into()),
}),
)
.unwrap();
set_tag_style(&vault_path, "work", None).unwrap();
let stored: serde_json::Value = serde_json::from_str(
&fs::read_to_string(helixnotes_dir(&vault_path).join("tag_styles.json")).unwrap(),
)
.unwrap();
assert_eq!(stored, serde_json::json!({}));
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn load_collapses_case_variant_tag_style_keys() {
let vault = std::env::temp_dir().join(format!(
"helixnotes-tag-style-collapse-test-{}",
Uuid::new_v4()
));
let vault_path = vault.to_string_lossy();
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
fs::write(
helixnotes_dir(&vault_path).join("tag_styles.json"),
r##"{"Work":{"icon":"builtin:briefcase"},"work":{"color":"#e11d48"}}"##,
)
.unwrap();
let styles = load_tag_styles(&vault_path).unwrap();
assert_eq!(styles.len(), 1);
let (key, style) = styles.iter().next().unwrap();
assert_eq!(key.to_ascii_lowercase(), "work");
assert!(style.icon.is_some() || style.color.is_some());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn rejects_invalid_tag_color() {
let vault = std::env::temp_dir().join(format!(
"helixnotes-tag-style-color-test-{}",
Uuid::new_v4()
));
let vault_path = vault.to_string_lossy();
let err = set_tag_style(
&vault_path,
"daily",
Some(TagStyle {
icon: None,
color: Some("red".into()),
}),
)
.unwrap_err();
assert!(err.contains("hex"));
fs::remove_dir_all(&vault).ok();
}
#[test]
fn clearing_empty_tag_style_removes_entry() {
let vault = std::env::temp_dir().join(format!(
"helixnotes-tag-style-clear-test-{}",
Uuid::new_v4()
));
let vault_path = vault.to_string_lossy();
set_tag_style(
&vault_path,
"daily",
Some(TagStyle {
icon: Some("builtin:calendar".into()),
color: None,
}),
)
.unwrap();
set_tag_style(
&vault_path,
"daily",
Some(TagStyle {
icon: None,
color: None,
}),
)
.unwrap();
assert!(load_tag_styles(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn loads_only_requested_note_switcher_titles() {
let vault =