cuando termines y verifica que esté todo subido

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 01:33:35 +02:00
parent e1e9bb7499
commit a90b7443e4
20 changed files with 1855 additions and 2 deletions
+10
View File
@@ -14,6 +14,12 @@ from .search_obsidian_notes import search_obsidian_notes
from .list_obsidian_vaults import list_obsidian_vaults
from .create_obsidian_vault import create_obsidian_vault
# CRUD de vaults REGISTRADOS en la app de escritorio Obsidian (obsidian.json)
from .register_obsidian_vault import register_obsidian_vault
from .list_registered_obsidian_vaults import list_registered_obsidian_vaults
from .unregister_obsidian_vault import unregister_obsidian_vault
from .open_obsidian_vault import open_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
@@ -34,6 +40,10 @@ __all__ = [
"search_obsidian_notes",
"list_obsidian_vaults",
"create_obsidian_vault",
"register_obsidian_vault",
"list_registered_obsidian_vaults",
"unregister_obsidian_vault",
"open_obsidian_vault",
"slugify_obsidian_name",
"extract_obsidian_embeds",
"resolve_obsidian_embed",
@@ -0,0 +1,52 @@
---
name: list_registered_obsidian_vaults
kind: function
lang: py
domain: obsidian
version: "1.0.0"
purity: impure
signature: "def list_registered_obsidian_vaults(config_path: str = '') -> list"
description: "Lista los vaults que la app de escritorio Obsidian conoce, leyendo la clave 'vaults' de ~/.config/obsidian/obsidian.json. Opera sobre la config de la app, NO inspecciona el filesystem como list_obsidian_vaults. Devuelve una entrada por vault registrado con id, path, open y ts, ordenada por path. Lista vacia si el archivo no existe."
tags: [obsidian, vault, list, config, desktop-app, obsidian-json]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os"]
params:
- name: config_path
desc: "ruta al obsidian.json de la app; vacio usa ~/.config/obsidian/obsidian.json"
output: "lista de dicts {'id','path','open','ts'} una por vault registrado, ordenada por path; lista vacia si el archivo no existe o no tiene vaults"
tested: true
tests:
- "lista vaults registrados ordenados por path"
- "config inexistente devuelve lista vacia"
test_file_path: "python/functions/obsidian/list_registered_obsidian_vaults_test.py"
file_path: "python/functions/obsidian/list_registered_obsidian_vaults.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from obsidian import list_registered_obsidian_vaults
vaults = list_registered_obsidian_vaults(config_path="") # ~/.config/obsidian/obsidian.json
for v in vaults:
print(v["id"], v["open"], v["path"])
# 3f9a1c0b7e2d4a86 True /home/enmanuel/vaults/osint
# a1b2c3d4e5f60718 False /home/enmanuel/vaults/personal
```
## Cuando usarla
Cuando necesites saber que vaults tiene dados de alta la app de escritorio Obsidian (no los que existan en disco): para auditar, desregistrar uno concreto (`unregister_obsidian_vault`), o comprobar si un vault ya esta registrado antes de abrirlo. Distinta de `list_obsidian_vaults`, que escanea un directorio del filesystem en busca de carpetas con `.obsidian/`.
## Gotchas
- **Lee la config de la app** (I/O impuro): refleja el estado del archivo `~/.config/obsidian/obsidian.json` en ese instante, no el filesystem real. Un vault listado aqui puede ya no existir en disco (entrada huerfana).
- **Single-instance**: si Obsidian esta abierto y se han registrado vaults despues de arrancar, la lista en disco puede divergir de la que la app tiene en memoria.
- El campo `ts` es epoch en milisegundos (no segundos). Convertir con `datetime.fromtimestamp(ts/1000)` si lo necesitas como fecha.
- Devuelve lista vacia (no lanza) cuando el archivo no existe — util para arranques en limpio.
@@ -0,0 +1,87 @@
"""Lista los vaults registrados en la app de escritorio Obsidian.
Lee el archivo de configuracion de la app (~/.config/obsidian/obsidian.json) y
devuelve las entradas de la clave "vaults". NO inspecciona el sistema de archivos
del vault — solo refleja lo que la app Obsidian conoce.
Funcion impura: lee un archivo del disco (la config de la app).
"""
import json
import os
def _default_config_path() -> str:
"""Ruta por defecto del obsidian.json de la app de escritorio Obsidian."""
return os.path.expanduser("~/.config/obsidian/obsidian.json")
def _load_config(config_path: str) -> dict:
"""Carga obsidian.json. Si no existe devuelve la estructura vacia base."""
if not os.path.exists(config_path):
return {"vaults": {}}
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"obsidian config is not a JSON object: {config_path}")
if "vaults" not in data or not isinstance(data.get("vaults"), dict):
data["vaults"] = {}
return data
def list_registered_obsidian_vaults(config_path: str = "") -> list:
"""Devuelve los vaults registrados en la app Obsidian, ordenados por path.
Args:
config_path: ruta al obsidian.json de la app. Vacio -> default
~/.config/obsidian/obsidian.json.
Returns:
Lista de dicts {"id", "path", "open", "ts"}, una por vault registrado,
ordenada por "path". Lista vacia si el archivo no existe o no tiene vaults.
Raises:
ValueError: si el obsidian.json existente no es un objeto JSON valido.
OSError: si la lectura del archivo falla por I/O.
"""
cfg_path = config_path or _default_config_path()
if not os.path.exists(cfg_path):
return []
data = _load_config(cfg_path)
out = []
for vid, entry in data["vaults"].items():
if not isinstance(entry, dict):
continue
out.append(
{
"id": vid,
"path": entry.get("path", ""),
"open": entry.get("open", False),
"ts": entry.get("ts", 0),
}
)
out.sort(key=lambda e: e["path"])
return out
if __name__ == "__main__":
import tempfile
import time
tmp = tempfile.mkdtemp()
cfg = os.path.join(tmp, "obsidian.json")
payload = {
"extra": "preservar",
"vaults": {
"aaaaaaaaaaaaaaaa": {"path": "/z/Zeta", "ts": int(time.time() * 1000), "open": True},
"bbbbbbbbbbbbbbbb": {"path": "/a/Alpha", "ts": int(time.time() * 1000), "open": False},
},
}
with open(cfg, "w", encoding="utf-8") as f:
json.dump(payload, f)
rows = list_registered_obsidian_vaults(config_path=cfg)
assert [r["path"] for r in rows] == ["/a/Alpha", "/z/Zeta"], rows
assert list_registered_obsidian_vaults(config_path=os.path.join(tmp, "nope.json")) == []
print("list_registered_obsidian_vaults smoke OK")
@@ -0,0 +1,36 @@
"""Tests para list_registered_obsidian_vaults."""
import json
from list_registered_obsidian_vaults import list_registered_obsidian_vaults
def test_lista_vaults_registrados_ordenados_por_path(tmp_path):
# Golden path: dos vaults registrados se devuelven ordenados por path.
cfg = str(tmp_path / "obsidian.json")
with open(cfg, "w", encoding="utf-8") as f:
json.dump(
{
"extra": "preservar",
"vaults": {
"aaaaaaaaaaaaaaaa": {"path": "/z/Zeta", "ts": 200, "open": True},
"bbbbbbbbbbbbbbbb": {"path": "/a/Alpha", "ts": 100, "open": False},
},
},
f,
)
rows = list_registered_obsidian_vaults(config_path=cfg)
assert len(rows) == 2
assert [r["path"] for r in rows] == ["/a/Alpha", "/z/Zeta"]
alpha = rows[0]
assert alpha["id"] == "bbbbbbbbbbbbbbbb"
assert alpha["open"] is False
assert alpha["ts"] == 100
def test_config_inexistente_devuelve_lista_vacia(tmp_path):
# Edge: archivo de config ausente -> lista vacia, sin excepcion.
cfg = str(tmp_path / "no_existe.json")
assert list_registered_obsidian_vaults(config_path=cfg) == []
@@ -0,0 +1,67 @@
---
name: open_obsidian_vault
kind: function
lang: py
domain: obsidian
version: "1.0.0"
purity: impure
signature: "def open_obsidian_vault(vault: str, register_if_missing: bool = True, launch: bool = True, config_path: str = '') -> dict"
description: "Abre un vault en la app de escritorio Obsidian construyendo el URI obsidian://open?vault=<name> (name = basename del path o el propio nombre, URL-encodeado) y lanzandolo desacoplado via xdg-open. Si recibe una ruta existente no registrada y register_if_missing=True, la registra antes componiendo register_obsidian_vault (open=True). Con launch=False construye el URI sin lanzar nada (util en tests)."
tags: [obsidian, vault, open, launch, uri, desktop-app, obsidian-json]
uses_functions: ["register_obsidian_vault_py_obsidian"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "subprocess", "urllib.parse"]
params:
- name: vault
desc: "ruta absoluta a un vault o su nombre (basename); si es ruta existente el URI usa su basename"
- name: register_if_missing
desc: "si True (default) y vault es una ruta existente no registrada, la registra (open=True) antes de abrir"
- name: launch
desc: "si True (default) lanza la app via xdg-open desacoplado; False solo construye el URI sin lanzar (tests)"
- name: config_path
desc: "ruta al obsidian.json de la app pasada a register_obsidian_vault; vacio usa ~/.config/obsidian/obsidian.json"
output: "dict con vault (arg original), uri (obsidian://open?vault=<name>), name (usado en el URI), launched (bool) y registered_now (bool, True si se registro en esta llamada)"
tested: true
tests:
- "registra si falta y construye uri sin lanzar gui"
- "por nombre construye uri sin registrar"
test_file_path: "python/functions/obsidian/open_obsidian_vault_test.py"
file_path: "python/functions/obsidian/open_obsidian_vault.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from obsidian import open_obsidian_vault
# Abrir por ruta: registra si falta y lanza Obsidian
res = open_obsidian_vault(
vault="/home/enmanuel/vaults/osint",
register_if_missing=True,
launch=True,
config_path="",
)
print(res["uri"]) # obsidian://open?vault=osint
print(res["registered_now"])# True si no estaba registrado
# Solo construir el URI (sin lanzar GUI, p.ej. para inspeccion o tests)
res = open_obsidian_vault("Mi Vault", launch=False)
print(res["uri"]) # obsidian://open?vault=Mi%20Vault
```
## Cuando usarla
Cuando quieras abrir un vault en la app de escritorio Obsidian desde codigo o un agente: tras crear (`create_obsidian_vault`) y registrar (`register_obsidian_vault`) un vault, esta funcion lo abre en un solo paso (registra si hace falta + lanza el URI). Para construir el URI sin lanzar la GUI, usa `launch=False`.
## Gotchas
- **Single-instance (lo importante)**: Obsidian es de instancia unica. Si ya hay una instancia corriendo con una config vieja en memoria, el URI `obsidian://open?vault=<name>` puede responder **"unable to find a vault"** para un vault recien registrado, hasta reiniciar la app. Si registras y abres en caliente, reinicia Obsidian para que recargue `obsidian.json`.
- **Lanza un proceso externo** (`xdg-open`, I/O impuro) de forma desacoplada (`start_new_session=True`, stdio a DEVNULL): no bloquea ni captura salida. `launch=False` evita lanzar nada.
- El nombre del URI es el **basename** del path (Obsidian resuelve vaults por nombre, no por ruta completa). Dos vaults con el mismo basename en rutas distintas pueden colisionar en el URI; Obsidian abrira el que tenga ese nombre en su config.
- Necesita un entorno grafico: hereda `DISPLAY`/`WAYLAND_DISPLAY` del entorno. En una sesion headless `xdg-open` puede fallar silenciosamente (el proceso se lanza pero no abre nada).
- Si `register_if_missing=True` y el vault es una ruta existente nueva, **escribe la config de la app** (via `register_obsidian_vault`, con su backup `.bak`).
@@ -0,0 +1,107 @@
"""Abre un vault en la app de escritorio Obsidian via el esquema obsidian://.
Construye el URI `obsidian://open?vault=<name>` y lanza la app para abrir ese vault.
Opcionalmente lo registra antes en la config de la app si recibe una ruta existente
no registrada (compone register_obsidian_vault del grupo obsidian).
Funcion impura: puede escribir la config de la app (al registrar) y lanza un proceso
externo (xdg-open) de forma desacoplada para abrir el URI en Obsidian.
"""
import os
import subprocess
import urllib.parse
from obsidian import register_obsidian_vault
def open_obsidian_vault(
vault: str,
register_if_missing: bool = True,
launch: bool = True,
config_path: str = "",
) -> dict:
"""Abre un vault en la app Obsidian construyendo y lanzando un URI obsidian://.
Args:
vault: ruta absoluta a un vault o su nombre (basename). Si es una ruta
existente, el nombre del URI es su basename; si no parece una ruta
existente se trata como nombre tal cual.
register_if_missing: si True (default) y vault es una ruta existente no
registrada en la app, la registra (open=True) antes de abrir.
launch: si True (default) lanza la app via `xdg-open <uri>` desacoplado.
Si False (util en tests) NO lanza nada, solo construye el URI.
config_path: ruta al obsidian.json de la app. Vacio -> default
~/.config/obsidian/obsidian.json. Se pasa a register_obsidian_vault.
Returns:
dict con:
- vault: el argumento original recibido.
- uri: el URI obsidian://open?vault=<name> construido.
- name: nombre del vault usado en el URI (basename o el propio vault).
- launched: True si se lanzo xdg-open.
- registered_now: True si se registro el vault en esta llamada.
Raises:
OSError: si el lanzamiento del proceso o el registro fallan por I/O.
"""
registered_now = False
is_path = os.path.sep in vault or vault.startswith("~")
abs_path = os.path.abspath(os.path.expanduser(vault)) if is_path else ""
# Si es una ruta existente, opcionalmente registrarla y usar su basename.
if abs_path and os.path.isdir(abs_path):
name = os.path.basename(abs_path.rstrip(os.path.sep))
if register_if_missing:
res = register_obsidian_vault(abs_path, open=True, config_path=config_path)
registered_now = bool(res.get("registered"))
elif is_path:
# Parece ruta pero no existe: usar su basename como nombre.
name = os.path.basename(abs_path.rstrip(os.path.sep))
else:
# Es un nombre, no una ruta.
name = vault
uri = "obsidian://open?vault=" + urllib.parse.quote(name)
launched = False
if launch:
env = dict(os.environ)
subprocess.Popen(
["xdg-open", uri],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
env=env,
)
launched = True
return {
"vault": vault,
"uri": uri,
"name": name,
"launched": launched,
"registered_now": registered_now,
}
if __name__ == "__main__":
import tempfile
tmp = tempfile.mkdtemp()
cfg = os.path.join(tmp, "obsidian.json")
vault = os.path.join(tmp, "Mi Vault")
os.makedirs(vault, exist_ok=True)
r = open_obsidian_vault(vault, launch=False, config_path=cfg)
assert r["launched"] is False, r
assert r["registered_now"] is True, r
assert r["name"] == "Mi Vault", r
assert r["uri"] == "obsidian://open?vault=Mi%20Vault", r
# Por nombre, sin lanzar ni registrar.
r2 = open_obsidian_vault("MiVaultPorNombre", launch=False, config_path=cfg)
assert r2["uri"] == "obsidian://open?vault=MiVaultPorNombre", r2
assert r2["registered_now"] is False, r2
print("open_obsidian_vault smoke OK")
@@ -0,0 +1,40 @@
"""Tests para open_obsidian_vault."""
import json
from open_obsidian_vault import open_obsidian_vault
def test_registra_si_falta_y_construye_uri_sin_lanzar_gui(tmp_path):
# Golden path: ruta existente no registrada -> se registra y se construye el
# URI con el basename URL-encodeado, sin lanzar la app (launch=False).
cfg = str(tmp_path / "obsidian.json")
vault = tmp_path / "Mi Vault"
vault.mkdir()
res = open_obsidian_vault(str(vault), launch=False, config_path=cfg)
assert res["launched"] is False
assert res["registered_now"] is True
assert res["name"] == "Mi Vault"
assert res["uri"] == "obsidian://open?vault=Mi%20Vault"
assert res["vault"] == str(vault)
# Quedo registrado en la config de la app.
with open(cfg, "r", encoding="utf-8") as f:
data = json.load(f)
paths = [e["path"] for e in data["vaults"].values()]
assert str(vault) in paths
# Registrado con open=True por open_obsidian_vault.
entry = next(e for e in data["vaults"].values() if e["path"] == str(vault))
assert entry["open"] is True
def test_por_nombre_construye_uri_sin_registrar(tmp_path):
# Edge: vault es un nombre (no ruta) -> URI directo, sin tocar la config.
cfg = str(tmp_path / "obsidian.json")
res = open_obsidian_vault("MiVaultPorNombre", launch=False, config_path=cfg)
assert res["uri"] == "obsidian://open?vault=MiVaultPorNombre"
assert res["name"] == "MiVaultPorNombre"
assert res["registered_now"] is False
assert res["launched"] is False
@@ -0,0 +1,64 @@
---
name: register_obsidian_vault
kind: function
lang: py
domain: obsidian
version: "1.0.0"
purity: impure
signature: "def register_obsidian_vault(vault_path: str, open: bool = False, config_path: str = '') -> dict"
description: "Registra un vault en la app de escritorio Obsidian anadiendo su entrada a la clave 'vaults' de ~/.config/obsidian/obsidian.json. Opera sobre la config de la app, NO sobre el filesystem del vault. Idempotente por path: no duplica entradas, solo actualiza el flag 'open' si difiere. Genera id hex de 16 chars (secrets.token_hex(8)) y ts en epoch ms. Hace backup .bak antes de escribir y preserva las demas claves del JSON y los demas vaults."
tags: [obsidian, vault, register, config, desktop-app, obsidian-json]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "secrets", "shutil", "time"]
params:
- name: vault_path
desc: "ruta al directorio del vault; se normaliza a ruta absoluta antes de registrar"
- name: open
desc: "flag 'open' de la entrada (si Obsidian deberia abrirlo al arrancar); default False"
- name: config_path
desc: "ruta al obsidian.json de la app; vacio usa ~/.config/obsidian/obsidian.json"
output: "dict con id (hex 16), path (abs), registered (bool, True si entrada nueva), already (bool, True si ya existia), open (bool final), config_path y backup_path (ruta del .bak o '' si no habia archivo previo)"
tested: true
tests:
- "registra entrada nueva"
- "segundo registro mismo path no duplica y devuelve already"
- "actualiza flag open de entrada existente"
- "preserva claves extra de nivel superior"
- "crea config y directorios si no existe"
- "hace backup bak antes de sobreescribir"
test_file_path: "python/functions/obsidian/register_obsidian_vault_test.py"
file_path: "python/functions/obsidian/register_obsidian_vault.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from obsidian import register_obsidian_vault
res = register_obsidian_vault(
vault_path="/home/enmanuel/vaults/osint",
open=True,
config_path="", # usa ~/.config/obsidian/obsidian.json
)
print(res["id"], res["registered"], res["already"])
# 3f9a1c0b7e2d4a86 True False (primera vez)
# 3f9a1c0b7e2d4a86 False True (segunda vez, ya registrado)
```
## Cuando usarla
Cuando quieras que la app de escritorio Obsidian "conozca" un vault y lo muestre en su selector de vaults (la pantalla de bienvenida / `Open another vault`). Usala despues de `create_obsidian_vault` (que solo crea la carpeta en disco) para dar de alta ese vault en la app. Es el paso previo natural a `open_obsidian_vault`.
## Gotchas
- **Escribe la config de la app** (I/O impuro) en `~/.config/obsidian/obsidian.json` y crea un backup `.bak` antes de sobreescribir. Si la entrada ya existe y nada cambia, NO reescribe ni genera backup.
- **NO crea la carpeta del vault**: solo registra la entrada. Si la ruta no existe en disco, Obsidian la mostrara pero no podra abrirla. Usa `create_obsidian_vault` para crear la carpeta.
- **Single-instance**: si Obsidian ya esta corriendo, tiene la lista de vaults cargada en memoria; el vault recien registrado puede no aparecer hasta reiniciar la app.
- Idempotente **por path absoluto**: dos rutas que resuelven al mismo path absoluto se consideran el mismo vault.
- Preserva las demas claves de nivel superior del JSON (`appVersionLastUsed`, `updateDisabled`, etc.) y los demas vaults; solo toca la entrada de este vault.
@@ -0,0 +1,154 @@
"""Registra un vault en la app de escritorio Obsidian.
Opera sobre el archivo de configuracion de la app (~/.config/obsidian/obsidian.json),
NO sobre el sistema de archivos del vault. Anade (o actualiza) la entrada del vault
en la clave "vaults" de ese JSON para que Obsidian lo conozca en su lista de vaults.
Funcion impura: lee y escribe el archivo de configuracion de la app y crea un
backup .bak antes de sobreescribir. Idempotente: si ya existe una entrada con ese
path no la duplica, solo actualiza su flag "open" si difiere.
"""
import json
import os
import secrets
import shutil
import time
def _default_config_path() -> str:
"""Ruta por defecto del obsidian.json de la app de escritorio Obsidian."""
return os.path.expanduser("~/.config/obsidian/obsidian.json")
def _load_config(config_path: str) -> dict:
"""Carga obsidian.json. Si no existe devuelve la estructura vacia base."""
if not os.path.exists(config_path):
return {"vaults": {}}
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"obsidian config is not a JSON object: {config_path}")
if "vaults" not in data or not isinstance(data.get("vaults"), dict):
data["vaults"] = {}
return data
def _save_config(config_path: str, data: dict) -> str:
"""Escribe obsidian.json haciendo backup .bak previo si ya existia.
Crea los directorios padre que falten. Devuelve la ruta del backup creado
(cadena vacia si no habia archivo previo que respaldar).
"""
parent = os.path.dirname(config_path)
if parent:
os.makedirs(parent, exist_ok=True)
backup_path = ""
if os.path.exists(config_path):
backup_path = config_path + ".bak"
shutil.copy2(config_path, backup_path)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return backup_path
def register_obsidian_vault(
vault_path: str,
open: bool = False,
config_path: str = "",
) -> dict:
"""Registra un vault en la lista de vaults de la app Obsidian.
Args:
vault_path: ruta al directorio del vault. Se normaliza a ruta absoluta.
open: valor del flag "open" de la entrada del vault (si Obsidian deberia
abrirlo al arrancar). Por defecto False.
config_path: ruta al obsidian.json de la app. Vacio -> default
~/.config/obsidian/obsidian.json.
Returns:
dict con:
- id: id hex de 16 chars de la entrada (existente o recien creada).
- path: ruta absoluta normalizada del vault.
- registered: True si se creo una entrada nueva.
- already: True si ya existia una entrada con ese path.
- open: flag "open" final de la entrada.
- config_path: ruta del obsidian.json usado.
- backup_path: ruta del backup .bak escrito (vacio si no habia archivo previo).
Raises:
ValueError: si el obsidian.json existente no es un objeto JSON valido.
OSError: si la lectura/escritura del archivo falla por I/O.
"""
cfg_path = config_path or _default_config_path()
abs_path = os.path.abspath(os.path.expanduser(vault_path))
data = _load_config(cfg_path)
vaults = data["vaults"]
# Idempotencia: buscar entrada existente por path.
existing_id = None
for vid, entry in vaults.items():
if isinstance(entry, dict) and entry.get("path") == abs_path:
existing_id = vid
break
if existing_id is not None:
entry = vaults[existing_id]
changed = entry.get("open") != open
if changed:
entry["open"] = open
backup_path = _save_config(cfg_path, data) if changed else ""
return {
"id": existing_id,
"path": abs_path,
"registered": False,
"already": True,
"open": entry.get("open", open),
"config_path": cfg_path,
"backup_path": backup_path,
}
# Entrada nueva.
new_id = secrets.token_hex(8)
while new_id in vaults:
new_id = secrets.token_hex(8)
vaults[new_id] = {
"path": abs_path,
"ts": int(time.time() * 1000),
"open": open,
}
backup_path = _save_config(cfg_path, data)
return {
"id": new_id,
"path": abs_path,
"registered": True,
"already": False,
"open": open,
"config_path": cfg_path,
"backup_path": backup_path,
}
if __name__ == "__main__":
import tempfile
tmp = tempfile.mkdtemp()
cfg = os.path.join(tmp, "obsidian.json")
vault = os.path.join(tmp, "MiVault")
os.makedirs(vault, exist_ok=True)
r1 = register_obsidian_vault(vault, open=True, config_path=cfg)
assert r1["registered"] is True and r1["already"] is False, r1
assert r1["open"] is True, r1
assert len(r1["id"]) == 16, r1
r2 = register_obsidian_vault(vault, open=True, config_path=cfg)
assert r2["already"] is True and r2["registered"] is False, r2
assert r2["id"] == r1["id"], (r1, r2)
assert os.path.isfile(cfg + ".bak") is False or True # backup solo si cambio
print("register_obsidian_vault smoke OK")
@@ -0,0 +1,120 @@
"""Tests para register_obsidian_vault."""
import json
import os
from register_obsidian_vault import register_obsidian_vault
def _read(cfg):
with open(cfg, "r", encoding="utf-8") as f:
return json.load(f)
def test_registra_entrada_nueva(tmp_path):
# Golden path: registra un vault nuevo en una config inexistente.
cfg = str(tmp_path / "obsidian.json")
vault = tmp_path / "MiVault"
vault.mkdir()
res = register_obsidian_vault(str(vault), open=True, config_path=cfg)
assert res["registered"] is True
assert res["already"] is False
assert res["open"] is True
assert len(res["id"]) == 16
assert res["path"] == str(vault)
data = _read(cfg)
assert res["id"] in data["vaults"]
entry = data["vaults"][res["id"]]
assert entry["path"] == str(vault)
assert entry["open"] is True
assert isinstance(entry["ts"], int) and entry["ts"] > 0
def test_segundo_registro_mismo_path_no_duplica_y_devuelve_already(tmp_path):
# Edge: idempotencia por path. La segunda llamada no crea otra entrada.
cfg = str(tmp_path / "obsidian.json")
vault = tmp_path / "MiVault"
vault.mkdir()
r1 = register_obsidian_vault(str(vault), open=True, config_path=cfg)
r2 = register_obsidian_vault(str(vault), open=True, config_path=cfg)
assert r2["already"] is True
assert r2["registered"] is False
assert r2["id"] == r1["id"]
data = _read(cfg)
assert len(data["vaults"]) == 1
def test_actualiza_flag_open_de_entrada_existente(tmp_path):
# Edge: misma ruta pero flag open distinto -> actualiza y reescribe.
cfg = str(tmp_path / "obsidian.json")
vault = tmp_path / "MiVault"
vault.mkdir()
register_obsidian_vault(str(vault), open=False, config_path=cfg)
r2 = register_obsidian_vault(str(vault), open=True, config_path=cfg)
assert r2["already"] is True
assert r2["open"] is True
data = _read(cfg)
assert data["vaults"][r2["id"]]["open"] is True
def test_preserva_claves_extra_de_nivel_superior(tmp_path):
# Edge: claves ajenas a "vaults" deben sobrevivir a la escritura.
cfg = str(tmp_path / "obsidian.json")
with open(cfg, "w", encoding="utf-8") as f:
json.dump(
{
"appVersionLastUsed": "1.5.3",
"updateDisabled": True,
"vaults": {
"0000000000000000": {"path": "/otro/Vault", "ts": 111, "open": False}
},
},
f,
)
vault = tmp_path / "Nuevo"
vault.mkdir()
register_obsidian_vault(str(vault), open=True, config_path=cfg)
data = _read(cfg)
assert data["appVersionLastUsed"] == "1.5.3"
assert data["updateDisabled"] is True
# La entrada previa de otro vault se conserva.
assert "0000000000000000" in data["vaults"]
assert len(data["vaults"]) == 2
def test_crea_config_y_directorios_si_no_existe(tmp_path):
# Edge: la config y sus directorios padre no existen y se crean.
cfg = str(tmp_path / "sub" / "dir" / "obsidian.json")
vault = tmp_path / "MiVault"
vault.mkdir()
res = register_obsidian_vault(str(vault), config_path=cfg)
assert res["registered"] is True
assert os.path.isfile(cfg)
# Sin archivo previo no hay backup.
assert res["backup_path"] == ""
def test_hace_backup_bak_antes_de_sobreescribir(tmp_path):
# Edge: una segunda escritura sobre config existente genera backup .bak.
cfg = str(tmp_path / "obsidian.json")
v1 = tmp_path / "V1"
v1.mkdir()
v2 = tmp_path / "V2"
v2.mkdir()
register_obsidian_vault(str(v1), config_path=cfg) # crea config
res = register_obsidian_vault(str(v2), config_path=cfg) # ahora si hay backup
assert res["backup_path"] == cfg + ".bak"
assert os.path.isfile(cfg + ".bak")
@@ -0,0 +1,58 @@
---
name: unregister_obsidian_vault
kind: function
lang: py
domain: obsidian
version: "1.0.0"
purity: impure
signature: "def unregister_obsidian_vault(vault_ref: str, config_path: str = '') -> dict"
description: "Desregistra un vault de la app de escritorio Obsidian quitando su entrada de la clave 'vaults' de ~/.config/obsidian/obsidian.json. Opera sobre la config de la app: NO borra la carpeta del vault en disco, solo hace que Obsidian deje de conocerlo. Acepta vault_ref como id exacto (hex 16) o como ruta (se normaliza a absoluta y se compara con path). Hace backup .bak antes de escribir y preserva el resto del JSON."
tags: [obsidian, vault, unregister, config, desktop-app, obsidian-json]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "shutil"]
params:
- name: vault_ref
desc: "id exacto de la entrada (hex 16 chars) O ruta al vault (se normaliza a absoluta y se compara con 'path'); primero intenta match por id, luego por path"
- name: config_path
desc: "ruta al obsidian.json de la app; vacio usa ~/.config/obsidian/obsidian.json"
output: "dict con removed (bool), id (de la entrada quitada o ''), path (de la entrada quitada o ''), config_path y backup_path (ruta del .bak o '')"
tested: true
tests:
- "desregistra por path"
- "desregistra por id"
- "preserva resto del json al quitar entrada"
- "ref inexistente devuelve removed false"
test_file_path: "python/functions/obsidian/unregister_obsidian_vault_test.py"
file_path: "python/functions/obsidian/unregister_obsidian_vault.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from obsidian import unregister_obsidian_vault
# Por ruta (se normaliza a absoluta)
res = unregister_obsidian_vault("/home/enmanuel/vaults/viejo", config_path="")
print(res["removed"], res["id"]) # True 3f9a1c0b7e2d4a86
# O por id exacto
unregister_obsidian_vault("a1b2c3d4e5f60718", config_path="")
```
## Cuando usarla
Cuando quieras que la app de escritorio Obsidian deje de mostrar un vault en su selector sin tocar los archivos del vault en disco: limpiar entradas obsoletas, quitar un vault movido a otra ruta, o sanear `obsidian.json`. Para listar las entradas y obtener sus ids usa `list_registered_obsidian_vaults`.
## Gotchas
- **NO borra la carpeta del vault**: solo elimina la entrada de la config de la app. Los archivos `.md` y el `.obsidian/` del vault siguen en disco.
- **Escribe la config de la app** (I/O impuro) y crea backup `.bak` antes de sobreescribir, pero **solo si encontro la entrada** (`removed=True`). Si no la encuentra, no escribe ni hace backup.
- Resolucion de `vault_ref`: primero intenta match exacto por id; si no, normaliza el ref a ruta absoluta y compara con `path`. Un id que coincide por azar con un path nunca pasara: el match por id va primero.
- **Single-instance**: si Obsidian esta corriendo, sigue teniendo el vault en memoria hasta reiniciar; el desregistro solo afecta al archivo de config.
- Preserva las demas claves de nivel superior del JSON y los demas vaults.
@@ -0,0 +1,151 @@
"""Desregistra un vault de la app de escritorio Obsidian.
Quita la entrada del vault de la clave "vaults" del archivo de configuracion de la
app (~/.config/obsidian/obsidian.json). NO borra la carpeta del vault en disco: solo
hace que la app Obsidian deje de conocerlo. Preserva el resto del JSON intacto.
Funcion impura: lee y escribe la config de la app y hace backup .bak antes de
sobreescribir.
"""
import json
import os
import shutil
def _default_config_path() -> str:
"""Ruta por defecto del obsidian.json de la app de escritorio Obsidian."""
return os.path.expanduser("~/.config/obsidian/obsidian.json")
def _load_config(config_path: str) -> dict:
"""Carga obsidian.json. Si no existe devuelve la estructura vacia base."""
if not os.path.exists(config_path):
return {"vaults": {}}
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"obsidian config is not a JSON object: {config_path}")
if "vaults" not in data or not isinstance(data.get("vaults"), dict):
data["vaults"] = {}
return data
def _save_config(config_path: str, data: dict) -> str:
"""Escribe obsidian.json haciendo backup .bak previo si ya existia.
Devuelve la ruta del backup creado (cadena vacia si no habia archivo previo).
"""
parent = os.path.dirname(config_path)
if parent:
os.makedirs(parent, exist_ok=True)
backup_path = ""
if os.path.exists(config_path):
backup_path = config_path + ".bak"
shutil.copy2(config_path, backup_path)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return backup_path
def unregister_obsidian_vault(vault_ref: str, config_path: str = "") -> dict:
"""Quita un vault de la lista de vaults conocidos por la app Obsidian.
Args:
vault_ref: id exacto de la entrada (hex de 16 chars) O una ruta al vault.
Si parece una ruta se normaliza a absoluta y se compara con "path".
Primero se intenta match por id; si no hay, por path.
config_path: ruta al obsidian.json de la app. Vacio -> default
~/.config/obsidian/obsidian.json.
Returns:
dict con:
- removed: True si se quito una entrada, False si no se encontro.
- id: id de la entrada quitada ("" si no se encontro).
- path: path de la entrada quitada ("" si no se encontro).
- config_path: ruta del obsidian.json usado.
- backup_path: ruta del backup .bak escrito ("" si no se escribio nada).
Raises:
ValueError: si el obsidian.json existente no es un objeto JSON valido.
OSError: si la lectura/escritura del archivo falla por I/O.
"""
cfg_path = config_path or _default_config_path()
not_found = {
"removed": False,
"id": "",
"path": "",
"config_path": cfg_path,
"backup_path": "",
}
if not os.path.exists(cfg_path):
return not_found
data = _load_config(cfg_path)
vaults = data["vaults"]
target_id = None
# 1) Match directo por id.
if vault_ref in vaults:
target_id = vault_ref
else:
# 2) Match por path normalizado.
abs_ref = os.path.abspath(os.path.expanduser(vault_ref))
for vid, entry in vaults.items():
if isinstance(entry, dict) and entry.get("path") == abs_ref:
target_id = vid
break
if target_id is None:
return not_found
removed_path = ""
entry = vaults.get(target_id)
if isinstance(entry, dict):
removed_path = entry.get("path", "")
del vaults[target_id]
backup_path = _save_config(cfg_path, data)
return {
"removed": True,
"id": target_id,
"path": removed_path,
"config_path": cfg_path,
"backup_path": backup_path,
}
if __name__ == "__main__":
import tempfile
import time
tmp = tempfile.mkdtemp()
cfg = os.path.join(tmp, "obsidian.json")
payload = {
"extra": "preservar",
"vaults": {
"aaaaaaaaaaaaaaaa": {"path": "/a/Alpha", "ts": int(time.time() * 1000), "open": True},
"bbbbbbbbbbbbbbbb": {"path": "/b/Beta", "ts": int(time.time() * 1000), "open": False},
},
}
with open(cfg, "w", encoding="utf-8") as f:
json.dump(payload, f)
r = unregister_obsidian_vault("/a/Alpha", config_path=cfg)
assert r["removed"] is True and r["id"] == "aaaaaaaaaaaaaaaa", r
r2 = unregister_obsidian_vault("bbbbbbbbbbbbbbbb", config_path=cfg)
assert r2["removed"] is True and r2["path"] == "/b/Beta", r2
with open(cfg, "r", encoding="utf-8") as f:
final = json.load(f)
assert final["extra"] == "preservar", final
assert final["vaults"] == {}, final
assert unregister_obsidian_vault("nope", config_path=cfg)["removed"] is False
print("unregister_obsidian_vault smoke OK")
@@ -0,0 +1,83 @@
"""Tests para unregister_obsidian_vault."""
import json
from unregister_obsidian_vault import unregister_obsidian_vault
def _write_cfg(cfg):
with open(cfg, "w", encoding="utf-8") as f:
json.dump(
{
"extra": "preservar",
"appVersionLastUsed": "1.5.3",
"vaults": {
"aaaaaaaaaaaaaaaa": {"path": "/a/Alpha", "ts": 100, "open": True},
"bbbbbbbbbbbbbbbb": {"path": "/b/Beta", "ts": 200, "open": False},
},
},
f,
)
def _read(cfg):
with open(cfg, "r", encoding="utf-8") as f:
return json.load(f)
def test_desregistra_por_path(tmp_path):
# Golden path: quita la entrada cuya 'path' coincide con la ruta dada.
cfg = str(tmp_path / "obsidian.json")
_write_cfg(cfg)
res = unregister_obsidian_vault("/a/Alpha", config_path=cfg)
assert res["removed"] is True
assert res["id"] == "aaaaaaaaaaaaaaaa"
assert res["path"] == "/a/Alpha"
assert res["backup_path"] == cfg + ".bak"
data = _read(cfg)
assert "aaaaaaaaaaaaaaaa" not in data["vaults"]
assert "bbbbbbbbbbbbbbbb" in data["vaults"]
def test_desregistra_por_id(tmp_path):
# Edge: quita la entrada por su id hex exacto.
cfg = str(tmp_path / "obsidian.json")
_write_cfg(cfg)
res = unregister_obsidian_vault("bbbbbbbbbbbbbbbb", config_path=cfg)
assert res["removed"] is True
assert res["id"] == "bbbbbbbbbbbbbbbb"
assert res["path"] == "/b/Beta"
data = _read(cfg)
assert "bbbbbbbbbbbbbbbb" not in data["vaults"]
def test_preserva_resto_del_json_al_quitar_entrada(tmp_path):
# Edge: las demas claves y vaults sobreviven al desregistro.
cfg = str(tmp_path / "obsidian.json")
_write_cfg(cfg)
unregister_obsidian_vault("/a/Alpha", config_path=cfg)
data = _read(cfg)
assert data["extra"] == "preservar"
assert data["appVersionLastUsed"] == "1.5.3"
assert "bbbbbbbbbbbbbbbb" in data["vaults"]
assert len(data["vaults"]) == 1
def test_ref_inexistente_devuelve_removed_false(tmp_path):
# Error path: ni id ni path coinciden -> removed False, sin reescritura.
cfg = str(tmp_path / "obsidian.json")
_write_cfg(cfg)
res = unregister_obsidian_vault("/no/existe", config_path=cfg)
assert res["removed"] is False
assert res["id"] == ""
assert res["backup_path"] == ""
data = _read(cfg)
assert len(data["vaults"]) == 2