feat: normalize system properties to _archived, _trashed, _trashed_at

Write operations now use underscore-prefixed canonical keys (_archived,
_trashed, _trashed_at). Read operations accept both old keys (Archived,
archived, Trashed, trashed, Trashed at, trashed_at) and new keys via
serde aliases, ensuring backward compatibility without migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-04-02 19:32:48 +02:00
parent 0fb115bbe1
commit 55352c0afd
8 changed files with 96 additions and 26 deletions

View File

@@ -207,7 +207,7 @@ pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(&path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
@@ -219,10 +219,10 @@ pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(&path, "_trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(
&path,
"Trashed at",
"_trashed_at",
FrontmatterValue::String(now.clone()),
)?;
count += 1;
@@ -276,7 +276,7 @@ mod tests {
1
);
let content = std::fs::read_to_string(&note).unwrap();
assert!(content.contains("Archived: true"));
assert!(content.contains("_archived: true"));
assert!(content.contains("Status: Active"));
}
@@ -288,8 +288,8 @@ mod tests {
1
);
let content = std::fs::read_to_string(&note).unwrap();
assert!(content.contains("Trashed: true"));
assert!(content.contains("Trashed at"));
assert!(content.contains("_trashed: true"));
assert!(content.contains("_trashed_at"));
}
#[test]

View File

@@ -12,14 +12,16 @@ pub(crate) struct Frontmatter {
#[serde(default)]
pub aliases: Option<StringOrList>,
#[serde(
rename = "Archived",
rename = "_archived",
alias = "Archived",
alias = "archived",
default,
deserialize_with = "deserialize_bool_or_string"
)]
pub archived: Option<bool>,
#[serde(
rename = "Trashed",
rename = "_trashed",
alias = "Trashed",
alias = "trashed",
default,
deserialize_with = "deserialize_bool_or_string"
@@ -27,7 +29,11 @@ pub(crate) struct Frontmatter {
pub trashed: Option<bool>,
#[serde(rename = "Status", alias = "status", default)]
pub status: Option<StringOrList>,
#[serde(rename = "Trashed at", alias = "trashed_at")]
#[serde(
rename = "_trashed_at",
alias = "Trashed at",
alias = "trashed_at"
)]
pub trashed_at: Option<StringOrList>,
#[serde(default)]
pub icon: Option<StringOrList>,
@@ -145,10 +151,13 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
"Is A",
"is_a",
"aliases",
"_archived",
"Archived",
"archived",
"_trashed",
"Trashed",
"trashed",
"_trashed_at",
"Trashed at",
"trashed_at",
"icon",
@@ -182,9 +191,13 @@ const SKIP_KEYS: &[&str] = &[
"is a",
"type",
"aliases",
"_archived",
"archived",
"_trashed",
"trashed",
"_trashed_at",
"trashed at",
"trashed_at",
"icon",
"color",
"order",

View File

@@ -1092,6 +1092,31 @@ fn test_parse_trashed_no() {
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
}
// --- new canonical underscore-prefixed keys ---
#[test]
fn test_parse_underscore_trashed_canonical() {
let dir = TempDir::new().unwrap();
let content = "---\n_trashed: true\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone-new.md", content);
assert!(entry.trashed, "'_trashed: true' must be parsed as trashed");
assert!(
entry.trashed_at.is_some(),
"'_trashed_at' must be parsed as trashed_at"
);
}
#[test]
fn test_parse_underscore_archived_canonical() {
let dir = TempDir::new().unwrap();
let content = "---\n_archived: true\n---\n# Old\n";
let entry = parse_test_entry(&dir, "old-new.md", content);
assert!(
entry.archived,
"'_archived: true' must be parsed as archived"
);
}
// --- visible field tests ---
#[test]

View File

@@ -14,7 +14,10 @@ fn extract_trashed_at_string(data: &Option<gray_matter::Pod>) -> Option<String>
let gray_matter::Pod::Hash(ref map) = data.as_ref()? else {
return None;
};
let pod = map.get("Trashed at").or_else(|| map.get("trashed_at"))?;
let pod = map
.get("_trashed_at")
.or_else(|| map.get("Trashed at"))
.or_else(|| map.get("trashed_at"))?;
match pod {
gray_matter::Pod::String(s) => Some(s.clone()),
_ => None,
@@ -74,7 +77,11 @@ pub fn is_file_trashed(path: &Path) -> bool {
// Check for "Trashed: true"
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
if let Some(pod) = map.get("Trashed").or_else(|| map.get("trashed")) {
if let Some(pod) = map
.get("_trashed")
.or_else(|| map.get("Trashed"))
.or_else(|| map.get("trashed"))
{
return match pod {
gray_matter::Pod::Boolean(b) => *b,
gray_matter::Pod::String(s) => {
@@ -373,6 +380,28 @@ mod tests {
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
}
#[test]
fn test_is_file_trashed_with_underscore_trashed() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"trashed.md",
"---\n_trashed: true\n---\n# Gone\n",
);
assert!(is_file_trashed(&dir.path().join("trashed.md")));
}
#[test]
fn test_is_file_trashed_with_underscore_trashed_at() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"trashed.md",
"---\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n",
);
assert!(is_file_trashed(&dir.path().join("trashed.md")));
}
#[test]
fn test_is_file_trashed_with_trashed_false() {
let dir = TempDir::new().unwrap();

View File

@@ -10,7 +10,9 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
icon: { icon: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
_archived: { archived: false }, archived: { archived: false },
_trashed: { trashed: false }, trashed: { trashed: false },
order: { order: null },
template: { template: null }, sort: { sort: null }, visible: { visible: null },
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
}
@@ -53,7 +55,8 @@ export function frontmatterToEntryPatch(
type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str },
icon: { icon: str },
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
_archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) },
_trashed: { trashed: Boolean(value) }, trashed: { trashed: Boolean(value) },
order: { order: typeof value === 'number' ? value : null },
template: { template: str },
sort: { sort: str },

View File

@@ -67,8 +67,8 @@ describe('useEntryActions', () => {
await result.current.handleTrashNote('/vault/note/test.md')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', true, { silent: true })
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), { silent: true })
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_trashed', true, { silent: true })
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_trashed_at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), { silent: true })
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
trashed: true,
trashedAt: expect.any(Number),
@@ -99,8 +99,8 @@ describe('useEntryActions', () => {
await result.current.handleRestoreNote('/vault/note/test.md')
})
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', { silent: true })
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at', { silent: true })
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_trashed', { silent: true })
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_trashed_at', { silent: true })
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
trashed: false,
@@ -119,7 +119,7 @@ describe('useEntryActions', () => {
await result.current.handleArchiveNote('/vault/note/test.md')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true, { silent: true })
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_archived', true, { silent: true })
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
@@ -146,7 +146,7 @@ describe('useEntryActions', () => {
await result.current.handleUnarchiveNote('/vault/note/test.md')
})
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'archived', { silent: true })
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_archived', { silent: true })
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')

View File

@@ -35,8 +35,8 @@ export function useEntryActions({
setToastMessage('Note moved to trash')
const now = new Date().toISOString().slice(0, 10)
try {
await handleUpdateFrontmatter(path, 'Trashed', true, { silent: true })
await handleUpdateFrontmatter(path, 'Trashed at', now, { silent: true })
await handleUpdateFrontmatter(path, '_trashed', true, { silent: true })
await handleUpdateFrontmatter(path, '_trashed_at', now, { silent: true })
onFrontmatterPersisted?.()
} catch (err) {
updateEntry(path, { trashed: false, trashedAt: null })
@@ -50,8 +50,8 @@ export function useEntryActions({
updateEntry(path, { trashed: false, trashedAt: null })
setToastMessage('Note restored from trash')
try {
await handleDeleteProperty(path, 'Trashed', { silent: true })
await handleDeleteProperty(path, 'Trashed at', { silent: true })
await handleDeleteProperty(path, '_trashed', { silent: true })
await handleDeleteProperty(path, '_trashed_at', { silent: true })
onFrontmatterPersisted?.()
} catch (err) {
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
@@ -66,7 +66,7 @@ export function useEntryActions({
updateEntry(path, { archived: true })
setToastMessage('Note archived')
try {
await handleUpdateFrontmatter(path, 'archived', true, { silent: true })
await handleUpdateFrontmatter(path, '_archived', true, { silent: true })
onFrontmatterPersisted?.()
} catch (err) {
updateEntry(path, { archived: false })
@@ -80,7 +80,7 @@ export function useEntryActions({
updateEntry(path, { archived: false })
setToastMessage('Note unarchived')
try {
await handleDeleteProperty(path, 'archived', { silent: true })
await handleDeleteProperty(path, '_archived', { silent: true })
onFrontmatterPersisted?.()
} catch (err) {
updateEntry(path, { archived: true })

View File

@@ -12,7 +12,7 @@ import { containsWikilinks } from '../components/DynamicPropertiesPanel'
// Keys to skip showing in Properties (handled by dedicated UI or internal)
// Compared case-insensitively via isVisibleProperty()
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', 'trashed', 'trashed_at', 'archived', 'archived_at', 'icon'])
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_trashed', 'trashed', '_trashed_at', 'trashed_at', 'trashed at', '_archived', 'archived', 'archived_at', 'icon'])
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true