mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-22 19:07:34 +02:00
feat: add custom tag icons and colors
This commit is contained in:
committed by
Yuri Karamian
parent
2983e3db29
commit
245cdacb68
@@ -1588,6 +1588,28 @@ pub fn set_notebook_icon(
|
||||
operations::set_notebook_icon(vault_path, ¬ebook_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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user