-
Notifications
You must be signed in to change notification settings - Fork 83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tana to Obsidian importer #333
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import { FormatImporter } from '../format-importer'; | ||
import { ImportContext } from '../main'; | ||
import { Notice } from 'obsidian'; | ||
import { TanaGraphImporter } from './tana/tana-import'; | ||
|
||
export class TanaJSONImporter extends FormatImporter { | ||
init() { | ||
this.addFileChooserSetting('Tana (.json)', ['json']); | ||
} | ||
|
||
async import(progress: ImportContext) { | ||
let { files } = this; | ||
if (files.length === 0) { | ||
new Notice('Please pick at least one file to import.'); | ||
return; | ||
} | ||
|
||
const importer = new TanaGraphImporter(); | ||
|
||
for (let file of files) { | ||
const data = await file.readText(); | ||
importer.importTanaGraph(data); | ||
if (importer.fatalError) { | ||
new Notice(importer.fatalError); | ||
return; | ||
} | ||
} | ||
|
||
const totalCount = importer.result.size; | ||
let index = 1; | ||
for (const [filename, markdownOutput] of importer.result) { | ||
if (progress.isCancelled()) { | ||
return; | ||
} | ||
try { | ||
await this.vault.create(filename, markdownOutput); | ||
progress.reportNoteSuccess(filename); | ||
progress.reportProgress(index, totalCount); | ||
} | ||
catch (error) { | ||
console.error('Error saving Markdown to file:', filename, error); | ||
progress.reportFailed(filename); | ||
} | ||
index++; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
export interface TanaProps { | ||
created: number; | ||
name: string; | ||
description: string; | ||
_docType: string | null; | ||
_ownerId: string; | ||
_metaNodeId: string | null; | ||
_flags: number | null; | ||
_done: number | null; | ||
} | ||
|
||
export interface TanaDoc { | ||
id: string; | ||
props: TanaProps; | ||
children: string[]; | ||
associationMap: any | undefined; | ||
} | ||
|
||
export interface TanaDatabase { | ||
formatVersion: number; | ||
docs: TanaDoc[]; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,306 @@ | ||
import { TanaDatabase, TanaDoc } from './models/tana-json'; | ||
|
||
const inlineRefRegex = /<span data-inlineref-node="(.+)"><\/span>/g; | ||
const boldRegex = /<b>(.*?)<\/b>/g; | ||
const italicRegex = /<i>(.*?)<\/i>/g; | ||
const strikeRegex = /<strike>(.*?)<\/strike>/g; | ||
const codeRegex = /<code>(.*?)<\/code>/g; | ||
|
||
export class TanaGraphImporter { | ||
public result: Map<string, string> = new Map(); | ||
private tanaDatabase: TanaDatabase; | ||
private nodes: Map<string, TanaDoc>; | ||
private convertedNodes: Set<string> = new Set(); | ||
public fatalError: string | null; | ||
public notices: string[] = []; | ||
private anchors: Set<string> = new Set(); | ||
private topLevelNodes = new Map<string, [TanaDoc, string]>(); | ||
|
||
public importTanaGraph(data: string) { | ||
this.tanaDatabase = JSON.parse(data) as TanaDatabase; | ||
this.nodes = new Map(); | ||
this.tanaDatabase.docs.forEach(n => this.nodes.set(n.id, n)); | ||
|
||
|
||
const rootNode = this.tanaDatabase.docs.find(n => n.props.name && n.props.name.startsWith('Root node for')); | ||
if (!rootNode) { | ||
this.fatalError = 'Root node not found'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would recommend throwing an exception and catching it in |
||
return; | ||
} | ||
this.convertedNodes.add(rootNode.id); | ||
|
||
this.prepareAnchors(rootNode); | ||
|
||
const workspaceNode = this.nodes.get(rootNode.children[0]); | ||
if (!workspaceNode) { | ||
this.fatalError = 'Workspace node not found'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Root node not found", "Workspace node not found", etc. sound like errors that might not be meaningful to the average user of this plugin. Maybe this should just be logged to |
||
return; | ||
} | ||
this.convertedNodes.add(workspaceNode.id); | ||
this.topLevelNodes.set(workspaceNode.id, [workspaceNode, workspaceNode.props.name]); | ||
|
||
let metaNodeId = workspaceNode.props._metaNodeId; | ||
if (metaNodeId) { | ||
const metaNode = this.nodes.get(metaNodeId); | ||
if (metaNode) { | ||
this.markSeen(metaNode); | ||
} | ||
} | ||
|
||
const libraryNode = this.nodes.get(rootNode.id + '_STASH'); | ||
if (libraryNode != null) { | ||
this.importLibraryNode(libraryNode); | ||
} | ||
else { | ||
this.notices.push('Library node not found'); | ||
} | ||
|
||
for (let suffix of ['_TRASH', '_SCHEMA', '_SIDEBAR_AREAS', '_USERS', '_SEARCHES', '_MOVETO', '_WORKSPACE', '_QUICK_ADD']) { | ||
const specialNode = this.nodes.get(rootNode.id + suffix); | ||
if (specialNode != null) { | ||
this.markSeen(specialNode); | ||
} | ||
else { | ||
this.notices.push('Special node ' + suffix + ' not found'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This error also probably will not be actionable for most users. |
||
} | ||
} | ||
|
||
this.enumerateChildren(workspaceNode, (childNode) => { | ||
if (childNode.props._docType == 'journal') { | ||
this.importDailyNotes(childNode); | ||
} | ||
}); | ||
|
||
for (const [node, file] of this.topLevelNodes.values()) { | ||
this.convertNode(node, file); | ||
} | ||
|
||
this.notices.push('Converted ' + this.convertedNodes.size + ' nodes'); | ||
let unconverted = 0; | ||
for (let node of this.tanaDatabase.docs) { | ||
if (!this.convertedNodes.has(node.id) && !node.id.startsWith('SYS') && | ||
node.props._docType != 'workspace') { | ||
const path = this.pathFromRoot(node, rootNode); | ||
if (path) { | ||
this.notices.push('Found unconverted node: ' + path); | ||
unconverted++; | ||
if (unconverted == 50) break; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the significancy of 50 unconverted notes? Why are we breaking here? |
||
} | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For clarity, I would suggest clearing the class-level variables which are only relevant to this call to |
||
} | ||
|
||
private prepareAnchors(node: TanaDoc) { | ||
if (node.props.name) { | ||
for (let m of node.props.name.matchAll(inlineRefRegex)) { | ||
this.anchors.add(m[1]); | ||
} | ||
} | ||
this.enumerateChildren(node, (childNode) => { | ||
if (childNode.props._ownerId != node.id) { | ||
this.anchors.add(childNode.id); | ||
} | ||
this.prepareAnchors(childNode); | ||
}); | ||
} | ||
|
||
private importDailyNotes(node: TanaDoc) { | ||
this.convertedNodes.add(node.id); | ||
this.enumerateChildren(node, (yearNode) => { | ||
this.convertedNodes.add(yearNode.id); | ||
this.enumerateChildren(yearNode, (weekNode) => { | ||
this.convertedNodes.add(weekNode.id); | ||
this.enumerateChildren(weekNode, (dayNode) => { | ||
if (dayNode.props.name) { | ||
this.topLevelNodes.set(dayNode.id, [dayNode, dayNode.props.name]); | ||
} | ||
}); | ||
}); | ||
}); | ||
} | ||
|
||
private importLibraryNode(node: TanaDoc) { | ||
this.convertedNodes.add(node.id); | ||
this.enumerateChildren(node, (childNode) => { | ||
this.topLevelNodes.set(childNode.id, [childNode, childNode.props.name]); | ||
}); | ||
} | ||
|
||
private convertNode(node: TanaDoc, filename: string) { | ||
const fragments: Array<string> = []; | ||
const properties = this.collectNodeProperties(node); | ||
if (properties.length > 0) { | ||
fragments.push('---'); | ||
for (const [, k, v] of properties) { | ||
fragments.push(k + ': ' + v); | ||
} | ||
fragments.push('---'); | ||
} | ||
this.convertNodeRecursive(node, fragments, 0); | ||
this.result.set(filename + '.md', fragments.join('\n')); | ||
} | ||
|
||
private collectNodeProperties(node: TanaDoc): Array<[string, string, string]> { | ||
const properties: Array<[string, string, string]> = []; | ||
this.enumerateChildren(node, (child) => { | ||
if (child.props._docType == 'tuple' && child.children.length >= 2) { | ||
const propNode = this.nodes.get(child.children[0]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Am I reading correctly that properties for a node are stored as separate |
||
const valueNode = this.nodes.get(child.children[1]); | ||
if (propNode != null && valueNode != null) { | ||
properties.push([propNode.id, propNode.props.name, valueNode.props.name]); | ||
} | ||
} | ||
}); | ||
return properties; | ||
} | ||
|
||
private convertNodeRecursive(node: TanaDoc, fragments: string[], indent: number) { | ||
if (node.props._docType == 'journal') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you explain why this is needed here? |
||
return; | ||
} | ||
if (node.props._docType == 'tuple') { | ||
this.markSeen(node); | ||
return; | ||
} | ||
|
||
this.convertedNodes.add(node.id); | ||
let props: any = {}; | ||
if (node.props._metaNodeId) { | ||
props = this.convertMetaNode(this.nodes.get(node.props._metaNodeId), fragments, indent); | ||
} | ||
this.markAssociatedNodesSeen(node); | ||
if (indent == 0 && props.tag) { | ||
fragments.push('#' + props.tag); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps this should go in the |
||
} | ||
if (indent == 0 && node.props.description) { | ||
fragments.push(node.props.description); | ||
} | ||
if (indent > 0) { | ||
const prefix = ' '.repeat(indent * 2) + '*'; | ||
const anchor = this.anchors.has(node.id) ? (' ^' + node.id.replace('_', '-')) : ''; | ||
const header = node.props._flags && ((node.props._flags & 2) != 0) ? '### ' : ''; | ||
const checkbox = props.checkbox | ||
? (node.props._done ? '[x] ' : '[ ] ') | ||
: ''; | ||
const tag = props.tag ? ' #' + props.tag : ''; | ||
fragments.push(prefix + ' ' + checkbox + header + this.convertMarkup(node.props.name ?? '') + tag + anchor); | ||
} | ||
this.enumerateChildren(node, (child) => { | ||
if (child.props._ownerId === node.id) { // skip nodes which are included by reference | ||
this.convertNodeRecursive(child, fragments, indent + 1); | ||
} | ||
else { | ||
fragments.push(this.generateLink(child.id)); | ||
} | ||
}); | ||
} | ||
|
||
private markAssociatedNodesSeen(node: TanaDoc) { | ||
if (node.associationMap) { | ||
for (let id of Object.values(node.associationMap)) { | ||
const associatedNode = this.nodes.get(id as string); | ||
if (associatedNode) { | ||
this.markSeen(associatedNode); | ||
} | ||
} | ||
} | ||
} | ||
|
||
private convertMetaNode(node: TanaDoc | undefined, fragments: string[], indent: number): any { | ||
const result: any = {}; | ||
if (!node) return; | ||
this.markSeen(node); | ||
const props = this.collectNodeProperties(node); | ||
for (const [id, , v] of props) { | ||
if (id == 'SYS_A13') { | ||
result.tag = v; | ||
} | ||
else if (id == 'SYS_A55') { | ||
result.checkbox = true; | ||
} | ||
} | ||
return result; | ||
} | ||
|
||
private generateLink(id: string): string { | ||
const tlNode = this.topLevelNodes.get(id); | ||
if (tlNode) { | ||
return '[[' + tlNode[1] + ']]'; | ||
} | ||
const targetNode = this.nodes.get(id); | ||
if (targetNode) { | ||
if (targetNode.props._docType == 'url') { | ||
this.markSeen(targetNode); | ||
return targetNode.props.name; | ||
} | ||
const tlParent = this.findTopLevelParent(targetNode); | ||
if (tlParent) { | ||
const tlFileName = this.topLevelNodes.get(tlParent.id)![1]; | ||
return '[[' + tlFileName + '#^' + id.replace('_', '-') + ']]'; | ||
} | ||
} | ||
|
||
return '[[#]]'; | ||
} | ||
|
||
private findTopLevelParent(node: TanaDoc): TanaDoc | null { | ||
const ownerId = node.props._ownerId; | ||
if (!ownerId) return null; | ||
const ownerNode = this.nodes.get(ownerId); | ||
if (ownerNode) { | ||
if (this.topLevelNodes.has(ownerNode.id)) { | ||
return ownerNode; | ||
} | ||
return this.findTopLevelParent(ownerNode); | ||
} | ||
return null; | ||
} | ||
|
||
private convertMarkup(text: string): string { | ||
return text | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this all of the markup allowed in Tana? |
||
.replace(inlineRefRegex, (_, id) => this.generateLink(id)) | ||
.replace(boldRegex, (_, content) => '**' + content + '**') | ||
.replace(italicRegex, (_, content) => '*' + content + '*') | ||
.replace(strikeRegex, (_, content) => '~~' + content + '~~') | ||
.replace(codeRegex, (_, content) => '`' + content + '`'); | ||
} | ||
|
||
private markSeen(node: TanaDoc) { | ||
if (this.convertedNodes.has(node.id)) return; | ||
this.convertedNodes.add(node.id); | ||
this.enumerateChildren(node, (child) => this.markSeen(child)); | ||
if (node.props._metaNodeId) { | ||
const metaNode = this.nodes.get(node.props._metaNodeId); | ||
if (metaNode) { | ||
this.markSeen(metaNode); | ||
} | ||
} | ||
this.markAssociatedNodesSeen(node); | ||
} | ||
|
||
private enumerateChildren(node: TanaDoc, callback: (child: TanaDoc) => void) { | ||
if (!node.children) return; | ||
for (const childId of node.children) { | ||
if (childId.startsWith('SYS_')) continue; | ||
const childNode = this.nodes.get(childId); | ||
if (childNode) { | ||
callback(childNode); | ||
} | ||
else { | ||
this.notices.push('Node with id ' + childId + ' (parent ' + (node.props.name ?? node.id) + ') not found'); | ||
} | ||
} | ||
} | ||
|
||
private pathFromRoot(node: TanaDoc, root: TanaDoc): string | null { | ||
if (node.props._ownerId) { | ||
const owner = this.nodes.get(node.props._ownerId); | ||
if (owner) { | ||
let pathFromRoot = owner == root ? 'root' : this.pathFromRoot(owner, root); | ||
if (pathFromRoot == null) return null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can remove the
|
||
return pathFromRoot + ' > ' + node.props.name + ' [' + node.id + ']'; | ||
} | ||
} | ||
return null; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is this being consumed?