Skip to content

Leafdesk 0.5.4

Choose a tag to compare

@erhanerbas erhanerbas released this 17 Oct 12:44

Upgrading from 0.5.3

  • Migrations (0070–0077) run automatically when the container starts. Compose users
    don't need a new docker-compose.yml: this release adds no settings.
  • Every passage is embedded again once. Passages now start with where they are (see
    Changed), so their text changes and the embeddings model sees each of them once more after
    the upgrade: through the background sweep as people search, or all at once with
    pnpm search:index. On large workspaces with a paid embeddings provider, run it at a quiet time.
  • Migrations 0075 and 0077 each rewrite the page table once. 0075 adds the stored search
    vector (see Changed) and fills it for every page, 0077 adds the pieces of Chinese and Japanese
    text to it and keeps it to the first 80,000 characters of each page's text; on a server with
    many pages each takes a while (about 15 seconds for 60,000 pages with 2 KB of text each), and
    pages can't be read or written until it's done: upgrade at a quiet time (from source, run
    pnpm db:migrate then).

Added

  • Eleven more interface languages. Leafdesk now speaks Italian, Brazilian Portuguese, Polish,
    Russian, Ukrainian, Arabic, Hebrew, Persian, Japanese, Korean and Simplified Chinese, next to
    English, Turkish, German, Spanish and French: in the app, emails, built-in templates and the
    editor's menus, picked in My account → Language or taken from the browser. Arabic, Hebrew and
    Persian lay the whole app out right to left, and emails follow. Each paragraph of a page now
    follows the direction of its own text, so a right-to-left paragraph reads correctly in a
    left-to-right interface and the other way round; alignment runs with it too, so a right-to-left
    paragraph starts on the right without being aligned and the align buttons still put it on the side
    they show. An Arabic, Hebrew or Persian paragraph aligned right before this release now sits on
    the left, where its text ends: align it again. CSV import knows the new languages' title column
    names and yes/no words, quick add understands their date words (Japanese and Chinese, written
    without spaces, a character at a time: "来週の金曜日に打ち合わせ"), and the AI chat's search leaves out their
    question words. The new translations haven't been reviewed by native speakers yet; corrections are
    welcome.

  • Apps can't overwrite edits they haven't seen. get_page (MCP) and GET /pages/{id} now give
    the body's version, and update_page / PATCH /pages/{id} take it back as expected_version:
    the page changes only while its body still has that version. When someone edited it after the
    app read it, also in an editor that is open right now, nothing is written; MCP tells the app to
    read the page again, and the REST API answers 409 version_conflict with the current version.
    Body writes report the new version, so an app can make several changes in a row without reading
    the page in between. Comments don't change the version. Without expected_version writes work
    as before. Covered in scripts/mcp-e2e.ts.

  • The AI chat answers from attached PDFs and Word documents. The text of a PDF or .docx shown
    in a page's file block now goes into the page's semantic index, in passages that start with the file name and point
    at the file block, so the chat can answer from inside the document and its
    citation opens the page at the file. A document is read once and its text kept (migration
    0071, file_text); the same file uploaded again isn't read twice. Only the first 50 pages or
    200,000 characters of a document are read (the record says when it was cut), up to 20 documents
    per page, and files over 25 MB are left out; a scan with no text layer, or a damaged or locked
    file, adds nothing. Only files the page's readers may open through the page count, and search
    checks access to the page when it runs, as for the rest of its text. Existing pages are indexed
    again by the next sweep or pnpm search:index; their unchanged passages aren't embedded again.

  • Workspace health. Settings → Health (owners and members) looks over the pages you can open
    and lists what is worth tidying: links and mentions to pages in the trash or deleted for good,
    top-level pages nothing links to, pages with a title and no body, pages nobody edited for 3, 6,
    12 or 24 months, pairs of pages that say nearly the same thing, and database relations to rows in
    the trash or deleted. Duplicates are compared by the pages' embeddings, so they need an
    embeddings model on the server and AI on in the workspace; otherwise the report says they
    weren't checked. Each finding links to its page, and many have a one-click fix: restore the page
    a link leads to, link an orphan where another page already writes its title, move an empty page
    to the trash, or take a dead row out of a relation. Pages someone can't open are never named or
    counted, not even as a broken link. The same report is the new MCP tool check_workspace, which
    changes nothing and says which tools fix each finding. Covered in
    scripts/workspace-check-e2e.ts.

  • More filter conditions. Text, URL, email, phone and title filters (and formulas and rollups
    with a text result) gain Does not contain, which ignores case as Contains does and matches
    empty values. Numbers gain ≥ and ≤, and dates, created and edited times Is on or before and
    Is on or after, which match a date range when any of its days does, as Is before and Is
    after
    do. Selects and statuses gain Is any of and Is none of, and tags, relations and
    people Contains any of and Contains none of: one rule with several values, where "Me" counts
    for whoever looks. They work the same in the app, on published pages, in dashboards, for
    restricted properties and through the API, MCP (query_database, saved views) and the AI chat,
    as does_not_contain, gte, lte, is_any_of and is_none_of (the last two with a list of
    values). Saved views keep working unchanged.

  • AI changes to a page can be kept or undone one by one. After an AI app, the chat or an agent
    writes to a page body, the page shows a bar above the body with how many blocks changed and
    who changed them: Review opens the changes block by block with Keep and Undo for each,
    next to Keep all and Undo all. Undoing goes through the page's shared document, so editors
    open on the page follow at once, and Undo all saves a version first. Writes in a row are
    reviewed together; the bar goes away once every change is decided or someone edits the page.
    Only people who may edit the page see it (migration 0072).

  • Changing one block instead of the whole body. The chat and agents have a new edit_page
    tool that replaces, inserts after, appends or deletes blocks by id, and update_page (MCP) and
    PATCH /pages/{id} take the modes replace_block, insert_after and delete_block with a
    block_id. get_page / GET /pages/{id} give the ids with with_block_ids. Like every AI
    write, these save the page first and can be reviewed on the page. Agents may use edit_page on
    the rows they already may update.

  • Approval cards show what changes. Before the chat changes a page's text, the approval card
    shows the blocks it changes, adds and removes; for a row it shows each property's old value
    next to the new one, and the old title when it is renamed. Covered in scripts/ai-chat-e2e.ts
    and scripts/mcp-e2e.ts.

  • Table cells work from the keyboard and the clipboard. Click a cell (or move with the arrow
    keys) to make it the active cell; Shift with an arrow, Shift+click or a drag selects a range.
    Enter opens the active cell's editor and Esc brings you back to the cell, Delete clears the
    range. Copy puts the range on the clipboard as tab-separated text, the way exports write values,
    and pasting cells copied from a spreadsheet writes them from the active cell on: each value is
    read as the CSV import reads it (numbers like 1.234,5 or 15%, dates, yes/no, option and
    people names), options a select lacks are added, and rows past the last one are added in an
    ungrouped table. One pasted value fills the whole selected range. Cells that don't fit their
    column or that you may not change are left out and counted.

  • Row activity tells AI values apart. A value AI autofill filled in now reads as the AI's in
    the row's activity, naming who it was worked out for: "AI (for Ada) set Category to Billing",
    with a sparkle in place of a face. That person is whoever refreshed the row or updated the
    view's rows, or whose change of the row set the value off; for an automation's change, the
    person it acts as, its own entry for the change sitting right below. AI entries never merge into
    that person's own edits of the value. list_row_activity (MCP) and GET /rows/{id}/activity
    give actor: "ai", with the person in by. Needs migration 0074.

  • Agents read every row and write rows safely. query_database (MCP and POST /databases/{id}/query) now returns next_cursor and has_more: pass the cursor back with the
    same filters and sorts to read the next page, until it is null. Pages follow on from the last row
    returned, so rows added or removed between calls neither repeat nor go missing; a cursor from
    another query is refused. properties narrows each row to the values asked for.
    create_database_row(s) take match_by (the title or a text, number, email, phone, URL, select
    or status property) to upsert: a row with the same value is updated instead of a new one being
    added, so a retried import adds nothing twice, and each row says whether it was created or
    updated. A value two given rows share, or that several existing rows hold, is refused before
    anything is written. update_database_rows takes expect (refuses, writing nothing, unless
    exactly that many rows would change) and dry_run. Covered in scripts/row-safety-e2e.ts and
    scripts/mcp-e2e.ts.

  • Published pages can show databases from elsewhere. A published page shows the databases that
    lie under it; a dashboard or database view over a database elsewhere used to stay empty for
    visitors. The Publish tab now lists those databases, before and after publishing: tick Show on
    this page
    to show one there anyway, or move it under the page to publish it with it. A database
    shown this way appears only in the page's widgets and views, without links to it or its rows, and
    Duplicate leaves it out. It is hidden again as soon as the publisher can no longer see it or it
    goes to the trash (migration 0076).

Changed

  • Search stays fast in large workspaces. Full-text search (the search box, MCP and REST
    search, the AI chat, agents) now matches and ranks pages through an index of a vector Postgres
    keeps for each page (migration 0075, page.search_vector: the title's words weighted above the
    body's), and checks access only for the best pages until there are enough. The vector holds the
    first 1,000 characters of a title and the first 80,000 of a body (migration 0077), so a page of
    a few megabytes of distinct words still saves: words past that point of a huge page aren't
    found by full-text search. Before, every search
    read the text of every page in the workspace: in a test workspace of 60,000 pages a search for a
    rare word took 3.7 seconds and now takes under 60 ms, one for a word most pages have went from
    3.6 seconds to 0.4. A word now also matches as the start of a longer word ("kond" finds
    "kondenser"), in the body as in the title, so pages show up while a word is typed; the middle of
    a word no longer matches. Long pages no longer rank above short ones only for their length. The
    list of pages that write a page's title without linking to it uses the same index. Covered in
    scripts/search-e2e.ts.
  • Japanese and Chinese words are found inside sentences, Korean ones with a particle joined
    on.
    Chinese and Japanese write no spaces between words, so search took a whole sentence as one
    word and found a page only by its first characters: "会議" didn't find "明日の会議について". The
    search vector now also keeps every two-character piece of such text (migration 0077,
    cjk_bigrams, for the first 30,000 such characters within the first 80,000 of a page), and
    queries are cut the same way: a word is found anywhere, a single character
    as it is typed, and the pieces of a word only side by side ("東京都" doesn't find a page about
    "東京" and "京都"). Korean is written with spaces, so its words are still found by their start
    ("회의" finds "회의를", not "국회의원"). The AI chat's any-word search takes the words of such a
    question without particles and function words ("车在哪里" looks for "车"), and Korean words
    without a particle at their end. The list of pages that write a page's title without linking to
    it finds such titles inside sentences too, but not inside a longer word ("会議" isn't offered in
    "国会議員", "メモ" not in "メモリー"), so linking one never cuts a word apart. Covered in
    scripts/search-e2e.ts and scripts/backlinks-e2e.ts.
  • The AI chat gets the matching passage without an embeddings model too. A page the chat's
    search finds by its words now comes with the part of its text that has the most of them, about
    900 characters, where the model used to get one line around the first word unless the page was
    in the semantic index. Covered in scripts/ai-chat-e2e.ts and src/server/ai-tools.test.ts.
  • Semantic search knows where a passage is. Each indexed passage now carries the titles of
    the pages above its page, the page's own title and the heading it lies under (at most 200
    characters; a database row's path includes its database), so a passage under Sales › 2026 plan
    › Risks
    is found for questions about sales risks even when it never says "sales". A heading now
    starts a new passage. Renaming or moving a page indexes the pages under it again. Results and
    the AI chat show the passage without that path, since it can name pages the reader can't open.
    Pages are indexed again after upgrading (the chunking version went up).
  • The AI chat cites the right block for pages found by their words. A page that only the
    full-text search found reached the model as a short snippet without a block, so its answer
    cited the whole page and it often read the page again. It now gets the page's indexed passage
    with the most of the question's words, and the citation links to that block.
  • Long tables render only the rows in view. From 120 rows on, a table view renders the rows
    near the visible part of the page and stands in for the rest, so tables with thousands of rows
    open and scroll without lag. Grouped tables, sub-items, frozen columns and group calculations
    work as before.

Fixed

  • A page that can't be saved says so. When the database refused to save a page (as an
    oversized search index once did), the failure only reached the server log: creating a page with
    a body, the REST API, AI apps and imports went on as if the body had been written, and the editor
    kept showing the page as live. Such writes now fail with an error (500 internal_error from the
    REST API, an error result for AI apps), and the editor's status says "Not saved" until a later
    save goes through. A page created with a body that couldn't be saved stays, without the body.

  • AI autofill values count as row changes. A value AI filled in was saved past everything a
    row write sets off: semantic search didn't index it until something else changed the row, the
    row's activity didn't show it, and automations waiting for the property to change never ran.
    Values are now saved like any other change (for the person who asked for them), and only when
    they differ from the stored one. They start automations, except when an automation's change set
    them off: those count as the automation's own changes, which start none, so automations and AI
    values can't keep setting each other off. After an AI value is written, only the automatic AI
    values that read it update, and none twice in a row of updates.

    • Other AI values, and formulas over them, go into an AI property's prompt only when its settings
      name them (a {Property} placeholder, the translated property); summaries no longer take them
      in on their own. Values whose prompt changes this way are worked out again the next time their
      row changes.
    • Settings that would make an AI property read itself through other AI properties are refused
      with a message naming the property in the way. Covered in scripts/ai-e2e.ts.
  • Semantic search catches up when the way pages are cut into chunks changes. The index
    noticed pages that changed and a new embeddings model, but not a release that chunks pages
    differently: pages kept their old chunks until someone edited them, and pnpm search:index
    skipped them. The index now records the chunking it was built with (migration 0070), and pages
    built with another one count as stale for the sweep and pnpm search:index. Chunks whose text
    stays the same aren't embedded again. Covered in scripts/semantic-search-e2e.ts.

  • Edits of different properties of one row no longer undo each other. Editing a row's value
    wrote back the whole row as it was read a moment before, so a value someone else, an agent or
    an automation had just set in another property of the same row could quietly disappear. Single
    row edits now merge into the stored values the way bulk edits already did. Covered in
    scripts/database-e2e.ts.

  • Stopping the server no longer loses the last edits. On SIGINT/SIGTERM the server started the
    saves still waiting out their debounce but exited half a second later whether they had finished
    or not, so on a large page or a slow database up to the last ten seconds of typing could be lost.
    It now closes the editors' connections, waits until every open page is saved (at most eight
    seconds, inside Docker's ten-second grace period) and logs it when it had to stop waiting. A
    second Ctrl+C exits at once.

  • Deleted files no longer stay behind in storage. Deleting a workspace, an account or orphaned
    uploads removed the files' records first and their bytes afterwards, at best once: when storage
    refused or the server stopped midway, the bytes stayed with nothing pointing at them. The objects
    to delete are now queued in the same transaction as the records (migration 0073), and what
    storage refuses is tried again by the hourly file cleanup, waiting longer after each failure (up
    to a day). Covered in scripts/storage-deletion-e2e.ts.