Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@ data.json
# Exclude files specific to personal development process
.hotreload
.devtarget

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.

Please do not add these entries here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FYI if you need to exclude files locally without modifying .gitignore, you can modify your .git/info/exclude file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

thanks, thats on me 🤦

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed these

# Riptide artifacts (cloud-synced)
.humanlayer/tasks/

# Reflect personal export data
/reflect/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Import guides are hosted on the [official Obsidian Help site](https://help.obsid
- [Import from Microsoft OneNote](https://help.obsidian.md/import/onenote)
- [Import from Notion](https://help.obsidian.md/import/notion)
- [Import from Roam Research](https://help.obsidian.md/import/roam)
- Import from Reflect (.json)
- [Import from HTML files](https://help.obsidian.md/import/html)
- [Import from Markdown files](https://help.obsidian.md/import/markdown)
- Import from Apple Journal (HTML export)
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Importer",
"version": "1.8.4",
"minAppVersion": "0.15.0",
"description": "Import data from Notion, Evernote, Apple Notes, Microsoft OneNote, Google Keep, Bear, Roam, Textbundle, CSV, and HTML files.",
"description": "Import data from Notion, Evernote, Apple Notes, Microsoft OneNote, Google Keep, Bear, Roam, Reflect, Textbundle, CSV, and HTML files.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"isDesktopOnly": false
Expand Down
1 change: 0 additions & 1 deletion src/format-importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export abstract class FormatImporter {
this.app = app;
this.vault = app.vault;
this.modal = modal;
this.init();
}

abstract init(): void;
Expand Down
347 changes: 347 additions & 0 deletions src/formats/reflect-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
import { moment, normalizePath, Notice, requestUrl, Setting, TFile } from 'obsidian';
import { parseFilePath } from '../filesystem';
import { FormatImporter } from '../format-importer';
import { ImportContext } from '../main';
import { sanitizeFileName, serializeFrontMatter } from '../util';
import { sanitizeTag } from './keep/util';
import { ReflectExport, ReflectNote } from './reflect/models';
import { convertDocument, ConvertOptions } from './reflect/convert';

const MAX_FILENAME_LENGTH = 200;

function truncateTitle(title: string): string {
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.

Use truncateText

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Switched to truncateText

if (title.length <= MAX_FILENAME_LENGTH) return title;
return title.substring(0, MAX_FILENAME_LENGTH).trim();
}

export class ReflectImporter extends FormatImporter {
downloadAttachments: boolean;
tagsFrontmatter: boolean;
dateFrontmatter: boolean;
titleFrontmatter: boolean;

init() {
// Initialize defaults in init() because FormatImporter calls init() from its constructor.
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.

This contradicts another change in this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This should be consistent again now that the extra importer.init() call in main.ts is gone

this.downloadAttachments = false;
this.tagsFrontmatter = true;
this.dateFrontmatter = false;
this.titleFrontmatter = false;

this.addFileChooserSetting('Reflect (.json)', ['json']);
this.addOutputLocationSetting('Reflect');

new Setting(this.modal.contentEl)
.setName('Import settings')
.setHeading();

new Setting(this.modal.contentEl)
.setName('Download all attachments')
.setDesc('If enabled, all attachments uploaded to Reflect will be downloaded to your attachments folder.')
.addToggle(toggle => {
toggle.setValue(this.downloadAttachments);
toggle.onChange(async (value) => {
this.downloadAttachments = value === true;
});
});

new Setting(this.modal.contentEl)
.setName('Add YAML tags')
.setDesc('If enabled, notes will have tags from Reflect added as properties.')
.addToggle(toggle => {
toggle.setValue(this.tagsFrontmatter);
toggle.onChange(async (value) => {
this.tagsFrontmatter = value;
});
});

new Setting(this.modal.contentEl)
.setName('Add YAML created/updated date')
.setDesc('If enabled, notes will have the created and updated timestamps from Reflect added as properties.')
.addToggle(toggle => {
toggle.setValue(this.dateFrontmatter);
toggle.onChange(async (value) => {
this.dateFrontmatter = value;
});
});

new Setting(this.modal.contentEl)
.setName('Add YAML title')
.setDesc('If enabled, notes will have the full title added as a property (regardless of illegal file name characters).')
.addToggle(toggle => {
toggle.setValue(this.titleFrontmatter);
toggle.onChange(async (value) => {
this.titleFrontmatter = value;
});
});
}

private getUserDNPFormat(): string {
// @ts-expect-error : Internal Method
const plugin = this.app.internalPlugins.getPluginById('daily-notes');
if (!plugin?.instance) {
return 'YYYY-MM-DD';
}
return plugin.instance.options?.format || 'YYYY-MM-DD';
}

private getNoteTitle(note: ReflectNote, userDNPFormat: string): string {
if (note.daily_at) {
return moment(note.daily_at).format(userDNPFormat);
}
return truncateTitle(note.subject);
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.

Provide a fallback title ("Untitled") if the returned value would be empty.

Suggested change
return truncateTitle(note.subject);
return truncateTitle(note.subject).trim() || 'Untitled';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added the Untitled fallback here

}

private getAvailableNotePath(folderPath: string, title: string, claimedPaths: Set<string>): string {
const baseName = sanitizeFileName(title);
let suffix = 0;

while (true) {
const candidateName = suffix === 0 ? baseName : `${baseName} ${suffix}`;
const candidatePath = normalizePath(`${folderPath}/${candidateName}.md`);
const candidateKey = candidatePath.toLowerCase();

const exists = this.vault.getAbstractFileByPath(candidatePath) || this.vault.getAbstractFileByPathInsensitive(candidatePath);
if (!claimedPaths.has(candidateKey) && !exists) {
claimedPaths.add(candidateKey);
return candidatePath;
}

suffix++;
}
}

private resolveImageUrl(url: string): string | null {
// Skip relative paths (orphaned refs from prior note app imports)
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return null;
}

// Unwrap reflect.academy Next.js image proxy to fetch the underlying URL directly
try {
const parsed = new URL(url);
if (parsed.hostname === 'reflect.academy' && parsed.pathname === '/_next/image') {
const inner = parsed.searchParams.get('url');
if (inner) return inner;
}
}
catch { /* use original url */ }

return url;
}

private async fetchImageData(url: string): Promise<{ data: ArrayBuffer, contentType: string }> {
// Try fetch first, fall back to requestUrl (bypasses CORS in Electron)
try {
const response = await fetch(url, {
mode: 'cors',
referrerPolicy: 'no-referrer',
});
if (response.ok) {
return {
data: await response.arrayBuffer(),
contentType: response.headers.get('content-type') || '',
};
}
}
catch { /* fall through to requestUrl */ }

const response = await requestUrl({ url, throw: false });
if (response.status !== 200) {
throw new Error(`HTTP ${response.status}`);
}
return {
data: response.arrayBuffer,
contentType: response.headers['content-type'] || '',
};
}

private async downloadImage(
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.

Where are images generally downloaded from? Is it a centralized location which would require rate-limiting or backoffs?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a good question. Reflect assets are stored with a link that looks something like this

https://reflect-assets.app/v1/users/tfgFpw.../999abc79-e45...?key=2227f9…

I doubt they have strict rate limits, but it's probably not a bad idea to put it in there as a safety measure.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yea, I actually didn't have enough images to even know if there were limits. My tests on my own export only had around 190 images and they all downloaded fine. I'll add some sort of rate limiting

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added retries with backoff for attachment downloads and respect Retry-After when the server sends it. Downloads are still sequential.

url: string,
fileName: string,
sourcePath: string,
claimedAttachmentPaths: string[],
downloadedImagePathsByUrl: Map<string, string>,
ctx: ImportContext,
): Promise<string | null> {
const resolvedUrl = this.resolveImageUrl(url);
if (!resolvedUrl) {
return null;
}

const cachedPath = downloadedImagePathsByUrl.get(resolvedUrl);
if (cachedPath) {
return cachedPath;
}

try {
const { data, contentType } = await this.fetchImageData(resolvedUrl);

// Determine filename
let name = fileName;
if (!name) {
const ext = this.getExtensionFromMimeType(contentType);
name = `reflect-image-${Date.now()}${ext}`;
}

// Respect vault attachment settings, including "Same folder as current file".
const filePath = await this.getAvailablePathForAttachment(name, claimedAttachmentPaths, sourcePath);
claimedAttachmentPaths.push(filePath);
const parentPath = parseFilePath(filePath).parent;
if (parentPath) {
await this.createFolders(parentPath);
}

await this.vault.createBinary(filePath, data);
downloadedImagePathsByUrl.set(resolvedUrl, filePath);
ctx.reportAttachmentSuccess(parseFilePath(filePath).name);
return filePath;
}
catch (e) {
ctx.reportFailed(fileName || url, e);
return null;
}
}

private getExtensionFromMimeType(mimeType: string): string {
const map: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/bmp': '.bmp',
};
for (const [mime, ext] of Object.entries(map)) {
if (mimeType.includes(mime)) return ext;
}
return '.png';
}

async import(ctx: ImportContext) {
// Snapshot option values for this run so they can't drift mid-import.
const shouldDownloadAttachments = this.downloadAttachments === true;
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.

The downloadAttachments toggle is snapshotted at import start (here) with a comment about preventing mid-import drift, but tagsFrontmatter, dateFrontmatter, and titleFrontmatter are read directly from this throughout the loop (lines 265, 282, 285, 289).

Either all four settings should be snapshotted for consistency, or none of them need to be — the import modal presumably isn't interactive while the import is running.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, all four settings are snapshotted at import start now


let { files } = this;
if (files.length === 0) {
new Notice('Please pick at least one file to import.');
return;
}

let folder = await this.getOutputFolder();
if (!folder) {
new Notice('Please select a location to export to.');
return;
}

const userDNPFormat = this.getUserDNPFormat();

for (let file of files) {
if (ctx.isCancelled()) return;

ctx.status('Reading ' + file.name);
const data = JSON.parse(await file.readText()) as ReflectExport;
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.

This should be in a try/catch and include validation that the imported data matches the expected format.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added a dedicated parse/validate step here, so malformed exports fail early with a readable error instead of blowing up in the middle of the import.


// Phase 1: Build ID → output path and backlink target maps
const idToSubject = new Map<string, string>();
const idToOutputPath = new Map<string, string>();
const claimedPaths = new Set<string>();
const claimedAttachmentPaths: string[] = [];
const downloadedImagePathsByUrl = new Map<string, string>();
for (const note of data.notes) {
const title = this.getNoteTitle(note, userDNPFormat);
const outputPath = this.getAvailableNotePath(folder.path, title, claimedPaths);
idToOutputPath.set(note.id, outputPath);
idToSubject.set(note.id, parseFilePath(outputPath).basename);
}

const total = data.notes.length;
for (let i = 0; i < data.notes.length; i++) {
if (ctx.isCancelled()) return;
const note = data.notes[i];

ctx.status('Importing ' + note.subject);
try {
const convertOptions: ConvertOptions = {
stripInlineTags: this.tagsFrontmatter,
};
const result = convertDocument(
note.document_json,
idToSubject,
note.subject,
convertOptions,
);
const outputPath = idToOutputPath.get(note.id);
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.

Consider putting the note.id in the frontmatter like is done in other importer format. That would allow incremental imports in the future.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added id to the frontmatter

if (!outputPath) {
throw new Error(`Missing output path for note ${note.id}`);
}
const outputName = parseFilePath(outputPath).basename;

// Build frontmatter
let content = result.markdown;
const frontMatter: Record<string, any> = {};
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.

Suggested change
const frontMatter: Record<string, any> = {};
const frontMatter: FrontmatterCache = {};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed to FrontMatterCache

if (this.titleFrontmatter) {
frontMatter['title'] = note.subject;
}
if (this.tagsFrontmatter && result.tags.size > 0) {
frontMatter['tags'] = [...result.tags].map(t => sanitizeTag(t));
}
if (this.dateFrontmatter) {
frontMatter['created'] = note.created_at;
frontMatter['updated'] = note.updated_at;
}
if (Object.keys(frontMatter).length > 0) {
content = serializeFrontMatter(frontMatter) + result.markdown;
}

// Download images and replace placeholders
if (shouldDownloadAttachments && result.images.length > 0) {
for (const image of result.images) {
const localPath = await this.downloadImage(
image.url,
image.fileName,
outputPath,
claimedAttachmentPaths,
downloadedImagePathsByUrl,
ctx,
);
if (localPath) {
content = content.replace(image.placeholder, `![[${localPath}]]`);
}
else {
content = content.replace(image.placeholder, `![](${image.url})`);
}
}
}
else if (result.images.length > 0) {
// Not downloading: replace placeholders with original URLs
for (const image of result.images) {
content = content.replace(image.placeholder, `![](${image.url})`);
}
}

let mdFile: TFile;
const existing = this.vault.getAbstractFileByPath(outputPath);
if (existing instanceof TFile) {
await this.vault.modify(existing, content);
mdFile = existing;
}
else {
mdFile = await this.vault.create(outputPath, content);
}

// Preserve timestamps
await this.vault.append(mdFile, '', {
ctime: new Date(note.created_at).getTime(),
mtime: new Date(note.updated_at).getTime(),
});

ctx.reportNoteSuccess(outputName);
}
catch (e) {
ctx.reportFailed(note.subject, e);
}
ctx.reportProgress(i + 1, total);
}
}
}
}
Loading