Trilium: automatiza tus tareas

Introducción

Hasta ahora hemos visto cómo instalar cómo instalar Trilium, sincronizarlo entre dispositivosorganizar el árbol con clones y workspaces y usar plantillas y metadatos para que cada nota nazca con una estructura. El siguiente paso es mover y actualizar tus notas de manera automática en lugar de hacerlo a mano.

En este artículo te voy a enseñar dos automatizaciones que uso a diario: 

  • Mi sistema de tareas en el workspace Personal, donde un script “vigila” los cambios en los atributos de las notas ( attributeChanged) y se encarga de moverlas entre carpetas, sincronizarlas con el diario y generar tareas recurrentes.
  • Un conjunto de scripts en el workspace Homelab que generan informes HTML con el estado de los contenedores, IPs y puertos utilizados.

La idea no es que copies mi sistema tal cual, sino que veas hasta dónde puedes llegar combinando atributos, plantillas y scripts.

Mi sistema de tareas en el workspace Personal 

En mi workspace Personal tengo un árbol de tareas bastante clásico: una rama `TODO` con subcarpetas para tareas sin fecha, planificadas, pendientes y hechas, y, por otro lado, mi diario personal organizado por año/mes/día.

Árbol de tareas en Trilium

Cada tarea es una nota normal con varios atributos, entre ellos: 

  • todoDate: fecha planificada.
  • doneDate: fecha en la que termino la tarea.
  • repetir: número de días para que la tarea se vuelva a repetir (tareas recurrentes).
  • cssClass: para pintar la tarea de un color u otro en el árbol.
  • Otros como area, tipo, tag, etc., para una mejor clasificación.

Atributos de una tarea en Trilium

La gracia es que yo ya no muevo esas notas a mano. Cuando cambio alguno de esos atributos, un script se dispara y decide qué hacer con la tarea.

El script attributeChanged

Se ejecuta automáticamente cada vez que cambio un atributo en una nota y se encarga de mover, clonar y generar tareas según su estado y fecha.

El script solo actúa si:

  • El cambio se hace sobre los atributos todoDate (fecha planificada), doneDate (fecha de finalización) o repetir (intervalo de frecuencia).
  • La tarea pertenece al Workspace Personal (#workspaceRoot=personal).

Mover las tareas por el árbol TODO / Pendientes / Done

Lo primero que hace el script después de las comprobaciones es decidir en qué carpeta debería estar la tarea.

A grandes rasgos, sigue estas reglas:

  • Si la tarea tiene doneDate (es decir, está terminada):
    • Se asegura de que esté dentro de la carpeta Done.
    • La saca de la carpeta Pendientes.
    • La quita de cualquier subcarpeta de TODO donde pudiera seguir como “abierta”.
  • Si la tarea no está terminada pero tiene **todoDate**:
    • Si la fecha ya ha pasado o está dentro de la semana actual, la coloca (o mantiene) en Pendientes.
    • Si la fecha es de semanas futuras, la saca de Pendientes: sigue planificada, pero no debe aparecer aún como “urgente”.
  • Si la tarea no está terminada y no tiene fecha:
    • Se asegura de que no esté en Done.
    • También la saca de Pendientes para que no se mezcle con las urgentes.

La clave es que tú solo te preocupas de poner o quitar doneDate y todoDate. El resto (mover notas entre carpetas) lo hace Trilium por ti.

Script attributeChanged moviendo tareas

Sincronizar tareas con el diario personal

La segunda parte del script se encarga de sincronizar las tareas con el diario, para que cada día tengas una vista de lo que hay que hacer.

El proceso es este:

  • El script localiza la raíz del calendario en el workspace personal usando una nota marcada con #workspaceCalendarRoot=personal.
  • A partir de una fecha (todoDate o doneDate), genera (si hace falta) la estructura año → mes → día dentro del diario.
  • Dentro del día correspondiente crea o mueve clones de la tarea original:
    • Si la tarea tiene todoDate y no está hecha:
      • Crea un clon en la nota del día planificado.
      • Le pone el prefijo TODO - al título del clon.
    • Si la tarea pasa a hecha (doneDate existe):
      • Mueve el clon al día en que se terminó.
      • Cambia el prefijo a DONE -.
  • Si cambias la fecha planificada, el script borra los clones antiguos y los vuelve a crear en la nueva fecha para que no se acumule basura en el calendario.

Sincronización de tareas con el diario en Trilium

Esto hace que puedas revisar tu día desde el diario y ver una lista de tareas TODO y DONE de un vistazo.

Tareas recurrentes con el atributo repetir

El tercer bloque del script maneja las tareas recurrentes usando el atributo repetir.

La idea es sencilla:

  • Si una tarea tiene repetir=7 y se marca como hecha (doneDate):
    • El script calcula la próxima fecha sumando esos 7 días a la fecha de finalización.
    • Crea una nueva tarea con el mismo título (limpiando el prefijo TODO/DONE), en la carpeta original de la tarea (por ejemplo, Ciclismo, Casa, etc.).
    • Le aplica la plantilla de tareas personales (buscando una nota con #workspaceTemplate #contexto=personal).
    • Copia atributos importantes (area, tipo, repetir).
    • Le pone un nuevo todoDate con esa próxima fecha calculada.

Tareas recurrentes en Trilium

Tú marcas la tarea como hecha, y Trilium se encarga de “crear la siguiente” para dentro de X días. Perfecto para cosas tipo mantenimiento del coche, limpieza recurrente, copias de seguridad, etc.

Colorear tareas con cssClass

El script también cambia el atributo cssClass según si la tarea está hecha o no:

  • Si está terminada (doneDate existe), pone cssClass=done.
  • Si no está terminada, cssClass=todo.

Con eso puedes hacer un CSS para que las tareas se vean de diferente color. Por ejemplo:

span.fancytree-node.todo .fancytree-title {
    color: red !important;
}

span.fancytree-node.done .fancytree-title {
    color: green !important;
}

De esta forma , tus tareas abiertas son de un color rojo y las terminadas en verde.

Tablero Personal

En el día a día me interesa más ver un tablero que una carpeta con listas.

Para eso uso una nota de tipo búsqueda a la que llamo Tablero Personal. Esta nota no guarda tareas fijas, sino que es el resultado de una consulta que muestra todas las notas que encajan con ciertos criterios.

La cadena de búsqueda que uso en los parámetros de la nota es esta:

#contexto="personal" (note.parents.title="Pendientes" OR note.parents.title="Sin fecha")

Tablero personal en Trilium con búsqueda guardada

Con esto consigo una vista de mis tareas activas sin importar en qué carpeta estén dentro de mi rama personal. Cada vez que cambio fechas o estados el tablero se actualiza solo.

El script completo

Así quedaría el script completo:

if (!["todoDate", "doneDate", "repetir"].includes(api.originEntity.name)) {
    return;
}

const note = api.originEntity.getNote();
if (note.isDeleted) return;

const ancestors = note.getAncestors();
const workspaceNote = ancestors.find(n => n.hasLabel('workspaceRoot'));
if (!workspaceNote) return;

const workspaceValue = workspaceNote.getLabelValue('workspaceRoot');

if (workspaceValue !== 'personal') {
    return;
}

const flagAttr = '_scriptRunning';
if (note.hasLabel(flagAttr)) return;
note.addLabel(flagAttr);

try {

const doneRootNote   = api.searchForNote(`#taskDoneRoot=${workspaceValue}`);
const todoRootNote   = api.searchForNote(`#taskTodoRoot=${workspaceValue}`);
const pendientesNote = api.searchForNote(`#taskPendientesRoot=${workspaceValue}`) || todoRootNote;

if (!doneRootNote || !todoRootNote) {
    api.log(`Falta taskDoneRoot o taskTodoRoot para workspace: ${workspaceValue}`);
    return;
}

const todoDate = note.getLabelValue('todoDate');
const doneDate = note.getLabelValue('doneDate');

function isWithinExpectedRange(date) {
    if (!date) return true;
    const year = parseInt(date.substr(0, 4));
    return year >= 2010 && year < 2050;
}

if (!isWithinExpectedRange(todoDate) || !isWithinExpectedRange(doneDate)) {
    api.log(`Fechas fuera de rango: ${todoDate}, ${doneDate}`);
    return;
}

const isTaskDone = !!doneDate;

// -------------------- Helpers --------------------
const calRoot = api.searchForNote(`#workspaceCalendarRoot=${workspaceValue}`);

function isCalendarParent(p) {
    if (!p.hasLabel("dateNote")) return false;
    return calRoot && p.getAncestors().some(a => a.noteId === calRoot.noteId);
}

function isInsideTodoTree(parentNote) {
    if (parentNote.noteId === todoRootNote.noteId) return true;
    return parentNote.getAncestors().some(a => a.noteId === todoRootNote.noteId);
}

const realParents = note.getParentNotes().filter(p => !isCalendarParent(p));

const originalParent = realParents.find(p => {
    if (p.noteId === doneRootNote.noteId) return false;
    if (p.noteId === pendientesNote.noteId) return false;
    return isInsideTodoTree(p);
}) || realParents.find(p => {
    if (p.noteId === doneRootNote.noteId) return false;
    return !isCalendarParent(p);
}) || todoRootNote;

function isDatePending(dateStr) {
    if (!dateStr) return false;
    const taskDate = new Date(`${dateStr}T12:00:00`);
    const today = new Date();
    today.setHours(12, 0, 0, 0);
    if (taskDate < today) return true;
    const currentDayOfWeek = today.getDay();
    const daysUntilSunday = currentDayOfWeek === 0 ? 0 : 7 - currentDayOfWeek;
    const endOfWeek = new Date(today);
    endOfWeek.setDate(today.getDate() + daysUntilSunday);
    return taskDate <= endOfWeek;
}

// -------------------- DONE/TODO/PENDIENTES --------------------
if (isTaskDone) {
    api.toggleNoteInParent(true, note.noteId, doneRootNote.noteId);
    api.toggleNoteInParent(false, note.noteId, pendientesNote.noteId);
    for (const parent of realParents) {
        if (isInsideTodoTree(parent)) {
            api.toggleNoteInParent(false, note.noteId, parent.noteId);
        }
    }
} else {
    api.toggleNoteInParent(false, note.noteId, doneRootNote.noteId);
    if (todoDate && isDatePending(todoDate)) {
        api.toggleNoteInParent(true, note.noteId, pendientesNote.noteId);
    } else {
        api.toggleNoteInParent(false, note.noteId, pendientesNote.noteId);
    }
}

note.toggleLabel(isTaskDone,  "cssClass", "done");
note.toggleLabel(!isTaskDone, "cssClass", "todo");

// -------------------- Diario --------------------
function getDayNoteInWorkspace(dateStr) {
    if (!dateStr) return null;
    const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio",
                   "Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
    const DIAS  = ["Domingo","Lunes","Martes","Miércoles","Jueves","Viernes","Sábado"];
    const [year, monthNum, dayNum] = dateStr.split('-');
    const date = new Date(`${dateStr}T12:00:00`);
    const monthIndex = parseInt(monthNum, 10) - 1;
    const month    = `${monthNum} - ${MESES[monthIndex]}`;
    const dayTitle = `${dayNum} - ${DIAS[date.getDay()]}`;
    if (!calRoot) return null;

    function getOrCreate(parentNote, title, labelName, labelValue) {
        let child = parentNote.getChildNotes().find(n => n.title === title);
        if (!child) {
            child = api.createTextNote(parentNote.noteId, title, "").note;
            child.addLabel(labelName, labelValue);
            child.addLabel("sorted");
        }
        return child;
    }

    function getOrCreateDay(parentNote, title, labelValue) {
        let child = parentNote.getChildNotes().find(n => n.title === title);
        if (!child) {
            child = api.createTextNote(parentNote.noteId, title, "").note;
            child.addLabel("dateNote", labelValue);
            const template = api.getNoteWithLabel("dayNoteTemplate");
            if (template) child.addRelation("template", template.noteId);
        }
        return child;
    }

    const yearNote  = getOrCreate(calRoot,  year,  "yearNote",  year);
    const monthNote = getOrCreate(yearNote, month, "monthNote", `${year}-${monthNum}`);
    const dayNote   = getOrCreateDay(monthNote, dayTitle, `${year}-${monthNum}-${dayNum}`);
    return dayNote.noteId;
}

if (calRoot) {
    for (const parent of note.getParentNotes()) {
        if (isCalendarParent(parent)) {
            api.toggleNoteInParent(false, note.noteId, parent.noteId);
        }
    }
}

if (isTaskDone && doneDate) {
    const doneDayId = getDayNoteInWorkspace(doneDate);
    if (doneDayId) {
        const cleanTitle = note.title.replace(/^(TODO|DONE) - /, "");
        if (note.title !== cleanTitle) { note.title = cleanTitle; note.save(); }
        api.toggleNoteInParent(true, note.noteId, doneDayId);
        const branch = note.getBranches().find(b => b.parentNoteId === doneDayId);
        if (branch) { branch.prefix = "DONE"; branch.save(); }
    }
} else if (!isTaskDone && todoDate) {
    const todoDayId = getDayNoteInWorkspace(todoDate);
    if (todoDayId) {
        const cleanTitle = note.title.replace(/^(TODO|DONE) - /, "");
        if (note.title !== cleanTitle) { note.title = cleanTitle; note.save(); }
        api.toggleNoteInParent(true, note.noteId, todoDayId);
        const branch = note.getBranches().find(b => b.parentNoteId === todoDayId);
        if (branch) { branch.prefix = "TODO"; branch.save(); }
    }
} else {
    const cleanTitle = note.title.replace(/^(TODO|DONE) - /, "");
    if (note.title !== cleanTitle) { note.title = cleanTitle; note.save(); }
}

// -------------------- Recurrentes --------------------
const repetir = note.getLabelValue("repetir");

if (isTaskDone && repetir) {
    const dias = parseInt(repetir);
    if (isNaN(dias) || dias <= 0) {
        api.log(`Valor de "repetir" no válido: ${repetir}`);
    } else {
        const baseDate = new Date(`${doneDate}T12:00:00`);
        baseDate.setDate(baseDate.getDate() + dias);
        const pad = n => String(n).padStart(2, "0");
        const nextDateStr = `${baseDate.getFullYear()}-${pad(baseDate.getMonth() + 1)}-${pad(baseDate.getDate())}`;
        const cleanTitle = note.title.replace(/^(TODO|DONE) - /, "");
        const newTask = api.createTextNote(originalParent.noteId, cleanTitle, "").note;
        const plantilla = api.searchForNote(`#workspaceTemplate #contexto=personal`);
        if (plantilla) newTask.addRelation("template", plantilla.noteId);
        newTask.addLabel("repetir", String(dias));
        const area = note.getLabelValue("area");
        if (area) newTask.addLabel("area", area);
        const tipo = note.getLabelValue("tipo");
        if (tipo) newTask.addLabel("tipo", tipo);
        newTask.addLabel("todoDate", nextDateStr);
        api.log(`Tarea repetida: "${cleanTitle}" → próxima en ${dias} días: ${nextDateStr}`);
    }
}

} finally {
    note.removeLabel(flagAttr);
}

Scripts de Homelab: inventario y estados de contenedores

En mi workspace Homelab tengo documentado el servidor, las VLAN, los contenedores y los runbooks. Cada contenedor es una nota con atributos como tipo=contenedor, estado=activo/pausado, iplocal o puertoInt.

Inventario de contenedores en homelab de Trilium

Para no tener que revisar nota por nota qué puertos tengo ocupados, tengo un script que recorre todos los contenedores y me genera un mini-dashboard en HTML.

Dashboard de contenedores en Trilium

La lógica del script es:

  1. Localiza la carpeta Inventario dentro de Homelab.
  2. Busca (o crea) una nota de reporte.
  3. Busca todas las notas que tengan #tipo=contenedor y #estado=activo o pausado.
  4. Mapea estos datos, los ordena por IP y por puerto, y construye una tabla HTML.
  5. Guarda esa tabla dentro de la nota de reporte.

A partir de ahí, solo tengo que abrir esa nota para ver un listado siempre actualizado con:

  • El nombre del contenedor (con enlace a su nota).
  • Un icono de estado (🟢 o 🔴).
  • Su IP local.
  • El puerto interno usado.

Informe de puertos internos en Trilium

Tengo otros dos scripts casi idénticos que filtran los contenedores los activos o los pausados.

Script completo

Al igual que antes, te dejo el script completo:

// Script Muestra activos y pausados (incluso sin puerto asignado)

// 1. Busca la carpeta Inventario
const inventarioNote = api.searchForNote('note.title = "Inventario" note.ancestors.title = "Homelab"');
if (!inventarioNote) {
    throw new Error('No encuentro la carpeta Inventario dentro de Homelab.');
}

// 2. Busca/crea nota de reporte
let reportNote = api.searchForNote('#reportePuertos=true');
if (!reportNote) {
    const created = api.createTextNote(inventarioNote.noteId, 'Puertos internos usados', '');
    reportNote = created.note;
    reportNote.setLabel('reportePuertos', 'true');
}

// 3. Busca activos Y pausados
const activos = api.searchForNotes('#tipo=contenedor #estado=activo');
const pausados = api.searchForNotes('#tipo=contenedor #estado=pausado');
const todos = [...activos, ...pausados];

// 4. Procesa datos (maneja puertoInt=null)
const rows = todos
    .map(n => {
        let pInt = n.getLabelValue('puertoInt');
        const ip = n.getLabelValue('iplocal') || '-';
        const estado = n.getLabelValue('estado') || 'desconocido';
        
        // Convierte null/"null" a cadena vacía, pero ahora lo permitiremos
        if (pInt === null || pInt === 'null' || !pInt) pInt = '';
        
        return {
            noteId: n.noteId,
            title: n.title,
            iplocal: ip,
            estado: estado,
            puertoInt: pInt
        };
    })
    // ELIMINADO: .filter(r => r.puertoInt !== '') -> Ahora mostramos TODOS, tengan puerto o no
    .sort((a, b) => {
        // 1. Primero ordenamos por IP
        if (a.iplocal !== b.iplocal) {
            return a.iplocal.localeCompare(b.iplocal, undefined, { numeric: true });
        }
        
        // 2. Manejo especial para ordenar puertos (si están vacíos, van al final o al principio)
        const pA = a.puertoInt === '' ? Infinity : parseInt(a.puertoInt);
        const pB = b.puertoInt === '' ? Infinity : parseInt(b.puertoInt);
        
        return pA - pB;
    });

// 5. Genera tabla HTML
let html = '<p>Reporte (Activos+pausados): <strong>' + new Date().toLocaleString('es-ES') + '</strong></p>';
html += '';

// 6. Guarda
if (reportNote.setContent) {
    reportNote.setContent(html);
} else {
    reportNote.content = html;
    reportNote.save();
}

api.log('✅ Reporte: ' + rows.length + ' contenedores mostrados');

Conclusión

Te he mostrado que, una vez que tienes workspaces, plantillas y atributos, puedes ir una un paso lejos: automatizar tu forma de trabajar.

En mi caso en concreto:

  • En el workspace personal, dejo que un script “vigile” los cambios en mis tareas y se encargue de moverlas, clonarlas en el diario y crear la siguiente repetición cuando toca.
  • Y, en el homelab para convierto mis notas de contenedores en un pequeño dashboard con IPs, puertos y estados.

En el próximo artículo veremos como utilizo Trilium para escribir y publicar en el blog.

Espero que te haya gustado, pasa un buen día. 🐧

Comentarios en Mastodon

Cargando respuestas…

Responder en Mastodon