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:
@@ -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(¬e).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(¬e).unwrap();
|
||||
assert!(content.contains("Trashed: true"));
|
||||
assert!(content.contains("Trashed at"));
|
||||
assert!(content.contains("_trashed: true"));
|
||||
assert!(content.contains("_trashed_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user