Migrating from Logseq to Trilium
Here's how I migrated my 500-something page knowledge graph/base/whatever from Logseq to Trilium. Just want the script? Download trilium-import.py.
Importing Logseq into Trilium is not streamlined. You can zip up parts of your Logseq directory and import them through the Trilium app, but the notes will retain Logseq's bullet point structure and document links will break. Block-level meta for things like Cards will be inlined into the bullet point. Document links are replaced with the text "missing note." The content comes over but overall it's a mess.
Trilium itself recommends upload_md_folder from trilium-py, but this is
only marginally better than the manual import. Linked documents retain their
names instead of saying "missing note" but they are not links. This still didn't
feel like a good starting point.
logseq2trilium did a good job of formatting my Logseq graph for Trilium. It splits bullet points into paragraphs, which is a better fit for Trilium. The links come across properly. Images linked in documents work after importing. The journal files also have a better name.
Cleaning up the import
Once imported, everything was tucked away inside its own top-level folder. I needed the 3 years of journal files to fit in with Trilium's Journal: year folders, with nested day-of-month folders, with journals inside.
As a first step, I selected all the journal notes in the Trilium app and manually applied the template "Day Note Template" using Bulk Actions > Add Relation: Template.
I wrote a script trilium-import.py that uses trilium-py to clean up the imported journal files:
- Create a set of year notes with nested month notes (this does not happen lazily)
- Grab all imported child notes from a hardcoded parent note ID
- Update titles to match the format "10 - Monday" (day of month, weekday)
- Add journal note attributes
- Move notes to correct spot in the hierarchy
The import felt much more manageable after getting the journal sorted, and I could get to the more enjoyable part of reorganising and settings icons.
trilium-import.py
from trilium_py.client import ETAPI
import calendar
import pprint
TOKEN_ID = "8EnnBezl2LMP_oa4bkgdp9oG/CYRjyl+MihBIJqbB5HcwipVomH+ovSU="
JOURNAL_ID = "TPz1EGZtugH3"
IMPORTED_JOURNAL_PARENT_ID = "Sy22f3PKxxka"
# Years to create (note that stop value is not included)
YEARS = range(2022, 2026)
def has_attr(note, attribute):
return any(attr["name"] == attribute for attr in note["attributes"])
class Graph:
def __init__(self):
self.notes = {}
server_url = 'http://localhost:37840'
self.ea = ETAPI(server_url, TOKEN_ID)
"""Find a note by its title."""
def find(self, parent, name):
res = self.ea.search_note(
search="note.title = '%s'" % (name),
ancestorNoteId=parent,
fastSearch=False,
orderBy=["title"],
limit=1,
)
if res['results']:
return res['results'][0]
"""Find single note by its ID, or raise an exception."""
def find_by_id(self, id):
res = self.ea.search_note(
search="note.noteId = '%s'" % (id),
fastSearch=False,
)
if res['results']:
return res['results'][0]
raise RuntimeException("could not find note with id %s" % (id))
"""Find a note by its name or create it."""
def find_or_create(self, parentNoteId, title):
note = self.find(parentNoteId, title)
if not note:
note = self.create_empty_note(parentNoteId, title)
return note
"""Create an empty note."""
def create_empty_note(self, parent, name):
res = self.ea.create_note(
parentNoteId=parent,
title=str(name),
type="text",
content="..."
)
# For some reason, we can't use an empty content above.
self.ea.update_note_content(res["note"]["noteId"], "")
return res["note"]
"""Set a label attribute if the attribute does not already exist."""
def add_label_if_missing(self, note, name, value):
if not has_attr(note, name):
self.ea.create_attribute(
noteId=note["noteId"],
type='label',
name=name,
value=value,
isInheritable=False
)
"""Create the requested year folders, if they do not exist.
Set missing attributes for yearNote and sorting.
"""
def populate_years(self):
# It would be better to create this lazily based on incoming notes.
for year in YEARS:
yearNote = self.find_or_create(JOURNAL_ID, year)
self.add_label_if_missing(yearNote, "yearNote", year)
self.add_label_if_missing(yearNote, "sorted", "")
self.notes[year] = (yearNote, {})
self.populate_months(year)
"""Create month folders for a year, if they do not exist.
Set missing attributes for monthNote and sorting.
"""
def populate_months(self, year):
(yearNote, months) = self.notes[year]
for i in range(1, 13):
title = "%02d - %s" % (i, calendar.month_name[i])
monthNote = self.find_or_create(yearNote["noteId"], title)
self.add_label_if_missing(monthNote, "monthNote", "%d-%02d" % (year, i))
self.add_label_if_missing(monthNote, "sorted", "")
months[i] = monthNote
"""Generator to return all children of a given note."""
def imported_journals(self, parentId):
results = self.ea.search_note(
search="note.noteId = '%s'" % (parentId),
fastSearch=False,
orderBy=["title"],
)
if len(results['results']) == 0:
raise RuntimeException("cannot find parent journal by ID")
parentJournal = results['results'][0]
for childId in parentJournal['childNoteIds']:
yield self.find_by_id(childId)
"""Given a journal note, fix titles and add journal attributes, and
relocate to Journal tree."""
def relocate(self, note):
title = note["title"]
(date, weekday) = title.split(" ")
(year, month, day) = date.split("-")
year = int(year)
month = int(month)
day = int(day)
newTitle = "%02d - %s" % (day, weekday)
print("{0} -> {1}".format(title, newTitle))
self.add_label_if_missing(note, 'dateNote', '%04d-%02d-%02d' % (year, month, day))
self.ea.patch_note(noteId=note["noteId"], title=newTitle)
monthNote = self.notes[year][1][month]
self.ea.create_branch(
noteId=note["noteId"],
parentNoteId=monthNote["noteId"]
)
self.ea.delete_branch(
branchId="%s_%s" % (IMPORTED_JOURNAL_PARENT_ID, note["noteId"])
)
if __name__ == "__main__":
graph = Graph()
print("Populating years... ", end='')
graph.populate_years()
print("done.")
for note in graph.imported_journals(IMPORTED_JOURNAL_PARENT_ID):
graph.relocate(note)