This commit is contained in:
Maclean Dunkin 2025-03-14 23:19:49 -06:00
parent 75ceb52a79
commit 0356665247
4 changed files with 296 additions and 166 deletions

117
README.md
View file

@ -1,94 +1,71 @@
# Obsidian Sample Plugin
# Limitless Lifelogs for Obsidian
This is a sample plugin for Obsidian (https://obsidian.md).
This plugin allows you to download and sync your Limitless AI lifelog entries into Obsidian markdown files.
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
## Features
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
- Download your Limitless AI lifelog entries as markdown files
- Automatically organize entries by date (YYYY-MM-DD.md)
- Sync new entries with a single click
- Preserves original markdown formatting and structure
- Supports incremental syncing (only fetches new or updated entries)
## First time developing plugins?
## Installation
Quick starting guide for new plugin devs:
1. Open Obsidian Settings
2. Go to Community Plugins and disable Safe Mode
3. Click Browse and search for "Limitless Lifelogs"
4. Install the plugin
5. Enable the plugin in your Community Plugins list
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Manual Installation
## Releasing new releases
1. Download the latest release from the releases page
2. Extract the zip file into your vault's `.obsidian/plugins` folder
3. Enable the plugin in your Community Plugins list
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
## Configuration
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
1. Open Obsidian Settings
2. Go to "Limitless Lifelogs" in the plugin list
3. Enter your Limitless AI API key
4. Choose the folder where you want your lifelog entries to be stored
5. (Optional) Modify the start date for initial sync (defaults to February 9th, 2025)
## Adding your plugin to the community plugin list
## Usage
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
### Initial Sync
## How to use
1. Configure your API key and folder settings
2. Click the sync icon in the left ribbon
3. Wait for the sync to complete
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
### Incremental Sync
## Manually installing the plugin
1. Click the sync icon in the left ribbon anytime to fetch new entries
2. The plugin will automatically detect the last synced date and only fetch new entries
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
### Command Palette
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint .\src\`
You can also trigger a sync using the command palette (Ctrl/Cmd + P):
- Search for "Limitless Lifelogs: Sync"
## Funding URL
## File Format
You can include funding URLs where people who use your plugin can financially support it.
Each lifelog entry is saved in a markdown file named with the date format `YYYY-MM-DD.md`. The content preserves the original structure from Limitless AI, including:
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
- Entry titles as H1 headings
- Sections as H2 headings
- Messages with timestamps and speaker names
- Original markdown formatting
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
## Support
If you have multiple URLs, you can also do:
If you encounter any issues or have suggestions, please:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
1. Check the [GitHub Issues](https://github.com/yourusername/obsidian-limitless-lifelogs/issues) page
2. Create a new issue if your problem hasn't been reported
## API Documentation
## License
See https://github.com/obsidianmd/obsidian-api
This project is licensed under the MIT License - see the LICENSE file for details.

327
main.ts
View file

@ -1,81 +1,43 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { App, Plugin, PluginSettingTab, Setting, normalizePath, Notice, requestUrl } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
interface LimitlessLifelogsSettings {
apiKey: string;
folderPath: string;
startDate: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
const DEFAULT_SETTINGS: LimitlessLifelogsSettings = {
apiKey: '',
folderPath: 'Limitless Lifelogs',
startDate: '2025-02-09'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
export default class LimitlessLifelogsPlugin extends Plugin {
settings: LimitlessLifelogsSettings;
api: LimitlessAPI;
async onload() {
await this.loadSettings();
this.api = new LimitlessAPI(this.settings.apiKey);
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
// Add settings tab
this.addSettingTab(new LimitlessLifelogsSettingTab(this.app, this));
// Add ribbon icon for syncing
this.addRibbonIcon('sync', 'Sync Limitless Lifelogs', async () => {
await this.syncLifelogs();
});
// 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 adds a simple command that can be triggered anywhere
// Add command for syncing
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
id: 'sync-limitless-lifelogs',
name: 'Sync Limitless Lifelogs',
callback: async () => {
await this.syncLifelogs();
}
});
// 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 complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// 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() {
@ -88,47 +50,238 @@ export default class MyPlugin extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
if (this.api) {
this.api.setApiKey(this.settings.apiKey);
}
}
async syncLifelogs() {
if (!this.settings.apiKey) {
new Notice('Please set your Limitless API key in settings');
return;
}
try {
// Ensure the folder exists
const folderPath = normalizePath(this.settings.folderPath);
await this.ensureFolderExists(folderPath);
// Get the last synced date
const lastSyncedDate = await this.getLastSyncedDate();
const startDate = lastSyncedDate || new Date(this.settings.startDate);
const endDate = new Date();
new Notice('Starting Limitless lifelog sync...');
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dateStr = currentDate.toISOString().split('T')[0];
const logs = await this.api.getLifelogs(currentDate);
if (logs && logs.length > 0) {
const content = logs.map(log => this.formatLifelogMarkdown(log)).join('\n\n');
const filePath = `${folderPath}/${dateStr}.md`;
await this.app.vault.adapter.write(filePath, content);
new Notice(`Synced entries for ${dateStr}`);
}
currentDate.setDate(currentDate.getDate() + 1);
}
new Notice('Limitless lifelog sync complete!');
} catch (error) {
console.error('Error syncing lifelogs:', error);
new Notice('Error syncing Limitless lifelogs. Check console for details.');
}
}
private async ensureFolderExists(path: string) {
const folderExists = await this.app.vault.adapter.exists(path);
if (!folderExists) {
await this.app.vault.createFolder(path);
}
}
private async getLastSyncedDate(): Promise<Date | null> {
const folderPath = normalizePath(this.settings.folderPath);
try {
const files = await this.app.vault.adapter.list(folderPath);
const dates = files.files
.map(file => file.split('/').pop()?.replace('.md', ''))
.filter(date => date && /^\d{4}-\d{2}-\d{2}$/.test(date))
.map(date => new Date(date as string))
.sort((a, b) => b.getTime() - a.getTime());
return dates.length > 0 ? dates[0] : null;
} catch {
return null;
}
}
private formatLifelogMarkdown(lifelog: any): string {
if (lifelog.markdown) {
return lifelog.markdown;
}
const content: string[] = [];
if (lifelog.title) {
content.push(`# ${lifelog.title}\n`);
}
if (lifelog.contents) {
let currentSection = '';
let sectionMessages: string[] = [];
for (const node of lifelog.contents) {
if (node.type === 'heading2') {
if (currentSection && sectionMessages.length > 0) {
content.push(`## ${currentSection}\n`);
content.push(...sectionMessages);
content.push('');
}
currentSection = node.content;
sectionMessages = [];
} else if (node.type === 'blockquote') {
const speaker = node.speakerName || 'Speaker';
let timestamp = '';
if (node.startTime) {
const dt = new Date(node.startTime);
timestamp = dt.toLocaleString('en-US', {
month: '2-digit',
day: '2-digit',
year: '2-digit',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
timestamp = `(${timestamp})`;
}
const message = `- ${speaker} ${timestamp}: ${node.content}`;
if (currentSection) {
sectionMessages.push(message);
} else {
content.push(message);
}
} else if (node.type !== 'heading1') {
content.push(node.content);
}
}
if (currentSection && sectionMessages.length > 0) {
content.push(`## ${currentSection}\n`);
content.push(...sectionMessages);
}
}
return content.join('\n\n');
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
class LimitlessLifelogsSettingTab extends PluginSettingTab {
plugin: LimitlessLifelogsPlugin;
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
constructor(app: App, plugin: LimitlessLifelogsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.setName('API Key')
.setDesc('Your Limitless AI API key')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.setPlaceholder('Enter your API key')
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Folder Path')
.setDesc('Where to store the lifelog entries')
.addText(text => text
.setPlaceholder('Folder path')
.setValue(this.plugin.settings.folderPath)
.onChange(async (value) => {
this.plugin.settings.folderPath = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Start Date')
.setDesc('Default start date for initial sync (YYYY-MM-DD)')
.addText(text => text
.setPlaceholder('YYYY-MM-DD')
.setValue(this.plugin.settings.startDate)
.onChange(async (value) => {
this.plugin.settings.startDate = value;
await this.plugin.saveSettings();
}));
}
}
class LimitlessAPI {
private apiKey: string;
private baseUrl = 'https://api.limitless.ai';
private batchSize = 10;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
setApiKey(apiKey: string) {
this.apiKey = apiKey;
}
async getLifelogs(date: Date): Promise<any[]> {
const allLifelogs: any[] = [];
let cursor: string | null = null;
const params = new URLSearchParams({
date: date.toISOString().split('T')[0],
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
includeMarkdown: 'true',
includeHeadings: 'true',
direction: 'asc',
limit: this.batchSize.toString()
});
do {
if (cursor) {
params.set('cursor', cursor);
}
try {
const response = await requestUrl({
url: `${this.baseUrl}/v1/lifelogs?${params.toString()}`,
method: 'GET',
headers: {
'X-API-Key': this.apiKey,
'Content-Type': 'application/json'
}
});
if (!response.json) {
throw new Error('Invalid response format');
}
const data = response.json;
const lifelogs = data.data?.lifelogs || [];
allLifelogs.push(...lifelogs);
cursor = data.meta?.lifelogs?.nextCursor || null;
} catch (error) {
console.error('Error fetching lifelogs:', error);
throw error;
}
} while (cursor);
return allLifelogs;
}
}

View file

@ -1,11 +1,11 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"id": "obsidian-limitless-lifelogs",
"name": "Limitless Lifelogs",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"description": "Download and sync your Limitless AI lifelog entries into Obsidian markdown files.",
"author": "Your Name",
"authorUrl": "https://github.com/yourusername",
"fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": false
}

View file

@ -1,15 +1,15 @@
{
"name": "obsidian-sample-plugin",
"name": "obsidian-limitless-lifelogs",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"description": "Download and sync your Limitless AI lifelog entries into Obsidian markdown files.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"keywords": ["obsidian", "plugin", "limitless", "lifelogs"],
"author": "Your Name",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",