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:
Roy Xue 2026-06-22 12:30:05 +08:00
commit a55adeff5d
13 changed files with 2920 additions and 225 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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('');
});
});

View file

@ -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 };
}

View file

@ -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
View 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');
});
});

View file

@ -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}&notes=${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}&notes=${deepLink}&when=${todo.date}`;
const url = buildThingsUrl('add', {
title: todo.title,
notes: deepLink,
when: todo.date,
});
window.open(url);
}

View file

@ -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;
}
}

View file

@ -20,5 +20,8 @@
},
"include": [
"**/*.ts"
],
"exclude": [
"**/*.test.ts"
]
}

View file

@ -1,3 +1,4 @@
{
"1.3.0": "0.15.0"
"1.3.0": "0.15.0",
"1.4.0": "0.15.0"
}