Merge pull request #16 from refactoringhq/task/rimuovi-is-a
feat: Rimuovi proprietà 'Is a' — type: come chiave canonica
This commit is contained in:
375
design/rimuovi-is-a.pen
Normal file
375
design/rimuovi-is-a.pen
Normal file
@@ -0,0 +1,375 @@
|
||||
{
|
||||
"version": 2.8,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeAfter",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Inspector — Is a Removed (Before / After)",
|
||||
"width": 700,
|
||||
"height": 500,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "horizontal",
|
||||
"gap": 40,
|
||||
"padding": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforePanel",
|
||||
"name": "Before",
|
||||
"width": 300,
|
||||
"height": 452,
|
||||
"fill": "#FAFAFA",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 16,
|
||||
"cornerRadius": 8,
|
||||
"stroke": "#E5E5E5",
|
||||
"strokeWidth": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeTitle",
|
||||
"content": "BEFORE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 700,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeSep1",
|
||||
"width": 268,
|
||||
"height": 1,
|
||||
"fill": "#E5E5E5"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeRow1",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeTypeLabel",
|
||||
"content": "TYPE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeTypePill",
|
||||
"fill": "#E8F5E9",
|
||||
"cornerRadius": 6,
|
||||
"padding": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeTypeVal",
|
||||
"content": "Project",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 500,
|
||||
"fill": "#388E3C"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeRow2",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"fill": "#FFF3E0",
|
||||
"cornerRadius": 4,
|
||||
"padding": 6,
|
||||
"stroke": "#FF9800",
|
||||
"strokeWidth": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeIsALabel",
|
||||
"content": "is_a",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#E65100",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeIsAVal",
|
||||
"content": "Project",
|
||||
"fontSize": 13,
|
||||
"fill": "#E65100"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeDupNote",
|
||||
"content": "⚠ Duplicate! 'is_a' duplicates 'Type'",
|
||||
"fontSize": 11,
|
||||
"fill": "#E65100",
|
||||
"fontWeight": 500
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeRow3",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeStatusLabel",
|
||||
"content": "STATUS",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeStatusPill",
|
||||
"fill": "#E8F5E9",
|
||||
"cornerRadius": 16,
|
||||
"padding": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeStatusVal",
|
||||
"content": "ACTIVE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#388E3C"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeRow4",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeModLabel",
|
||||
"content": "MODIFIED",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeModVal",
|
||||
"content": "Feb 23, 2026",
|
||||
"fontSize": 12,
|
||||
"fill": "#666666"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "beforeRow5",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeWordsLabel",
|
||||
"content": "WORDS",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "beforeWordsVal",
|
||||
"content": "342",
|
||||
"fontSize": 12,
|
||||
"fill": "#666666"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterPanel",
|
||||
"name": "After",
|
||||
"width": 300,
|
||||
"height": 452,
|
||||
"fill": "#FAFAFA",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 16,
|
||||
"cornerRadius": 8,
|
||||
"stroke": "#388E3C",
|
||||
"strokeWidth": 2,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterTitle",
|
||||
"content": "AFTER",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 700,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#388E3C"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterSep1",
|
||||
"width": 268,
|
||||
"height": 1,
|
||||
"fill": "#E5E5E5"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterRow1",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterTypeLabel",
|
||||
"content": "TYPE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterTypePill",
|
||||
"fill": "#E8F5E9",
|
||||
"cornerRadius": 6,
|
||||
"padding": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterTypeVal",
|
||||
"content": "Project",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 500,
|
||||
"fill": "#388E3C"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterNote",
|
||||
"content": "✓ 'is_a' removed — only 'Type' shown",
|
||||
"fontSize": 11,
|
||||
"fill": "#388E3C",
|
||||
"fontWeight": 500
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterRow3",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterStatusLabel",
|
||||
"content": "STATUS",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterStatusPill",
|
||||
"fill": "#E8F5E9",
|
||||
"cornerRadius": 16,
|
||||
"padding": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterStatusVal",
|
||||
"content": "ACTIVE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#388E3C"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterRow4",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterModLabel",
|
||||
"content": "MODIFIED",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterModVal",
|
||||
"content": "Feb 23, 2026",
|
||||
"fontSize": 12,
|
||||
"fill": "#666666"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "afterRow5",
|
||||
"layout": "horizontal",
|
||||
"width": 268,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterWordsLabel",
|
||||
"content": "WORDS",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"fill": "#999999",
|
||||
"width": 80
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "afterWordsVal",
|
||||
"content": "342",
|
||||
"fontSize": 12,
|
||||
"fill": "#666666"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"themes": {},
|
||||
"variables": {},
|
||||
"fonts": []
|
||||
}
|
||||
@@ -100,6 +100,11 @@ fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
vault::migrate_is_a_to_type(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings() -> Result<Settings, String> {
|
||||
settings::get_settings()
|
||||
@@ -154,7 +159,8 @@ pub fn run() {
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
if vault_path.is_dir() {
|
||||
match vault::purge_trash(vault_path.to_str().unwrap_or_default()) {
|
||||
let vp_str = vault_path.to_str().unwrap_or_default();
|
||||
match vault::purge_trash(vp_str) {
|
||||
Ok(deleted) if !deleted.is_empty() => {
|
||||
log::info!("Purged {} trashed files on startup", deleted.len());
|
||||
}
|
||||
@@ -163,6 +169,16 @@ pub fn run() {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Migrate legacy is_a/Is A frontmatter to type
|
||||
match vault::migrate_is_a_to_type(vp_str) {
|
||||
Ok(n) if n > 0 => {
|
||||
log::info!("Migrated {} files from is_a to type on startup", n);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to migrate is_a on startup: {}", e);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -183,6 +199,7 @@ pub fn run() {
|
||||
ai_chat,
|
||||
save_image,
|
||||
purge_trash,
|
||||
migrate_is_a_to_type,
|
||||
get_settings,
|
||||
save_settings,
|
||||
github_list_repos,
|
||||
|
||||
@@ -50,8 +50,12 @@ pub struct VaultEntry {
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct Frontmatter {
|
||||
#[serde(rename = "Is A")]
|
||||
/// Legacy "Is A" / "is_a" field — migrated to `type` in new notes.
|
||||
#[serde(rename = "Is A", alias = "is_a")]
|
||||
is_a: Option<StringOrList>,
|
||||
/// New canonical "type" field (preferred over is_a).
|
||||
#[serde(default, rename = "type")]
|
||||
type_field: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
aliases: Option<StringOrList>,
|
||||
#[serde(rename = "Belongs to")]
|
||||
@@ -232,6 +236,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
/// Only skip keys that can never contain wikilinks.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"is a",
|
||||
"is_a",
|
||||
"type",
|
||||
"aliases",
|
||||
"status",
|
||||
"cadence",
|
||||
@@ -313,9 +319,14 @@ fn infer_type_from_folder(folder: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Resolve `is_a` from frontmatter, falling back to parent folder inference.
|
||||
fn resolve_is_a(fm_is_a: Option<StringOrList>, path: &Path) -> Option<String> {
|
||||
fm_is_a
|
||||
/// Resolve note type: prefer `type` field, fall back to `is_a`, then parent folder inference.
|
||||
fn resolve_type(
|
||||
type_field: Option<StringOrList>,
|
||||
is_a: Option<StringOrList>,
|
||||
path: &Path,
|
||||
) -> Option<String> {
|
||||
type_field
|
||||
.or(is_a)
|
||||
.and_then(|a| a.into_vec().into_iter().next())
|
||||
.or_else(|| {
|
||||
path.parent()
|
||||
@@ -377,7 +388,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
let snippet = extract_snippet(&content);
|
||||
let (modified_at, file_size) = read_file_metadata(path)?;
|
||||
let created_at = parse_created_at(&frontmatter);
|
||||
let is_a = resolve_is_a(frontmatter.is_a, path);
|
||||
let is_a = resolve_type(frontmatter.type_field, frontmatter.is_a, path);
|
||||
|
||||
// Add "Type" relationship: isA becomes a navigable link to the type document.
|
||||
// Skip for type documents themselves (isA == "Type") to avoid self-referential links.
|
||||
@@ -480,6 +491,120 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a frontmatter section contains legacy `is_a` or `Is A` keys.
|
||||
fn has_legacy_is_a(fm_content: &str) -> bool {
|
||||
fm_content.lines().any(|line| {
|
||||
let t = line.trim_start();
|
||||
t.starts_with("is_a:")
|
||||
|| t.starts_with("\"Is A\":")
|
||||
|| t.starts_with("'Is A':")
|
||||
|| t.starts_with("Is A:")
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the value from a legacy `is_a` / `Is A` line.
|
||||
fn extract_is_a_value(line: &str) -> Option<&str> {
|
||||
let t = line.trim_start();
|
||||
for prefix in &["is_a:", "\"Is A\":", "'Is A':", "Is A:"] {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let v = rest.trim();
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Migrate a single file's frontmatter from `is_a`/`Is A` to `type`.
|
||||
/// Returns Ok(true) if the file was modified, Ok(false) if no migration needed.
|
||||
fn migrate_file_is_a_to_type(path: &Path) -> Result<bool, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
|
||||
if !content.starts_with("---\n") {
|
||||
return Ok(false);
|
||||
}
|
||||
let fm_end = match content[4..].find("\n---") {
|
||||
Some(i) => i + 4,
|
||||
None => return Ok(false),
|
||||
};
|
||||
let fm_content = &content[4..fm_end];
|
||||
|
||||
if !has_legacy_is_a(fm_content) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if `type:` already exists
|
||||
let has_type = fm_content.lines().any(|line| {
|
||||
let t = line.trim_start();
|
||||
t.starts_with("type:")
|
||||
});
|
||||
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut is_a_value: Option<String> = None;
|
||||
|
||||
for line in fm_content.lines() {
|
||||
if let Some(val) = extract_is_a_value(line) {
|
||||
is_a_value = Some(val.to_string());
|
||||
// Skip list continuations after is_a
|
||||
continue;
|
||||
}
|
||||
new_lines.push(line.to_string());
|
||||
}
|
||||
|
||||
// If type: doesn't exist and we found an is_a value, add type:
|
||||
if !has_type {
|
||||
if let Some(ref val) = is_a_value {
|
||||
// Insert type: at the beginning (after other keys is fine too, but beginning is clean)
|
||||
new_lines.insert(0, format!("type: {}", val));
|
||||
}
|
||||
}
|
||||
|
||||
let rest = &content[fm_end + 4..];
|
||||
let new_content = format!("---\n{}\n---{}", new_lines.join("\n"), rest);
|
||||
|
||||
fs::write(path, &new_content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Migrate all markdown files in the vault from `is_a`/`Is A` to `type`.
|
||||
/// Returns the number of files migrated.
|
||||
pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let mut migrated = 0;
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match migrate_file_is_a_to_type(path) {
|
||||
Ok(true) => {
|
||||
log::info!("Migrated is_a → type: {}", path.display());
|
||||
migrated += 1;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to migrate {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `Trashed at` frontmatter is more than 30 days ago.
|
||||
/// Returns the list of deleted file paths.
|
||||
@@ -565,8 +690,7 @@ pub fn get_note_content(path: &str) -> Result<String, String> {
|
||||
pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let file_path = Path::new(path);
|
||||
validate_save_path(file_path, path)?;
|
||||
fs::write(file_path, content)
|
||||
.map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
}
|
||||
|
||||
fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> {
|
||||
@@ -2061,7 +2185,9 @@ References:
|
||||
fn test_save_note_content_nonexistent_parent() {
|
||||
let result = save_note_content("/nonexistent/parent/dir/file.md", "content");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Parent directory does not exist"));
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.contains("Parent directory does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2093,7 +2219,8 @@ References:
|
||||
let original = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nOriginal body.";
|
||||
create_test_file(dir.path(), "note.md", original);
|
||||
|
||||
let updated = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes.";
|
||||
let updated =
|
||||
"---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes.";
|
||||
save_note_content(file_path.to_str().unwrap(), updated).unwrap();
|
||||
|
||||
let saved = fs::read_to_string(&file_path).unwrap();
|
||||
@@ -2438,4 +2565,127 @@ References:
|
||||
assert!(content.contains("title: New Name"));
|
||||
assert!(content.contains("# New Name"));
|
||||
}
|
||||
|
||||
// --- type field parsing tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_type_field() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project/my-project.md",
|
||||
"---\ntype: Project\nStatus: Active\n---\n# My Project\n",
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_type_field_takes_precedence_over_is_a() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project/conflict.md",
|
||||
"---\ntype: Project\nIs A: Note\n---\n# Conflict\n",
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_legacy_is_a_still_works() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note/legacy.md",
|
||||
"---\nIs A: Note\nStatus: Active\n---\n# Legacy\n",
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_snake_case_is_a_still_works() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note/snake.md",
|
||||
"---\nis_a: Note\nStatus: Draft\n---\n# Snake Case\n",
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
}
|
||||
|
||||
// --- migration tests ---
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_is_a_to_type() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(&path, "---\nis_a: Project\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
assert!(migrate_file_is_a_to_type(&path).unwrap());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("type: Project"));
|
||||
assert!(!content.contains("is_a:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_quoted_is_a_to_type() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(&path, "---\n\"Is A\": Type\norder: 1\n---\n# My Type\n").unwrap();
|
||||
|
||||
assert!(migrate_file_is_a_to_type(&path).unwrap());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(!content.contains("Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_preserves_existing_type() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"---\ntype: Project\nis_a: Note\nStatus: Active\n---\n# Note\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(migrate_file_is_a_to_type(&path).unwrap());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("type: Project"));
|
||||
assert!(!content.contains("is_a:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_no_change_needed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(&path, "---\ntype: Project\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
assert!(!migrate_file_is_a_to_type(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note/a.md", "---\nis_a: Note\n---\n# A\n");
|
||||
create_test_file(dir.path(), "project/b.md", "---\ntype: Project\n---\n# B\n");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"type/c.md",
|
||||
"---\n\"Is A\": Type\norder: 1\n---\n# C\n",
|
||||
);
|
||||
|
||||
let migrated = migrate_is_a_to_type(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(migrated, 2); // a.md and c.md
|
||||
|
||||
let a = fs::read_to_string(dir.path().join("note/a.md")).unwrap();
|
||||
assert!(a.contains("type: Note"));
|
||||
assert!(!a.contains("is_a:"));
|
||||
|
||||
let b = fs::read_to_string(dir.path().join("project/b.md")).unwrap();
|
||||
assert!(b.contains("type: Project")); // unchanged
|
||||
|
||||
let c = fs::read_to_string(dir.path().join("type/c.md")).unwrap();
|
||||
assert!(c.contains("type: Type"));
|
||||
assert!(!c.contains("Is A"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,21 +128,21 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips is_a key since it is shown via TypeRow', () => {
|
||||
it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ isA: 'Project' })}
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ is_a: 'Project', Status: 'Active' }}
|
||||
frontmatter={{ is_a: 'Note', type: 'Note', 'Is A': 'Note', Status: 'Active' }}
|
||||
/>
|
||||
)
|
||||
// Type is shown via TypeRow, is_a should NOT appear as a separate editable property
|
||||
const editableRows = screen.getAllByTestId('editable-property')
|
||||
const editableLabels = editableRows.map(row => row.querySelector('span')?.textContent?.replace('×', '').trim())
|
||||
expect(editableLabels).not.toContain('is_a')
|
||||
// But Type row should still show
|
||||
expect(screen.getByText('Type')).toBeInTheDocument()
|
||||
expect(screen.getByText('Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('is_a')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Is A')).not.toBeInTheDocument()
|
||||
// 'type' as a property label should not appear (the TypeRow renders 'Type' differently)
|
||||
const typeLabels = screen.getAllByText('Type')
|
||||
// Only the TypeRow label should exist, not a property row
|
||||
expect(typeLabels).toHaveLength(1)
|
||||
expect(screen.getByTitle('Active')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders boolean property as toggle', () => {
|
||||
|
||||
@@ -27,8 +27,8 @@ export const RELATIONSHIP_KEYS = new Set([
|
||||
'Advances', 'Parent', 'Children', 'Has', 'Notes',
|
||||
])
|
||||
|
||||
// Keys to skip showing in Properties
|
||||
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'is_a', 'Is A'])
|
||||
// Keys to skip showing in Properties (handled by dedicated UI or internal)
|
||||
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'title', 'type', 'is_a', 'Is A'])
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
|
||||
@@ -166,12 +166,12 @@ describe('entryMatchesTarget', () => {
|
||||
describe('buildNoteContent', () => {
|
||||
it('generates frontmatter with status for regular types', () => {
|
||||
const content = buildNoteContent('My Note', 'Note', 'Active')
|
||||
expect(content).toBe('---\ntitle: My Note\nis_a: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
})
|
||||
|
||||
it('omits status when null', () => {
|
||||
const content = buildNoteContent('AI', 'Topic', null)
|
||||
expect(content).toBe('---\ntitle: AI\nis_a: Topic\n---\n\n# AI\n\n')
|
||||
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('resolveNewNote', () => {
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/project/my-project.md')
|
||||
expect(entry.isA).toBe('Project')
|
||||
expect(entry.status).toBe('Active')
|
||||
expect(content).toContain('is_a: Project')
|
||||
expect(content).toContain('type: Project')
|
||||
expect(content).toContain('status: Active')
|
||||
})
|
||||
|
||||
@@ -208,13 +208,14 @@ describe('resolveNewType', () => {
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/type/recipe.md')
|
||||
expect(entry.isA).toBe('Type')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).toContain('Is A: Type')
|
||||
expect(content).toContain('type: Type')
|
||||
expect(content).toContain('# Recipe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('frontmatterToEntryPatch', () => {
|
||||
it.each([
|
||||
['type', 'Project', { isA: 'Project' }],
|
||||
['is_a', 'Project', { isA: 'Project' }],
|
||||
['status', 'Done', { status: 'Done' }],
|
||||
['color', 'red', { color: 'red' }],
|
||||
|
||||
@@ -112,7 +112,7 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
|
||||
const NO_STATUS_TYPES = new Set(['Topic', 'Person'])
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
@@ -127,7 +127,7 @@ export function frontmatterToEntryPatch(
|
||||
const str = value != null ? String(value) : null
|
||||
const arr = Array.isArray(value) ? value.map(String) : []
|
||||
const updates: Record<string, Partial<VaultEntry>> = {
|
||||
is_a: { isA: str }, status: { status: str }, color: { color: str },
|
||||
type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str },
|
||||
icon: { icon: str }, owner: { owner: str }, cadence: { cadence: str },
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
@@ -142,7 +142,7 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
|
||||
}
|
||||
|
||||
export function buildNoteContent(title: string, type: string, status: string | null): string {
|
||||
const lines = ['---', `title: ${title}`, `is_a: ${type}`]
|
||||
const lines = ['---', `title: ${title}`, `type: ${type}`]
|
||||
if (status) lines.push(`status: ${status}`)
|
||||
lines.push('---')
|
||||
return `${lines.join('\n')}\n\n# ${title}\n\n`
|
||||
@@ -159,7 +159,7 @@ export function resolveNewNote(title: string, type: string): { entry: VaultEntry
|
||||
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(typeName)
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
return { entry, content: `---\nIs A: Type\n---\n\n# ${typeName}\n\n` }
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
|
||||
}
|
||||
|
||||
function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
|
||||
|
||||
@@ -24,7 +24,7 @@ async function checkVaultApi(): Promise<boolean> {
|
||||
const MOCK_CONTENT: Record<string, string> = {
|
||||
'/Users/luca/Laputa/project/26q1-laputa-app.md': `---
|
||||
title: Build Laputa App
|
||||
is_a: Project
|
||||
type: Project
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
tags: [Tauri, React, TypeScript, CodeMirror]
|
||||
@@ -102,7 +102,7 @@ function loadVault(path: string): VaultEntry[] {
|
||||
|
||||
\`\`\`yaml
|
||||
title: Some Title
|
||||
is_a: Project
|
||||
type: Project
|
||||
status: Active
|
||||
\`\`\`
|
||||
|
||||
@@ -130,7 +130,7 @@ And this is a second paragraph to verify inter-paragraph spacing is correct. Goo
|
||||
`,
|
||||
'/Users/luca/Laputa/responsibility/grow-newsletter.md': `---
|
||||
title: Grow Newsletter
|
||||
is_a: Responsibility
|
||||
type: Responsibility
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
---
|
||||
@@ -160,7 +160,7 @@ The newsletter is the *engine* that drives everything else — sponsorships, con
|
||||
`,
|
||||
'/Users/luca/Laputa/responsibility/manage-sponsorships.md': `---
|
||||
title: Manage Sponsorships
|
||||
is_a: Responsibility
|
||||
type: Responsibility
|
||||
status: Active
|
||||
owner: Matteo Cellini
|
||||
---
|
||||
@@ -184,7 +184,7 @@ Revenue stream from newsletter sponsorships. [[Matteo Cellini]] handles day-to-d
|
||||
`,
|
||||
'/Users/luca/Laputa/procedure/write-weekly-essays.md': `---
|
||||
title: Write Weekly Essays
|
||||
is_a: Procedure
|
||||
type: Procedure
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
cadence: Weekly
|
||||
@@ -228,7 +228,7 @@ belongs_to:
|
||||
`,
|
||||
'/Users/luca/Laputa/procedure/run-sponsorships.md': `---
|
||||
title: Run Sponsorships
|
||||
is_a: Procedure
|
||||
type: Procedure
|
||||
status: Active
|
||||
owner: Matteo Cellini
|
||||
cadence: Weekly
|
||||
@@ -250,7 +250,7 @@ belongs_to:
|
||||
`,
|
||||
'/Users/luca/Laputa/experiment/stock-screener.md': `---
|
||||
title: Stock Screener — EMA200 Wick Bounce
|
||||
is_a: Experiment
|
||||
type: Experiment
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
domains: [Finance, Quantitative Analysis]
|
||||
@@ -285,7 +285,7 @@ Stocks that wick below the 200-day EMA and close above it show a **statistically
|
||||
`,
|
||||
'/Users/luca/Laputa/note/facebook-ads-strategy.md': `---
|
||||
title: Facebook Ads Strategy
|
||||
is_a: Note
|
||||
type: Note
|
||||
belongs_to:
|
||||
- "[[project/26q1-laputa-app]]"
|
||||
related_to:
|
||||
@@ -310,7 +310,7 @@ related_to:
|
||||
`,
|
||||
'/Users/luca/Laputa/note/budget-allocation.md': `---
|
||||
title: Budget Allocation
|
||||
is_a: Note
|
||||
type: Note
|
||||
belongs_to:
|
||||
- "[[project/26q1-laputa-app]]"
|
||||
---
|
||||
@@ -330,7 +330,7 @@ belongs_to:
|
||||
`,
|
||||
'/Users/luca/Laputa/person/matteo-cellini.md': `---
|
||||
title: Matteo Cellini
|
||||
is_a: Person
|
||||
type: Person
|
||||
aliases:
|
||||
- Matteo
|
||||
---
|
||||
@@ -350,7 +350,7 @@ Sponsorship manager — handles all sponsor relationships, proposals, and report
|
||||
`,
|
||||
'/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md': `---
|
||||
title: Laputa App Design Session
|
||||
is_a: Event
|
||||
type: Event
|
||||
related_to:
|
||||
- "[[project/26q1-laputa-app]]"
|
||||
- "[[person/matteo-cellini]]"
|
||||
@@ -378,7 +378,7 @@ related_to:
|
||||
`,
|
||||
'/Users/luca/Laputa/topic/software-development.md': `---
|
||||
title: Software Development
|
||||
is_a: Topic
|
||||
type: Topic
|
||||
aliases:
|
||||
- Dev
|
||||
- Coding
|
||||
@@ -396,7 +396,7 @@ A broad topic covering everything from frontend to systems programming.
|
||||
`,
|
||||
'/Users/luca/Laputa/topic/trading.md': `---
|
||||
title: Trading
|
||||
is_a: Topic
|
||||
type: Topic
|
||||
aliases:
|
||||
- Algorithmic Trading
|
||||
---
|
||||
@@ -413,7 +413,7 @@ aliases:
|
||||
`,
|
||||
'/Users/luca/Laputa/essay/on-writing-well.md': `---
|
||||
title: On Writing Well
|
||||
is_a: Essay
|
||||
type: Essay
|
||||
Belongs to:
|
||||
- "[[responsibility/grow-newsletter]]"
|
||||
---
|
||||
@@ -424,7 +424,7 @@ Good writing is lean and confident. Every sentence should serve a purpose.
|
||||
`,
|
||||
'/Users/luca/Laputa/essay/engineering-leadership-101.md': `---
|
||||
title: Engineering Leadership 101
|
||||
is_a: Essay
|
||||
type: Essay
|
||||
Belongs to:
|
||||
- "[[responsibility/grow-newsletter]]"
|
||||
Related to:
|
||||
@@ -437,7 +437,7 @@ The transition from IC to manager is the hardest career shift in engineering.
|
||||
`,
|
||||
'/Users/luca/Laputa/essay/ai-agents-primer.md': `---
|
||||
title: AI Agents Primer
|
||||
is_a: Essay
|
||||
type: Essay
|
||||
Belongs to:
|
||||
- "[[responsibility/grow-newsletter]]"
|
||||
---
|
||||
@@ -448,7 +448,7 @@ AI agents are autonomous systems that can plan, execute, and adapt to achieve go
|
||||
`,
|
||||
// --- Type documents ---
|
||||
'/Users/luca/Laputa/type/project.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 0
|
||||
---
|
||||
|
||||
@@ -462,7 +462,7 @@ A **time-bound initiative** that advances a [[type/responsibility|Responsibility
|
||||
- **Belongs to**: Usually a Quarter or Responsibility
|
||||
`,
|
||||
'/Users/luca/Laputa/type/responsibility.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 1
|
||||
---
|
||||
|
||||
@@ -475,7 +475,7 @@ An **ongoing area of ownership** — something you're accountable for indefinite
|
||||
- **Owner**: The person accountable
|
||||
`,
|
||||
'/Users/luca/Laputa/type/procedure.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 2
|
||||
---
|
||||
|
||||
@@ -490,7 +490,7 @@ A **recurring process** tied to a [[type/responsibility|Responsibility]]. Proced
|
||||
- **Belongs to**: A Responsibility
|
||||
`,
|
||||
'/Users/luca/Laputa/type/experiment.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 3
|
||||
---
|
||||
|
||||
@@ -503,7 +503,7 @@ A **hypothesis-driven investigation** with a clear test and measurable outcome.
|
||||
- **Owner**: The person running the experiment
|
||||
`,
|
||||
'/Users/luca/Laputa/type/person.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 4
|
||||
---
|
||||
|
||||
@@ -515,7 +515,7 @@ A **person** you interact with — team members, collaborators, contacts. People
|
||||
- **Aliases**: Alternative names for wikilink resolution
|
||||
`,
|
||||
'/Users/luca/Laputa/type/event.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 5
|
||||
---
|
||||
|
||||
@@ -527,7 +527,7 @@ A **point-in-time occurrence** — meetings, launches, milestones. Events are li
|
||||
- **Related to**: Entities this event is about
|
||||
`,
|
||||
'/Users/luca/Laputa/type/topic.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 6
|
||||
---
|
||||
|
||||
@@ -539,7 +539,7 @@ A **subject area** for categorization. Topics group related notes, projects, and
|
||||
- **Aliases**: Alternative names
|
||||
`,
|
||||
'/Users/luca/Laputa/type/essay.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 7
|
||||
---
|
||||
|
||||
@@ -551,7 +551,7 @@ A **published piece of writing** — newsletter essays, blog posts, articles. Es
|
||||
- **Belongs to**: Usually a Responsibility
|
||||
`,
|
||||
'/Users/luca/Laputa/type/note.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
order: 8
|
||||
---
|
||||
|
||||
@@ -564,7 +564,7 @@ A **general-purpose document** — research notes, meeting notes, strategy docs.
|
||||
`,
|
||||
// --- Custom type documents (user-created types) ---
|
||||
'/Users/luca/Laputa/type/recipe.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
icon: cooking-pot
|
||||
color: orange
|
||||
---
|
||||
@@ -579,7 +579,7 @@ A **recipe** for cooking or baking. Recipes have ingredients, steps, and serving
|
||||
- **Cook Time**: Time to cook
|
||||
`,
|
||||
'/Users/luca/Laputa/type/book.md': `---
|
||||
Is A: Type
|
||||
type: Type
|
||||
icon: book-open
|
||||
color: green
|
||||
---
|
||||
@@ -596,7 +596,7 @@ A **book** you're reading or have read. Track reading progress, notes, and key t
|
||||
// --- Trashed entries ---
|
||||
'/Users/luca/Laputa/note/old-draft-notes.md': `---
|
||||
title: Old Draft Notes
|
||||
is_a: Note
|
||||
type: Note
|
||||
trashed: true
|
||||
trashed_at: ${new Date(Date.now() - 86400000 * 5).toISOString().slice(0, 10)}
|
||||
belongs_to:
|
||||
@@ -609,7 +609,7 @@ Some rough draft content that is no longer relevant. Moving to trash.
|
||||
`,
|
||||
'/Users/luca/Laputa/note/deprecated-api-notes.md': `---
|
||||
title: Deprecated API Notes
|
||||
is_a: Note
|
||||
type: Note
|
||||
trashed: true
|
||||
trashed_at: ${new Date(Date.now() - 86400000 * 35).toISOString().slice(0, 10)}
|
||||
---
|
||||
@@ -620,7 +620,7 @@ Old API documentation for the v1 endpoint. Replaced by v2 docs.
|
||||
`,
|
||||
'/Users/luca/Laputa/experiment/failed-seo-experiment.md': `---
|
||||
title: Failed SEO Experiment
|
||||
is_a: Experiment
|
||||
type: Experiment
|
||||
status: Dropped
|
||||
trashed: true
|
||||
trashed_at: ${new Date(Date.now() - 86400000 * 10).toISOString().slice(0, 10)}
|
||||
@@ -635,7 +635,7 @@ Tried programmatic SEO pages. Results were negligible — trashing this.
|
||||
// --- Archived entries ---
|
||||
'/Users/luca/Laputa/project/25q3-website-redesign.md': `---
|
||||
title: Website Redesign
|
||||
is_a: Project
|
||||
type: Project
|
||||
status: Done
|
||||
archived: true
|
||||
owner: Luca Rossi
|
||||
@@ -654,7 +654,7 @@ Completed redesign of the company website. Migrated from WordPress to Next.js wi
|
||||
`,
|
||||
'/Users/luca/Laputa/experiment/twitter-thread-experiment.md': `---
|
||||
title: Twitter Thread Growth Experiment
|
||||
is_a: Experiment
|
||||
type: Experiment
|
||||
status: Done
|
||||
archived: true
|
||||
owner: Luca Rossi
|
||||
@@ -676,7 +676,7 @@ Reverted to 1 high-quality thread per week. Archived this experiment.
|
||||
// --- Instances of custom types ---
|
||||
'/Users/luca/Laputa/recipe/pasta-carbonara.md': `---
|
||||
title: Pasta Carbonara
|
||||
is_a: Recipe
|
||||
type: Recipe
|
||||
servings: 4
|
||||
prep_time: 10 min
|
||||
cook_time: 20 min
|
||||
@@ -695,7 +695,7 @@ Classic Roman pasta dish with eggs, pecorino, guanciale, and black pepper.
|
||||
`,
|
||||
'/Users/luca/Laputa/book/designing-data-intensive-applications.md': `---
|
||||
title: Designing Data-Intensive Applications
|
||||
is_a: Book
|
||||
type: Book
|
||||
author: Martin Kleppmann
|
||||
status: Finished
|
||||
rating: 5
|
||||
@@ -1610,7 +1610,7 @@ index abc1234..def5678 100644
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
title: Example Note
|
||||
is_a: Note
|
||||
type: Note
|
||||
+status: Active
|
||||
---
|
||||
|
||||
@@ -1758,6 +1758,8 @@ const mockHandlers: Record<string, (args: any) => any> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
|
||||
purge_trash: () => [],
|
||||
migrate_is_a_to_type: () => 0,
|
||||
}
|
||||
|
||||
export function isTauri(): boolean {
|
||||
|
||||
Reference in New Issue
Block a user