feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
from .parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
from .extract_obsidian_wikilinks import extract_obsidian_wikilinks
|
||||
from .format_obsidian_note import format_obsidian_note
|
||||
|
||||
# CRUD impuro de notas en disco (grupo obsidian)
|
||||
from .read_obsidian_note import read_obsidian_note
|
||||
from .create_obsidian_note import create_obsidian_note
|
||||
from .update_obsidian_note import update_obsidian_note
|
||||
from .delete_obsidian_note import delete_obsidian_note
|
||||
|
||||
# Listado/busqueda de notas y CRUD de vaults (grupo obsidian)
|
||||
from .list_obsidian_notes import list_obsidian_notes
|
||||
from .search_obsidian_notes import search_obsidian_notes
|
||||
from .list_obsidian_vaults import list_obsidian_vaults
|
||||
from .create_obsidian_vault import create_obsidian_vault
|
||||
|
||||
# Utilidades de migracion / extraccion de subgrafos (grupo obsidian)
|
||||
from .slugify_obsidian_name import slugify_obsidian_name
|
||||
from .extract_obsidian_embeds import extract_obsidian_embeds
|
||||
from .resolve_obsidian_embed import resolve_obsidian_embed
|
||||
|
||||
__all__ = [
|
||||
"parse_obsidian_frontmatter",
|
||||
"extract_obsidian_wikilinks",
|
||||
"format_obsidian_note",
|
||||
"read_obsidian_note",
|
||||
"create_obsidian_note",
|
||||
"update_obsidian_note",
|
||||
"delete_obsidian_note",
|
||||
"list_obsidian_notes",
|
||||
"search_obsidian_notes",
|
||||
"list_obsidian_vaults",
|
||||
"create_obsidian_vault",
|
||||
"slugify_obsidian_name",
|
||||
"extract_obsidian_embeds",
|
||||
"resolve_obsidian_embed",
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: create_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def create_obsidian_note(vault_dir: str, rel_path: str, body: str = '', frontmatter: dict = None, overwrite: bool = False) -> str"
|
||||
description: "Crea una nota Markdown nueva en un vault de Obsidian. Anade extension .md si falta, crea directorios padre, serializa frontmatter YAML + body con la funcion pura format_obsidian_note. Falla si la nota existe salvo overwrite=True. No depende de la app GUI de Obsidian: solo escribe un archivo .md plano en disco."
|
||||
tags: [obsidian, markdown, frontmatter, create, write, notes]
|
||||
uses_functions: ["format_obsidian_note_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "directorio raiz del vault de Obsidian donde se crea la nota"
|
||||
- name: rel_path
|
||||
desc: "ruta relativa de la nota dentro del vault; se le anade .md si no lo trae"
|
||||
- name: body
|
||||
desc: "cuerpo Markdown de la nota sin frontmatter (default cadena vacia)"
|
||||
- name: frontmatter
|
||||
desc: "dict con el frontmatter YAML a escribir; None se trata como {}"
|
||||
- name: overwrite
|
||||
desc: "si False (default) y la nota existe lanza FileExistsError; True sobreescribe"
|
||||
output: "ruta absoluta (str) del archivo .md escrito"
|
||||
tested: true
|
||||
tests:
|
||||
- "crea nota con frontmatter y body"
|
||||
- "anade extension md si falta"
|
||||
- "crea directorios padre"
|
||||
- "existente sin overwrite lanza fileexistserror"
|
||||
- "overwrite true sobreescribe"
|
||||
- "destino directorio lanza isadirectoryerror"
|
||||
test_file_path: "python/functions/obsidian/create_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/create_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import create_obsidian_note
|
||||
|
||||
path = create_obsidian_note(
|
||||
vault_dir="/home/me/vault",
|
||||
rel_path="Inbox/Idea rapida", # se convierte en Inbox/Idea rapida.md
|
||||
body="Primer apunte. Ver [[Proyecto X]].",
|
||||
frontmatter={"title": "Idea rapida", "tags": ["inbox", "wip"]},
|
||||
)
|
||||
print(path) # /home/me/vault/Inbox/Idea rapida.md
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras crear una nota nueva en un vault de Obsidian desde codigo o un agente: capturar una idea en el Inbox, generar notas a partir de datos, o materializar plantillas. Crea automaticamente los directorios padre, asi que sirve para sembrar estructuras de carpetas nuevas.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Escribe en disco** (I/O impuro): crea el archivo y los directorios padre que falten (`os.makedirs(..., exist_ok=True)`).
|
||||
- **No respeta locks de la app GUI**: si Obsidian esta abierto, el archivo nuevo aparecera en el vault, pero crear una nota cuyo nombre choque con una abierta y sin guardar puede provocar conflictos de version en el editor.
|
||||
- Por defecto **no sobreescribe**: lanza `FileExistsError` si la nota ya existe. Pasa `overwrite=True` para reemplazar.
|
||||
- Lanza `IsADirectoryError` si el destino resuelto es un directorio existente.
|
||||
- El nombre de archivo se respeta tal cual (incluidos espacios); Obsidian admite espacios en nombres de nota.
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Crea una nota nueva de Obsidian (.md) en un vault, en disco.
|
||||
|
||||
Compone la funcion pura format_obsidian_note del grupo obsidian para serializar
|
||||
frontmatter + body. Funcion impura: escribe un archivo nuevo en disco y crea
|
||||
directorios padre.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import format_obsidian_note
|
||||
|
||||
|
||||
def create_obsidian_note(
|
||||
vault_dir: str,
|
||||
rel_path: str,
|
||||
body: str = "",
|
||||
frontmatter: dict = None,
|
||||
overwrite: bool = False,
|
||||
) -> str:
|
||||
"""Crea una nota Markdown nueva dentro de un vault de Obsidian.
|
||||
|
||||
Args:
|
||||
vault_dir: directorio raiz del vault donde se crea la nota.
|
||||
rel_path: ruta relativa de la nota dentro del vault. Si no termina en
|
||||
".md" se le anade la extension automaticamente.
|
||||
body: cuerpo Markdown de la nota (sin frontmatter). Por defecto vacio.
|
||||
frontmatter: dict con el frontmatter YAML a escribir. None -> {}.
|
||||
overwrite: si False (default) y la nota ya existe, lanza FileExistsError.
|
||||
Si True, sobreescribe el archivo existente.
|
||||
|
||||
Returns:
|
||||
La ruta absoluta del archivo .md escrito.
|
||||
|
||||
Raises:
|
||||
FileExistsError: si la nota existe y overwrite=False.
|
||||
IsADirectoryError: si la ruta destino es un directorio existente.
|
||||
OSError: si la escritura falla por otro motivo de I/O.
|
||||
"""
|
||||
if not rel_path.endswith(".md"):
|
||||
rel_path = rel_path + ".md"
|
||||
|
||||
abs_path = os.path.abspath(os.path.join(vault_dir, rel_path))
|
||||
|
||||
if os.path.isdir(abs_path):
|
||||
raise IsADirectoryError(f"destination is a directory: {abs_path}")
|
||||
if os.path.exists(abs_path) and not overwrite:
|
||||
raise FileExistsError(
|
||||
f"obsidian note already exists (use overwrite=True): {abs_path}"
|
||||
)
|
||||
|
||||
parent = os.path.dirname(abs_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
content = format_obsidian_note(frontmatter or {}, body or "")
|
||||
|
||||
with open(abs_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return abs_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
vault = tempfile.mkdtemp()
|
||||
p = create_obsidian_note(
|
||||
vault, "subdir/Nota Nueva", body="Cuerpo.", frontmatter={"title": "X"}
|
||||
)
|
||||
assert os.path.isfile(p), p
|
||||
assert p.endswith("Nota Nueva.md"), p
|
||||
try:
|
||||
create_obsidian_note(vault, "subdir/Nota Nueva", body="otra")
|
||||
raise AssertionError("debio lanzar FileExistsError")
|
||||
except FileExistsError:
|
||||
pass
|
||||
p2 = create_obsidian_note(vault, "subdir/Nota Nueva", body="z", overwrite=True)
|
||||
assert p2 == p, (p2, p)
|
||||
os.remove(p)
|
||||
os.rmdir(os.path.dirname(p))
|
||||
os.rmdir(vault)
|
||||
print("create_obsidian_note smoke OK")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests para create_obsidian_note."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from create_obsidian_note import create_obsidian_note
|
||||
from read_obsidian_note import read_obsidian_note
|
||||
|
||||
|
||||
def test_crea_nota_con_frontmatter_y_body(tmp_path):
|
||||
# Golden path: crea nota con frontmatter + body y crea el subdir padre.
|
||||
vault = str(tmp_path)
|
||||
path = create_obsidian_note(
|
||||
vault,
|
||||
"Proyectos/Idea.md",
|
||||
body="Primer apunte. [[Proyecto X]].",
|
||||
frontmatter={"title": "Idea", "tags": ["inbox"]},
|
||||
)
|
||||
assert os.path.isfile(path)
|
||||
assert path.endswith("Proyectos/Idea.md")
|
||||
|
||||
note = read_obsidian_note(path)
|
||||
assert note["frontmatter"]["title"] == "Idea"
|
||||
assert note["tags"] == ["inbox"]
|
||||
assert "Primer apunte" in note["body"]
|
||||
assert note["wikilinks"] == ["Proyecto X"]
|
||||
|
||||
|
||||
def test_anade_extension_md_si_falta(tmp_path):
|
||||
# Edge: rel_path sin extension -> se le anade .md.
|
||||
path = create_obsidian_note(str(tmp_path), "Inbox/Nota rapida", body="x")
|
||||
assert path.endswith("Nota rapida.md")
|
||||
assert os.path.isfile(path)
|
||||
|
||||
|
||||
def test_crea_directorios_padre(tmp_path):
|
||||
# Edge: crea toda la jerarquia de carpetas que no existe.
|
||||
path = create_obsidian_note(str(tmp_path), "a/b/c/Honda.md", body="y")
|
||||
assert os.path.isfile(path)
|
||||
assert os.path.isdir(os.path.join(str(tmp_path), "a", "b", "c"))
|
||||
|
||||
|
||||
def test_existente_sin_overwrite_lanza_fileexistserror(tmp_path):
|
||||
# Error path: la nota ya existe y overwrite=False (default).
|
||||
create_obsidian_note(str(tmp_path), "Dup.md", body="original")
|
||||
with pytest.raises(FileExistsError):
|
||||
create_obsidian_note(str(tmp_path), "Dup.md", body="nuevo")
|
||||
|
||||
|
||||
def test_overwrite_true_sobreescribe(tmp_path):
|
||||
# Edge: overwrite=True reemplaza el contenido y devuelve la misma ruta.
|
||||
p1 = create_obsidian_note(str(tmp_path), "Dup.md", body="original")
|
||||
p2 = create_obsidian_note(
|
||||
str(tmp_path), "Dup.md", body="reemplazado", overwrite=True
|
||||
)
|
||||
assert p1 == p2
|
||||
note = read_obsidian_note(p2)
|
||||
assert note["body"].strip() == "reemplazado"
|
||||
|
||||
|
||||
def test_destino_directorio_lanza_isadirectoryerror(tmp_path):
|
||||
# Error path: el destino resuelto ya es un directorio.
|
||||
target = tmp_path / "EsCarpeta.md"
|
||||
target.mkdir()
|
||||
with pytest.raises(IsADirectoryError):
|
||||
create_obsidian_note(str(tmp_path), "EsCarpeta.md", body="x")
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: create_obsidian_vault
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "create_obsidian_vault(parent_dir: str, name: str) -> str"
|
||||
description: "Crea un vault de Obsidian nuevo: parent_dir/name/ + parent_dir/name/.obsidian/app.json con {} (config minima valida). Lanza error si el vault ya existe (ya tiene .obsidian/). Devuelve el path absoluto del vault."
|
||||
tags: [obsidian, vault, create, crud, filesystem]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json", "os"]
|
||||
params:
|
||||
- name: parent_dir
|
||||
desc: "directorio bajo el cual se crea la carpeta del vault; se crea si no existe"
|
||||
- name: name
|
||||
desc: "nombre de la carpeta del nuevo vault (un solo segmento de path, sin separadores)"
|
||||
output: "path absoluto del directorio del vault creado"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/create_obsidian_vault.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import create_obsidian_vault
|
||||
|
||||
path = create_obsidian_vault("/home/enmanuel/Obsidian", "Proyectos2026")
|
||||
print(path) # /home/enmanuel/Obsidian/Proyectos2026
|
||||
# Crea ademas /home/enmanuel/Obsidian/Proyectos2026/.obsidian/app.json -> {}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites crear un vault de Obsidian listo para abrir desde cero (scaffolding de un workspace nuevo, automatizar la creacion de vaults por proyecto) sin pasar por la GUI de Obsidian.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe en el filesystem. Crea `parent_dir/name/.obsidian/` y un `app.json` con `{}` (config minima que Obsidian reconoce como vault valido).
|
||||
- **No sobrescribe**: si el destino ya parece un vault (ya tiene `.obsidian/`) lanza `FileExistsError`; nunca pisa un vault existente.
|
||||
- **Nombre validado**: lanza `ValueError` si `name` es vacio o contiene un separador de path (`/`), para evitar crear estructuras anidadas accidentales.
|
||||
- Lo que hace vault a una carpeta es la presencia de `.obsidian/`; este es el mismo criterio que usa `list_obsidian_vaults` para descubrir vaults, asi que un vault recien creado aparece de inmediato en ese listado.
|
||||
- `parent_dir` se crea si no existe (`makedirs`), de modo que se puede crear un vault en una ruta nueva en una sola llamada.
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Create a new, valid Obsidian vault on disk."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def create_obsidian_vault(parent_dir: str, name: str) -> str:
|
||||
"""Create a new Obsidian vault under ``parent_dir`` and return its path.
|
||||
|
||||
Creates ``parent_dir/name/`` together with a minimal but valid Obsidian
|
||||
configuration: ``parent_dir/name/.obsidian/app.json`` containing ``{}``.
|
||||
The presence of an ``.obsidian/`` directory is what Obsidian uses to
|
||||
recognise a folder as a vault, so the result opens cleanly in Obsidian.
|
||||
|
||||
Impure: it writes to the filesystem. If the target already looks like a
|
||||
vault (it already has an ``.obsidian/`` directory) a ``FileExistsError`` is
|
||||
raised so an existing vault is never silently overwritten. A
|
||||
``ValueError`` is raised for an empty ``name`` or one containing a path
|
||||
separator.
|
||||
|
||||
Args:
|
||||
parent_dir: Directory under which the new vault folder is created. It is
|
||||
created if it does not exist yet.
|
||||
name: Name of the new vault folder (a single path segment, no
|
||||
separators).
|
||||
|
||||
Returns:
|
||||
The absolute path of the created vault directory.
|
||||
"""
|
||||
if not name or os.sep in name or (os.altsep and os.altsep in name):
|
||||
raise ValueError(f"invalid vault name: {name!r}")
|
||||
|
||||
vault_path = os.path.abspath(os.path.join(parent_dir, name))
|
||||
obsidian_dir = os.path.join(vault_path, ".obsidian")
|
||||
|
||||
if os.path.isdir(obsidian_dir):
|
||||
raise FileExistsError(f"vault already exists: {vault_path}")
|
||||
|
||||
os.makedirs(obsidian_dir, exist_ok=True)
|
||||
app_json = os.path.join(obsidian_dir, "app.json")
|
||||
with open(app_json, "w", encoding="utf-8") as handle:
|
||||
json.dump({}, handle)
|
||||
|
||||
return vault_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = create_obsidian_vault(tmp, "MyVault")
|
||||
assert os.path.isdir(path), path
|
||||
assert os.path.isfile(os.path.join(path, ".obsidian", "app.json"))
|
||||
with open(os.path.join(path, ".obsidian", "app.json")) as f:
|
||||
assert json.load(f) == {}
|
||||
|
||||
# Re-creating the same vault must fail.
|
||||
try:
|
||||
create_obsidian_vault(tmp, "MyVault")
|
||||
raise AssertionError("expected FileExistsError")
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
# Invalid name must fail.
|
||||
try:
|
||||
create_obsidian_vault(tmp, "bad/name")
|
||||
raise AssertionError("expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: delete_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def delete_obsidian_note(path: str) -> bool"
|
||||
description: "Borra un archivo de nota Markdown de Obsidian del disco. Por seguridad solo borra un archivo concreto, nunca un directorio. No depende de la app GUI de Obsidian: opera directamente sobre el archivo .md plano."
|
||||
tags: [obsidian, markdown, delete, write, notes]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta al archivo .md de la nota a borrar"
|
||||
output: "True (bool) si el archivo fue borrado correctamente"
|
||||
tested: true
|
||||
tests:
|
||||
- "borra archivo existente y devuelve true"
|
||||
- "archivo inexistente lanza filenotfounderror"
|
||||
- "directorio lanza isadirectoryerror"
|
||||
- "no borra otros archivos"
|
||||
test_file_path: "python/functions/obsidian/delete_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/delete_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import delete_obsidian_note
|
||||
|
||||
ok = delete_obsidian_note("/home/me/vault/Inbox/Idea descartada.md")
|
||||
print(ok) # True
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras eliminar una nota concreta de un vault de Obsidian desde codigo o un agente: limpiar el Inbox, borrar notas generadas temporalmente, o eliminar una nota tras consolidar su contenido en otra. Para varias notas, llama una vez por archivo (no acepta directorios).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Borra del disco** (I/O impuro) y de forma **irreversible**: no manda a papelera, hace `os.remove`. El archivo no se puede recuperar salvo backup del vault.
|
||||
- **Solo archivos, nunca directorios**: lanza `IsADirectoryError` si el path apunta a una carpeta, para evitar borrados masivos accidentales.
|
||||
- **No respeta locks de la app GUI**: si Obsidian tiene la nota abierta, borrarla en disco dejara la pestana huerfana en el editor; al guardar desde Obsidian podria recrearse el archivo.
|
||||
- Lanza `FileNotFoundError` si el archivo no existe.
|
||||
- No borra archivos asociados (adjuntos, attachments referenciados): solo el `.md` indicado.
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Borra una nota de Obsidian (.md) del disco.
|
||||
|
||||
Funcion impura: elimina un archivo del sistema de archivos. Por seguridad solo
|
||||
borra archivos, nunca directorios.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def delete_obsidian_note(path: str) -> bool:
|
||||
"""Borra un archivo de nota Markdown de Obsidian.
|
||||
|
||||
Args:
|
||||
path: ruta al archivo .md a borrar.
|
||||
|
||||
Returns:
|
||||
True si el archivo fue borrado correctamente.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si el archivo no existe.
|
||||
IsADirectoryError: si la ruta apunta a un directorio (nunca se borra un
|
||||
directorio, solo un archivo concreto).
|
||||
OSError: si el borrado falla por otro motivo de I/O.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"obsidian note not found: {path}")
|
||||
if os.path.isdir(path):
|
||||
raise IsADirectoryError(
|
||||
f"refusing to delete a directory, only single files: {path}"
|
||||
)
|
||||
|
||||
os.remove(path)
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
fd, tmp = tempfile.mkstemp(suffix=".md")
|
||||
os.close(fd)
|
||||
assert delete_obsidian_note(tmp) is True
|
||||
assert not os.path.exists(tmp)
|
||||
try:
|
||||
delete_obsidian_note(tmp)
|
||||
raise AssertionError("debio lanzar FileNotFoundError")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
print("delete_obsidian_note smoke OK")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests para delete_obsidian_note."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from delete_obsidian_note import delete_obsidian_note
|
||||
|
||||
|
||||
def test_borra_archivo_existente_y_devuelve_true(tmp_path):
|
||||
# Golden path: borra un .md existente y confirma que desaparece.
|
||||
note = tmp_path / "Borrame.md"
|
||||
note.write_text("contenido", encoding="utf-8")
|
||||
|
||||
result = delete_obsidian_note(str(note))
|
||||
assert result is True
|
||||
assert not note.exists()
|
||||
|
||||
|
||||
def test_archivo_inexistente_lanza_filenotfounderror(tmp_path):
|
||||
# Error path: borrar algo que no existe.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_obsidian_note(str(tmp_path / "fantasma.md"))
|
||||
|
||||
|
||||
def test_directorio_lanza_isadirectoryerror(tmp_path):
|
||||
# Error path: se niega a borrar un directorio.
|
||||
sub = tmp_path / "carpeta"
|
||||
sub.mkdir()
|
||||
with pytest.raises(IsADirectoryError):
|
||||
delete_obsidian_note(str(sub))
|
||||
# El directorio sigue intacto tras el error.
|
||||
assert sub.is_dir()
|
||||
|
||||
|
||||
def test_no_borra_otros_archivos(tmp_path):
|
||||
# Edge: borrar una nota no afecta a las demas del vault.
|
||||
a = tmp_path / "A.md"
|
||||
b = tmp_path / "B.md"
|
||||
a.write_text("a", encoding="utf-8")
|
||||
b.write_text("b", encoding="utf-8")
|
||||
|
||||
assert delete_obsidian_note(str(a)) is True
|
||||
assert not a.exists()
|
||||
assert b.exists()
|
||||
assert os.path.isfile(str(b))
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: extract_obsidian_embeds
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def extract_obsidian_embeds(body: str) -> list"
|
||||
description: "Extrae SOLO los embeds ![[...]] (attachments incrustados: imagenes, pdf, otras notas) del cuerpo de una nota de Obsidian, ignorando los wikilinks normales [[...]]. Para cada embed devuelve el target tal cual (nombre de archivo), quitando alias (|...) y anclas (#...). Deduplica preservando orden de aparicion. Pura, sin I/O. Util para detectar que attachments arrastra una nota al migrar un subgrafo."
|
||||
tags: [obsidian, embed, attachment, image, markdown, extract, migrate]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["re"]
|
||||
params:
|
||||
- name: body
|
||||
desc: "Cuerpo Markdown de una nota de Obsidian (idealmente sin frontmatter). Puede mezclar wikilinks [[...]] y embeds ![[...]] con alias (|) o ancla (#)."
|
||||
output: "Lista de strings con los nombres de los attachments embebidos (el target de cada ![[...]]), unicos y en orden de aparicion. Solo embeds: los wikilinks normales [[...]] se ignoran. Lista vacia si no hay embeds."
|
||||
tested: true
|
||||
tests:
|
||||
- "solo embeds ignora wikilinks"
|
||||
- "varios embeds orden y dedup"
|
||||
- "quita alias y ancla"
|
||||
- "nombre con espacios y parentesis"
|
||||
- "sin embeds solo wikilinks"
|
||||
- "body vacio"
|
||||
test_file_path: "python/functions/obsidian/extract_obsidian_embeds_test.py"
|
||||
file_path: "python/functions/obsidian/extract_obsidian_embeds.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
body = (
|
||||
"Texto con [[Nota normal]] y un embed ![[imagen.jpg]]. "
|
||||
"Luego ![[doc.pdf]] y otra vez ![[imagen.jpg]]."
|
||||
)
|
||||
extract_obsidian_embeds(body)
|
||||
# ["imagen.jpg", "doc.pdf"]
|
||||
|
||||
extract_obsidian_embeds("![[dni enmanuel (2).jpg]]")
|
||||
# ["dni enmanuel (2).jpg"]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando migres o extraigas un subgrafo de notas y necesites saber QUE
|
||||
attachments hay que copiar junto a cada nota. A diferencia de
|
||||
`extract_obsidian_wikilinks` (que devuelve todos los enlaces, incluidos los
|
||||
embeds), esta funcion aisla solo los `![[...]]`, que son los archivos fisicos
|
||||
incrustados. Combinala con `resolve_obsidian_embed` para localizar el path real
|
||||
de cada attachment dentro del vault.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Devuelve el nombre del attachment tal cual aparece en el embed (p.ej.
|
||||
`dni enmanuel (2).jpg`), NO un path. Obsidian resuelve embeds por nombre de
|
||||
archivo unico; para obtener la ruta real usa `resolve_obsidian_embed`.
|
||||
- Quita deliberadamente el alias (`|300`, util para dimensionar imagenes) y el
|
||||
ancla (`#Seccion`, util para embeber un trozo de otra nota). Si necesitas esos
|
||||
modificadores, parsea el body tu mismo.
|
||||
- Solo reconoce embeds con doble corchete `![[...]]`. Los embeds Markdown
|
||||
estandar de imagen `` NO se detectan.
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Extrae los embeds ![[...]] (attachments incrustados) de una nota de Obsidian.
|
||||
|
||||
Funcion pura: solo procesa texto. A diferencia de extract_obsidian_wikilinks,
|
||||
ignora los wikilinks normales [[...]] y devuelve unicamente los embeds ![[...]].
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Matchea SOLO embeds: el '!' inicial es obligatorio (?<! evitaria capturar
|
||||
# un '[[' precedido de '!', pero aqui exigimos el '!' como parte del match).
|
||||
# Captura el contenido entre los dobles corchetes del embed.
|
||||
_EMBED_RE = re.compile(r"!\[\[([^\[\]]+?)\]\]")
|
||||
|
||||
|
||||
def extract_obsidian_embeds(body: str) -> list:
|
||||
"""Extrae SOLO los embeds ``![[...]]`` del cuerpo de una nota de Obsidian.
|
||||
|
||||
Un embed `![[archivo.jpg]]` incrusta un attachment (imagen, pdf, otra nota)
|
||||
dentro de la nota. Esta funcion devuelve el target de cada embed, mientras
|
||||
que los wikilinks normales `[[...]]` (que NO empiezan por `!`) se ignoran.
|
||||
|
||||
Para cada embed se normaliza el target:
|
||||
|
||||
![[imagen.jpg]] -> "imagen.jpg"
|
||||
![[imagen.jpg|300]] -> "imagen.jpg" (se quita el alias |...)
|
||||
![[nota#Seccion]] -> "nota" (se quita el ancla #...)
|
||||
![[dni enmanuel (2).jpg]] -> "dni enmanuel (2).jpg"
|
||||
|
||||
Los targets se deduplican preservando el orden de primera aparicion. Pura y
|
||||
deterministica: no hace I/O ni muta nada.
|
||||
|
||||
Args:
|
||||
body: Cuerpo Markdown de una nota de Obsidian (idealmente sin
|
||||
frontmatter).
|
||||
|
||||
Returns:
|
||||
Lista de strings con los nombres de los attachments embebidos, unicos y
|
||||
en orden de aparicion. Lista vacia si no hay embeds.
|
||||
"""
|
||||
if not body:
|
||||
return []
|
||||
|
||||
seen = set()
|
||||
targets = []
|
||||
for match in _EMBED_RE.finditer(body):
|
||||
inner = match.group(1)
|
||||
# Quitar alias tras el primer '|'.
|
||||
target = inner.split("|", 1)[0]
|
||||
# Quitar ancla/heading tras el primer '#'.
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.strip()
|
||||
if not target:
|
||||
continue
|
||||
if target in seen:
|
||||
continue
|
||||
seen.add(target)
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
body = (
|
||||
"Texto con [[Nota normal]] y un embed ![[imagen.jpg]]. "
|
||||
"Otra cosa [[Otra Nota|alias]] y ![[doc.pdf]] y ![[imagen.jpg]]."
|
||||
)
|
||||
assert extract_obsidian_embeds(body) == ["imagen.jpg", "doc.pdf"], body
|
||||
assert extract_obsidian_embeds("") == []
|
||||
assert extract_obsidian_embeds("solo [[wikilink]] aqui") == []
|
||||
assert extract_obsidian_embeds("![[dni enmanuel (2).jpg]]") == ["dni enmanuel (2).jpg"]
|
||||
print("extract_obsidian_embeds smoke OK")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests para extract_obsidian_embeds."""
|
||||
|
||||
from extract_obsidian_embeds import extract_obsidian_embeds
|
||||
|
||||
|
||||
def test_solo_embeds_ignora_wikilinks():
|
||||
# Golden path: mezcla de [[Nota normal]] y ![[imagen.jpg]] -> solo el embed.
|
||||
body = "Texto con [[Nota normal]] y un embed ![[imagen.jpg]]."
|
||||
assert extract_obsidian_embeds(body) == ["imagen.jpg"]
|
||||
|
||||
|
||||
def test_varios_embeds_orden_y_dedup():
|
||||
# Edge: varios embeds preservan orden y deduplican.
|
||||
body = "![[a.jpg]] luego ![[b.pdf]] luego ![[a.jpg]] y ![[c.png]]"
|
||||
assert extract_obsidian_embeds(body) == ["a.jpg", "b.pdf", "c.png"]
|
||||
|
||||
|
||||
def test_quita_alias_y_ancla():
|
||||
# Edge: alias (|) y heading/ancla (#) se descartan del target.
|
||||
body = "![[imagen.jpg|300]] y ![[nota#Seccion]]"
|
||||
assert extract_obsidian_embeds(body) == ["imagen.jpg", "nota"]
|
||||
|
||||
|
||||
def test_nombre_con_espacios_y_parentesis():
|
||||
# Edge: nombre de archivo real con espacios y parentesis se preserva.
|
||||
body = "Mira ![[dni enmanuel (2).jpg]] adjunto."
|
||||
assert extract_obsidian_embeds(body) == ["dni enmanuel (2).jpg"]
|
||||
|
||||
|
||||
def test_sin_embeds_solo_wikilinks():
|
||||
assert extract_obsidian_embeds("solo [[wikilink]] y [[otro|alias]]") == []
|
||||
|
||||
|
||||
def test_body_vacio():
|
||||
assert extract_obsidian_embeds("") == []
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: extract_obsidian_wikilinks
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def extract_obsidian_wikilinks(body: str) -> list"
|
||||
description: "Extrae los targets de los wikilinks [[...]] del cuerpo de una nota de Obsidian. Normaliza alias ([[nota|alias]] -> nota), heading ([[nota#h]] -> nota) y block id ([[nota#^id]] -> nota). Incluye tambien los embeds ![[...]] como links (Obsidian los trata como tales). Deduplica preservando orden de aparicion. Pura, sin I/O."
|
||||
tags: [obsidian, wikilink, links, markdown, extract, note, graph]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["re"]
|
||||
params:
|
||||
- name: body
|
||||
desc: "Cuerpo Markdown de una nota de Obsidian (idealmente sin frontmatter). Puede contener wikilinks [[...]] y embeds ![[...]] con alias (|), heading (#) o block id (#^)."
|
||||
output: "Lista de strings con los nombres de nota target unicos, en orden de primera aparicion. Cada target esta normalizado (sin alias, sin heading/block anchor, sin espacios al borde). Los embeds de imagen/nota se incluyen igual que los links normales. Lista vacia si no hay wikilinks."
|
||||
tested: true
|
||||
tests:
|
||||
- "links basicos y normalizacion"
|
||||
- "incluye embeds"
|
||||
- "dedup preserva orden"
|
||||
- "alias y heading combinados"
|
||||
- "whitespace se strippa"
|
||||
- "sin links"
|
||||
- "body vacio"
|
||||
test_file_path: "python/functions/obsidian/extract_obsidian_wikilinks_test.py"
|
||||
file_path: "python/functions/obsidian/extract_obsidian_wikilinks.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
body = (
|
||||
"See [[Note A]] and [[Note B|the second]] plus [[Note A#Section]] "
|
||||
"and [[Note C#^block123]]. Embed: ![[diagram.png]]. Repeat [[Note A]]."
|
||||
)
|
||||
extract_obsidian_wikilinks(body)
|
||||
# ["Note A", "Note B", "Note C", "diagram.png"]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala para construir el grafo de enlaces de un vault (backlinks/forward-links),
|
||||
detectar notas huerfanas o referenciadas, o validar enlaces rotos antes de un
|
||||
refactor. Aplicala al `body` que devuelve `parse_obsidian_frontmatter` para
|
||||
ignorar wikilinks que pudieran aparecer dentro de valores YAML del frontmatter.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Los embeds `![[...]]` se incluyen como links (decision intencional: Obsidian
|
||||
los cuenta en el grafo). Si necesitas separar links de embeds, filtra por la
|
||||
extension del target o por el `!` aparte — esta funcion no distingue.
|
||||
- Solo normaliza al nombre de nota: pierde deliberadamente el alias, el heading
|
||||
y el block id. Si necesitas el anchor completo, parsea el body tu mismo.
|
||||
- No resuelve rutas relativas ni desambigua notas con el mismo nombre en
|
||||
carpetas distintas: devuelve el texto del link tal cual (sin la carpeta si el
|
||||
link no la incluye).
|
||||
- No procesa Markdown links estandar `[texto](url)` — solo wikilinks `[[...]]`.
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Extract wikilink targets from the body of an Obsidian note."""
|
||||
|
||||
import re
|
||||
|
||||
# Matches both plain wikilinks [[...]] and embeds ![[...]].
|
||||
# The captured group is everything between the double brackets.
|
||||
_WIKILINK_RE = re.compile(r"!?\[\[([^\[\]]+?)\]\]")
|
||||
|
||||
|
||||
def extract_obsidian_wikilinks(body: str) -> list:
|
||||
"""Extract the note targets from the wikilinks in a note body.
|
||||
|
||||
Recognizes both plain links `[[...]]` and embeds `![[...]]` (Obsidian
|
||||
treats embeds as links too). Each target is normalized to the bare note
|
||||
name:
|
||||
|
||||
[[note|alias]] -> "note"
|
||||
[[note#heading]] -> "note"
|
||||
[[note#^blockid]] -> "note"
|
||||
[[note]] -> "note"
|
||||
![[image.png]] -> "image.png"
|
||||
|
||||
The alias (after `|`), the heading/block anchor (after `#`) and surrounding
|
||||
whitespace are stripped. Targets are deduplicated while preserving the
|
||||
order of first appearance. Pure and deterministic: no I/O, no mutation.
|
||||
|
||||
Args:
|
||||
body: The Markdown body of an Obsidian note (without frontmatter).
|
||||
|
||||
Returns:
|
||||
A list of unique target note names (strings), in order of appearance.
|
||||
"""
|
||||
if not body:
|
||||
return []
|
||||
|
||||
seen = set()
|
||||
targets = []
|
||||
for match in _WIKILINK_RE.finditer(body):
|
||||
inner = match.group(1)
|
||||
# Drop the alias part after the first pipe.
|
||||
target = inner.split("|", 1)[0]
|
||||
# Drop the heading / block anchor after the first hash.
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.strip()
|
||||
if not target:
|
||||
continue
|
||||
if target in seen:
|
||||
continue
|
||||
seen.add(target)
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
body = (
|
||||
"See [[Note A]] and [[Note B|the second]] plus [[Note A#Section]] "
|
||||
"and [[Note C#^block123]]. Embed: ![[diagram.png]]. Repeat [[Note A]]."
|
||||
)
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note A", "Note B", "Note C", "diagram.png"], links
|
||||
|
||||
assert extract_obsidian_wikilinks("") == []
|
||||
assert extract_obsidian_wikilinks("no links here") == []
|
||||
assert extract_obsidian_wikilinks("[[ spaced |alias]]") == ["spaced"]
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests para extract_obsidian_wikilinks."""
|
||||
|
||||
from extract_obsidian_wikilinks import extract_obsidian_wikilinks
|
||||
|
||||
|
||||
def test_links_basicos_y_normalizacion():
|
||||
body = (
|
||||
"See [[Note A]] and [[Note B|the second]] plus [[Note A#Section]] "
|
||||
"and [[Note C#^block123]]."
|
||||
)
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note A", "Note B", "Note C"]
|
||||
|
||||
|
||||
def test_incluye_embeds():
|
||||
body = "Text [[Note A]] and embed ![[diagram.png]] and ![[Note D]]."
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note A", "diagram.png", "Note D"]
|
||||
|
||||
|
||||
def test_dedup_preserva_orden():
|
||||
body = "[[Z]] [[A]] [[Z]] [[A|alias]] [[B]]"
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Z", "A", "B"]
|
||||
|
||||
|
||||
def test_alias_y_heading_combinados():
|
||||
body = "[[Note E#Heading|Custom Alias]]"
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note E"]
|
||||
|
||||
|
||||
def test_whitespace_se_strippa():
|
||||
body = "[[ spaced note |alias]] and [[ tight ]]"
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["spaced note", "tight"]
|
||||
|
||||
|
||||
def test_sin_links():
|
||||
assert extract_obsidian_wikilinks("no links here") == []
|
||||
|
||||
|
||||
def test_body_vacio():
|
||||
assert extract_obsidian_wikilinks("") == []
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: format_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def format_obsidian_note(frontmatter: dict, body: str) -> str"
|
||||
description: "Serializa una nota completa de Obsidian a partir de un dict de frontmatter y un body. Si frontmatter no esta vacio, emite '---\\n<yaml>---\\n\\n<body>' usando yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True). Si frontmatter es vacio o None, devuelve solo el body. Es la inversa de parse_obsidian_frontmatter para un round-trip razonable. Pura, sin I/O."
|
||||
tags: [obsidian, frontmatter, yaml, markdown, format, serialize, note]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["yaml"]
|
||||
params:
|
||||
- name: frontmatter
|
||||
desc: "Dict con los metadatos YAML de la nota. Vacio o None significa que no se emite bloque frontmatter (solo el body). El orden de las claves se preserva (sort_keys=False)."
|
||||
- name: body
|
||||
desc: "Cuerpo Markdown de la nota. None se trata como cadena vacia."
|
||||
output: "String con el texto completo de la nota. Con frontmatter: '---\\n<yaml>---\\n\\n<body>' (unicode literal, orden de claves preservado). Sin frontmatter: solo el body."
|
||||
tested: true
|
||||
tests:
|
||||
- "con frontmatter"
|
||||
- "frontmatter vacio devuelve body"
|
||||
- "frontmatter none devuelve body"
|
||||
- "preserva orden de claves"
|
||||
- "unicode literal"
|
||||
- "round trip con parse"
|
||||
test_file_path: "python/functions/obsidian/format_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/format_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
out = format_obsidian_note({"title": "My Note", "tags": ["a", "b"]}, "Hello.")
|
||||
# "---\ntitle: My Note\ntags:\n- a\n- b\n---\n\nHello."
|
||||
|
||||
format_obsidian_note({}, "just a body")
|
||||
# "just a body"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala como ultimo paso del round-trip parse -> modificar -> format: tras leer
|
||||
una nota con `parse_obsidian_frontmatter`, mutar el dict de frontmatter (anadir
|
||||
un tag, cambiar status, actualizar una fecha) y volver a serializar la nota
|
||||
lista para escribir a disco. Tambien para generar notas nuevas desde cero con
|
||||
metadatos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Inserta una linea en blanco entre el bloque frontmatter y el body (`---\n\n`).
|
||||
En el round-trip con `parse_obsidian_frontmatter`, el body recuperado lleva
|
||||
un salto de linea inicial extra; comparar con `.strip()` o asumir ese
|
||||
separador. La inversa es "razonable", no byte-a-byte.
|
||||
- `yaml.safe_dump` reformatea el YAML a su estilo canonico: las listas salen en
|
||||
estilo bloque sin indentar (`tags:\n- a`), las claves no se reordenan
|
||||
(`sort_keys=False`) y el unicode queda literal (`allow_unicode=True`). El
|
||||
texto exacto del frontmatter original puede no preservarse aunque el mapping
|
||||
si.
|
||||
- Un dict vacio o `None` produce solo el body (sin `---`), coherente con que
|
||||
`parse_obsidian_frontmatter` trate un frontmatter vacio como ausente.
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Serialize an Obsidian note from a frontmatter mapping and a body."""
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def format_obsidian_note(frontmatter: dict, body: str) -> str:
|
||||
"""Serialize a complete Obsidian note from frontmatter and body.
|
||||
|
||||
When `frontmatter` is a non-empty mapping, the result is:
|
||||
|
||||
---\n<yaml>---\n\n<body>
|
||||
|
||||
where `<yaml>` is produced by
|
||||
`yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True)` (which
|
||||
already ends in a newline). When `frontmatter` is empty or None, only the
|
||||
body is returned. This is the inverse of `parse_obsidian_frontmatter` for a
|
||||
reasonable round-trip (key order preserved, unicode kept literal).
|
||||
|
||||
Pure and deterministic: no I/O, no mutation of the inputs.
|
||||
|
||||
Args:
|
||||
frontmatter: Mapping of YAML metadata for the note. Empty or None means
|
||||
no frontmatter block is emitted.
|
||||
body: The Markdown body of the note.
|
||||
|
||||
Returns:
|
||||
The full note text as a string.
|
||||
"""
|
||||
safe_body = body if body is not None else ""
|
||||
|
||||
if not frontmatter:
|
||||
return safe_body
|
||||
|
||||
yaml_block = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True)
|
||||
# yaml.safe_dump already terminates with a trailing newline.
|
||||
return f"---\n{yaml_block}---\n\n{safe_body}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = format_obsidian_note({"title": "My Note", "tags": ["a", "b"]}, "Hello.")
|
||||
assert out == "---\ntitle: My Note\ntags:\n- a\n- b\n---\n\nHello.", repr(out)
|
||||
|
||||
# Empty frontmatter -> body only.
|
||||
assert format_obsidian_note({}, "just a body") == "just a body"
|
||||
assert format_obsidian_note(None, "just a body") == "just a body"
|
||||
|
||||
# Round-trip with parse_obsidian_frontmatter: the frontmatter mapping is
|
||||
# recovered exactly; the body is recovered modulo the blank separator line
|
||||
# that the format inserts between the frontmatter block and the body.
|
||||
from parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
|
||||
fm = {"title": "Round Trip", "status": "open"}
|
||||
body = "Body with a [[link]]."
|
||||
note = format_obsidian_note(fm, body)
|
||||
parsed = parse_obsidian_frontmatter(note)
|
||||
assert parsed["frontmatter"] == fm, parsed
|
||||
assert parsed["body"].strip() == body, repr(parsed["body"])
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests para format_obsidian_note."""
|
||||
|
||||
from format_obsidian_note import format_obsidian_note
|
||||
from parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
|
||||
|
||||
def test_con_frontmatter():
|
||||
out = format_obsidian_note({"title": "My Note", "tags": ["a", "b"]}, "Hello.")
|
||||
assert out == "---\ntitle: My Note\ntags:\n- a\n- b\n---\n\nHello."
|
||||
|
||||
|
||||
def test_frontmatter_vacio_devuelve_body():
|
||||
assert format_obsidian_note({}, "just a body") == "just a body"
|
||||
|
||||
|
||||
def test_frontmatter_none_devuelve_body():
|
||||
assert format_obsidian_note(None, "just a body") == "just a body"
|
||||
|
||||
|
||||
def test_preserva_orden_de_claves():
|
||||
fm = {"zeta": 1, "alpha": 2, "mid": 3}
|
||||
out = format_obsidian_note(fm, "body")
|
||||
# sort_keys=False -> insertion order preserved.
|
||||
assert out.index("zeta") < out.index("alpha") < out.index("mid")
|
||||
|
||||
|
||||
def test_unicode_literal():
|
||||
out = format_obsidian_note({"title": "Año Nuevo"}, "Cuerpo con ñ.")
|
||||
assert "Año Nuevo" in out
|
||||
assert "Cuerpo con ñ." in out
|
||||
|
||||
|
||||
def test_round_trip_con_parse():
|
||||
fm = {"title": "Round Trip", "status": "open", "count": 3}
|
||||
body = "Body with a [[link]] and #tag."
|
||||
note = format_obsidian_note(fm, body)
|
||||
parsed = parse_obsidian_frontmatter(note)
|
||||
assert parsed["frontmatter"] == fm
|
||||
# The format inserts a blank separator line before the body.
|
||||
assert parsed["body"].strip() == body
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: list_obsidian_notes
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "list_obsidian_notes(vault_dir: str, subfolder: str = \"\", tag: str = \"\") -> list"
|
||||
description: "Recorre recursivamente un vault de Obsidian (o un subfolder) y devuelve los paths absolutos de todas las notas .md, ordenados. Excluye siempre .obsidian/ y .trash/. Si se da tag, filtra a las notas cuyo frontmatter tags lo contenga (usa parse_obsidian_frontmatter)."
|
||||
tags: [obsidian, notes, list, vault, frontmatter, filesystem]
|
||||
uses_functions: ["parse_obsidian_frontmatter_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "path (absoluto o relativo) a la raiz del vault de Obsidian"
|
||||
- name: subfolder
|
||||
desc: "subcarpeta relativa dentro del vault a la que restringir el recorrido; vacio recorre todo el vault"
|
||||
- name: tag
|
||||
desc: "tag opcional; si no es vacio, solo devuelve notas cuyo frontmatter tags lo contenga (lista o escalar)"
|
||||
output: "lista ordenada de paths absolutos a las notas .md que cumplen el filtro"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/list_obsidian_notes.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import list_obsidian_notes
|
||||
|
||||
# Todas las notas de un vault real
|
||||
notas = list_obsidian_notes("/home/enmanuel/Obsidian/Finanzas")
|
||||
print(len(notas), notas[:3])
|
||||
|
||||
# Solo las notas etiquetadas con #inversion
|
||||
inversion = list_obsidian_notes("/home/enmanuel/Obsidian/Finanzas", tag="inversion")
|
||||
|
||||
# Restringir a una subcarpeta del vault
|
||||
plantillas = list_obsidian_notes("/home/enmanuel/Obsidian/Finanzas", subfolder="Plantillas")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enumerar las notas de un vault de Obsidian (para indexar, exportar, contar o alimentar otra funcion) o filtrar por tag sin abrir Obsidian. Es el punto de entrada antes de leer/buscar contenido nota a nota.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee el filesystem. Lanza `FileNotFoundError` si la ruta no existe y `NotADirectoryError` si no es un directorio.
|
||||
- **Exclusion obligatoria**: `.obsidian/` y `.trash/` se podan del `os.walk` y nunca aparecen en el resultado; los `.md` internos de la config de Obsidian no se listan.
|
||||
- **Coste en vaults grandes**: recorre todo el arbol. En vaults pesados como `NotasDeObsidian` (~554M) el `os.walk` completo es costoso; usa `subfolder` para acotar cuando puedas.
|
||||
- **Filtro por tag**: cuando `tag` esta dado, ABRE cada nota para parsear su frontmatter — multiplica el coste de I/O. El campo `tags` se acepta como lista (`[a, b]`) o escalar (`a`); notas sin frontmatter o sin el tag se excluyen.
|
||||
- Las notas con encoding invalido se leen con `errors="replace"`; el frontmatter ilegible se trata como ausente (la nota se excluye del filtro por tag).
|
||||
@@ -0,0 +1,111 @@
|
||||
"""List the Markdown notes inside an Obsidian vault, optionally filtered by tag."""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import parse_obsidian_frontmatter
|
||||
|
||||
# Directories that are part of Obsidian's machinery, never user notes.
|
||||
_EXCLUDED_DIRS = {".obsidian", ".trash"}
|
||||
|
||||
|
||||
def list_obsidian_notes(vault_dir: str, subfolder: str = "", tag: str = "") -> list:
|
||||
"""Return the absolute paths of every Markdown note under an Obsidian vault.
|
||||
|
||||
Walks ``vault_dir`` (or ``vault_dir/subfolder`` when ``subfolder`` is given)
|
||||
recursively and collects every ``.md`` file. The ``.obsidian/`` and
|
||||
``.trash/`` directories are always pruned from the walk so their internal
|
||||
files never appear in the result.
|
||||
|
||||
When ``tag`` is provided, only notes whose frontmatter ``tags`` field
|
||||
contains that tag are kept. The frontmatter is read with
|
||||
``parse_obsidian_frontmatter`` (the registry's pure parser). The ``tags``
|
||||
field may be a list (``[a, b]``) or a single scalar (``a``); both forms are
|
||||
handled. Notes without frontmatter or without the tag are excluded.
|
||||
|
||||
Impure: it reads the filesystem. Raises ``FileNotFoundError`` if the root
|
||||
directory does not exist and ``NotADirectoryError`` if it is not a directory.
|
||||
|
||||
Args:
|
||||
vault_dir: Absolute or relative path to the vault root.
|
||||
subfolder: Optional relative subfolder inside the vault to restrict the
|
||||
walk to. Empty string walks the whole vault.
|
||||
tag: Optional tag. When non-empty, only notes whose frontmatter ``tags``
|
||||
contains it are returned.
|
||||
|
||||
Returns:
|
||||
A sorted list of absolute paths to the matching ``.md`` notes.
|
||||
"""
|
||||
root = os.path.join(vault_dir, subfolder) if subfolder else vault_dir
|
||||
root = os.path.abspath(root)
|
||||
|
||||
if not os.path.exists(root):
|
||||
raise FileNotFoundError(f"vault path does not exist: {root}")
|
||||
if not os.path.isdir(root):
|
||||
raise NotADirectoryError(f"vault path is not a directory: {root}")
|
||||
|
||||
notes: list[str] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
# Prune Obsidian machinery in-place so os.walk never descends into them.
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
for filename in filenames:
|
||||
if not filename.lower().endswith(".md"):
|
||||
continue
|
||||
full = os.path.abspath(os.path.join(dirpath, filename))
|
||||
if not tag:
|
||||
notes.append(full)
|
||||
continue
|
||||
if _note_has_tag(full, tag):
|
||||
notes.append(full)
|
||||
|
||||
return sorted(notes)
|
||||
|
||||
|
||||
def _note_has_tag(note_path: str, tag: str) -> bool:
|
||||
"""Return True if the note's frontmatter ``tags`` contains ``tag``."""
|
||||
try:
|
||||
with open(note_path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
content = handle.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
frontmatter = parse_obsidian_frontmatter(content).get("frontmatter", {})
|
||||
tags = frontmatter.get("tags")
|
||||
if tags is None:
|
||||
return False
|
||||
if isinstance(tags, str):
|
||||
return tags == tag
|
||||
if isinstance(tags, (list, tuple, set)):
|
||||
return tag in tags
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, ".obsidian"))
|
||||
os.makedirs(os.path.join(tmp, "sub"))
|
||||
# A note in .obsidian must never be listed.
|
||||
with open(os.path.join(tmp, ".obsidian", "ignored.md"), "w") as f:
|
||||
f.write("should be excluded")
|
||||
with open(os.path.join(tmp, "a.md"), "w") as f:
|
||||
f.write("---\ntags:\n - work\n - todo\n---\nbody A")
|
||||
with open(os.path.join(tmp, "sub", "b.md"), "w") as f:
|
||||
f.write("---\ntags: personal\n---\nbody B")
|
||||
with open(os.path.join(tmp, "c.md"), "w") as f:
|
||||
f.write("no frontmatter")
|
||||
|
||||
all_notes = list_obsidian_notes(tmp)
|
||||
assert len(all_notes) == 3, all_notes
|
||||
assert all(".obsidian" not in p for p in all_notes), all_notes
|
||||
|
||||
work = list_obsidian_notes(tmp, tag="work")
|
||||
assert len(work) == 1 and work[0].endswith("a.md"), work
|
||||
|
||||
personal = list_obsidian_notes(tmp, tag="personal")
|
||||
assert len(personal) == 1 and personal[0].endswith("b.md"), personal
|
||||
|
||||
only_sub = list_obsidian_notes(tmp, subfolder="sub")
|
||||
assert len(only_sub) == 1 and only_sub[0].endswith("b.md"), only_sub
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: list_obsidian_vaults
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "list_obsidian_vaults(base_dir: str) -> list"
|
||||
description: "Devuelve los vaults de Obsidian que cuelgan directamente (1 nivel) de base_dir: subdirectorios que contienen un .obsidian/. Devuelve lista de {name, path} ordenada por name. Util para enumerar /home/enmanuel/Obsidian/."
|
||||
tags: [obsidian, vault, list, discover, filesystem]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: base_dir
|
||||
desc: "directorio que contiene vaults de Obsidian como subcarpetas (p.ej. /home/enmanuel/Obsidian)"
|
||||
output: "lista de dicts {name, path} (uno por vault), ordenada por name; path es absoluto"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/list_obsidian_vaults.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import list_obsidian_vaults
|
||||
|
||||
vaults = list_obsidian_vaults("/home/enmanuel/Obsidian")
|
||||
for v in vaults:
|
||||
print(v["name"], "->", v["path"])
|
||||
# NotasDeObsidian, AurgiObsidian, DataScientist, Finanzas, LLM_agentes
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites descubrir que vaults de Obsidian existen bajo una carpeta raiz (por ejemplo para construir un selector, iterar sobre todos los vaults, o validar que un nombre de vault existe) sin abrir Obsidian.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee el filesystem. Lanza `FileNotFoundError` si `base_dir` no existe y `NotADirectoryError` si no es un directorio.
|
||||
- **Solo 1 nivel**: inspecciona unicamente los hijos inmediatos de `base_dir`; no es recursivo, asi que un vault anidado dentro de otro vault no se detecta. Esto es intencional para evitar recorrer arboles pesados.
|
||||
- **Criterio de vault**: una carpeta cuenta como vault solo si contiene un subdirectorio `.obsidian/` (la carpeta de config que crea Obsidian). Carpetas con solo notas `.md` pero sin `.obsidian/` no se consideran vaults.
|
||||
- No abre las notas ni recorre su contenido, asi que es barato incluso cuando algun vault es enorme (`NotasDeObsidian` ~554M): solo mira la presencia de `.obsidian/`.
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Discover the Obsidian vaults that live directly under a base directory."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def list_obsidian_vaults(base_dir: str) -> list:
|
||||
"""Return the Obsidian vaults found one level below ``base_dir``.
|
||||
|
||||
A directory is considered an Obsidian vault when it contains an
|
||||
``.obsidian/`` subdirectory (the folder Obsidian creates to hold its
|
||||
per-vault configuration). Only the immediate children of ``base_dir`` are
|
||||
inspected; the search is not recursive, so nested vaults inside a vault are
|
||||
not reported.
|
||||
|
||||
Impure: it reads the filesystem. Raises ``FileNotFoundError`` if
|
||||
``base_dir`` does not exist and ``NotADirectoryError`` if it is not a
|
||||
directory.
|
||||
|
||||
Args:
|
||||
base_dir: Directory that holds Obsidian vaults as subfolders, e.g.
|
||||
``/home/enmanuel/Obsidian``.
|
||||
|
||||
Returns:
|
||||
A list of dicts ``{"name": str, "path": str}`` (one per vault), sorted
|
||||
by name. ``path`` is the absolute path of the vault directory.
|
||||
"""
|
||||
base = os.path.abspath(base_dir)
|
||||
if not os.path.exists(base):
|
||||
raise FileNotFoundError(f"base directory does not exist: {base}")
|
||||
if not os.path.isdir(base):
|
||||
raise NotADirectoryError(f"base path is not a directory: {base}")
|
||||
|
||||
vaults: list[dict] = []
|
||||
for entry in os.scandir(base):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if os.path.isdir(os.path.join(entry.path, ".obsidian")):
|
||||
vaults.append({"name": entry.name, "path": os.path.abspath(entry.path)})
|
||||
|
||||
vaults.sort(key=lambda v: v["name"])
|
||||
return vaults
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Two real vaults, one plain dir, one file.
|
||||
os.makedirs(os.path.join(tmp, "VaultA", ".obsidian"))
|
||||
os.makedirs(os.path.join(tmp, "VaultB", ".obsidian"))
|
||||
os.makedirs(os.path.join(tmp, "NotAVault"))
|
||||
with open(os.path.join(tmp, "loose.md"), "w") as f:
|
||||
f.write("not a dir")
|
||||
|
||||
vaults = list_obsidian_vaults(tmp)
|
||||
names = [v["name"] for v in vaults]
|
||||
assert names == ["VaultA", "VaultB"], names
|
||||
assert all(os.path.isabs(v["path"]) for v in vaults), vaults
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: parse_obsidian_frontmatter
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def parse_obsidian_frontmatter(content: str) -> dict"
|
||||
description: "Separa una nota de Obsidian (Markdown plano) en su frontmatter YAML y su cuerpo. Parsea el bloque YAML delimitado por --- al inicio del archivo con yaml.safe_load. Si no hay frontmatter valido al inicio, devuelve frontmatter vacio y el contenido completo como body. Soporta finales de linea \\n y \\r\\n. Pura, sin I/O."
|
||||
tags: [obsidian, frontmatter, yaml, markdown, parse, note]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["yaml"]
|
||||
params:
|
||||
- name: content
|
||||
desc: "Texto completo de una nota de Obsidian/Markdown. El frontmatter, si existe, debe ser un bloque YAML delimitado por lineas '---' que empieza en la primera linea del archivo."
|
||||
output: "Dict con dos claves: 'frontmatter' (dict con el mapping YAML parseado, o {} si no hay frontmatter valido) y 'body' (str con el cuerpo de la nota tras el bloque frontmatter, o el contenido completo cuando no hay frontmatter valido)."
|
||||
tested: true
|
||||
tests:
|
||||
- "frontmatter basico"
|
||||
- "crlf line endings"
|
||||
- "sin frontmatter devuelve content completo"
|
||||
- "frontmatter sin cierre es body"
|
||||
- "frontmatter vacio"
|
||||
- "yaml invalido es body"
|
||||
- "content vacio"
|
||||
test_file_path: "python/functions/obsidian/parse_obsidian_frontmatter_test.py"
|
||||
file_path: "python/functions/obsidian/parse_obsidian_frontmatter.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
note = "---\ntitle: My Note\ntags:\n - a\n - b\n---\n\nHello [[other]]."
|
||||
result = parse_obsidian_frontmatter(note)
|
||||
# {
|
||||
# "frontmatter": {"title": "My Note", "tags": ["a", "b"]},
|
||||
# "body": "\nHello [[other]].",
|
||||
# }
|
||||
|
||||
plain = "just a body, no frontmatter"
|
||||
parse_obsidian_frontmatter(plain)
|
||||
# {"frontmatter": {}, "body": "just a body, no frontmatter"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala al leer una nota de Obsidian desde disco cuando necesites acceder a sus
|
||||
metadatos YAML (tags, aliases, status, fechas) por separado del texto, o antes
|
||||
de modificar el frontmatter y volver a serializar con `format_obsidian_note`.
|
||||
Es el primer paso del round-trip parse -> modificar -> format.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Un bloque frontmatter vacio (`---\n---`) parsea a `None` en YAML, que no es
|
||||
un dict, por lo que se trata como "sin frontmatter" y el contenido completo
|
||||
vuelve como body. Esto es intencional para mantener la inversa con
|
||||
`format_obsidian_note` (que omite frontmatter vacio).
|
||||
- El `---` de apertura debe estar en la primera linea exacta del contenido. Un
|
||||
`---` precedido de lineas en blanco o texto NO se considera frontmatter.
|
||||
- YAML invalido se trata como "sin frontmatter": devuelve el contenido como
|
||||
body en lugar de lanzar excepcion (funcion pura, sin error_type).
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Parse the YAML frontmatter of an Obsidian note treated as plain Markdown."""
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def parse_obsidian_frontmatter(content: str) -> dict:
|
||||
"""Split an Obsidian note into its YAML frontmatter and body.
|
||||
|
||||
The frontmatter is the YAML block delimited by `---` lines at the very
|
||||
start of the file. It is parsed with `yaml.safe_load`. If there is no
|
||||
valid frontmatter block at the start of the content (no leading `---`,
|
||||
no closing `---`, or the YAML does not parse into a mapping), the whole
|
||||
content is returned as the body and the frontmatter is an empty dict.
|
||||
|
||||
Supports both `\\n` and `\\r\\n` line endings. Pure and deterministic:
|
||||
no I/O, no mutation of the input.
|
||||
|
||||
Args:
|
||||
content: Full text of an Obsidian/Markdown note.
|
||||
|
||||
Returns:
|
||||
A dict with two keys:
|
||||
- "frontmatter": the parsed YAML mapping (dict), or {} if absent.
|
||||
- "body": the note body after the frontmatter block, or the full
|
||||
content when there is no valid frontmatter.
|
||||
"""
|
||||
if not content:
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
# Normalize line endings for splitting without mutating the original body.
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
|
||||
# Frontmatter must start on the very first line with an exact `---`.
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
# Find the closing `---` delimiter.
|
||||
closing_index = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
closing_index = i
|
||||
break
|
||||
|
||||
if closing_index is None:
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
yaml_block = "\n".join(lines[1:closing_index])
|
||||
body = "\n".join(lines[closing_index + 1:])
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(yaml_block)
|
||||
except yaml.YAMLError:
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
return {"frontmatter": parsed, "body": body}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
note = "---\ntitle: My Note\ntags:\n - a\n - b\n---\n\nHello [[other]]."
|
||||
result = parse_obsidian_frontmatter(note)
|
||||
assert result["frontmatter"] == {"title": "My Note", "tags": ["a", "b"]}
|
||||
assert result["body"] == "\nHello [[other]]."
|
||||
|
||||
# CRLF line endings.
|
||||
crlf = "---\r\ntitle: X\r\n---\r\nbody line"
|
||||
assert parse_obsidian_frontmatter(crlf)["frontmatter"] == {"title": "X"}
|
||||
|
||||
# No frontmatter -> body is the full content.
|
||||
plain = "just a body, no frontmatter"
|
||||
assert parse_obsidian_frontmatter(plain) == {"frontmatter": {}, "body": plain}
|
||||
|
||||
# Unterminated frontmatter -> treated as plain body.
|
||||
broken = "---\ntitle: X\nno closing delimiter"
|
||||
assert parse_obsidian_frontmatter(broken) == {"frontmatter": {}, "body": broken}
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests para parse_obsidian_frontmatter."""
|
||||
|
||||
from parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_basico():
|
||||
note = "---\ntitle: My Note\ntags:\n - a\n - b\n---\n\nHello [[other]]."
|
||||
result = parse_obsidian_frontmatter(note)
|
||||
assert result["frontmatter"] == {"title": "My Note", "tags": ["a", "b"]}
|
||||
assert result["body"] == "\nHello [[other]]."
|
||||
|
||||
|
||||
def test_crlf_line_endings():
|
||||
crlf = "---\r\ntitle: X\r\nstatus: done\r\n---\r\nbody line\r\nsecond line"
|
||||
result = parse_obsidian_frontmatter(crlf)
|
||||
assert result["frontmatter"] == {"title": "X", "status": "done"}
|
||||
assert "body line" in result["body"]
|
||||
|
||||
|
||||
def test_sin_frontmatter_devuelve_content_completo():
|
||||
plain = "just a body, no frontmatter\nwith two lines"
|
||||
result = parse_obsidian_frontmatter(plain)
|
||||
assert result == {"frontmatter": {}, "body": plain}
|
||||
|
||||
|
||||
def test_frontmatter_sin_cierre_es_body():
|
||||
broken = "---\ntitle: X\nno closing delimiter\nmore text"
|
||||
result = parse_obsidian_frontmatter(broken)
|
||||
assert result == {"frontmatter": {}, "body": broken}
|
||||
|
||||
|
||||
def test_frontmatter_vacio():
|
||||
empty = "---\n---\nbody after empty frontmatter"
|
||||
result = parse_obsidian_frontmatter(empty)
|
||||
# An empty YAML block parses to None (not a dict) -> treated as no frontmatter.
|
||||
assert result == {"frontmatter": {}, "body": empty}
|
||||
|
||||
|
||||
def test_yaml_invalido_es_body():
|
||||
bad = "---\nthis: is: invalid: yaml: ::\n---\nbody"
|
||||
result = parse_obsidian_frontmatter(bad)
|
||||
assert result == {"frontmatter": {}, "body": bad}
|
||||
|
||||
|
||||
def test_content_vacio():
|
||||
result = parse_obsidian_frontmatter("")
|
||||
assert result == {"frontmatter": {}, "body": ""}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: read_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def read_obsidian_note(path: str) -> dict"
|
||||
description: "Lee una nota Markdown de Obsidian desde disco y la descompone en frontmatter YAML, body, wikilinks [[...]] y tags normalizados. Compone las funciones puras parse_obsidian_frontmatter y extract_obsidian_wikilinks. No depende de la app GUI de Obsidian: solo lee el archivo .md plano."
|
||||
tags: [obsidian, markdown, frontmatter, wikilinks, read, notes]
|
||||
uses_functions: ["parse_obsidian_frontmatter_py_obsidian", "extract_obsidian_wikilinks_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta al archivo .md de la nota a leer"
|
||||
output: "dict con path (str), frontmatter (dict), body (str), wikilinks (list de destinos [[...]]) y tags (list normalizada desde frontmatter['tags'], acepta CSV o lista)"
|
||||
tested: true
|
||||
tests:
|
||||
- "lee nota con frontmatter y wikilinks"
|
||||
- "normaliza tags csv a lista"
|
||||
- "nota sin frontmatter"
|
||||
- "archivo inexistente lanza filenotfounderror"
|
||||
- "directorio lanza isadirectoryerror"
|
||||
test_file_path: "python/functions/obsidian/read_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/read_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import read_obsidian_note
|
||||
|
||||
note = read_obsidian_note("/home/me/vault/Proyectos/Idea.md")
|
||||
print(note["frontmatter"]) # {'title': 'Idea', 'tags': ['proyecto', 'wip']}
|
||||
print(note["tags"]) # ['proyecto', 'wip']
|
||||
print(note["wikilinks"]) # ['Nota Relacionada', 'Otra Idea']
|
||||
print(note["body"][:80]) # primeras lineas del cuerpo Markdown
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites cargar el contenido de una nota de Obsidian de forma estructurada: leer su frontmatter, su cuerpo, los wikilinks que apunta o sus tags. Es el primer paso natural antes de actualizar una nota (`update_obsidian_note`) o de construir un grafo de enlaces a partir de los `wikilinks`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Lee de disco** (I/O impuro): el resultado refleja el estado del archivo en ese instante.
|
||||
- **No respeta locks de la app GUI**: si Obsidian esta abierto y tiene la nota con cambios sin guardar, leeras la version persistida en disco, no la del editor en memoria.
|
||||
- Lanza `FileNotFoundError` si el path no existe e `IsADirectoryError` si apunta a un directorio.
|
||||
- `tags` se normaliza siempre a lista: acepta tanto `tags: proyecto, wip` (CSV) como `tags: [proyecto, wip]` (lista YAML). Otros tipos se convierten a string en un unico tag.
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Lee una nota de Obsidian (.md) desde disco y la descompone en sus partes.
|
||||
|
||||
Compone funciones puras del grupo obsidian: parse_obsidian_frontmatter y
|
||||
extract_obsidian_wikilinks. Funcion impura: hace I/O de lectura de archivo.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import extract_obsidian_wikilinks, parse_obsidian_frontmatter
|
||||
|
||||
|
||||
def _normalize_tags(raw) -> list:
|
||||
"""Normaliza el campo tags del frontmatter a una lista de strings.
|
||||
|
||||
Acepta None, un string CSV ("a, b, c") o una lista. Devuelve siempre
|
||||
una lista de strings sin espacios sobrantes y sin entradas vacias.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, str):
|
||||
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return [str(t).strip() for t in raw if str(t).strip()]
|
||||
# Cualquier otro tipo (int, etc.) -> representacion como unico tag.
|
||||
return [str(raw).strip()] if str(raw).strip() else []
|
||||
|
||||
|
||||
def read_obsidian_note(path: str) -> dict:
|
||||
"""Lee una nota Markdown de Obsidian y devuelve sus partes estructuradas.
|
||||
|
||||
Args:
|
||||
path: ruta al archivo .md a leer.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
- path: la ruta leida (tal cual fue pasada).
|
||||
- frontmatter: dict con el frontmatter YAML parseado.
|
||||
- body: str con el cuerpo Markdown sin el frontmatter.
|
||||
- wikilinks: list con los destinos de los wikilinks [[...]] del body.
|
||||
- tags: list normalizada a partir de frontmatter["tags"].
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si el archivo no existe.
|
||||
IsADirectoryError: si la ruta apunta a un directorio.
|
||||
OSError: si la lectura falla por otro motivo de I/O.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"obsidian note not found: {path}")
|
||||
if os.path.isdir(path):
|
||||
raise IsADirectoryError(f"expected a file, got a directory: {path}")
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
parsed = parse_obsidian_frontmatter(content)
|
||||
frontmatter = parsed.get("frontmatter", {}) or {}
|
||||
body = parsed.get("body", "") or ""
|
||||
|
||||
wikilinks = extract_obsidian_wikilinks(body)
|
||||
tags = _normalize_tags(frontmatter.get("tags"))
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"frontmatter": frontmatter,
|
||||
"body": body,
|
||||
"wikilinks": wikilinks,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
sample = "---\ntitle: Demo\ntags: [a, b]\n---\n\nHola [[Otra Nota]] y [[Tercera]].\n"
|
||||
fd, tmp = tempfile.mkstemp(suffix=".md")
|
||||
os.close(fd)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(sample)
|
||||
note = read_obsidian_note(tmp)
|
||||
assert note["frontmatter"].get("title") == "Demo", note["frontmatter"]
|
||||
assert note["tags"] == ["a", "b"], note["tags"]
|
||||
assert "Otra Nota" in note["wikilinks"], note["wikilinks"]
|
||||
os.remove(tmp)
|
||||
print("read_obsidian_note smoke OK")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests para read_obsidian_note."""
|
||||
|
||||
import pytest
|
||||
|
||||
from read_obsidian_note import read_obsidian_note
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_lee_nota_con_frontmatter_y_wikilinks(tmp_path):
|
||||
# Golden path: nota con frontmatter YAML + body con wikilinks.
|
||||
note = _write(
|
||||
tmp_path / "Idea.md",
|
||||
"---\ntitle: Idea\ntags: [proyecto, wip]\n---\n\n"
|
||||
"Cuerpo con [[Otra Nota]] y [[Tercera|alias]].",
|
||||
)
|
||||
result = read_obsidian_note(note)
|
||||
|
||||
assert result["path"] == note
|
||||
assert result["frontmatter"]["title"] == "Idea"
|
||||
assert result["tags"] == ["proyecto", "wip"]
|
||||
assert result["wikilinks"] == ["Otra Nota", "Tercera"]
|
||||
assert "Cuerpo con" in result["body"]
|
||||
|
||||
|
||||
def test_normaliza_tags_csv_a_lista(tmp_path):
|
||||
# Edge: tags como CSV en vez de lista YAML.
|
||||
note = _write(
|
||||
tmp_path / "CSV.md",
|
||||
"---\ntitle: CSV\ntags: proyecto, wip , done\n---\n\nTexto.",
|
||||
)
|
||||
result = read_obsidian_note(note)
|
||||
assert result["tags"] == ["proyecto", "wip", "done"]
|
||||
|
||||
|
||||
def test_nota_sin_frontmatter(tmp_path):
|
||||
# Edge: nota plana sin frontmatter -> frontmatter vacio, tags vacios.
|
||||
note = _write(tmp_path / "Plana.md", "Solo cuerpo, [[Enlace]] suelto.")
|
||||
result = read_obsidian_note(note)
|
||||
assert result["frontmatter"] == {}
|
||||
assert result["tags"] == []
|
||||
assert result["wikilinks"] == ["Enlace"]
|
||||
assert result["body"] == "Solo cuerpo, [[Enlace]] suelto."
|
||||
|
||||
|
||||
def test_archivo_inexistente_lanza_filenotfounderror(tmp_path):
|
||||
# Error path: ruta inexistente.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_obsidian_note(str(tmp_path / "no_existe.md"))
|
||||
|
||||
|
||||
def test_directorio_lanza_isadirectoryerror(tmp_path):
|
||||
# Error path: ruta a directorio, no archivo.
|
||||
sub = tmp_path / "carpeta"
|
||||
sub.mkdir()
|
||||
with pytest.raises(IsADirectoryError):
|
||||
read_obsidian_note(str(sub))
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: resolve_obsidian_embed
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def resolve_obsidian_embed(vault_dir: str, embed_name: str) -> str"
|
||||
description: "Resuelve el path absoluto real de un attachment embebido buscandolo por nombre dentro de un vault de Obsidian. Obsidian resuelve los embeds ![[...]] por nombre de archivo unico (no por path), asi que esta funcion recorre el vault recursivamente (una pasada con os.walk) y devuelve el primer archivo cuyo basename coincida (case-insensitive), excluyendo .obsidian/ y .trash/. Si el embed no trae extension, acepta cualquier match de ese basename. Devuelve cadena vacia si no existe (NO lanza). Lanza si vault_dir no existe. No depende de la app GUI."
|
||||
tags: [obsidian, embed, attachment, resolve, filesystem, migrate]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "ruta a la raiz del vault Obsidian donde buscar el attachment"
|
||||
- name: embed_name
|
||||
desc: "nombre del attachment embebido (el target de un ![[...]], lo que devuelve extract_obsidian_embeds), con o sin extension"
|
||||
output: "string con el path absoluto del primer archivo del vault cuyo basename coincide (case-insensitive) con embed_name, o cadena vacia '' si ningun archivo coincide. Excluye .obsidian/ y .trash/."
|
||||
tested: true
|
||||
tests:
|
||||
- "match exacto en subcarpeta"
|
||||
- "match case insensitive"
|
||||
- "sin extension acepta cualquier match"
|
||||
- "no existe devuelve vacio"
|
||||
- "excluye obsidian y trash"
|
||||
- "vault inexistente lanza"
|
||||
test_file_path: "python/functions/obsidian/resolve_obsidian_embed_test.py"
|
||||
file_path: "python/functions/obsidian/resolve_obsidian_embed.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import extract_obsidian_embeds, resolve_obsidian_embed
|
||||
|
||||
body = "Mi DNI: ![[dni enmanuel (2).jpg]]"
|
||||
for embed in extract_obsidian_embeds(body):
|
||||
path = resolve_obsidian_embed("/home/me/MiVault", embed)
|
||||
print(embed, "->", path or "(no encontrado)")
|
||||
# dni enmanuel (2).jpg -> /home/me/MiVault/attachments/dni enmanuel (2).jpg
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala junto a `extract_obsidian_embeds` cuando migres o extraigas un subgrafo de
|
||||
notas y necesites el path fisico real de cada attachment para copiarlo al nuevo
|
||||
destino. Replica la resolucion por-nombre de Obsidian sin abrir la app GUI:
|
||||
basta con el directorio del vault en disco.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Resuelve por NOMBRE de archivo, no por path.** Igual que Obsidian: busca el
|
||||
basename en TODO el vault. Si hay dos archivos con el mismo nombre en carpetas
|
||||
distintas (`fotos/logo.png` y `assets/logo.png`), devuelve el PRIMERO que
|
||||
encuentra `os.walk` — el orden de `os.walk` no esta garantizado entre sistemas,
|
||||
asi que con nombres duplicados el resultado puede no ser el embed que Obsidian
|
||||
mostraria. Para vaults con nombres ambiguos, deduplica los nombres antes de
|
||||
migrar.
|
||||
- Si el embed no existe en el vault devuelve `""` (cadena vacia), NO lanza
|
||||
excepcion. Comprueba el valor antes de usarlo como ruta.
|
||||
- SI lanza `FileNotFoundError` / `NotADirectoryError` si `vault_dir` no existe o
|
||||
no es un directorio.
|
||||
- Es impura: recorre el filesystem en cada llamada. Si vas a resolver muchos
|
||||
embeds del mismo vault, considera construir tu propio indice `basename -> path`
|
||||
con un unico `os.walk` en lugar de llamar a esta funcion en bucle.
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Resuelve el path absoluto real de un attachment embebido en un vault Obsidian.
|
||||
|
||||
Funcion impura: recorre el filesystem del vault. Obsidian resuelve los embeds
|
||||
![[...]] por nombre de archivo unico (no por path), asi que esta funcion busca
|
||||
recursivamente un archivo cuyo basename coincida con el nombre del embed.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Directorios de maquinaria de Obsidian que nunca contienen attachments de usuario.
|
||||
_EXCLUDED_DIRS = {".obsidian", ".trash"}
|
||||
|
||||
|
||||
def resolve_obsidian_embed(vault_dir: str, embed_name: str) -> str:
|
||||
"""Resuelve el path absoluto de un attachment embebido buscandolo por nombre.
|
||||
|
||||
Obsidian resuelve los embeds `![[archivo.jpg]]` por nombre de archivo unico
|
||||
dentro del vault, no por ruta. Esta funcion replica ese comportamiento:
|
||||
recorre ``vault_dir`` recursivamente (una sola pasada con ``os.walk``) y
|
||||
devuelve el primer archivo cuyo basename coincida, de forma
|
||||
case-insensitive, con ``embed_name``.
|
||||
|
||||
Si ``embed_name`` no trae extension (p.ej. ``"foto"``), se acepta cualquier
|
||||
archivo cuyo basename sin extension coincida (p.ej. ``foto.jpg``).
|
||||
|
||||
Los directorios ``.obsidian/`` y ``.trash/`` se excluyen del recorrido.
|
||||
|
||||
Impura: lee el filesystem. NO lanza si el embed no se encuentra (devuelve
|
||||
cadena vacia). SI lanza si ``vault_dir`` no existe.
|
||||
|
||||
Args:
|
||||
vault_dir: Ruta a la raiz del vault Obsidian.
|
||||
embed_name: Nombre del attachment embebido (lo que devuelve
|
||||
``extract_obsidian_embeds``), con o sin extension.
|
||||
|
||||
Returns:
|
||||
El path absoluto del primer archivo que coincide, o cadena vacia ``""``
|
||||
si ningun archivo del vault coincide.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si ``vault_dir`` no existe.
|
||||
NotADirectoryError: si ``vault_dir`` no es un directorio.
|
||||
"""
|
||||
if not os.path.exists(vault_dir):
|
||||
raise FileNotFoundError(f"vault path does not exist: {vault_dir}")
|
||||
if not os.path.isdir(vault_dir):
|
||||
raise NotADirectoryError(f"vault path is not a directory: {vault_dir}")
|
||||
|
||||
target = embed_name.strip()
|
||||
if not target:
|
||||
return ""
|
||||
|
||||
target_lower = target.lower()
|
||||
has_extension = os.path.splitext(target)[1] != ""
|
||||
target_stem_lower = os.path.splitext(target_lower)[0]
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(vault_dir):
|
||||
# Podar maquinaria de Obsidian in-place para no descender en ella.
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
for filename in filenames:
|
||||
filename_lower = filename.lower()
|
||||
if has_extension:
|
||||
# Comparar basename completo, case-insensitive.
|
||||
if filename_lower == target_lower:
|
||||
return os.path.abspath(os.path.join(dirpath, filename))
|
||||
else:
|
||||
# Sin extension en el embed: comparar solo el stem del archivo.
|
||||
stem_lower = os.path.splitext(filename_lower)[0]
|
||||
if stem_lower == target_stem_lower:
|
||||
return os.path.abspath(os.path.join(dirpath, filename))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, "attachments"))
|
||||
os.makedirs(os.path.join(tmp, ".obsidian"))
|
||||
with open(os.path.join(tmp, "attachments", "Foto.JPG"), "w") as f:
|
||||
f.write("x")
|
||||
with open(os.path.join(tmp, "doc.pdf"), "w") as f:
|
||||
f.write("y")
|
||||
|
||||
# Match case-insensitive con extension.
|
||||
hit = resolve_obsidian_embed(tmp, "foto.jpg")
|
||||
assert hit.endswith(os.path.join("attachments", "Foto.JPG")), hit
|
||||
|
||||
# Match sin extension en el embed.
|
||||
hit2 = resolve_obsidian_embed(tmp, "doc")
|
||||
assert hit2.endswith("doc.pdf"), hit2
|
||||
|
||||
# No existe -> "".
|
||||
assert resolve_obsidian_embed(tmp, "no_existe.png") == ""
|
||||
|
||||
print("resolve_obsidian_embed smoke OK")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests para resolve_obsidian_embed."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from resolve_obsidian_embed import resolve_obsidian_embed
|
||||
|
||||
|
||||
def _write(path, content="x"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_match_exacto_en_subcarpeta(tmp_path):
|
||||
# Golden path: archivo en subcarpeta resuelto por nombre exacto.
|
||||
target = _write(tmp_path / "attachments" / "imagen.jpg")
|
||||
result = resolve_obsidian_embed(str(tmp_path), "imagen.jpg")
|
||||
assert result == os.path.abspath(target)
|
||||
|
||||
|
||||
def test_match_case_insensitive(tmp_path):
|
||||
# Edge: el nombre en disco difiere en mayusculas/minusculas.
|
||||
target = _write(tmp_path / "media" / "Foto.JPG")
|
||||
result = resolve_obsidian_embed(str(tmp_path), "foto.jpg")
|
||||
assert result == os.path.abspath(target)
|
||||
|
||||
|
||||
def test_sin_extension_acepta_cualquier_match(tmp_path):
|
||||
# Edge: embed sin extension -> coincide cualquier archivo con ese stem.
|
||||
target = _write(tmp_path / "doc.pdf")
|
||||
result = resolve_obsidian_embed(str(tmp_path), "doc")
|
||||
assert result == os.path.abspath(target)
|
||||
|
||||
|
||||
def test_no_existe_devuelve_vacio(tmp_path):
|
||||
# Error de dominio (no excepcion): nombre inexistente -> "".
|
||||
_write(tmp_path / "otra.png")
|
||||
assert resolve_obsidian_embed(str(tmp_path), "no_existe.png") == ""
|
||||
|
||||
|
||||
def test_excluye_obsidian_y_trash(tmp_path):
|
||||
# Edge: archivos en .obsidian/ y .trash/ no se resuelven.
|
||||
_write(tmp_path / ".obsidian" / "config.jpg")
|
||||
_write(tmp_path / ".trash" / "borrada.jpg")
|
||||
assert resolve_obsidian_embed(str(tmp_path), "config.jpg") == ""
|
||||
assert resolve_obsidian_embed(str(tmp_path), "borrada.jpg") == ""
|
||||
|
||||
|
||||
def test_vault_inexistente_lanza(tmp_path):
|
||||
# Error path: vault_dir que no existe -> FileNotFoundError.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
resolve_obsidian_embed(str(tmp_path / "no_vault"), "imagen.jpg")
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: search_obsidian_notes
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "search_obsidian_notes(vault_dir: str, query: str, in_body: bool = True, in_frontmatter: bool = True) -> list"
|
||||
description: "Busca un substring (case-insensitive) en todas las notas .md de un vault de Obsidian, excluyendo .obsidian/ y .trash/. Devuelve por nota las lineas que contienen el query con su numero de linea. Los flags in_body/in_frontmatter acotan donde buscar."
|
||||
tags: [obsidian, notes, search, grep, vault, frontmatter, filesystem]
|
||||
uses_functions: ["parse_obsidian_frontmatter_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "path a la raiz del vault de Obsidian a buscar"
|
||||
- name: query
|
||||
desc: "substring a buscar (matcheado de forma case-insensitive); no puede ser vacio"
|
||||
- name: in_body
|
||||
desc: "buscar en el cuerpo de la nota cuando es True (defecto True)"
|
||||
- name: in_frontmatter
|
||||
desc: "buscar en el bloque de frontmatter cuando es True (defecto True)"
|
||||
output: "lista de dicts {path, matches} (uno por nota con coincidencias), ordenada por path; cada match es {line, text} con numero de linea 1-based relativo al archivo completo"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/search_obsidian_notes.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import search_obsidian_notes
|
||||
|
||||
# Buscar "presupuesto" en todo el vault (frontmatter + cuerpo)
|
||||
hits = search_obsidian_notes("/home/enmanuel/Obsidian/Finanzas", "presupuesto")
|
||||
for h in hits:
|
||||
print(h["path"])
|
||||
for m in h["matches"]:
|
||||
print(f" L{m['line']}: {m['text']}")
|
||||
|
||||
# Solo en el cuerpo, ignorando el frontmatter
|
||||
solo_cuerpo = search_obsidian_notes(
|
||||
"/home/enmanuel/Obsidian/Finanzas", "TODO", in_frontmatter=False
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un grep de un solo paso sobre un vault de Obsidian: encontrar en que notas aparece un termino y en que lineas, antes de abrir/editar. Util para auditar tags, localizar referencias o construir un indice de busqueda.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee el filesystem. Lanza `ValueError` si `query` es vacio, `FileNotFoundError` si el vault no existe y `NotADirectoryError` si no es un directorio.
|
||||
- **Exclusion obligatoria**: `.obsidian/` y `.trash/` se podan del recorrido; su contenido nunca se busca ni aparece en resultados.
|
||||
- **Coste en vaults grandes**: abre y lee CADA nota `.md` linea a linea. En vaults pesados como `NotasDeObsidian` (~554M) esto recorre todo el contenido en memoria por archivo; puede tardar. Acota el vault o pre-filtra con `list_obsidian_notes` si necesitas rendimiento.
|
||||
- **Numeros de linea**: son 1-based y relativos al archivo completo (incluyen las lineas del frontmatter `---`), de modo que mapean directamente sobre el archivo en disco aunque se busque solo en cuerpo o solo en frontmatter.
|
||||
- La delimitacion frontmatter/cuerpo se calcula con `parse_obsidian_frontmatter`; si la nota no tiene frontmatter valido, todo se considera cuerpo.
|
||||
- Archivos con encoding invalido se leen con `errors="replace"`.
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Full-text substring search across the notes of an Obsidian vault."""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import parse_obsidian_frontmatter
|
||||
|
||||
# Directories that are part of Obsidian's machinery, never user notes.
|
||||
_EXCLUDED_DIRS = {".obsidian", ".trash"}
|
||||
|
||||
|
||||
def search_obsidian_notes(
|
||||
vault_dir: str,
|
||||
query: str,
|
||||
in_body: bool = True,
|
||||
in_frontmatter: bool = True,
|
||||
) -> list:
|
||||
"""Search a case-insensitive substring across every note of a vault.
|
||||
|
||||
Walks ``vault_dir`` recursively (pruning ``.obsidian/`` and ``.trash/``),
|
||||
reads every ``.md`` note and looks for ``query`` as a case-insensitive
|
||||
substring. Each line that contains the query is reported together with its
|
||||
1-based line number.
|
||||
|
||||
The ``in_body`` and ``in_frontmatter`` flags control which part of a note is
|
||||
searched. The frontmatter is delimited with ``parse_obsidian_frontmatter``:
|
||||
its raw lines (between the opening and closing ``---``) are searched when
|
||||
``in_frontmatter`` is True, and the body lines when ``in_body`` is True. Line
|
||||
numbers are always relative to the full file so they map directly onto the
|
||||
note on disk.
|
||||
|
||||
Impure: it reads the filesystem. Raises ``ValueError`` if ``query`` is empty,
|
||||
``FileNotFoundError`` if the vault does not exist and ``NotADirectoryError``
|
||||
if it is not a directory.
|
||||
|
||||
Args:
|
||||
vault_dir: Path to the vault root.
|
||||
query: Substring to look for (matched case-insensitively).
|
||||
in_body: Search the note body when True.
|
||||
in_frontmatter: Search the note frontmatter block when True.
|
||||
|
||||
Returns:
|
||||
A list of dicts ``{"path": str, "matches": list}`` (one per matching
|
||||
note), sorted by path. Each match is
|
||||
``{"line": int, "text": str}``.
|
||||
"""
|
||||
if not query:
|
||||
raise ValueError("query must be a non-empty string")
|
||||
|
||||
root = os.path.abspath(vault_dir)
|
||||
if not os.path.exists(root):
|
||||
raise FileNotFoundError(f"vault path does not exist: {root}")
|
||||
if not os.path.isdir(root):
|
||||
raise NotADirectoryError(f"vault path is not a directory: {root}")
|
||||
|
||||
needle = query.lower()
|
||||
results: list[dict] = []
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
for filename in filenames:
|
||||
if not filename.lower().endswith(".md"):
|
||||
continue
|
||||
full = os.path.abspath(os.path.join(dirpath, filename))
|
||||
matches = _search_note(full, needle, in_body, in_frontmatter)
|
||||
if matches:
|
||||
results.append({"path": full, "matches": matches})
|
||||
|
||||
results.sort(key=lambda r: r["path"])
|
||||
return results
|
||||
|
||||
|
||||
def _frontmatter_line_count(content: str) -> int:
|
||||
"""Number of full-file lines occupied by the frontmatter block (0 if none).
|
||||
|
||||
Counts the opening ``---``, the YAML lines and the closing ``---``. Returns
|
||||
0 when the note has no valid frontmatter (per ``parse_obsidian_frontmatter``).
|
||||
"""
|
||||
if parse_obsidian_frontmatter(content).get("frontmatter"):
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
if lines and lines[0].strip() == "---":
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
return i + 1 # inclusive of both delimiters
|
||||
return 0
|
||||
|
||||
|
||||
def _search_note(
|
||||
note_path: str, needle: str, in_body: bool, in_frontmatter: bool
|
||||
) -> list:
|
||||
"""Return the matching lines (with 1-based line numbers) inside one note."""
|
||||
try:
|
||||
with open(note_path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
content = handle.read()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
fm_lines = _frontmatter_line_count(content)
|
||||
|
||||
matches: list[dict] = []
|
||||
for idx, text in enumerate(lines):
|
||||
is_frontmatter = idx < fm_lines
|
||||
if is_frontmatter and not in_frontmatter:
|
||||
continue
|
||||
if not is_frontmatter and not in_body:
|
||||
continue
|
||||
if needle in text.lower():
|
||||
matches.append({"line": idx + 1, "text": text})
|
||||
return matches
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, ".obsidian"))
|
||||
with open(os.path.join(tmp, ".obsidian", "noise.md"), "w") as f:
|
||||
f.write("ALPHA hidden in obsidian config")
|
||||
with open(os.path.join(tmp, "note.md"), "w") as f:
|
||||
f.write("---\ntitle: Alpha note\n---\nfirst line\nsecond ALPHA line\n")
|
||||
|
||||
hits = search_obsidian_notes(tmp, "alpha")
|
||||
assert len(hits) == 1, hits # .obsidian note excluded
|
||||
assert hits[0]["path"].endswith("note.md")
|
||||
lines = [m["line"] for m in hits[0]["matches"]]
|
||||
assert 2 in lines and 5 in lines, hits # frontmatter + body
|
||||
|
||||
body_only = search_obsidian_notes(tmp, "alpha", in_frontmatter=False)
|
||||
body_lines = [m["line"] for m in body_only[0]["matches"]]
|
||||
assert body_lines == [5], body_only
|
||||
|
||||
fm_only = search_obsidian_notes(tmp, "alpha", in_body=False)
|
||||
fm_lines = [m["line"] for m in fm_only[0]["matches"]]
|
||||
assert fm_lines == [2], fm_only
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: slugify_obsidian_name
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def slugify_obsidian_name(name: str) -> str"
|
||||
description: "Convierte un nombre o titulo a un slug kebab-case estable: transliteracion Unicode (NFKD + descarte de combining marks, mapeo explicito de ñ/Ñ -> n), minusculas, colapsa cualquier secuencia de caracteres no [a-z0-9] a un solo guion, y strip de guiones en los bordes. Sin guiones dobles. Deterministica, pura, sin I/O. Util para migrar notas de Obsidian a nombres de archivo/identificadores estables."
|
||||
tags: [obsidian, slug, kebab-case, transliterate, unicode, normalize, migrate]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["re", "unicodedata"]
|
||||
params:
|
||||
- name: name
|
||||
desc: "Nombre o titulo arbitrario (puede llevar acentos, mayusculas, espacios y simbolos). P.ej. el titulo de una nota de Obsidian que se quiere convertir en slug estable."
|
||||
output: "String con el slug kebab-case: solo caracteres [a-z0-9-], sin guiones al inicio/fin ni guiones dobles. Cadena vacia si name no contiene ningun caracter slugificable."
|
||||
tested: true
|
||||
tests:
|
||||
- "acentos y espacios"
|
||||
- "enye se mapea a n"
|
||||
- "mezcla mayusculas y acentos"
|
||||
- "simbolos y dobles separadores"
|
||||
- "string vacio"
|
||||
- "solo simbolos devuelve vacio"
|
||||
test_file_path: "python/functions/obsidian/slugify_obsidian_name_test.py"
|
||||
file_path: "python/functions/obsidian/slugify_obsidian_name.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
slugify_obsidian_name("Enmanuel Gutiérrez Pérez") # "enmanuel-gutierrez-perez"
|
||||
slugify_obsidian_name("Jose manuel camaño castro") # "jose-manuel-camano-castro"
|
||||
slugify_obsidian_name("DNI de María del Mar") # "dni-de-maria-del-mar"
|
||||
slugify_obsidian_name(" raro__nombre!! ") # "raro-nombre"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala al migrar o extraer subgrafos de notas de Obsidian cuando necesites un
|
||||
identificador estable a partir de un titulo: renombrar archivos `.md`, generar
|
||||
claves de un mapa de equivalencias `titulo -> slug`, o crear nombres de carpeta
|
||||
deterministas. Es pura, asi que sirve igual de bien para indexar, ordenar o
|
||||
comparar nombres normalizados sin tocar disco.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Mapea `ñ`/`Ñ` -> `n` explicitamente porque `NFKD` NO descompone la enye en
|
||||
`n` + tilde (la cedilla queda como combining mark sobre la `n` base ya
|
||||
formada). Otros idiomas con letras especiales (ß, ø, đ) NO se transliteran:
|
||||
caen bajo el colapso de no-`[a-z0-9]` y desaparecen. Si necesitas soportarlas,
|
||||
amplia `_TRANSLIT_MAP`.
|
||||
- Dos titulos distintos pueden colisionar en el mismo slug (`"Mi Nota!"` y
|
||||
`"Mi Nota"` -> `"mi-nota"`). Si el slug debe ser unico en un vault, anade un
|
||||
desambiguador (sufijo numerico) por fuera de esta funcion.
|
||||
- No preserva ningun caracter no ASCII: emojis, ideogramas CJK, cirilico, etc.
|
||||
se descartan. Un titulo formado solo por esos caracteres devuelve `""`.
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Convierte un nombre/titulo de Obsidian a un slug kebab-case estable.
|
||||
|
||||
Funcion pura: transliteracion Unicode + normalizacion a [a-z0-9-]. No hace I/O.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# Cualquier secuencia de caracteres que NO sea [a-z0-9] colapsa a un solo '-'.
|
||||
_NON_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
# Mapeo explicito de caracteres que NFKD no descompone como queremos.
|
||||
_TRANSLIT_MAP = {
|
||||
"ñ": "n",
|
||||
"Ñ": "n",
|
||||
}
|
||||
|
||||
|
||||
def slugify_obsidian_name(name: str) -> str:
|
||||
"""Convierte un nombre o titulo a un slug kebab-case estable.
|
||||
|
||||
Pasos, en orden:
|
||||
1. Mapea caracteres especiales que NFKD no descompone (ñ/Ñ -> n).
|
||||
2. Normaliza con ``unicodedata.normalize('NFKD', ...)`` y descarta los
|
||||
combining marks (acentos, diacriticos), transliterando a ASCII.
|
||||
3. Pasa todo a minusculas.
|
||||
4. Reemplaza cualquier secuencia de caracteres no ``[a-z0-9]`` por un
|
||||
unico ``-``.
|
||||
5. Hace strip de ``-`` al inicio y al final. Nunca quedan guiones dobles.
|
||||
|
||||
Es deterministica y pura: misma entrada -> mismo slug, sin efectos
|
||||
secundarios ni I/O.
|
||||
|
||||
Args:
|
||||
name: Nombre o titulo arbitrario (puede llevar acentos, mayusculas,
|
||||
espacios, simbolos).
|
||||
|
||||
Returns:
|
||||
El slug kebab-case correspondiente. Cadena vacia si ``name`` no
|
||||
contiene ningun caracter slugificable.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
# 1. Mapeo explicito antes de NFKD (la cedilla de la enye se trataria mal).
|
||||
for src, dst in _TRANSLIT_MAP.items():
|
||||
name = name.replace(src, dst)
|
||||
|
||||
# 2. Transliteracion Unicode: descompone y descarta combining marks.
|
||||
decomposed = unicodedata.normalize("NFKD", name)
|
||||
ascii_only = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
|
||||
|
||||
# 3. Minusculas.
|
||||
lowered = ascii_only.lower()
|
||||
|
||||
# 4. Colapsar todo lo no [a-z0-9] a un unico '-'.
|
||||
slug = _NON_SLUG_RE.sub("-", lowered)
|
||||
|
||||
# 5. Strip de guiones en los bordes.
|
||||
return slug.strip("-")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
assert slugify_obsidian_name("Enmanuel Gutiérrez Pérez") == "enmanuel-gutierrez-perez"
|
||||
assert slugify_obsidian_name("Jose manuel camaño castro") == "jose-manuel-camano-castro"
|
||||
assert slugify_obsidian_name("DNI de María del Mar") == "dni-de-maria-del-mar"
|
||||
assert slugify_obsidian_name(" raro__nombre!! ") == "raro-nombre"
|
||||
assert slugify_obsidian_name("") == ""
|
||||
assert slugify_obsidian_name("!!!") == ""
|
||||
print("slugify_obsidian_name smoke OK")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tests para slugify_obsidian_name."""
|
||||
|
||||
from slugify_obsidian_name import slugify_obsidian_name
|
||||
|
||||
|
||||
def test_acentos_y_espacios():
|
||||
# Golden path: transliteracion de acentos + espacios a kebab-case.
|
||||
assert slugify_obsidian_name("Enmanuel Gutiérrez Pérez") == "enmanuel-gutierrez-perez"
|
||||
|
||||
|
||||
def test_enye_se_mapea_a_n():
|
||||
# Edge: la enye no la descompone NFKD, la mapeamos explicitamente.
|
||||
assert slugify_obsidian_name("Jose manuel camaño castro") == "jose-manuel-camano-castro"
|
||||
|
||||
|
||||
def test_mezcla_mayusculas_y_acentos():
|
||||
assert slugify_obsidian_name("DNI de María del Mar") == "dni-de-maria-del-mar"
|
||||
|
||||
|
||||
def test_simbolos_y_dobles_separadores():
|
||||
# Edge: secuencias de no-alfanumericos colapsan a un solo '-', strip en bordes.
|
||||
assert slugify_obsidian_name(" raro__nombre!! ") == "raro-nombre"
|
||||
|
||||
|
||||
def test_string_vacio():
|
||||
assert slugify_obsidian_name("") == ""
|
||||
|
||||
|
||||
def test_solo_simbolos_devuelve_vacio():
|
||||
# Edge: nada slugificable -> cadena vacia, sin guiones colgando.
|
||||
assert slugify_obsidian_name("!!!---???") == ""
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: update_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def update_obsidian_note(path: str, body: str = None, set_frontmatter: dict = None, append: str = None) -> str"
|
||||
description: "Actualiza una nota Markdown de Obsidian existente. Lee el estado actual con parse_obsidian_frontmatter, hace merge de claves del frontmatter (set_frontmatter), reemplaza el body (body) o concatena texto al final (append), y reescribe con la funcion pura format_obsidian_note. No depende de la app GUI de Obsidian: solo lee y reescribe el archivo .md plano."
|
||||
tags: [obsidian, markdown, frontmatter, update, write, notes]
|
||||
uses_functions: ["parse_obsidian_frontmatter_py_obsidian", "format_obsidian_note_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta al archivo .md de la nota a actualizar"
|
||||
- name: body
|
||||
desc: "si no es None, reemplaza por completo el cuerpo de la nota"
|
||||
- name: set_frontmatter
|
||||
desc: "dict que se mergea (update de claves) sobre el frontmatter actual; las no mencionadas se conservan"
|
||||
- name: append
|
||||
desc: "texto que se concatena al final del cuerpo (separado por salto de linea); se aplica despues de body"
|
||||
output: "la ruta (str) del archivo actualizado"
|
||||
tested: true
|
||||
tests:
|
||||
- "merge frontmatter conserva claves previas"
|
||||
- "reemplazo de body"
|
||||
- "append concatena al final"
|
||||
- "body y append combinados"
|
||||
- "nota inexistente lanza filenotfounderror"
|
||||
- "directorio lanza isadirectoryerror"
|
||||
test_file_path: "python/functions/obsidian/update_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/update_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import update_obsidian_note
|
||||
|
||||
# Marcar como hecha y anadir una linea de log al final
|
||||
update_obsidian_note(
|
||||
"/home/me/vault/Tareas/Revisar PR.md",
|
||||
set_frontmatter={"status": "done", "closed": "2026-06-09"},
|
||||
append="- Cerrada tras revisar [[PR 42]].",
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites modificar una nota existente sin reescribirla entera: cambiar campos del frontmatter (status, fechas, tags), reemplazar el cuerpo, o ir anadiendo entradas a un log/diario al final. El merge de `set_frontmatter` permite tocar solo las claves que cambian sin perder el resto.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Lee y reescribe en disco** (I/O impuro): sobreescribe el archivo completo con el contenido reserializado por `format_obsidian_note`. El formato/orden del frontmatter puede normalizarse respecto al original escrito a mano.
|
||||
- **No respeta locks de la app GUI**: si Obsidian tiene la nota abierta con cambios sin guardar, esos cambios en memoria se perderan cuando esta funcion reescriba el archivo (la app puede recargarlo o sobreescribirlo de vuelta). Cierra/guarda en Obsidian antes de editar desde codigo.
|
||||
- `set_frontmatter` hace **merge superficial** (`dict.update`): claves nuevas se anaden, existentes se reemplazan, las demas se conservan. No borra claves.
|
||||
- `append` se aplica **despues** de un eventual reemplazo de `body`, garantizando un salto de linea de separacion.
|
||||
- Lanza `FileNotFoundError` si la nota no existe (no la crea — usa `create_obsidian_note` para eso).
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Actualiza una nota existente de Obsidian (.md) en disco.
|
||||
|
||||
Compone funciones puras del grupo obsidian: parse_obsidian_frontmatter para
|
||||
leer el estado actual y format_obsidian_note para reescribir. Funcion impura:
|
||||
lee y reescribe un archivo en disco.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import format_obsidian_note, parse_obsidian_frontmatter
|
||||
|
||||
|
||||
def update_obsidian_note(
|
||||
path: str,
|
||||
body: str = None,
|
||||
set_frontmatter: dict = None,
|
||||
append: str = None,
|
||||
) -> str:
|
||||
"""Actualiza una nota Markdown de Obsidian existente.
|
||||
|
||||
Lee la nota actual, aplica las modificaciones pedidas y la reescribe.
|
||||
|
||||
Args:
|
||||
path: ruta al archivo .md a actualizar.
|
||||
body: si se da (no None), reemplaza por completo el cuerpo de la nota.
|
||||
set_frontmatter: si se da, hace merge (update de claves) sobre el
|
||||
frontmatter actual. Las claves nuevas se anaden, las existentes se
|
||||
sobreescriben. Las no mencionadas se conservan.
|
||||
append: si se da, concatena este texto al final del cuerpo (separado
|
||||
por un salto de linea). Se aplica DESPUES de un eventual reemplazo
|
||||
de body.
|
||||
|
||||
Returns:
|
||||
La ruta del archivo actualizado.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si la nota no existe.
|
||||
IsADirectoryError: si la ruta apunta a un directorio.
|
||||
OSError: si la lectura/escritura falla por otro motivo de I/O.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"obsidian note not found: {path}")
|
||||
if os.path.isdir(path):
|
||||
raise IsADirectoryError(f"expected a file, got a directory: {path}")
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
parsed = parse_obsidian_frontmatter(content)
|
||||
frontmatter = dict(parsed.get("frontmatter", {}) or {})
|
||||
current_body = parsed.get("body", "") or ""
|
||||
|
||||
if set_frontmatter:
|
||||
frontmatter.update(set_frontmatter)
|
||||
|
||||
new_body = current_body if body is None else body
|
||||
|
||||
if append is not None:
|
||||
if new_body and not new_body.endswith("\n"):
|
||||
new_body = new_body + "\n" + append
|
||||
else:
|
||||
new_body = new_body + append
|
||||
|
||||
new_content = format_obsidian_note(frontmatter, new_body)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
fd, tmp = tempfile.mkstemp(suffix=".md")
|
||||
os.close(fd)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write("---\ntitle: A\n---\n\nLinea original.")
|
||||
update_obsidian_note(tmp, set_frontmatter={"status": "wip"}, append="Linea nueva.")
|
||||
with open(tmp, "r", encoding="utf-8") as f:
|
||||
out = f.read()
|
||||
assert "status" in out, out
|
||||
assert "Linea nueva." in out, out
|
||||
assert "Linea original." in out, out
|
||||
os.remove(tmp)
|
||||
print("update_obsidian_note smoke OK")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests para update_obsidian_note."""
|
||||
|
||||
import pytest
|
||||
|
||||
from read_obsidian_note import read_obsidian_note
|
||||
from update_obsidian_note import update_obsidian_note
|
||||
|
||||
|
||||
def _seed(tmp_path, name="Nota.md", content="---\ntitle: A\nstatus: open\n---\n\nLinea original."):
|
||||
path = tmp_path / name
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_merge_frontmatter_conserva_claves_previas(tmp_path):
|
||||
# Golden path: set_frontmatter mergea sin perder claves no mencionadas.
|
||||
note = _seed(tmp_path)
|
||||
ret = update_obsidian_note(
|
||||
note, set_frontmatter={"status": "done", "closed": "2026-06-09"}
|
||||
)
|
||||
assert ret == note
|
||||
|
||||
fm = read_obsidian_note(note)["frontmatter"]
|
||||
assert fm["title"] == "A" # clave previa conservada
|
||||
assert fm["status"] == "done" # clave existente sobreescrita
|
||||
assert fm["closed"] == "2026-06-09" # clave nueva anadida
|
||||
|
||||
|
||||
def test_reemplazo_de_body(tmp_path):
|
||||
# Edge: body no None reemplaza por completo el cuerpo.
|
||||
note = _seed(tmp_path)
|
||||
update_obsidian_note(note, body="Cuerpo nuevo entero.")
|
||||
parsed = read_obsidian_note(note)
|
||||
assert parsed["body"].strip() == "Cuerpo nuevo entero."
|
||||
assert "Linea original" not in parsed["body"]
|
||||
# El frontmatter se conserva intacto.
|
||||
assert parsed["frontmatter"]["title"] == "A"
|
||||
|
||||
|
||||
def test_append_concatena_al_final(tmp_path):
|
||||
# Edge: append concatena tras el body existente con salto de linea.
|
||||
note = _seed(tmp_path)
|
||||
update_obsidian_note(note, append="Linea anadida.")
|
||||
body = read_obsidian_note(note)["body"]
|
||||
assert "Linea original." in body
|
||||
assert "Linea anadida." in body
|
||||
assert body.index("Linea original.") < body.index("Linea anadida.")
|
||||
|
||||
|
||||
def test_body_y_append_combinados(tmp_path):
|
||||
# Edge: append se aplica DESPUES del reemplazo de body.
|
||||
note = _seed(tmp_path)
|
||||
update_obsidian_note(note, body="Base.", append="Extra.")
|
||||
body = read_obsidian_note(note)["body"]
|
||||
assert "Base." in body
|
||||
assert "Extra." in body
|
||||
assert "Linea original" not in body
|
||||
assert body.index("Base.") < body.index("Extra.")
|
||||
|
||||
|
||||
def test_nota_inexistente_lanza_filenotfounderror(tmp_path):
|
||||
# Error path: actualizar una nota que no existe no la crea.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
update_obsidian_note(str(tmp_path / "no_existe.md"), body="x")
|
||||
|
||||
|
||||
def test_directorio_lanza_isadirectoryerror(tmp_path):
|
||||
# Error path: ruta a directorio.
|
||||
sub = tmp_path / "carpeta"
|
||||
sub.mkdir()
|
||||
with pytest.raises(IsADirectoryError):
|
||||
update_obsidian_note(str(sub), body="x")
|
||||
Reference in New Issue
Block a user