Skip to content

ENG-2157 Decorate imported node titles in Obsidian from core_title - #1330

Open
sid597 wants to merge 3 commits into
mainfrom
eng-2157-decorate-imported-node-titles-in-obsidian-from-core_title
Open

ENG-2157 Decorate imported node titles in Obsidian from core_title#1330
sid597 wants to merge 3 commits into
mainfrom
eng-2157-decorate-imported-node-titles-in-obsidian-from-core_title

Conversation

@sid597

@sid597 sid597 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator
eng-2157.mp4

This is what we are following:
image
This PR is the bottom path of the diagram: decorate on import, Obsidian.

Obsidian's import used the incoming title verbatim, so a node published from Roam arrived carrying [[CLM]] - and ignored what the local vault calls that node type. This rebuilds the file name from core_title with the local node type's format, so the same node lands as CLM - sleep improves memory in a vault that formats claims that way. Fallback is the incoming title, used when core_title is absent (rows published before ENG-2153/2154), the local type has no format, or the format cannot be rebuilt from the core title alone (no {content} placeholder, or a second placeholder such as {Source} whose value the database does not hold). That last case is why Roam's shipped [[EVD]] - {content} - {Source} keeps the incoming title: substituting the empty string dropped the source from a name that used to carry it and left a dangling separator.

Three pieces:

  • The instance query in importNodes.ts reads the single JSON key by path (core_title:literal_content->>core_title), so the import-preview path that shares it stays free of the payload column. postgrest-js types that projection as string, but it is null for rows published before ENG-2153, so the ?? undefined is load-bearing.
  • Local node type resolution moves out of processFileContent into the per-node loop, because the local format has to be known before the file name is built. processFileContent now only writes the file and frontmatter. Its only error came from that resolution, so it returns a TFile instead of a result union.
  • The name build applies the format with decorateTitle, a pure helper in packages/database/src/lib because ENG-2156 needs the same transform in Roam. It returns null when the format cannot be rebuilt, and the caller falls back. I didn't reuse formatNodeName: it splits the compiled regex source on the first (.*?), so a multi-placeholder format produces a name ending in a literal (.*?). The placeholder pattern is exported as FORMAT_PLACEHOLDER and getDiscourseNodeFormatExpression now uses it, so decorate and match cannot drift apart.

decorateTitle is deterministic and one sanitizedFileName feeds both the create path and the rename guard, so once a file carries the decorated name, re-import and refresh leave it alone. Files imported before this change carry the incoming title, so their next refresh renames them once (and rewrites inbound wikilinks); that is the intended migration. A related fix falls out: Obsidian-origin nodes that lived in a subfolder used to be created at their full source path, skipping the decorated name, and a later refresh would then rename them. The create path now keeps the folders and replaces the last segment.

Merge order: nothing on main writes core_title yet (#1317 / #1318 are open). Merged alone, every coreTitle is undefined and the import keeps today's names; the decoration in the demo needs those producers. The subfolder create-path fix is live regardless.

Refactor parity (what moved and what changed on the way):

Old New Parity
fetchNodeTypeSchemasForInstancesMap<id, Schema> fetchNodeImportInfoForInstancesMap<id, {schema?, coreTitle?}> changed: an entry per visible instance, not only schema-resolved ones
early return on empty schemaIds / schema-fetch error fall through with schema: undefined changed: core titles survive a schema-fetch failure
node-type derivation inside processFileContent same code in the importSelectedNodes loop identical logic; errors go to console.error + continue instead of a result union
mapNodeTypeIdToLocal after the vault write before the vault write changed: a failed import can leave a created node type behind
processFileContent{file} | {error} TFile changed: the one error source moved out, the caller's result.file! is gone
create path sanitizePathForImport(contentFilePath) source folder + derived basename changed: create and refresh agree on the name

Tests for the helper live in packages/database (apps/obsidian has no test runner), so the ticket's test bullet is covered for the pure part and the importNodes wiring is not. That part is on the demo.

Known and deferred, each pre-existing and left as it was:

  • core_title is a bare literal_content key on both sides: CrossAppNode.coreTitle is the contract field (ENG-2153), the serialized key has no shared constant. The producers live on ENG-2153 Undecorate Roam node titles into core_title on publish #1317/ENG-2154 Undecorate Obsidian node titles into core_title on publish #1318 and this PR is based on main, so the constant is a follow-up once those land.
  • formatNodeName (local node creation) and decorateTitle (import) now disagree on multi-placeholder formats; converging them needs a ticket because formatNodeName's null doubles as form validation.
  • mapNodeTypeIdToLocal runs one my_concepts query per imported node, as it did inside processFileContent before; batching it is not this ticket.
  • A type created from a Roam schema keeps Roam's [[CLM]] - {content} format without going through checkInvalidChars, so it decorates as [[CLM]] - x.md; the incoming title carried the same brackets before this change.
  • The create path has never allocated a unique name when the target file exists; decorated names are unique whenever the incoming ones are (one placeholder, distinct type prefixes), so this change does not widen that gap.

Open in Devin Review

@linear-code

linear-code Bot commented Aug 23, 2026

Copy link
Copy Markdown

ENG-2157

@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
discourse-graph Ready Ready Preview Aug 24, 2026 2:50pm

Request Review

@sid597 sid597 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annotations on the non-obvious decisions in this diff.

};

export const fetchNodeTypeSchemasForInstances = async ({
type NodeInstanceImportInfo = {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NodeInstanceImportInfo nests the existing NodeTypeSchemaForInstance instead of flattening its fields. nodeTypeId and name only ever get set together from one schema row, so two independent optional fields would describe a state this code can't produce. The map now holds an entry for every visible instance rather than only the schema-resolved ones. That's what lets a core title reach the caller when the schema lookup comes back empty.

.from("my_concepts")
.select("source_local_id, schema_id")
.select(
"source_local_id, schema_id, core_title:literal_content->>core_title",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads the single JSON key by path instead of selecting the whole literal_content column. computeImportPreview shares this query and only wants node type names, so the preview path stays free of the jsonb payload. The key is the bare core_title that ENG-2153 writes.

nodeTypeId: row.source_local_id,
name: row.name,
});
if (schemaIds.length > 0) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema fetch moved inside an if instead of guarding two early returns. Both former early exits, no schema ids and a failed schema fetch, now fall through to the merge loop so core titles survive them. On a schema error the caller gets titles with no nodeTypeId, which it already guards for.

const schema = schemasById.get(row.schema_id);
if (schema) result.set(row.source_local_id, schema);
if (row.source_local_id === null) continue;
result.set(row.source_local_id, {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One write per instance instead of two passes over instanceRows. schema is genuinely undefined here when schema_id is null or the schema row isn't visible under RLS.

result.set(row.source_local_id, {
schema:
row.schema_id === null ? undefined : schemasById.get(row.schema_id),
coreTitle: row.core_title ?? undefined,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ?? undefined looks redundant because postgrest-js types a ->> projection as plain string. It doesn't model a JSON path extraction as nullable, and this one is. The key is absent on every row published before ENG-2153, so Postgres returns NULL and PostgREST sends null. I pinned the declared type down with a temporary Exact<A, B> assertion before relying on it, so the guard stays.

// Parse frontmatter from content (metadataCache is updated async and is
// often empty immediately after create/modify) and resolve the node type
// before any vault write, so a failed lookup leaves existing files untouched.
const { frontmatter } = parseFrontmatter(content);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This derivation moved up from processFileContent, and its comment came with it. We parse the raw content rather than metadataCache because the cache is often empty right after a write. Resolving before any vault write still means a failed lookup leaves existing files untouched.

continue;
}

const mappedNodeTypeId = await mapNodeTypeIdToLocal({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapNodeTypeIdToLocal now runs before the file is written rather than after. That's the point of the move, since the local node type has to exist before we can read its format. The function itself is unchanged. One consequence worth knowing: it can create a node type as a side effect, so a failure later in the loop can leave a type behind for a node that didn't import.

Comment thread apps/obsidian/src/utils/importNodes.ts Outdated

const localNodeType = getNodeTypeById(plugin, mappedNodeTypeId);
const coreTitle = nodeImportInfo?.coreTitle;
const titleForFileName =

@sid597 sid597 Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decoration. We rebuild the title from core_title using the local type's format, so a Roam-origin [[CLM]] - x lands as whatever this vault calls a claim. decorateTitle returns null when the format cannot be rebuilt from the core title alone: no {content} placeholder, or a second placeholder such as {Source} that the database has no value for. Roam's shipped Evidence format is that case, so an Evidence note keeps its incoming title rather than landing as [[EVD]] - x - with the source dropped. decorateTitle is pure, so a re-import or a refresh computes the same name and the rename guard below leaves the file alone.

contentFilePath && contentFilePath.includes("/")
? sanitizePathForImport(contentFilePath)
: `${sanitizedFileName}.md`;
? sanitizePathForImport(contentFilePath.replace(/\/[^/]*$/, ""))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obsidian-origin nodes that lived in a subfolder used to be created at their full source path, which skipped the decorated name entirely. A later refresh would then compare against the decorated basename and rename the file. Keeping the folders but replacing the last segment makes create and refresh agree.


for (const { nodeTypeId, name } of nodeTypeSchemasByInstance.values()) {
for (const { schema } of nodeImportInfoByInstance.values()) {
if (!schema) continue;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preview only wants node type names, so it skips entries whose schema didn't resolve. One check instead of two, since nesting makes the co-presence of nodeTypeId and name structural.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2b5f237de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1395 to +1397
const pathUnderImport = sourceFolder
? `${sourceFolder}/${sanitizedFileName}.md`
: `${sanitizedFileName}.md`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent decorated imports from overwriting filename collisions

When two selected nodes in the same source folder produce the same sanitizedFileName—for example, multi-placeholder titles with the same core_title after the other placeholders are erased—this assigns both nodes the same path. Because processFileContent treats any file already at that path as an update, the second import overwrites the first node's content and identity frontmatter while both are reported as successful. Allocate a unique path for new imports, as the existing-file rename path already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and the case you describe is closed by the placeholder gate in 94ea2de: a format with a placeholder other than {content} no longer decorates, so two nodes can only share a derived name when their incoming titles would have collided too (one placeholder, distinct type prefixes, unique source titles). The create path has never allocated a unique name when the target exists; that gap predates this PR and is listed in the description as deferred rather than fixed here.

@supabase

supabase Bot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

sid597 added 3 commits August 24, 2026 20:18
…annot fill

decorateTitle now returns null for formats without a {content} placeholder or
with placeholders such as {Source}: substituting the empty string dropped the
source from a Roam-format Evidence name and produced a title that no longer
matched the format. The Obsidian format-expression helper reuses the shared
placeholder pattern so decorate and match agree.
@sid597
sid597 force-pushed the eng-2157-decorate-imported-node-titles-in-obsidian-from-core_title branch from 94ea2de to 907869b Compare August 24, 2026 14:48
@sid597
sid597 requested review from maparent and mdroidian August 24, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant