Skip to content
130 changes: 125 additions & 5 deletions src/formats/apple-notes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Notice, Platform, Setting, TFile, TFolder } from 'obsidian';
import { Notice, Platform, Setting, TFile, TFolder, moment } from 'obsidian';
import { NoteConverter } from './apple-notes/convert-note';
import { ANAccount, ANAttachment, ANConverter, ANConverterType, ANFolderType } from './apple-notes/models';
import { descriptor } from './apple-notes/descriptor';
Expand All @@ -14,6 +14,13 @@ const NOTE_FOLDER_PATH = 'Library/Group Containers/group.com.apple.notes';
const NOTE_DB = 'NoteStore.sqlite';
/** Additional amount of seconds that Apple CoreTime datatypes start at, to convert them into Unix timestamps. */
const CORETIME_OFFSET = 978307200;
const LOCAL_STORAGE_KEY = 'apple-notes-importer-file-prefix';

enum DuplicateHandling {
Skip = 'skip',
ImportUpdated = 'import-updated',
CreateCopy = 'create-copy'
}

export class AppleNotesImporter extends FormatImporter {
ctx: ImportContext;
Expand All @@ -35,7 +42,9 @@ export class AppleNotesImporter extends FormatImporter {
omitFirstLine = true;
importTrashed = false;
includeHandwriting = false;
duplicateHandling = DuplicateHandling.ImportUpdated;
trashFolders: number[] = [];
filePrefixFormat = '';

init(): void {
if (!Platform.isMacOS || !Platform.isDesktop) {
Expand All @@ -50,6 +59,25 @@ export class AppleNotesImporter extends FormatImporter {

this.addOutputLocationSetting('Apple Notes');

// Retrieve stored file prefix format
const storedPrefix = localStorage.getItem(LOCAL_STORAGE_KEY) || '';
this.filePrefixFormat = storedPrefix;

new Setting(this.modal.contentEl)
.setName('File prefix format')
.setDesc(
'Format for the date prefix in filenames. Use YYYY, MM, DD for year, month, day.' +
' Leave blank for no prefix.'
)
.addText(t => t
.setValue(storedPrefix)
.setPlaceholder('YYYY-MM-DD')
.onChange(async v => {
this.filePrefixFormat = v;
localStorage.setItem(LOCAL_STORAGE_KEY, v);
})
);

new Setting(this.modal.contentEl)
.setName('Import recently deleted notes')
.setDesc(
Expand Down Expand Up @@ -81,6 +109,19 @@ export class AppleNotesImporter extends FormatImporter {
.setValue(false)
.onChange(async v => this.includeHandwriting = v)
);

new Setting(this.modal.contentEl)
.setName('Handle duplicate files')
.setDesc(
'How to handle notes that already exist in the vault.'
)
.addDropdown(d => d
.addOption(DuplicateHandling.Skip, 'Skip import')
.addOption(DuplicateHandling.ImportUpdated, 'Import only updated')
.addOption(DuplicateHandling.CreateCopy, 'Create a copy')
.setValue(DuplicateHandling.ImportUpdated)
.onChange(async v => this.duplicateHandling = v as DuplicateHandling)
);
}

async getNotesDatabase(): Promise<SQLiteTagSpawned | null> {
Expand Down Expand Up @@ -251,8 +292,39 @@ export class AppleNotesImporter extends FormatImporter {

const folder = this.resolvedFolders[row.ZFOLDER] || this.rootFolder;

const title = `${row.ZTITLE1}.md`;
const file = await this.saveAsMarkdownFile(folder, title, '');
// Get creation date and format it according to user preference
let title = row.ZTITLE1;
if (this.filePrefixFormat) {
const creationTimestamp = this.decodeTime(row.ZCREATIONDATE3 || row.ZCREATIONDATE2 || row.ZCREATIONDATE1);
const datePrefix = moment(creationTimestamp).format(this.filePrefixFormat);
title = `${datePrefix} ${title}`;
}

const fullPath = path.join(folder.path, `${title}.md`);

// Check for duplicate notes based on the selected handling option
const existingFile = this.vault.getAbstractFileByPath(fullPath);

if (existingFile && existingFile instanceof TFile) {
if (this.duplicateHandling === DuplicateHandling.Skip) {
this.ctx.reportSkipped(row.ZTITLE1, 'note is a duplicate');
return existingFile;
} else if (this.duplicateHandling === DuplicateHandling.ImportUpdated) {
// Check modification times before skipping
const appleNoteModTime = this.decodeTime(row.ZMODIFICATIONDATE1);
const existingFileModTime = existingFile.stat.mtime;

// Only skip if the Apple Note hasn't been modified since the existing file
if (appleNoteModTime <= existingFileModTime) {
this.ctx.reportSkipped(row.ZTITLE1, 'note unchanged since last import');
return existingFile;
}
// If Apple Note is newer, continue with import (will overwrite)
}
// For CreateCopy option, we continue without skipping (will create numbered copy)
}

const file = await this.saveAsMarkdownFile(folder, `${title}.md`, '');

this.ctx.status(`Importing note ${title}`);
this.resolvedFiles[id] = file;
Expand All @@ -268,6 +340,7 @@ export class AppleNotesImporter extends FormatImporter {

this.parsedNotes++;
this.ctx.reportProgress(this.parsedNotes, this.noteCount);
this.ctx.reportNoteSuccess(title);
return file;
}

Expand Down Expand Up @@ -352,9 +425,39 @@ export class AppleNotesImporter extends FormatImporter {
break;
}

// Apply date prefix to attachment name if configured
let finalAttachmentName = outName;
if (this.filePrefixFormat && row.ZCREATIONDATE) {
const creationTimestamp = this.decodeTime(row.ZCREATIONDATE);
const datePrefix = moment(creationTimestamp).format(this.filePrefixFormat);
finalAttachmentName = `${datePrefix} ${outName}`;
}

// Check for existing attachment based on the selected handling option
const attachmentPath = await this.getAvailablePathForAttachment(`${finalAttachmentName}.${outExt}`, []);
const existingAttachment = this.vault.getAbstractFileByPath(attachmentPath);

if (existingAttachment && existingAttachment instanceof TFile) {
if (this.duplicateHandling === DuplicateHandling.Skip) {
this.ctx.reportSkipped(finalAttachmentName, 'attachment already exists');
return existingAttachment;
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be returning null?

Copy link
Contributor

Choose a reason for hiding this comment

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

This comment was missed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually it seems like maybe the return null below should not be returning null? Because these cases are successful, they just don't need to do anything. So we don't want the caller of this function to think this was unsuccessful.

} else if (this.duplicateHandling === DuplicateHandling.ImportUpdated) {
// Check modification times for attachments
const appleAttachmentModTime = this.decodeTime(row.ZMODIFICATIONDATE);
const existingAttachmentModTime = existingAttachment.stat.mtime;

if (appleAttachmentModTime <= existingAttachmentModTime) {
this.ctx.reportSkipped(finalAttachmentName, 'attachment unchanged since last import');
return existingAttachment;
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be returning null?

}
// If Apple attachment is newer, continue with import (will overwrite)
}
// For CreateCopy option, we continue without skipping (will create numbered copy)
}

try {
const binary = await this.getAttachmentSource(this.resolvedAccounts[this.owners[row.ZNOTE]], sourcePath);
const attachmentPath = await this.getAvailablePathForAttachment(`${outName}.${outExt}`, []);
const attachmentPath = await this.getAvailablePathForAttachment(`${finalAttachmentName}.${outExt}`, []);

file = await this.vault.createBinary(
attachmentPath, binary,
Expand All @@ -368,7 +471,7 @@ export class AppleNotesImporter extends FormatImporter {
}

this.resolvedFiles[id] = file;
this.ctx.reportAttachmentSuccess(this.resolvedFiles[id].path);
this.ctx.reportAttachmentSuccess(`${finalAttachmentName}.${outExt}`);
return file;
}

Expand All @@ -391,4 +494,21 @@ export class AppleNotesImporter extends FormatImporter {
return await fsPromises.readFile(path.join(os.homedir(), NOTE_FOLDER_PATH, sourcePath));
}
}

async saveAsMarkdownFile(folder: TFolder, title: string, content: string): Promise<TFile> {
if (this.duplicateHandling === DuplicateHandling.Skip || this.duplicateHandling === DuplicateHandling.ImportUpdated) {
// For Skip and ImportUpdated, create the file directly without numeric suffix
const sanitizedName = sanitizeFileName(title);
const fullPath = path.join(folder.path, sanitizedName);

// Check if file already exists and handle overwriting
const existingFile = this.vault.getAbstractFileByPath(fullPath);
if (existingFile && existingFile instanceof TFile) {
// File exists -- will be updated later in resolveNote
return existingFile;
}
}
// For CreateCopy option, use the default behavior from FormatImporter (creates numbered copies)
return super.saveAsMarkdownFile(folder, title, content);
}
}