Refactored into multiple files

This commit is contained in:
Dale de Silva 2023-01-08 18:31:54 +11:00
parent 78c40f1739
commit 433922c72a
13 changed files with 793 additions and 795 deletions

View file

@ -17,7 +17,7 @@ esbuild.build({
banner: {
js: banner,
},
entryPoints: ['main.ts', 'styles.scss'],
entryPoints: ['./src/main.ts', './src/styles.scss'],
bundle: true,
external: [
'obsidian',
@ -48,7 +48,7 @@ esbuild.build({
copy({
resolveFrom: 'cwd', // Returns name of current working directory
assets: {
from: ['./static/**/*'],
from: ['./src/static/**/*'],
to: ['./dist'],
},
}),

791
main.ts
View file

@ -1,791 +0,0 @@
import { fileSyntax } from 'esbuild-sass-plugin/lib/utils';
import { App, DataWriteOptions, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder, Vault } from 'obsidian';
const IMPORT_FOLDER = 'Keep Imports';
const ASSET_FOLDER = 'Attachments'
var plugin: MyPlugin;
var successCount = 0;
var failCount = 0;
interface KeepListItem {
text: string;
isChecked: boolean;
}
interface KeepAttachment {
filePath: string;
mimetype: string;
}
interface KeepJson {
color: string;
createdTimestampUsec: number;
isArchived: boolean;
isPinned: boolean;
isTrashed: boolean;
textContent?: string;
listContent?: Array<KeepListItem>;
attachments?: Array<KeepAttachment>;
title: string;
userEditedTimestampUsec: number;
}
interface MyPluginSettings {
hideOpenVaultButton: boolean;
vaultNames: [string];
vaultLinks: [string];
}
const DEFAULT_SETTINGS: MyPluginSettings = {
hideOpenVaultButton: false,
vaultNames: ['name'],
vaultLinks: ['utl'],
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
plugin = this; // TODO: Not sure if this is a good idea or not
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Open Philsophy Vault', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('Opening Philosophy Vault');
window.open('obsidian://vault/Philosophy/');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
this.addCommand({
id: 'ublik-om_import-google-keep-jsons',
name: 'Import backup from Google Keep',
callback: () => {
new StartImportModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class StartImportModal extends Modal {
result: string;
duplicateNotes: number = 0;
noteSpan: HTMLSpanElement;
assetSpan: HTMLSpanElement;
fileBacklog: Array<File> = [];
uploadInput: HTMLInputElement;
modalActions: Setting;
constructor(app: App) {
super(app);
}
onOpen() {
const {titleEl, contentEl} = this;
titleEl.setText('Import Google Keep backup');
contentEl.createEl('p', {text: 'Here you can upload a set of jsons output from a Google Keep backup.'});
contentEl.createEl('p', {text: 'Upload each json one at a time or all together. You should also upload any attachments in the backup as well such as png\'s jpgs, etc.'});
contentEl.createEl('p', {text: 'If you import attachments or jsons separately and close this dialog, they will will automatically link together once their counterparts are imported later provided you haven\'t changed the names of attachments or modified the markdown embeds in the notes.'});
const dropFrame = contentEl.createEl('div', {cls: 'uo_drop-frame'});
const dropFrameText = dropFrame.createEl('p', { text: 'Drag your files here or ' });
// const linkText = dropFrameText.createEl('a', {text: 'browse local files'})
dropFrameText.createEl('label', {
text: 'browse local files',
attr: {
'class': 'uo_file-label',
'for': 'uo_file',
}
})
this.uploadInput = dropFrameText.createEl('input', {
type: 'file',
attr: {
'multiple': true,
'id': 'uo_file',
'accept': '.json, .jpg, .png, .3gp',
}
})
const summaryP = contentEl.createEl('p', {cls: 'uo_before-import-summary'});
summaryP.createEl('span', {text: `notes: `});
this.noteSpan = summaryP.createEl('span', {cls: 'uo_import-number', text: `0`});
summaryP.createEl('span', {text: ` | attachments: `});
this.assetSpan = summaryP.createEl('span', {cls: 'uo_import-number', text: `0`});
this.modalActions = new Setting(contentEl).addButton(btn => {
btn.setClass('uo_button');
btn.setCta();
btn.setButtonText('Start Import');
btn.setDisabled(true);
btn.onClick( (e) => {
this.close();
importFiles( this.fileBacklog );
})
})
this.uploadInput.addEventListener('change', () => {
// Add imports to accumulative array
this.addToFilesBacklog( Object.values(this.uploadInput.files as FileList) );
});
dropFrame.addEventListener('dragenter', (e) => {
dropFrame.addClass('uo_drag-over-active');
});
dropFrame.addEventListener('dragover', (e) => {
// Prevent default to allow drop
e.preventDefault();
});
dropFrame.addEventListener('dragleave', (e) => {
dropFrame.removeClass('uo_drag-over-active');
});
dropFrame.addEventListener('drop', (e) => {
// Prevent default to stop browser opening file
e.preventDefault();
dropFrame.removeClass('uo_drag-over-active');
const files: Array<File> = [];
if (e.dataTransfer.items) {
// DataTransferItems is supporter in this browser
const items = [...e.dataTransfer.items];
for(let i=0; i<items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
files.push(file);
}
};
} else {
// Use DataTransfer interface to access the file(s)
files.push(...e.dataTransfer.files);
}
this.addToFilesBacklog(files);
});
}
addToFilesBacklog( files: Array<File> ) {
let newFiles = 0;
let duplicateFiles = 0;
// Add non-duplicates to backlog
files.forEach( (file) => {
if( this.backlogContains(file) ) {
duplicateFiles++;
} else {
this.fileBacklog.push(file);
newFiles++;
}
})
if(duplicateFiles>0) new Notice(`${duplicateFiles} ${singleOrPlural(duplicateFiles, 'file')} ignored because ${singleOrPlural(duplicateFiles, 'it\'s', 'they\'re')} already in the import list.`, 9000);
new Notice(`${newFiles} new ${singleOrPlural(newFiles, 'file')} queued for import.`, 10000);
// Erase references in upload component to prepare for new set
this.uploadInput.files = null;
// Update summary numbers
const breakdown = this.getFilesBacklogBreakdown();
this.noteSpan.setText(`${breakdown.notes}`);
this.assetSpan.setText(`${breakdown.assets}`);
// Activate start button
const importBtn = this.modalActions.components[0];
importBtn.setDisabled(false);
}
backlogContains(file: File) {
for(let i=0; i<this.fileBacklog.length; i++) {
// NOTE: Path isn't necessarily guaranteed in all environments.
// Could do a name and content comparison for jsons, and a so too for binary if possible.
// Maybe even just a content comparison. For not this is fine.
if((file as any).path) {
if((file as any).path == (this.fileBacklog[i] as any).path) return true;
}
}
// If no duplicates found
return false;
}
getFilesBacklogBreakdown(): { notes : number, assets: number } {
let notes = 0;
let assets = 0;
for(let i=0; i<this.fileBacklog.length; i++) {
const file = this.fileBacklog[i] as File;
switch(file.type) {
case 'application/json': notes++; break;
default: assets++; break;
}
}
return {
notes,
assets
}
}
onClose() {
const {titleEl, contentEl} = this;
titleEl.empty();
contentEl.empty();
}
}
class ImportProgressModal extends Modal {
result: string;
bar: HTMLDivElement;
remainingSpan: HTMLSpanElement;
failedSpan: HTMLSpanElement;
importedSpan: HTMLSpanElement;
constructor(app: App) {
super(app);
}
onOpen() {
const {titleEl, contentEl} = this;
titleEl.setText('Import in progress');
const progressBarEl = contentEl.createEl('div', {cls: 'uo_progress-bar'});
this.bar = progressBarEl.createEl('div', {cls: 'uo_bar'});
const summaryEl = contentEl.createDiv('uo_during-import-summary');
let bubbleEl;
let pBubbleEl
bubbleEl = summaryEl.createDiv('uo_import-remaining');
pBubbleEl = bubbleEl.createEl('p');
this.remainingSpan = pBubbleEl.createEl('span', {cls: 'uo_import-number', text: `-`});
pBubbleEl.createEl('br');
pBubbleEl.createEl('span', {cls: 'uo_import-label', text: `remaining`});
bubbleEl = summaryEl.createDiv('uo_import-failed');
pBubbleEl = bubbleEl.createEl('p');
this.failedSpan = pBubbleEl.createEl('span', {cls: 'uo_import-number', text: `${failCount}`});
pBubbleEl.createEl('br');
pBubbleEl.createEl('span', {cls: 'uo_import-label', text: `failed/skipped`});
bubbleEl = summaryEl.createDiv('uo_import-imported');
pBubbleEl = bubbleEl.createEl('p');
this.importedSpan = pBubbleEl.createEl('span', {cls: 'uo_import-number', text: `${successCount}`});
pBubbleEl.createEl('br');
pBubbleEl.createEl('span', {cls: 'uo_import-label', text: `imported`});
}
public updateProgress(options: {successCount: number, failCount: number, totalImports: number}) {
const {
successCount,
failCount,
totalImports
} = options;
// Update bar visual
const perc = (successCount + failCount)/totalImports * 100;
this.bar.setAttr('style', `width: ${perc}%`);
// Update text
this.remainingSpan.setText(`${totalImports-successCount-failCount}`);
this.failedSpan.setText(`${failCount}`);
this.importedSpan.setText(`${successCount}`);
// Update modal if finished
if(perc == 100) {
const modalActions = new Setting(this.contentEl).addButton(btn => {
btn.setClass('uo_button');
// btn.setCta();
btn.setButtonText('Close');
btn.onClick( (e) => {
this.close();
})
})
}
}
onClose() {
const {titleEl, contentEl} = this;
titleEl.empty();
contentEl.empty();
}
}
async function importFiles(files: Array<Object>) {
const importProgressModal = new ImportProgressModal(plugin.app)
importProgressModal.open();
const importFolder = await getImportFolder();
const assetFolder = await getAssetFolder();
successCount = 0;
failCount = 0;
updateProgressBar({
totalImports: files.length,
modal: importProgressModal,
})
for(let i=0; i<files.length; i++) {
const file = files[i] as File;
switch(file.type) {
case 'application/json': await importJson(file, importFolder); break;
case 'image/png': await importBinaryFile(file, assetFolder); break;
case 'video/3gpp': await importBinaryFile(file, assetFolder); break;
case 'image/jpeg': await importBinaryFile(file, assetFolder); break;
}
}
}
function updateProgressBar(options: {totalImports: number, modal: ImportProgressModal}) {
const {totalImports, modal} = options;
const barEl = modal.bar;
modal.updateProgress({
successCount,
failCount,
totalImports
})
if(successCount + failCount == totalImports) {
return
}
requestAnimationFrame( function() {
updateProgressBar(options);
});
}
async function getImportFolder(): Promise<TFolder> {
const root = plugin.app.vault.getRoot();
let importFolder: TFolder | undefined;
// Find the folder if it exists
for(let i=0; i<root.children.length; i++) {
if(root.children[i].path == IMPORT_FOLDER) {
importFolder = root.children[i] as TFolder;
break;
}
}
// Create the folder if it doesn't exist
if(importFolder === undefined) {
await plugin.app.vault.createFolder(IMPORT_FOLDER);
importFolder = await getImportFolder()
}
return importFolder;
}
async function getAssetFolder(): Promise<TFolder> {
const importFolder = await getImportFolder()
let assetFolder: TFolder | undefined;
// Find the folder if it exists
for(let i=0; i<importFolder.children.length; i++) {
if(importFolder.children[i].name == ASSET_FOLDER) {
assetFolder = importFolder.children[i] as TFolder;
break;
}
}
// Create the folder if it doesn't exist
if(assetFolder === undefined) {
await plugin.app.vault.createFolder(`${importFolder.path}/${ASSET_FOLDER}`);
assetFolder = await getAssetFolder()
}
return assetFolder;
}
async function importJson(file: File, folder: TFolder) {
// setting up the reader
var reader = new FileReader();
reader.readAsText(file as Blob,'UTF-8');
return new Promise( (resolve, reject) => {
reader.onerror = reject;
reader.onload = async (readerEvent) => {
// Bail if the file hasn't been interpreted properly
if(!readerEvent || !readerEvent.target) {
return Promise.reject(new Error(`Something went wrong reading json: ${file.name}`));
}
const content: KeepJson = JSON.parse(readerEvent.target.result as string);
let path: string = `${folder.path}/${filenameSanitize(content.title || file.name)}`; // TODO: Strip file extension from filename
let fileRef: TFile;
// Create new file
try {
// path = await getUnusedFilename(path);
// fileRef = await plugin.app.vault.create(`${path}.md`, '');
fileRef = await createNewEmptyMdFile(path, {});
} catch (error) {
failCount++;
return Promise.reject(new Error(`Error creating new file ${path} (from ${file.name}. Error: ${error})`));
}
// Add in tags to represent Keep properties
try {
await plugin.app.vault.append(fileRef, `#Keep/Colour/${content.color} `);
content.isPinned ? await plugin.app.vault.append(fileRef, `#Keep/Pinned `) : null;
content.attachments ? await plugin.app.vault.append(fileRef, `#Keep/Attachments `) : null;
content.isArchived ? await plugin.app.vault.append(fileRef, `#Keep/Archived `) : null;
content.isTrashed ? await plugin.app.vault.append(fileRef, `#Keep/Trashed `) : null;
} catch (error) {
failCount++;
return Promise.reject(Error(`Error adding tags to new file ${path} (from ${file.name})`));
}
// Add in text content
try {
if(content.textContent) {
await plugin.app.vault.append(fileRef, `\n\n`);
await plugin.app.vault.append(fileRef, `${content.textContent}\n`);
}
} catch (error) {
failCount++;
return Promise.reject(new Error(`Error adding paragraph content to new file ${path} (from ${file.name})`));
}
// Add in text content if check box
try {
if(content.listContent) {
await plugin.app.vault.append(fileRef, `\n\n`);
for(let i=0; i<content.listContent.length; i++) {
const listItem = content.listContent[i];
// Skip to next line if this one is blank
if(!listItem.text) continue;
let listItemContent = `- [${listItem.isChecked ? 'X' : ' '}] ${listItem.text}\n`;
await plugin.app.vault.append(fileRef, listItemContent);
}
}
} catch (error) {
failCount++;
return Promise.reject(new Error(`Error adding list content to new file ${path} (from ${file.name})`));
}
// Embed attachments
// NOTE: The files for these may not have been created yet, but since it's just markdown text, they can be created after.
if(content.attachments) {
for(let i=0; i<content.attachments.length; i++) {
const attachment = content.attachments[i];
try {
await plugin.app.vault.append(fileRef, `\n\n![[${attachment.filePath}]]`);
} catch (error) {
failCount++;
return Promise.reject(new Error(`Error embedding attachment ${attachment.filePath} to new file ${path} (from ${file.name})`));
}
}
}
// Update created and modified date to match Keep data
const options: DataWriteOptions = {
ctime: content.createdTimestampUsec/1000,
mtime: content.userEditedTimestampUsec/1000
}
await plugin.app.vault.append(fileRef, '', options);
// await plugin.app.vault.process(fileRef, (str) => str, options); // In docs, but not in class
successCount++;
resolve(fileRef);
}
})
/*
JSON
//////////
{
"color": "DEFAULT",
"isTrashed": false,
"isPinned": false,
"isArchived": true,
"title": "",
"userEditedTimestampUsec": 1462811176816000,
"createdTimestampUsec": 1462763160359000
"textContent": "",
OR
"listContent": [
{
"text": "",
"isChecked": false
},
{
"text": "",
"isChecked": false
},
{
"text": "175.32.124.133\n",
"isChecked": false
}
],
OPTIONAL:
"attachments": [
{
"filePath": "16271b560b6.ba20e87f973dd142.png",
"mimetype": "image/png"
}
],
}
*/
}
async function importBinaryFile(file: File, folder: TFolder) {
const path: string = `${folder.path}/${file.name}`;
try {
const fileRef: TFile = await plugin.app.vault.createBinary(path, await file.arrayBuffer());
successCount++;
} catch (error) {
console.log(error)
failCount++;
return;
}
/*
Potential formats:
- jpg
- jpeg
- png
- 3gp (Audio/video recording)
*/
}
function filenameSanitize(str: string) {
// Remove /
let newArr = str.split('/');
let newStr = newArr.join();
// Remove \
newArr = newStr.split('\\');
newStr = newArr.join();
// Remove :
newArr = newStr.split(':');
newStr = newArr.join();
return newStr;
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
new Setting(containerEl)
.setName('Hide "Open Other Vault" button')
.setDesc('This will hide the button at the bottom left that returns you to the Vault Selector screen. This is mostly useful for mobile users that find this button disruptive to flow.')
.addToggle(value => value
.setValue(this.plugin.settings.hideOpenVaultButton)
.onChange(async (value) => {
this.plugin.settings.hideOpenVaultButton = !value;
await this.plugin.saveSettings();
}));
// new Setting(contentEl)
// .setName("Name")
// .addText((text) =>
// text.onChange((value) => {
// this.result = value
// }));
// new Setting(contentEl)
// .addColorPicker(color => {});
// new Setting(contentEl)
// .addExtraButton(btn => {});
// new Setting(contentEl)
// .addMomentFormat(test => {})
// new Setting(contentEl)
// .addSearch(test => {});
// new Setting(contentEl)
// .addSlider(test => {});
// new Setting(contentEl)
// .addTextArea(test => {});
}
}
const singleOrPlural = (count: number, singleVersion: string, pluralVersion?: string) => {
if(count == 1 || count == -1) {
return singleVersion;
} else {
if(pluralVersion) {
// custom plural version passed in
return pluralVersion;
} else {
// just add an s
return `${singleVersion}s`;
}
}
}
// async function getUnusedFilename(path: string) : Promise<string> {
// let newPath: string = path;
// let fileRef = plugin.app.vault.getAbstractFileByPath(`${newPath}.md`); // TODO: This is case sensitive. So I've replaced it with the function below.
// if(fileRef == null || fileRef == undefined) {
// // File doesn't exist yet, so just return the same path
// console.log('newPath', newPath);
// console.log('doesn\'t exist', fileRef);
// } else {
// let version = 2;
// while(fileRef && fileRef instanceof TFile) {
// newPath = `${path} v${version}`;
// fileRef = plugin.app.vault.getAbstractFileByPath(`${newPath}.md`);
// version++;
// }
// }
// return newPath;
// }
async function createNewEmptyMdFile(path: string, options: DataWriteOptions, version: number = 1) : Promise<TFile> {
let fileRef: TFile;
try {
if(version == 1) {
fileRef = await plugin.app.vault.create(`${path}.md`, '', options);
} else {
fileRef = await plugin.app.vault.create(`${path} (${version}).md`, '', options);
}
} catch(error) {
; fileRef = await createNewEmptyMdFile(path, options, version+1);
}
return fileRef;
}

300
src/logic/import-logic.ts Normal file
View file

@ -0,0 +1,300 @@
import { DataWriteOptions, TFile, TFolder, Vault } from "obsidian";
import MyPlugin, { ASSET_FOLDER, IMPORT_FOLDER } from "src/main";
import { ImportProgressModal, updateProgress } from "src/modals/import-progress-modal/import-progress-modal";
import { filenameSanitize } from "./string-processes";
// Types definitions
////////////////////
interface KeepListItem {
text: string;
isChecked: boolean;
}
interface KeepAttachment {
filePath: string;
mimetype: string;
}
interface KeepJson {
color: string;
createdTimestampUsec: number;
isArchived: boolean;
isPinned: boolean;
isTrashed: boolean;
textContent?: string;
listContent?: Array<KeepListItem>;
attachments?: Array<KeepAttachment>;
title: string;
userEditedTimestampUsec: number;
}
async function createNewEmptyMdFile(vault: Vault, path: string, options: DataWriteOptions, version: number = 1) : Promise<TFile> {
let fileRef: TFile;
try {
if(version == 1) {
fileRef = await vault.create(`${path}.md`, '', options);
} else {
fileRef = await vault.create(`${path} (${version}).md`, '', options);
}
} catch(error) {
; fileRef = await createNewEmptyMdFile(vault, path, options, version+1);
}
return fileRef;
}
async function getImportFolder(vault: Vault): Promise<TFolder> {
const root = vault.getRoot();
let importFolder: TFolder | undefined;
// Find the folder if it exists
for(let i=0; i<root.children.length; i++) {
if(root.children[i].path == IMPORT_FOLDER) {
importFolder = root.children[i] as TFolder;
break;
}
}
// Create the folder if it doesn't exist
if(importFolder === undefined) {
await vault.createFolder(IMPORT_FOLDER);
importFolder = await getImportFolder(vault)
}
return importFolder;
}
async function getAssetFolder(vault: Vault): Promise<TFolder> {
const importFolder = await getImportFolder(vault)
let assetFolder: TFolder | undefined;
// Find the folder if it exists
for(let i=0; i<importFolder.children.length; i++) {
if(importFolder.children[i].name == ASSET_FOLDER) {
assetFolder = importFolder.children[i] as TFolder;
break;
}
}
// Create the folder if it doesn't exist
if(assetFolder === undefined) {
await vault.createFolder(`${importFolder.path}/${ASSET_FOLDER}`);
assetFolder = await getAssetFolder(vault)
}
return assetFolder;
}
export async function importFiles(plugin: MyPlugin, files: Array<Object>) {
const importProgressModal = new ImportProgressModal(plugin)
importProgressModal.open();
const importFolder = await getImportFolder(plugin.app.vault);
const assetFolder = await getAssetFolder(plugin.app.vault); // TODO: Only do this if there is an attachement to be imported
let successCount = 0;
let failCount = 0;
updateProgress({
successCount,
failCount,
totalImports: files.length,
modal: importProgressModal,
})
for(let i=0; i<files.length; i++) {
const file = files[i] as File;
let fileRef: TFile | Error;
if(file.type === 'image/png') {
fileRef = await importBinaryFile(plugin.app.vault, assetFolder, file);
} else if(file.type === 'video/3gpp') {
fileRef = await importBinaryFile(plugin.app.vault, assetFolder, file);
} else if(file.type === 'image/jpeg') {
fileRef = await importBinaryFile(plugin.app.vault, assetFolder, file);
} else { // file.type === 'application/json'
fileRef = await importJson(plugin.app.vault, importFolder, file);
}
if(fileRef instanceof Error) {
failCount++;
} else {
successCount++;
}
updateProgress({
successCount,
failCount,
totalImports: files.length,
modal: importProgressModal,
})
}
}
async function importJson(vault: Vault, folder: TFolder, file: File) : Promise<TFile> {
// setting up the reader
var reader = new FileReader();
reader.readAsText(file as Blob,'UTF-8');
// TODO: This composition is confusing - Attempt to simplify
return new Promise( (resolve, reject) => {
reader.onerror = reject;
reader.onload = async (readerEvent) => {
// Bail if the file hasn't been interpreted properly
if(!readerEvent || !readerEvent.target) {
return reject(new Error(`Something went wrong reading json: ${file.name}`));
}
const content: KeepJson = JSON.parse(readerEvent.target.result as string);
let path: string = `${folder.path}/${filenameSanitize(content.title || file.name)}`; // TODO: Strip file extension from filename
let fileRef: TFile;
// Create new file
try {
// path = await getUnusedFilename(path);
// fileRef = await plugin.app.vault.create(`${path}.md`, '');
fileRef = await createNewEmptyMdFile(vault, path, {});
} catch (error) {
return reject(new Error(`Error creating new file ${path} (from ${file.name}. Error: ${error})`));
}
// Add in tags to represent Keep properties
try {
await vault.append(fileRef, `#Keep/Colour/${content.color} `);
content.isPinned ? await vault.append(fileRef, `#Keep/Pinned `) : null;
content.attachments ? await vault.append(fileRef, `#Keep/Attachments `) : null;
content.isArchived ? await vault.append(fileRef, `#Keep/Archived `) : null;
content.isTrashed ? await vault.append(fileRef, `#Keep/Trashed `) : null;
} catch (error) {
return reject(Error(`Error adding tags to new file ${path} (from ${file.name})`));
}
// Add in text content
try {
if(content.textContent) {
await vault.append(fileRef, `\n\n`);
await vault.append(fileRef, `${content.textContent}\n`);
}
} catch (error) {
return reject(new Error(`Error adding paragraph content to new file ${path} (from ${file.name})`));
}
// Add in text content if check box
try {
if(content.listContent) {
await vault.append(fileRef, `\n\n`);
for(let i=0; i<content.listContent.length; i++) {
const listItem = content.listContent[i];
// Skip to next line if this one is blank
if(!listItem.text) continue;
let listItemContent = `- [${listItem.isChecked ? 'X' : ' '}] ${listItem.text}\n`;
await vault.append(fileRef, listItemContent);
}
}
} catch (error) {
return reject(new Error(`Error adding list content to new file ${path} (from ${file.name})`));
}
// Embed attachments
// NOTE: The files for these may not have been created yet, but since it's just markdown text, they can be created after.
if(content.attachments) {
for(let i=0; i<content.attachments.length; i++) {
const attachment = content.attachments[i];
try {
await vault.append(fileRef, `\n\n![[${attachment.filePath}]]`);
} catch (error) {
return reject(new Error(`Error embedding attachment ${attachment.filePath} to new file ${path} (from ${file.name})`));
}
}
}
// Update created and modified date to match Keep data
const options: DataWriteOptions = {
ctime: content.createdTimestampUsec/1000,
mtime: content.userEditedTimestampUsec/1000
}
await vault.append(fileRef, '', options);
// await plugin.app.vault.process(fileRef, (str) => str, options); // In docs, but not in class
return resolve(fileRef);
}
})
}
async function importBinaryFile(vault: Vault, folder: TFolder, file: File) : Promise<TFile> {
let fileRef: TFile;
const path: string = `${folder.path}/${file.name}`;
try {
fileRef = await vault.createBinary(path, await file.arrayBuffer());
} catch (error) {
return Promise.reject(new Error(`Error creating attachment (Binary file) ${path} (from ${file.name})`));
}
return Promise.resolve(fileRef);
}

View file

@ -0,0 +1,35 @@
export const singleOrPlural = (count: number, singleVersion: string, pluralVersion?: string) => {
if(count == 1 || count == -1) {
return singleVersion;
} else {
if(pluralVersion) {
// custom plural version passed in
return pluralVersion;
} else {
// just add an s
return `${singleVersion}s`;
}
}
}
export function filenameSanitize(str: string) {
// Remove /
let newArr = str.split('/');
let newStr = newArr.join();
// Remove \
newArr = newStr.split('\\');
newStr = newArr.join();
// Remove :
newArr = newStr.split(':');
newStr = newArr.join();
return newStr;
}

5
src/logic/types.ts Normal file
View file

@ -0,0 +1,5 @@
export interface MyPluginSettings {
hideOpenVaultButton: boolean;
vaultNames: [string];
vaultLinks: [string];
}

91
src/main.ts Normal file
View file

@ -0,0 +1,91 @@
import { fileSyntax } from 'esbuild-sass-plugin/lib/utils';
import { App, DataWriteOptions, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder, Vault } from 'obsidian';
import { MyPluginSettings } from './logic/types';
import { StartImportModal } from './modals/import-modal/import-modal';
import { SampleSettingTab } from './tabs/settings-tab/settings-tab';
// TODO: Put these inside the plugin settings
export const IMPORT_FOLDER = 'Keep Imports';
export const ASSET_FOLDER = 'Attachments'
const DEFAULT_SETTINGS: MyPluginSettings = {
hideOpenVaultButton: false,
vaultNames: ['name'],
vaultLinks: ['utl'],
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Open Philsophy Vault', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('Opening Philosophy Vault');
window.open('obsidian://vault/Philosophy/');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
this.addCommand({
id: 'ublik-om_import-google-keep-jsons',
name: 'Import backup from Google Keep',
callback: () => {
new StartImportModal(this).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}

View file

@ -0,0 +1,192 @@
import { App, Modal, Notice, Setting } from "obsidian";
import { importFiles } from "src/logic/import-logic";
import { singleOrPlural } from "src/logic/string-processes";
import MyPlugin from "src/main";
export class StartImportModal extends Modal {
plugin: MyPlugin;
result: string;
duplicateNotes: number = 0;
noteSpan: HTMLSpanElement;
assetSpan: HTMLSpanElement;
fileBacklog: Array<File> = [];
uploadInput: HTMLInputElement;
modalActions: Setting;
constructor(plugin: MyPlugin) {
super(plugin.app);
this.plugin = plugin;
}
onOpen() {
const {titleEl, contentEl} = this;
titleEl.setText('Import Google Keep backup');
contentEl.createEl('p', {text: 'Here you can upload a set of jsons output from a Google Keep backup.'});
contentEl.createEl('p', {text: 'Upload each json one at a time or all together. You should also upload any attachments in the backup as well such as png\'s jpgs, etc.'});
contentEl.createEl('p', {text: 'If you import attachments or jsons separately and close this dialog, they will will automatically link together once their counterparts are imported later provided you haven\'t changed the names of attachments or modified the markdown embeds in the notes.'});
const dropFrame = contentEl.createEl('div', {cls: 'uo_drop-frame'});
const dropFrameText = dropFrame.createEl('p', { text: 'Drag your files here or ' });
// const linkText = dropFrameText.createEl('a', {text: 'browse local files'})
dropFrameText.createEl('label', {
text: 'browse local files',
attr: {
'class': 'uo_file-label',
'for': 'uo_file',
}
})
this.uploadInput = dropFrameText.createEl('input', {
type: 'file',
attr: {
'multiple': true,
'id': 'uo_file',
'accept': '.json, .jpg, .png, .3gp',
}
})
const summaryP = contentEl.createEl('p', {cls: 'uo_before-import-summary'});
summaryP.createEl('span', {text: `notes: `});
this.noteSpan = summaryP.createEl('span', {cls: 'uo_import-number', text: `0`});
summaryP.createEl('span', {text: ` | attachments: `});
this.assetSpan = summaryP.createEl('span', {cls: 'uo_import-number', text: `0`});
this.modalActions = new Setting(contentEl).addButton(btn => {
btn.setClass('uo_button');
btn.setCta();
btn.setButtonText('Start Import');
btn.setDisabled(true);
btn.onClick( (e) => {
this.close();
console.log("click to start import");
importFiles(this.plugin, this.fileBacklog );
})
})
this.uploadInput.addEventListener('change', () => {
// Add imports to accumulative array
this.addToFilesBacklog( Object.values(this.uploadInput.files as FileList) );
});
dropFrame.addEventListener('dragenter', (e) => {
dropFrame.addClass('uo_drag-over-active');
});
dropFrame.addEventListener('dragover', (e) => {
// Prevent default to allow drop
e.preventDefault();
});
dropFrame.addEventListener('dragleave', (e) => {
dropFrame.removeClass('uo_drag-over-active');
});
dropFrame.addEventListener('drop', (e) => {
// Prevent default to stop browser opening file
e.preventDefault();
if(e.dataTransfer === null) return;
dropFrame.removeClass('uo_drag-over-active');
const files: Array<File> = [];
if (e.dataTransfer.items) {
// DataTransferItems is supporter in this browser
const items = [...e.dataTransfer.items];
for(let i=0; i<items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file: File | null = item.getAsFile();
if(file) files.push(file);
}
};
} else {
// Use DataTransfer interface to access the file(s)
files.push(...e.dataTransfer.files);
}
this.addToFilesBacklog(files);
});
}
addToFilesBacklog( files: Array<File> ) {
let newFiles = 0;
let duplicateFiles = 0;
// Add non-duplicates to backlog
files.forEach( (file) => {
if( this.backlogContains(file) ) {
duplicateFiles++;
} else {
this.fileBacklog.push(file);
newFiles++;
}
})
if(duplicateFiles>0) new Notice(`${duplicateFiles} ${singleOrPlural(duplicateFiles, 'file')} ignored because ${singleOrPlural(duplicateFiles, 'it\'s', 'they\'re')} already in the import list.`, 9000);
new Notice(`${newFiles} new ${singleOrPlural(newFiles, 'file')} queued for import.`, 10000);
// Erase references in upload component to prepare for new set
this.uploadInput.files = null;
// Update summary numbers
const breakdown = this.getFilesBacklogBreakdown();
this.noteSpan.setText(`${breakdown.notes}`);
this.assetSpan.setText(`${breakdown.assets}`);
// Activate start button
const importBtn = this.modalActions.components[0];
importBtn.setDisabled(false);
}
backlogContains(file: File) {
for(let i=0; i<this.fileBacklog.length; i++) {
// NOTE: Path isn't necessarily guaranteed in all environments.
// Could do a name and content comparison for jsons, and a so too for binary if possible.
// Maybe even just a content comparison. For not this is fine.
if((file as any).path) {
if((file as any).path == (this.fileBacklog[i] as any).path) return true;
}
}
// If no duplicates found
return false;
}
getFilesBacklogBreakdown(): { notes : number, assets: number } {
let notes = 0;
let assets = 0;
for(let i=0; i<this.fileBacklog.length; i++) {
const file = this.fileBacklog[i] as File;
switch(file.type) {
case 'application/json': notes++; break;
default: assets++; break;
}
}
return {
notes,
assets
}
}
onClose() {
const {titleEl, contentEl} = this;
titleEl.empty();
contentEl.empty();
}
}

View file

@ -0,0 +1,107 @@
import { App, Modal, Setting, TFile, TFolder } from "obsidian";
import MyPlugin from "src/main";
export class ImportProgressModal extends Modal {
result: string;
bar: HTMLDivElement;
remainingSpan: HTMLSpanElement;
failedSpan: HTMLSpanElement;
importedSpan: HTMLSpanElement;
constructor(plugin: MyPlugin) {
super(plugin.app);
}
onOpen() {
const {titleEl, contentEl} = this;
titleEl.setText('Import in progress');
const progressBarEl = contentEl.createEl('div', {cls: 'uo_progress-bar'});
this.bar = progressBarEl.createEl('div', {cls: 'uo_bar'});
const summaryEl = contentEl.createDiv('uo_during-import-summary');
let bubbleEl;
let pBubbleEl
bubbleEl = summaryEl.createDiv('uo_import-remaining');
pBubbleEl = bubbleEl.createEl('p');
this.remainingSpan = pBubbleEl.createEl('span', {cls: 'uo_import-number', text: `-`});
pBubbleEl.createEl('br');
pBubbleEl.createEl('span', {cls: 'uo_import-label', text: `remaining`});
bubbleEl = summaryEl.createDiv('uo_import-failed');
pBubbleEl = bubbleEl.createEl('p');
this.failedSpan = pBubbleEl.createEl('span', {cls: 'uo_import-number', text: `-`});
pBubbleEl.createEl('br');
pBubbleEl.createEl('span', {cls: 'uo_import-label', text: `failed/skipped`});
bubbleEl = summaryEl.createDiv('uo_import-imported');
pBubbleEl = bubbleEl.createEl('p');
this.importedSpan = pBubbleEl.createEl('span', {cls: 'uo_import-number', text: `-`});
pBubbleEl.createEl('br');
pBubbleEl.createEl('span', {cls: 'uo_import-label', text: `imported`});
}
public updateProgressVisuals(options: {successCount: number, failCount: number, totalImports: number}) {
const {
successCount,
failCount,
totalImports
} = options;
// Update bar visual
const perc = (successCount + failCount)/totalImports * 100;
this.bar.setAttr('style', `width: ${perc}%`);
// Update text
this.remainingSpan.setText(`${totalImports-successCount-failCount}`);
this.failedSpan.setText(`${failCount}`);
this.importedSpan.setText(`${successCount}`);
}
public applyCompletedState() {
const modalActions = new Setting(this.contentEl).addButton(btn => {
btn.setCta();
btn.setClass('uo_button');
btn.setButtonText('Close');
btn.onClick( (e) => {
this.close();
})
})
}
onClose() {
const {titleEl, contentEl} = this;
titleEl.empty();
contentEl.empty();
}
}
export function updateProgress(options: {successCount: number, failCount: number, totalImports: number, modal: ImportProgressModal}) {
const {successCount, failCount, totalImports, modal} = options;
modal.updateProgressVisuals({
successCount,
failCount,
totalImports
})
if(successCount + failCount == totalImports) {
modal.applyCompletedState();
}
}

View file

@ -1,7 +1,7 @@
{
"id": "ublik-om_keep-import",
"name": "Keep Import",
"version": "0.0.1",
"version": "0.0.5",
"minAppVersion": "1.00.0",
"description": "Allows import of Google Keep backup json files",
"author": "Dale de Silva",

View file

@ -0,0 +1,59 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import MyPlugin from "src/main";
export class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
new Setting(containerEl)
.setName('Hide "Open Other Vault" button')
.setDesc('This will hide the button at the bottom left that returns you to the Vault Selector screen. This is mostly useful for mobile users that find this button disruptive to flow.')
.addToggle(value => value
.setValue(this.plugin.settings.hideOpenVaultButton)
.onChange(async (value) => {
this.plugin.settings.hideOpenVaultButton = !value;
await this.plugin.saveSettings();
}));
// new Setting(contentEl)
// .setName("Name")
// .addText((text) =>
// text.onChange((value) => {
// this.result = value
// }));
// new Setting(contentEl)
// .addColorPicker(color => {});
// new Setting(contentEl)
// .addExtraButton(btn => {});
// new Setting(contentEl)
// .addMomentFormat(test => {})
// new Setting(contentEl)
// .addSearch(test => {});
// new Setting(contentEl)
// .addSlider(test => {});
// new Setting(contentEl)
// .addTextArea(test => {});
}
}

View file

@ -12,7 +12,7 @@
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"DOM", "dom.iterable",
"ES5",
"ES6",
"ES7"