mirror of
https://github.com/royxue/obsidian-things3-sync.git
synced 2026-07-22 07:00:32 +00:00
Merge branch 'v1.4.0'
Release 1.4.0: URL-encoding fix, detached-mode toggle, README clarity, dead-code removal, and a vitest test suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
a55adeff5d
13 changed files with 2920 additions and 225 deletions
10
README.md
10
README.md
|
|
@ -7,6 +7,16 @@ A cross-platform Obsidian plugin for syncing todos (as well as tags, dates) betw
|
|||
* **Todo Tags**: You can enter tags after your todo text, or via the default tags in the settings
|
||||
* **Date Capture**: If the Obsidian note includes a date, it will be included when creating the todo in Things3.
|
||||
|
||||
## How It Works (and What It Doesn't)
|
||||
|
||||
This plugin is **one-way**: you push changes *from* Obsidian *to* Things3. Things3's URL scheme doesn't allow listening for changes, so:
|
||||
|
||||
* ✅ Creating a todo and toggling its status are triggered from Obsidian, per line.
|
||||
* ✅ Toggling status updates **both** Obsidian and Things3.
|
||||
* ❌ Completing or editing a task **in Things3 does not update Obsidian** — there is no automatic sync back.
|
||||
* ℹ️ Only the title, tags, and done/not-done state are synced. Tags must already exist in Things3 (see the note under *Creating A Todo*).
|
||||
* ℹ️ *Creating a Todo from a Note* adds a backlink to your note inside the Things3 todo, but does **not** modify the Obsidian note itself.
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating A Todo
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.4.0] 2026-06-22
|
||||
* Fix URL encoding for Things3 deep links (titles/tags/notes with spaces or special characters no longer break the link).
|
||||
* Add the missing Detached mode toggle to the settings tab.
|
||||
* Clarify in the README that sync is one-way (Obsidian → Things3).
|
||||
* Internal: remove dead code, add a vitest test suite, and type the internal commands API.
|
||||
|
||||
## [1.3.0] 2024-04-23
|
||||
* Add detached mode: only create todo in Things, without changing Obsidian note.
|
||||
* Support '&' in things link.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "obsidian-things3-sync",
|
||||
"name": "Things3 Sync",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "An Obsidian plugin for sync between Obsidian and Things3, create Todo and sync Todo status",
|
||||
"author": "Royx",
|
||||
|
|
|
|||
2711
package-lock.json
generated
2711
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +1,26 @@
|
|||
{
|
||||
"name": "obsidian-things3-sync",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "An Obsidian plugin for sync between Obsidian and Things3, create Todo and sync Todo status",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "vitest run",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.14.47",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
"typescript": "4.7.4",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
src/extractor.test.ts
Normal file
56
src/extractor.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { extractDate, extractTitle, extractTags, extractTarget } from './extractor';
|
||||
|
||||
describe('extractDate', () => {
|
||||
it('extracts an ISO-like date at the start of a line', () => {
|
||||
expect(extractDate('2026-06-22 Buy milk')).toBe('2026-06-22');
|
||||
expect(extractDate('2026/06/22 Buy milk')).toBe('2026/06/22');
|
||||
});
|
||||
|
||||
it('returns empty string when there is no leading date', () => {
|
||||
expect(extractDate('Buy milk 2026-06-22')).toBe('');
|
||||
expect(extractDate('Buy milk')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTitle', () => {
|
||||
it('strips leading checkbox / list markers', () => {
|
||||
expect(extractTitle('- [ ] Buy milk')).toBe('Buy milk');
|
||||
expect(extractTitle('* Buy milk')).toBe('Buy milk');
|
||||
expect(extractTitle('# Heading')).toBe('Heading');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(extractTitle(' Buy milk ')).toBe('Buy milk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTags', () => {
|
||||
it('collects inline tags', () => {
|
||||
expect(extractTags('Buy milk #home #errand', '')).toBe('home,errand');
|
||||
});
|
||||
|
||||
it('appends default tags', () => {
|
||||
expect(extractTags('Buy milk #home', 'work')).toBe('home,work');
|
||||
});
|
||||
|
||||
it('returns empty string when there are no tags', () => {
|
||||
expect(extractTags('Buy milk', '')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTarget', () => {
|
||||
it('parses the todo id and a pending (to be completed) status', () => {
|
||||
const line = '- [ ] [Buy milk](things:///show?id=ABC123)';
|
||||
expect(extractTarget(line)).toEqual({ todoId: 'ABC123', completed: true });
|
||||
});
|
||||
|
||||
it('treats a checked box as completed:false (about to reopen)', () => {
|
||||
const line = '- [x] [Buy milk](things:///show?id=ABC123)';
|
||||
expect(extractTarget(line)).toEqual({ todoId: 'ABC123', completed: false });
|
||||
});
|
||||
|
||||
it('returns an empty id for a non-Things line', () => {
|
||||
expect(extractTarget('- [ ] Buy milk').todoId).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -10,14 +10,9 @@ export function extractDate(line:string) {
|
|||
}
|
||||
|
||||
export function extractTitle(line: string) {
|
||||
const regex = /[^#\s\-\[\]*](.*)/gs
|
||||
const content = line.match(regex);
|
||||
let title = '';
|
||||
if (content != null) {
|
||||
title = content[0]
|
||||
}
|
||||
|
||||
return title;
|
||||
// Strip a leading markdown list / checkbox / heading marker
|
||||
// (e.g. "- [ ] ", "* ", "# ") and surrounding whitespace.
|
||||
return line.replace(/^[\s#\-\[\]*]+/, '').trim();
|
||||
}
|
||||
|
||||
export function extractTags(line: string, setting_tags: string){
|
||||
|
|
@ -34,23 +29,12 @@ export function extractTags(line: string, setting_tags: string){
|
|||
}
|
||||
|
||||
export function extractTarget(line: string) {
|
||||
const regexId = /id=(\w+)/
|
||||
const id = line.match(regexId);
|
||||
let todoId: string;
|
||||
if (id != null) {
|
||||
todoId = id[1];
|
||||
} else {
|
||||
todoId = ''
|
||||
}
|
||||
const idMatch = line.match(/id=(\w+)/);
|
||||
const todoId = idMatch != null ? idMatch[1] : '';
|
||||
|
||||
const regexStatus = /\[(.)\]/
|
||||
const status = line.match(regexStatus)
|
||||
let afterStatus: string;
|
||||
if (status && status[1] == ' ') {
|
||||
afterStatus = 'true'
|
||||
} else {
|
||||
afterStatus = 'false'
|
||||
}
|
||||
// An unchecked box ("[ ]") means toggling will mark it completed.
|
||||
const statusMatch = line.match(/\[(.)\]/);
|
||||
const completed = statusMatch != null && statusMatch[1] === ' ';
|
||||
|
||||
return {todoId, afterStatus}
|
||||
return { todoId, completed };
|
||||
}
|
||||
|
|
|
|||
184
src/main.ts
184
src/main.ts
|
|
@ -17,14 +17,19 @@ import {
|
|||
extractTags,
|
||||
extractTarget,
|
||||
extractTitle
|
||||
} from './extractor'
|
||||
} from './extractor';
|
||||
|
||||
// import { rangeByStep, Queue } from './utils';
|
||||
// `commands` is an internal Obsidian API not covered by the public typings.
|
||||
declare module 'obsidian' {
|
||||
interface App {
|
||||
commands: {
|
||||
executeCommandById(id: string): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentLine(editor: Editor, view: MarkdownView) {
|
||||
const lineNumber = editor.getCursor().line
|
||||
const lineText = editor.getLine(lineNumber)
|
||||
return lineText
|
||||
function getCurrentLine(editor: Editor): string {
|
||||
return editor.getLine(editor.getCursor().line);
|
||||
}
|
||||
|
||||
interface PluginSettings {
|
||||
|
|
@ -39,33 +44,28 @@ const DEFAULT_SETTINGS: PluginSettings = {
|
|||
detachedMode: false
|
||||
}
|
||||
|
||||
function contructTodo(line: string, settings: PluginSettings, fileName: string){
|
||||
function constructTodo(line: string, settings: PluginSettings, fileName: string): TodoInfo {
|
||||
line = line.trim();
|
||||
const tags = extractTags(line, settings.defaultTags);
|
||||
const date = extractDate(line) || extractDate(fileName);
|
||||
line = line.replace(/#([^\s]+)/gs, '');
|
||||
|
||||
const todo: TodoInfo = {
|
||||
return {
|
||||
title: extractTitle(line),
|
||||
tags: tags,
|
||||
date: date
|
||||
}
|
||||
|
||||
return todo;
|
||||
};
|
||||
}
|
||||
|
||||
export default class Things3Plugin extends Plugin {
|
||||
settings: PluginSettings;
|
||||
|
||||
async onload() {
|
||||
// Queue for update multi liens
|
||||
// const toChange = new Queue<number>();
|
||||
|
||||
// Setup Settings Tab
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new Things3SyncSettingTab(this.app, this));
|
||||
|
||||
// Register Protocol Handler
|
||||
// Register Protocol Handler: Things3 calls back with the new todo id so
|
||||
// we can rewrite the source line into a linked checkbox.
|
||||
this.registerObsidianProtocolHandler("things-sync-id", async (id) => {
|
||||
if (this.settings.detachedMode) {
|
||||
return;
|
||||
|
|
@ -74,106 +74,93 @@ export default class Things3Plugin extends Plugin {
|
|||
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (view == null) {
|
||||
return;
|
||||
} else {
|
||||
const editor = view.editor
|
||||
// const l = toChange.dequeue();
|
||||
// editor.setCursor(l);
|
||||
const currentLine = getCurrentLine(editor, view)
|
||||
const firstLetterIndex = currentLine.search(/[^\s#\-\[\]*]/);
|
||||
const line = currentLine.substring(firstLetterIndex, currentLine.length)
|
||||
const editorPosition = view.editor.getCursor()
|
||||
const lineLength = view.editor.getLine(editorPosition.line).length
|
||||
const startRange: EditorPosition = {
|
||||
line: editorPosition.line,
|
||||
ch: firstLetterIndex
|
||||
}
|
||||
const endRange: EditorPosition = {
|
||||
line: editorPosition.line,
|
||||
ch: lineLength
|
||||
}
|
||||
}
|
||||
const editor = view.editor;
|
||||
const currentLine = getCurrentLine(editor);
|
||||
const firstLetterIndex = currentLine.search(/[^\s#\-\[\]*]/);
|
||||
const line = currentLine.substring(firstLetterIndex, currentLine.length);
|
||||
const editorPosition = editor.getCursor();
|
||||
const lineLength = editor.getLine(editorPosition.line).length;
|
||||
const startRange: EditorPosition = {
|
||||
line: editorPosition.line,
|
||||
ch: firstLetterIndex
|
||||
};
|
||||
const endRange: EditorPosition = {
|
||||
line: editorPosition.line,
|
||||
ch: lineLength
|
||||
};
|
||||
|
||||
if (firstLetterIndex > 0) {
|
||||
view.editor.replaceRange(`[${line}](things:///show?id=${todoID})`, startRange, endRange);
|
||||
} else {
|
||||
view.editor.replaceRange(`- [ ] [${line}](things:///show?id=${todoID})`, startRange, endRange);
|
||||
}
|
||||
if (firstLetterIndex > 0) {
|
||||
editor.replaceRange(`[${line}](things:///show?id=${todoID})`, startRange, endRange);
|
||||
} else {
|
||||
editor.replaceRange(`- [ ] [${line}](things:///show?id=${todoID})`, startRange, endRange);
|
||||
}
|
||||
});
|
||||
|
||||
// Create TODO Command
|
||||
// Create a Things3 todo from the current line.
|
||||
this.addCommand({
|
||||
id: 'create-things-todo',
|
||||
name: 'Create Things Todo',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
const workspace = this.app.workspace;
|
||||
const vault = this.app.vault;
|
||||
const fileTitle = workspace.getActiveFile()
|
||||
if (fileTitle == null) {
|
||||
editorCallback: (editor: Editor) => {
|
||||
const context = this.getActiveNoteContext();
|
||||
if (context == null) {
|
||||
new Notice('No active note');
|
||||
return;
|
||||
} else {
|
||||
let fileName = urlEncode(fileTitle.name)
|
||||
fileName = fileName.replace(/\.md$/, '')
|
||||
const vaultName = urlEncode(vault.getName());
|
||||
const obsidianDeepLink = constructDeeplink(fileName, vaultName);
|
||||
// const obsidianDeepLink = (this.app as any).getObsidianUrl(fileTitle)
|
||||
const encodedLink = urlEncode(obsidianDeepLink);
|
||||
const line = getCurrentLine(editor, view);
|
||||
const todo = contructTodo(line, this.settings, fileName);
|
||||
createTodo(todo, encodedLink)
|
||||
}
|
||||
const line = getCurrentLine(editor);
|
||||
const todo = constructTodo(line, this.settings, context.fileName);
|
||||
createTodo(todo, context.deepLink);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle task status and sync to things
|
||||
// Toggle the current todo's status in both Obsidian and Things3.
|
||||
this.addCommand({
|
||||
id: 'toggle-things-todo',
|
||||
name: 'Toggle Things Todo',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
const workspace = this.app.workspace;
|
||||
const fileTitle = workspace.getActiveFile()
|
||||
if (fileTitle == null) {
|
||||
editorCallback: (editor: Editor) => {
|
||||
const line = getCurrentLine(editor);
|
||||
const target = extractTarget(line);
|
||||
if (target.todoId === '') {
|
||||
new Notice('This is not a Things3 todo');
|
||||
return;
|
||||
} else {
|
||||
const line = getCurrentLine(editor, view)
|
||||
const target = extractTarget(line)
|
||||
if (target.todoId == '') {
|
||||
new Notice(`This is not a things3 todo`);
|
||||
} else {
|
||||
view.app.commands.executeCommandById("editor:toggle-checklist-status")
|
||||
updateTodo(target.todoId, target.afterStatus, this.settings.authToken)
|
||||
new Notice(`${target.todoId} set completed:${target.afterStatus} on things3`);
|
||||
}
|
||||
}
|
||||
this.app.commands.executeCommandById('editor:toggle-checklist-status');
|
||||
updateTodo(target.todoId, target.completed, this.settings.authToken);
|
||||
new Notice(`${target.todoId} set completed:${target.completed} on Things3`);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle task status and sync to things
|
||||
// Create a Things3 todo from the whole note (title becomes the todo).
|
||||
this.addCommand({
|
||||
id: 'create-things-todo-from-note',
|
||||
name: 'Create Things Todo from Note',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
const workspace = this.app.workspace;
|
||||
const vault = this.app.vault;
|
||||
const fileTitle = workspace.getActiveFile()
|
||||
if (fileTitle == null) {
|
||||
editorCallback: () => {
|
||||
const context = this.getActiveNoteContext();
|
||||
if (context == null) {
|
||||
new Notice('No active note');
|
||||
return;
|
||||
} else {
|
||||
let fileName = urlEncode(fileTitle.name)
|
||||
fileName = fileName.replace(/\.md$/, '')
|
||||
const vaultName = urlEncode(vault.getName());
|
||||
const obsidianDeepLink = constructDeeplink(fileName, vaultName);
|
||||
const encodedLink = urlEncode(obsidianDeepLink);
|
||||
const todo = contructTodo(fileName, this.settings, fileName);
|
||||
createTodoFromNote(todo, encodedLink)
|
||||
}
|
||||
const todo = constructTodo(context.fileName, this.settings, context.fileName);
|
||||
createTodoFromNote(todo, context.deepLink);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
onunload() {
|
||||
}
|
||||
|
||||
// Resolve the active note's bare name and an encoded obsidian:// deep link
|
||||
// back to it, or null when there is no active file.
|
||||
private getActiveNoteContext(): { fileName: string; deepLink: string } | null {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
const fileName = file.name.replace(/\.md$/, '');
|
||||
const deepLink = constructDeeplink(urlEncode(fileName), urlEncode(this.app.vault.getName()));
|
||||
return { fileName, deepLink };
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
|
@ -192,17 +179,17 @@ class Things3SyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
display(): void {
|
||||
const {containerEl} = this;
|
||||
const { containerEl } = this;
|
||||
|
||||
containerEl.empty();
|
||||
containerEl.createEl('h2', {text: 'Settings for Obsidian Things3 Sync.'});
|
||||
|
||||
new Setting(containerEl).setName('Things3 Sync').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Auth Token')
|
||||
.setDesc('Require Things3 Auth Token for syncing Todo status; Get Auth Token\
|
||||
via Things3 -> Preferences -> General -> Enable things URL -> Manage.')
|
||||
.setName('Auth token')
|
||||
.setDesc('Required for syncing todo status. Get it via Things3 → Settings → General → Enable Things URLs → Manage.')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Leave Things3 Auth Token here')
|
||||
.setPlaceholder('Enter your Things3 auth token')
|
||||
.setValue(this.plugin.settings.authToken)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.authToken = value;
|
||||
|
|
@ -210,15 +197,24 @@ class Things3SyncSettingTab extends PluginSettingTab {
|
|||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Default Tags')
|
||||
.setDesc('The default tags for Obsidian Todo; Using comma(,) \
|
||||
to separate multiple tags; Leave this in blank for no default tags')
|
||||
.setName('Default tags')
|
||||
.setDesc('Comma-separated tags added to every todo. Leave blank for none.')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Leave your tags here')
|
||||
.setPlaceholder('tag1,tag2')
|
||||
.setValue(this.plugin.settings.defaultTags)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultTags = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Detached mode')
|
||||
.setDesc('When enabled, new todos are not linked back into the note (the source line is left unchanged).')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.detachedMode)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.detachedMode = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
src/things3.test.ts
Normal file
46
src/things3.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createTodo, updateTodo, createTodoFromNote } from './things3';
|
||||
|
||||
let lastUrl: string;
|
||||
|
||||
beforeEach(() => {
|
||||
lastUrl = '';
|
||||
vi.stubGlobal('window', { open: (url: string) => { lastUrl = url; } });
|
||||
});
|
||||
|
||||
describe('createTodo', () => {
|
||||
it('percent-encodes the title, tags and deep link', () => {
|
||||
createTodo(
|
||||
{ title: 'Buy milk & eggs', tags: 'home,errand', date: '2026-06-22' },
|
||||
'obsidian://open?vault=My Vault&file=Note'
|
||||
);
|
||||
expect(lastUrl).toContain('title=Buy%20milk%20%26%20eggs');
|
||||
expect(lastUrl).toContain('tags=home%2Cerrand');
|
||||
expect(lastUrl).toContain('when=2026-06-22');
|
||||
expect(lastUrl).toContain('notes=obsidian%3A%2F%2Fopen%3Fvault%3DMy%20Vault%26file%3DNote');
|
||||
expect(lastUrl).toContain('x-success=obsidian%3A%2F%2Fthings-sync-id');
|
||||
});
|
||||
|
||||
it('omits empty parameters', () => {
|
||||
createTodo({ title: 'Plain', tags: '', date: '' }, 'obsidian://open');
|
||||
expect(lastUrl).not.toContain('tags=');
|
||||
expect(lastUrl).not.toContain('when=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTodoFromNote', () => {
|
||||
it('encodes the title (previously left raw)', () => {
|
||||
createTodoFromNote({ title: 'My Note & Stuff', tags: '', date: '' }, 'obsidian://open');
|
||||
expect(lastUrl).toContain('title=My%20Note%20%26%20Stuff');
|
||||
expect(lastUrl).not.toContain('x-success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTodo', () => {
|
||||
it('serializes the boolean completed flag', () => {
|
||||
updateTodo('ABC123', true, 'tok en');
|
||||
expect(lastUrl).toContain('id=ABC123');
|
||||
expect(lastUrl).toContain('completed=true');
|
||||
expect(lastUrl).toContain('auth-token=tok%20en');
|
||||
});
|
||||
});
|
||||
|
|
@ -4,18 +4,41 @@ export interface TodoInfo {
|
|||
date: string
|
||||
}
|
||||
|
||||
// Build a things:/// URL, percent-encoding every parameter value and
|
||||
// dropping empty ones so special characters never break the link.
|
||||
function buildThingsUrl(action: string, params: Record<string, string>): string {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, value]) => value != null && value !== '')
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
return `things:///${action}?${query}`;
|
||||
}
|
||||
|
||||
export function createTodo(todo: TodoInfo, deepLink: string){
|
||||
const title = todo.title.replace(/ /g, '%20').replace(/&/g, '%26');
|
||||
const url = `things:///add?title=${title}¬es=${deepLink}&when=${todo.date}&x-success=obsidian://things-sync-id&tags=${todo.tags}`;
|
||||
const url = buildThingsUrl('add', {
|
||||
title: todo.title,
|
||||
notes: deepLink,
|
||||
when: todo.date,
|
||||
tags: todo.tags,
|
||||
'x-success': 'obsidian://things-sync-id',
|
||||
});
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
export function updateTodo(todoId: string, completed: string, authToken: string){
|
||||
const url = `things:///update?id=${todoId}&completed=${completed}&auth-token=${authToken}`;
|
||||
export function updateTodo(todoId: string, completed: boolean, authToken: string){
|
||||
const url = buildThingsUrl('update', {
|
||||
id: todoId,
|
||||
completed: String(completed),
|
||||
'auth-token': authToken,
|
||||
});
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
export function createTodoFromNote(todo: TodoInfo, deepLink: string){
|
||||
const url = `things:///add?title=${todo.title}¬es=${deepLink}&when=${todo.date}`;
|
||||
const url = buildThingsUrl('add', {
|
||||
title: todo.title,
|
||||
notes: deepLink,
|
||||
when: todo.date,
|
||||
});
|
||||
window.open(url);
|
||||
}
|
||||
|
|
|
|||
49
src/utils.ts
49
src/utils.ts
|
|
@ -1,49 +0,0 @@
|
|||
export function rangeByStep(start: number, end: number, step: number): number[] {
|
||||
if (end === start || step === 0) {
|
||||
return [start];
|
||||
}
|
||||
if (step < 0) {
|
||||
step = -step;
|
||||
}
|
||||
|
||||
const stepNumOfDecimal = step.toString().split(".")[1]?.length || 0;
|
||||
const endNumOfDecimal = end.toString().split(".")[1]?.length || 0;
|
||||
const maxNumOfDecimal = Math.max(stepNumOfDecimal, endNumOfDecimal);
|
||||
const power = Math.pow(10, maxNumOfDecimal);
|
||||
const diff = Math.abs(end - start);
|
||||
const count = Math.trunc(diff / step + 1);
|
||||
step = end - start > 0 ? step : -step;
|
||||
|
||||
const intStart = Math.trunc(start * power);
|
||||
return Array.from(Array(count).keys())
|
||||
.map(x => {
|
||||
const increment = Math.trunc(x * step * power);
|
||||
const value = intStart + increment;
|
||||
return Math.trunc(value) / power;
|
||||
});
|
||||
}
|
||||
|
||||
interface IQueue<T> {
|
||||
enqueue(item: T): void;
|
||||
dequeue(): T | undefined;
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export class Queue<T> implements IQueue<T> {
|
||||
private storage: T[] = [];
|
||||
|
||||
constructor(private capacity: number = Infinity) {}
|
||||
|
||||
enqueue(item: T): void {
|
||||
if (this.size() === this.capacity) {
|
||||
throw Error("Queue has reached max capacity, you cannot add more items");
|
||||
}
|
||||
this.storage.push(item);
|
||||
}
|
||||
dequeue(): T | undefined {
|
||||
return this.storage.shift();
|
||||
}
|
||||
size(): number {
|
||||
return this.storage.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,5 +20,8 @@
|
|||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"1.3.0": "0.15.0"
|
||||
"1.3.0": "0.15.0",
|
||||
"1.4.0": "0.15.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue