yangcht_obsidian-things-too.../main.js
2026-06-13 19:10:09 +02:00

1749 lines
86 KiB
JavaScript

'use strict';
var obsidian = require('obsidian');
var child_process = require('child_process');
var os = require('os');
var fs = require('fs');
var path = require('path');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var obsidian__default = /*#__PURE__*/_interopDefaultLegacy(obsidian);
const DEFAULT_DAILY_NOTE_FORMAT = "YYYY-MM-DD";
const DEFAULT_WEEKLY_NOTE_FORMAT = "gggg-[W]ww";
const DEFAULT_MONTHLY_NOTE_FORMAT = "YYYY-MM";
function shouldUsePeriodicNotesSettings(periodicity) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const periodicNotes = window.app.plugins.getPlugin("periodic-notes");
return periodicNotes && periodicNotes.settings?.[periodicity]?.enabled;
}
/**
* Read the user settings for the `daily-notes` plugin
* to keep behavior of creating a new note in-sync.
*/
function getDailyNoteSettings() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { internalPlugins, plugins } = window.app;
if (shouldUsePeriodicNotesSettings("daily")) {
const { format, folder, template } = plugins.getPlugin("periodic-notes")?.settings?.daily || {};
return {
format: format || DEFAULT_DAILY_NOTE_FORMAT,
folder: folder?.trim() || "",
template: template?.trim() || "",
};
}
const { folder, format, template } = internalPlugins.getPluginById("daily-notes")?.instance?.options || {};
return {
format: format || DEFAULT_DAILY_NOTE_FORMAT,
folder: folder?.trim() || "",
template: template?.trim() || "",
};
}
catch (err) {
console.info("No custom daily note settings found!", err);
}
}
/**
* Read the user settings for the `weekly-notes` plugin
* to keep behavior of creating a new note in-sync.
*/
function getWeeklyNoteSettings() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginManager = window.app.plugins;
const calendarSettings = pluginManager.getPlugin("calendar")?.options;
const periodicNotesSettings = pluginManager.getPlugin("periodic-notes")
?.settings?.weekly;
if (shouldUsePeriodicNotesSettings("weekly")) {
return {
format: periodicNotesSettings.format || DEFAULT_WEEKLY_NOTE_FORMAT,
folder: periodicNotesSettings.folder?.trim() || "",
template: periodicNotesSettings.template?.trim() || "",
};
}
const settings = calendarSettings || {};
return {
format: settings.weeklyNoteFormat || DEFAULT_WEEKLY_NOTE_FORMAT,
folder: settings.weeklyNoteFolder?.trim() || "",
template: settings.weeklyNoteTemplate?.trim() || "",
};
}
catch (err) {
console.info("No custom weekly note settings found!", err);
}
}
/**
* Read the user settings for the `periodic-notes` plugin
* to keep behavior of creating a new note in-sync.
*/
function getMonthlyNoteSettings() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginManager = window.app.plugins;
try {
const settings = (shouldUsePeriodicNotesSettings("monthly") &&
pluginManager.getPlugin("periodic-notes")?.settings?.monthly) ||
{};
return {
format: settings.format || DEFAULT_MONTHLY_NOTE_FORMAT,
folder: settings.folder?.trim() || "",
template: settings.template?.trim() || "",
};
}
catch (err) {
console.info("No custom monthly note settings found!", err);
}
}
// Credit: @creationix/path.js
function join(...partSegments) {
// Split the inputs into a list of path commands.
let parts = [];
for (let i = 0, l = partSegments.length; i < l; i++) {
parts = parts.concat(partSegments[i].split("/"));
}
// Interpret the path commands to get the new resolved path.
const newParts = [];
for (let i = 0, l = parts.length; i < l; i++) {
const part = parts[i];
// Remove leading and trailing slashes
// Also remove "." segments
if (!part || part === ".")
continue;
// Push new path segments.
else
newParts.push(part);
}
// Preserve the initial slash if there was one.
if (parts[0] === "")
newParts.unshift("");
// Turn back into a single string path.
return newParts.join("/");
}
async function ensureFolderExists(path) {
const dirs = path.replace(/\\/g, "/").split("/");
dirs.pop(); // remove basename
if (dirs.length) {
const dir = join(...dirs);
if (!window.app.vault.getAbstractFileByPath(dir)) {
await window.app.vault.createFolder(dir);
}
}
}
async function getNotePath(directory, filename) {
if (!filename.endsWith(".md")) {
filename += ".md";
}
const path = obsidian__default['default'].normalizePath(join(directory, filename));
await ensureFolderExists(path);
return path;
}
async function getTemplateInfo(template) {
const { metadataCache, vault } = window.app;
const templatePath = obsidian__default['default'].normalizePath(template);
if (templatePath === "/") {
return Promise.resolve(["", null]);
}
try {
const templateFile = metadataCache.getFirstLinkpathDest(templatePath, "");
const contents = await vault.cachedRead(templateFile);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const IFoldInfo = window.app.foldManager.load(templateFile);
return [contents, IFoldInfo];
}
catch (err) {
console.error(`Failed to read the daily note template '${templatePath}'`, err);
new obsidian__default['default'].Notice("Failed to read the daily note template");
return ["", null];
}
}
/**
* dateUID is a way of weekly identifying daily/weekly/monthly notes.
* They are prefixed with the granularity to avoid ambiguity.
*/
function getDateUID(date, granularity = "day") {
const ts = date.clone().startOf(granularity).format();
return `${granularity}-${ts}`;
}
function removeEscapedCharacters(format) {
return format.replace(/\[[^\]]*\]/g, ""); // remove everything within brackets
}
/**
* XXX: When parsing dates that contain both week numbers and months,
* Moment choses to ignore the week numbers. For the week dateUID, we
* want the opposite behavior. Strip the MMM from the format to patch.
*/
function isFormatAmbiguous(format, granularity) {
if (granularity === "week") {
const cleanFormat = removeEscapedCharacters(format);
return (/w{1,2}/i.test(cleanFormat) &&
(/M{1,4}/.test(cleanFormat) || /D{1,4}/.test(cleanFormat)));
}
return false;
}
function getDateFromFile(file, granularity) {
return getDateFromFilename(file.basename, granularity);
}
function getDateFromFilename(filename, granularity) {
const getSettings = {
day: getDailyNoteSettings,
week: getWeeklyNoteSettings,
month: getMonthlyNoteSettings,
};
const format = getSettings[granularity]().format.split("/").pop();
const noteDate = window.moment(filename, format, true);
if (!noteDate.isValid()) {
return null;
}
if (isFormatAmbiguous(format, granularity)) {
if (granularity === "week") {
const cleanFormat = removeEscapedCharacters(format);
if (/w{1,2}/i.test(cleanFormat)) {
return window.moment(filename,
// If format contains week, remove day & month formatting
format.replace(/M{1,4}/g, "").replace(/D{1,4}/g, ""), false);
}
}
}
return noteDate;
}
class DailyNotesFolderMissingError extends Error {
}
/**
* This function mimics the behavior of the daily-notes plugin
* so it will replace {{date}}, {{title}}, and {{time}} with the
* formatted timestamp.
*
* Note: it has an added bonus that it's not 'today' specific.
*/
async function createDailyNote(date) {
const app = window.app;
const { vault } = app;
const moment = window.moment;
const { template, format, folder } = getDailyNoteSettings();
const [templateContents, IFoldInfo] = await getTemplateInfo(template);
const filename = date.format(format);
const normalizedPath = await getNotePath(folder, filename);
try {
const createdFile = await vault.create(normalizedPath, templateContents
.replace(/{{\s*date\s*}}/gi, filename)
.replace(/{{\s*time\s*}}/gi, moment().format("HH:mm"))
.replace(/{{\s*title\s*}}/gi, filename)
.replace(/{{\s*(date|time)\s*(([+-]\d+)([yqmwdhs]))?\s*(:.+?)?}}/gi, (_, _timeOrDate, calc, timeDelta, unit, momentFormat) => {
const now = moment();
const currentDate = date.clone().set({
hour: now.get("hour"),
minute: now.get("minute"),
second: now.get("second"),
});
if (calc) {
currentDate.add(parseInt(timeDelta, 10), unit);
}
if (momentFormat) {
return currentDate.format(momentFormat.substring(1).trim());
}
return currentDate.format(format);
})
.replace(/{{\s*yesterday\s*}}/gi, date.clone().subtract(1, "day").format(format))
.replace(/{{\s*tomorrow\s*}}/gi, date.clone().add(1, "d").format(format)));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.foldManager.save(createdFile, IFoldInfo);
return createdFile;
}
catch (err) {
console.error(`Failed to create file: '${normalizedPath}'`, err);
new obsidian__default['default'].Notice("Unable to create new file.");
}
}
function getDailyNote(date, dailyNotes) {
return dailyNotes[getDateUID(date, "day")] ?? null;
}
function getAllDailyNotes() {
/**
* Find all daily notes in the daily note folder
*/
const { vault } = window.app;
const { folder } = getDailyNoteSettings();
const dailyNotesFolder = vault.getAbstractFileByPath(obsidian__default['default'].normalizePath(folder));
if (!dailyNotesFolder) {
throw new DailyNotesFolderMissingError("Failed to find daily notes folder");
}
const dailyNotes = {};
obsidian__default['default'].Vault.recurseChildren(dailyNotesFolder, (note) => {
if (note instanceof obsidian__default['default'].TFile) {
const date = getDateFromFile(note, "day");
if (date) {
const dateString = getDateUID(date, "day");
dailyNotes[dateString] = note;
}
}
});
return dailyNotes;
}
var createDailyNote_1 = createDailyNote;
var getAllDailyNotes_1 = getAllDailyNotes;
var getDailyNote_1 = getDailyNote;
class ConfirmationModal extends obsidian.Modal {
constructor(app, config) {
super(app);
this.config = config;
const { cta, onAccept, text, title } = config;
this.containerEl.addClass('mod-confirmation');
this.titleEl.setText(title);
this.contentEl.setText(text);
this.buttonContainerEl = this.modalEl.createDiv('modal-button-container');
const acceptBtnEl = this.buttonContainerEl.createEl("button", {
cls: "mod-cta",
text: cta,
});
acceptBtnEl.addEventListener("click", async (e) => {
e.preventDefault();
this.accepted = true;
this.close();
onAccept(e);
});
const cancelBtnEl = this.buttonContainerEl.createEl("button", { text: "Never mind" });
cancelBtnEl.addEventListener("click", e => {
e.preventDefault();
this.close();
});
}
onClose() {
if (!this.accepted) {
this.config.onCancel?.();
}
}
}
function getEditorForFile(app, file) {
let editor = null;
app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof obsidian.MarkdownView && leaf.view.file === file) {
const view = leaf.view;
editor = view.editor || view.sourceMode?.cmEditor;
}
});
return editor;
}
function getHeadingLevel(line = "") {
const heading = line.match(/^(#{1,6})\s+\S/);
return heading ? heading[1].length : null;
}
function toHeading(title, level, addEmptyLine) {
const emptyLine = addEmptyLine ? "\n" : "";
const hash = "".padStart(level, "#");
return `${emptyLine}${hash} ${title}`;
}
function getTab(useTab, tabSize) {
if (useTab) {
return "\t";
}
return "".padStart(tabSize, " ");
}
function groupBy(arr, predicate) {
return arr.reduce((acc, elem) => {
const val = predicate(elem);
acc[val] = acc[val] || [];
acc[val].push(elem);
return acc;
}, {});
}
function isMacOS() {
return navigator.appVersion.indexOf("Mac") !== -1;
}
async function updateSection(app, file, heading, sectionContents) {
const headingLevel = getHeadingLevel(heading);
if (!headingLevel) {
throw new Error(`Invalid logbook section heading: ${heading}`);
}
const { vault } = app;
const fileContents = await vault.read(file);
const fileLines = fileContents.split("\n");
let logbookSectionLineNum = -1;
let nextSectionLineNum = -1;
for (let i = 0; i < fileLines.length; i++) {
if (fileLines[i].trim() === heading) {
logbookSectionLineNum = i;
}
else if (logbookSectionLineNum !== -1) {
const currLevel = getHeadingLevel(fileLines[i]);
if (currLevel && currLevel <= headingLevel) {
nextSectionLineNum = i;
break;
}
}
}
const editor = getEditorForFile(app, file);
if (editor) {
// if the "## Logbook" header exists, we just replace the
// section. If it doesn't, we need to append it to the end
// if the file and add `\n` for separation.
if (logbookSectionLineNum !== -1) {
const currentSection = fileLines
.slice(logbookSectionLineNum, nextSectionLineNum !== -1 ? nextSectionLineNum : fileLines.length)
.join("\n")
.trimEnd();
if (currentSection === sectionContents.trimEnd()) {
return false;
}
const from = { line: logbookSectionLineNum, ch: 0 };
const to = nextSectionLineNum !== -1
? { line: nextSectionLineNum, ch: 0 }
: { line: fileLines.length, ch: 0 };
editor.replaceRange(`${sectionContents}\n`, from, to);
return true;
}
else {
const pos = { line: fileLines.length, ch: 0 };
editor.replaceRange(`\n\n${sectionContents}`, pos, pos);
return true;
}
}
// Editor is not open, modify the file on disk...
if (logbookSectionLineNum !== -1) {
// Section already exists, just replace
const prefix = fileLines.slice(0, logbookSectionLineNum);
const suffix = nextSectionLineNum !== -1 ? fileLines.slice(nextSectionLineNum) : [];
const currentSection = fileLines
.slice(logbookSectionLineNum, nextSectionLineNum !== -1 ? nextSectionLineNum : fileLines.length)
.join("\n")
.trimEnd();
if (currentSection === sectionContents.trimEnd()) {
return false;
}
await vault.process(file, () => [...prefix, sectionContents, ...suffix].join("\n"));
return true;
}
else {
// Section does not exist, append to end of file.
await vault.process(file, () => [...fileLines, "", sectionContents].join("\n"));
return true;
}
}
function getSectionContents(fileContents, heading) {
const headingLevel = getHeadingLevel(heading);
if (!headingLevel) {
return "";
}
const fileLines = fileContents.split("\n");
let sectionLineNum = -1;
let nextSectionLineNum = fileLines.length;
for (let i = 0; i < fileLines.length; i++) {
if (fileLines[i].trim() === heading) {
sectionLineNum = i;
}
else if (sectionLineNum !== -1) {
const currLevel = getHeadingLevel(fileLines[i]);
if (currLevel && currLevel <= headingLevel) {
nextSectionLineNum = i;
break;
}
}
}
if (sectionLineNum === -1) {
return "";
}
return fileLines.slice(sectionLineNum, nextSectionLineNum).join("\n");
}
function countThingsTasksInSection(fileContents, heading) {
const sectionContents = getSectionContents(fileContents, heading);
if (!sectionContents) {
return 0;
}
return sectionContents
.split("\n")
.filter((line) => /^- \[[ xXcC]\] .*things:\/\/\/show\?id=/.test(line))
.length;
}
class ToolkitRenderer {
constructor(app, settings) {
this.app = app;
this.settings = settings;
this.renderTask = this.renderTask.bind(this);
}
renderTask(task) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vault = this.app.vault;
const tab = getTab(vault.getConfig("useTab"), vault.getConfig("tabSize"));
const prefix = this.settings.tagPrefix;
const tags = Array.from(new Set(task.tags
.filter((tag) => !!tag)
.map((tag) => tag.replace(/\s+/g, "-").toLowerCase())
.sort()))
.map((tag) => `#${prefix}${tag}`)
.join(" ");
const taskTitle = `[${task.title}](things:///show?id=${task.uuid}) ${tags}`.trimEnd();
const notes = this.settings.doesSyncNoteBody
? String(task.notes || "")
.trimEnd()
.split("\n")
.filter((line) => !!line)
.map((noteLine) => `${tab}${noteLine}`)
: "";
return [
`- [${task.cancelled ? this.settings.canceledMark : 'x'}] ${taskTitle}`,
...notes,
...task.subtasks.map((subtask) => `${tab}- [${subtask.completed ? "x" : " "}] ${subtask.title}`),
]
.filter((line) => !!line)
.join("\n");
}
render(tasks) {
const { sectionHeading, doesSyncProject, doesAddNewlineBeforeHeadings } = this.settings;
const headings = groupBy(tasks, (task) => task.area || (doesSyncProject ? task.project : "") || "");
const headingLevel = getHeadingLevel(sectionHeading);
const output = [sectionHeading];
Object.entries(headings).map(([heading, tasks]) => {
if (heading !== "") {
output.push(toHeading(heading, headingLevel + 1, doesAddNewlineBeforeHeadings));
}
output.push(...tasks.map(this.renderTask));
});
return output.join("\n");
}
}
const VIEW_TYPE_THINGS_TOOLKIT_REVIEW = "things-toolkit-review";
const RATING_LABELS = {
good: "Good day",
steady: "Steady day",
improve: "Needs improvement",
};
class ThingsToolkitReviewView extends obsidian.ItemView {
constructor(leaf, plugin) {
super(leaf);
this.plugin = plugin;
this.selectedDate = obsidian.moment().format("YYYY-MM-DD");
}
getViewType() {
return VIEW_TYPE_THINGS_TOOLKIT_REVIEW;
}
getDisplayText() {
return "Things toolkit review";
}
getIcon() {
return "calendar-check";
}
async onOpen() {
await this.plugin.refreshRecentDailyStats();
this.display();
}
display() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("things-toolkit-review");
this.renderSummary(contentEl);
this.renderCalendar(contentEl);
this.renderSelectedDay(contentEl);
}
renderSummary(containerEl) {
const today = obsidian.moment().format("YYYY-MM-DD");
const todayCount = this.plugin.getTaskCountForDay(today);
const weeklyCount = this.getRecentTaskTotal(7);
const streak = this.plugin.getCurrentCompletionStreak();
const summaryEl = containerEl.createDiv("things-toolkit-review-summary");
this.addSummaryMetric(summaryEl, String(todayCount), "Today");
this.addSummaryMetric(summaryEl, String(weeklyCount), "Last 7 days");
this.addSummaryMetric(summaryEl, String(streak), "Current streak");
}
addSummaryMetric(containerEl, value, label) {
const metricEl = containerEl.createDiv("things-toolkit-review-metric");
metricEl.createDiv({
cls: "things-toolkit-review-metric-value",
text: value,
});
metricEl.createDiv({
cls: "things-toolkit-review-metric-label",
text: label,
});
}
renderCalendar(containerEl) {
const dates = this.getRecentDates(this.plugin.getReviewWindowDayCount());
const startDate = dates[0];
const endDate = dates[dates.length - 1];
containerEl.createEl("h4", { text: "Review calendar" });
containerEl.createDiv({
cls: "things-toolkit-review-range",
text: `${startDate.format("MMM D, YYYY")} - ${endDate.format("MMM D, YYYY")}`,
});
for (let month = startDate.clone().startOf("month"); month.isSameOrBefore(endDate, "month"); month.add(1, "month")) {
this.renderMonth(containerEl, month.clone(), startDate, endDate);
}
}
renderSelectedDay(containerEl) {
const dateKey = this.selectedDate;
const date = obsidian.moment(dateKey, "YYYY-MM-DD");
const review = this.plugin.options.dailyReviews[dateKey] || {};
const stat = this.plugin.options.dailyStats[dateKey];
const detailEl = containerEl.createDiv("things-toolkit-review-detail");
detailEl.createEl("h4", {
text: date.format("dddd, MMM D, YYYY"),
});
detailEl.createDiv({
cls: "things-toolkit-review-detail-count",
text: `${stat?.taskCount || 0} completed tasks • Week ${date.isoWeek()}`,
});
const contextEl = detailEl.createDiv("things-toolkit-review-context");
this.addContextMetric(contextEl, String(this.getWeekTotal(date)), `Week ${date.isoWeek()} total`);
this.addContextMetric(contextEl, String(this.getMonthTotal(date)), `${date.format("MMM")} total`);
this.addContextMetric(contextEl, String(this.getMonthActiveDays(date)), "Active days");
const ratingEl = detailEl.createDiv("things-toolkit-review-rating");
Object.keys(RATING_LABELS).forEach((rating) => {
const buttonEl = ratingEl.createEl("button", {
text: RATING_LABELS[rating],
});
if (review.rating === rating) {
buttonEl.addClass("is-active");
}
buttonEl.addEventListener("click", async () => {
await this.plugin.writeDayReview(dateKey, { rating });
this.display();
});
});
const textareaEl = detailEl.createEl("textarea", {
cls: "things-toolkit-review-reflection",
attr: {
placeholder: "One short note about what worked, what slipped, or what to adjust tomorrow.",
},
});
textareaEl.value = review.reflection || "";
const actionsEl = detailEl.createDiv("things-toolkit-review-actions");
actionsEl
.createEl("button", { text: "Save reflection" })
.addEventListener("click", async () => {
await this.plugin.writeDayReview(dateKey, {
reflection: textareaEl.value.trim(),
});
new obsidian.Notice("Things review saved");
this.display();
});
actionsEl
.createEl("button", { text: "Open daily note" })
.addEventListener("click", async () => {
await this.plugin.openDailyNote(dateKey);
});
}
renderMonth(containerEl, month, startDate, endDate) {
const monthEl = containerEl.createDiv("things-toolkit-review-month");
const summary = this.getMonthSummary(month);
const headerEl = monthEl.createDiv("things-toolkit-review-month-header");
headerEl.createDiv({
cls: "things-toolkit-review-month-title",
text: month.format("MMMM YYYY"),
});
headerEl.createDiv({
cls: "things-toolkit-review-month-meta",
text: `${summary.total} tasks • ${summary.activeDays} active days • best ${summary.bestDay}`,
});
const gridEl = monthEl.createDiv("things-toolkit-review-calendar-grid");
["Wk", "M", "T", "W", "T", "F", "S", "S"].forEach((label) => {
gridEl.createDiv({
cls: "things-toolkit-review-calendar-heading",
text: label,
});
});
const monthEnd = month.clone().endOf("month").startOf("day");
const finalWeekStart = monthEnd.clone().startOf("isoWeek");
for (let weekStart = month.clone().startOf("isoWeek"); weekStart.isSameOrBefore(finalWeekStart, "day"); weekStart.add(1, "week")) {
gridEl.createDiv({
cls: "things-toolkit-review-week-number",
text: String(weekStart.isoWeek()),
});
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
const date = weekStart.clone().add(dayOffset, "days");
if (!date.isSame(month, "month") ||
date.isBefore(startDate, "day") ||
date.isAfter(endDate, "day")) {
gridEl.createDiv("things-toolkit-review-day-spacer");
}
else {
this.renderCalendarDay(gridEl, date);
}
}
}
}
renderCalendarDay(containerEl, date) {
const dateKey = date.format("YYYY-MM-DD");
const count = this.plugin.getTaskCountForDay(dateKey);
const review = this.plugin.options.dailyReviews[dateKey];
const buttonEl = containerEl.createEl("button", {
cls: "things-toolkit-review-day",
});
buttonEl.setAttribute("aria-label", `${date.format("MMM D, YYYY")}: ${count} tasks, week ${date.isoWeek()}`);
if (dateKey === this.selectedDate) {
buttonEl.addClass("is-selected");
}
if (review?.rating) {
buttonEl.addClass(`is-${review.rating}`);
}
if (count > 0) {
buttonEl.addClass("has-tasks");
}
buttonEl.createDiv({
cls: "things-toolkit-review-day-number",
text: date.format("D"),
});
buttonEl.createDiv({
cls: "things-toolkit-review-day-count",
text: String(count),
});
buttonEl.addEventListener("click", async () => {
this.selectedDate = dateKey;
this.display();
await this.plugin.openDailyNote(dateKey);
});
}
addContextMetric(containerEl, value, label) {
const metricEl = containerEl.createDiv("things-toolkit-review-context-metric");
metricEl.createDiv({
cls: "things-toolkit-review-context-value",
text: value,
});
metricEl.createDiv({
cls: "things-toolkit-review-context-label",
text: label,
});
}
getRecentDates(dayCount) {
const end = obsidian.moment().startOf("day");
const start = end.clone().subtract(dayCount - 1, "days");
const dates = [];
for (let date = start; date.isSameOrBefore(end); date.add(1, "day")) {
dates.push(date.clone());
}
return dates;
}
getRecentTaskTotal(dayCount) {
return this.getRecentDates(dayCount).reduce((sum, date) => sum + this.plugin.getTaskCountForDay(date.format("YYYY-MM-DD")), 0);
}
getWeekTotal(date) {
return this.sumDateRange(date.clone().startOf("isoWeek"), date.clone().endOf("isoWeek"));
}
getMonthTotal(date) {
return this.sumDateRange(date.clone().startOf("month"), date.clone().endOf("month"));
}
getMonthActiveDays(date) {
const end = date.clone().endOf("month");
let activeDays = 0;
for (let day = date.clone().startOf("month"); day.isSameOrBefore(end, "day"); day.add(1, "day")) {
if (this.plugin.getTaskCountForDay(day.format("YYYY-MM-DD")) > 0) {
activeDays++;
}
}
return activeDays;
}
getMonthSummary(month) {
const end = month.clone().endOf("month");
let activeDays = 0;
let bestCount = 0;
let bestDay = "0";
let total = 0;
for (let day = month.clone().startOf("month"); day.isSameOrBefore(end, "day"); day.add(1, "day")) {
const count = this.plugin.getTaskCountForDay(day.format("YYYY-MM-DD"));
total += count;
if (count > 0) {
activeDays++;
}
if (count > bestCount) {
bestCount = count;
bestDay = `${day.format("MMM D")} (${count})`;
}
}
return { activeDays, bestDay, total };
}
sumDateRange(start, end) {
let total = 0;
for (let date = start.clone().startOf("day"); date.isSameOrBefore(end, "day"); date.add(1, "day")) {
total += this.plugin.getTaskCountForDay(date.format("YYYY-MM-DD"));
}
return total;
}
}
// Documented here: https://culturedcode.com/things/support/articles/2982272/
const THINGS_DB_PATH_START = "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/";
const THINGS_DB_PATH_END = "Things Database.thingsdatabase/main.sqlite";
var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
function createCommonjsModule(fn) {
var module = { exports: {} };
return fn(module, module.exports), module.exports;
}
/* @license
Papa Parse
v5.3.1
https://github.com/mholt/PapaParse
License: MIT
*/
var papaparse_min = createCommonjsModule(function (module, exports) {
!function(e,t){module.exports=t();}(commonjsGlobal,function s(){var f="undefined"!=typeof self?self:"undefined"!=typeof window?window:void 0!==f?f:{};var n=!f.document&&!!f.postMessage,o=n&&/blob:/i.test((f.location||{}).protocol),a={},h=0,b={parse:function(e,t){var i=(t=t||{}).dynamicTyping||!1;M(i)&&(t.dynamicTypingFunction=i,i={});if(t.dynamicTyping=i,t.transform=!!M(t.transform)&&t.transform,t.worker&&b.WORKERS_SUPPORTED){var r=function(){if(!b.WORKERS_SUPPORTED)return !1;var e=(i=f.URL||f.webkitURL||null,r=s.toString(),b.BLOB_URL||(b.BLOB_URL=i.createObjectURL(new Blob(["(",r,")();"],{type:"text/javascript"})))),t=new f.Worker(e);var i,r;return t.onmessage=_,t.id=h++,a[t.id]=t}();return r.userStep=t.step,r.userChunk=t.chunk,r.userComplete=t.complete,r.userError=t.error,t.step=M(t.step),t.chunk=M(t.chunk),t.complete=M(t.complete),t.error=M(t.error),delete t.worker,void r.postMessage({input:e,config:t,workerId:r.id})}var n=null;"string"==typeof e?n=t.download?new l(t):new p(t):!0===e.readable&&M(e.read)&&M(e.on)?n=new g(t):(f.File&&e instanceof File||e instanceof Object)&&(n=new c(t));return n.stream(e)},unparse:function(e,t){var n=!1,_=!0,m=",",y="\r\n",s='"',a=s+s,i=!1,r=null,o=!1;!function(){if("object"!=typeof t)return;"string"!=typeof t.delimiter||b.BAD_DELIMITERS.filter(function(e){return -1!==t.delimiter.indexOf(e)}).length||(m=t.delimiter);("boolean"==typeof t.quotes||"function"==typeof t.quotes||Array.isArray(t.quotes))&&(n=t.quotes);"boolean"!=typeof t.skipEmptyLines&&"string"!=typeof t.skipEmptyLines||(i=t.skipEmptyLines);"string"==typeof t.newline&&(y=t.newline);"string"==typeof t.quoteChar&&(s=t.quoteChar);"boolean"==typeof t.header&&(_=t.header);if(Array.isArray(t.columns)){if(0===t.columns.length)throw new Error("Option columns is empty");r=t.columns;}void 0!==t.escapeChar&&(a=t.escapeChar+s);"boolean"==typeof t.escapeFormulae&&(o=t.escapeFormulae);}();var h=new RegExp(j(s),"g");"string"==typeof e&&(e=JSON.parse(e));if(Array.isArray(e)){if(!e.length||Array.isArray(e[0]))return u(null,e,i);if("object"==typeof e[0])return u(r||Object.keys(e[0]),e,i)}else if("object"==typeof e)return "string"==typeof e.data&&(e.data=JSON.parse(e.data)),Array.isArray(e.data)&&(e.fields||(e.fields=e.meta&&e.meta.fields),e.fields||(e.fields=Array.isArray(e.data[0])?e.fields:"object"==typeof e.data[0]?Object.keys(e.data[0]):[]),Array.isArray(e.data[0])||"object"==typeof e.data[0]||(e.data=[e.data])),u(e.fields||[],e.data||[],i);throw new Error("Unable to serialize unrecognized input");function u(e,t,i){var r="";"string"==typeof e&&(e=JSON.parse(e)),"string"==typeof t&&(t=JSON.parse(t));var n=Array.isArray(e)&&0<e.length,s=!Array.isArray(t[0]);if(n&&_){for(var a=0;a<e.length;a++)0<a&&(r+=m),r+=v(e[a],a);0<t.length&&(r+=y);}for(var o=0;o<t.length;o++){var h=n?e.length:t[o].length,u=!1,f=n?0===Object.keys(t[o]).length:0===t[o].length;if(i&&!n&&(u="greedy"===i?""===t[o].join("").trim():1===t[o].length&&0===t[o][0].length),"greedy"===i&&n){for(var d=[],l=0;l<h;l++){var c=s?e[l]:l;d.push(t[o][c]);}u=""===d.join("").trim();}if(!u){for(var p=0;p<h;p++){0<p&&!f&&(r+=m);var g=n&&s?e[p]:p;r+=v(t[o][g],p);}o<t.length-1&&(!i||0<h&&!f)&&(r+=y);}}return r}function v(e,t){if(null==e)return "";if(e.constructor===Date)return JSON.stringify(e).slice(1,25);!0===o&&"string"==typeof e&&null!==e.match(/^[=+\-@].*$/)&&(e="'"+e);var i=e.toString().replace(h,a),r="boolean"==typeof n&&n||"function"==typeof n&&n(e,t)||Array.isArray(n)&&n[t]||function(e,t){for(var i=0;i<t.length;i++)if(-1<e.indexOf(t[i]))return !0;return !1}(i,b.BAD_DELIMITERS)||-1<i.indexOf(m)||" "===i.charAt(0)||" "===i.charAt(i.length-1);return r?s+i+s:i}}};if(b.RECORD_SEP=String.fromCharCode(30),b.UNIT_SEP=String.fromCharCode(31),b.BYTE_ORDER_MARK="\ufeff",b.BAD_DELIMITERS=["\r","\n",'"',b.BYTE_ORDER_MARK],b.WORKERS_SUPPORTED=!n&&!!f.Worker,b.NODE_STREAM_INPUT=1,b.LocalChunkSize=10485760,b.RemoteChunkSize=5242880,b.DefaultDelimiter=",",b.Parser=E,b.ParserHandle=i,b.NetworkStreamer=l,b.FileStreamer=c,b.StringStreamer=p,b.ReadableStreamStreamer=g,f.jQuery){var d=f.jQuery;d.fn.parse=function(o){var i=o.config||{},h=[];return this.each(function(e){if(!("INPUT"===d(this).prop("tagName").toUpperCase()&&"file"===d(this).attr("type").toLowerCase()&&f.FileReader)||!this.files||0===this.files.length)return !0;for(var t=0;t<this.files.length;t++)h.push({file:this.files[t],inputElem:this,instanceConfig:d.extend({},i)});}),e(),this;function e(){if(0!==h.length){var e,t,i,r,n=h[0];if(M(o.before)){var s=o.before(n.file,n.inputElem);if("object"==typeof s){if("abort"===s.action)return e="AbortError",t=n.file,i=n.inputElem,r=s.reason,void(M(o.error)&&o.error({name:e},t,i,r));if("skip"===s.action)return void u();"object"==typeof s.config&&(n.instanceConfig=d.extend(n.instanceConfig,s.config));}else if("skip"===s)return void u()}var a=n.instanceConfig.complete;n.instanceConfig.complete=function(e){M(a)&&a(e,n.file,n.inputElem),u();},b.parse(n.file,n.instanceConfig);}else M(o.complete)&&o.complete();}function u(){h.splice(0,1),e();}};}function u(e){this._handle=null,this._finished=!1,this._completed=!1,this._halted=!1,this._input=null,this._baseIndex=0,this._partialLine="",this._rowCount=0,this._start=0,this._nextChunk=null,this.isFirstChunk=!0,this._completeResults={data:[],errors:[],meta:{}},function(e){var t=w(e);t.chunkSize=parseInt(t.chunkSize),e.step||e.chunk||(t.chunkSize=null);this._handle=new i(t),(this._handle.streamer=this)._config=t;}.call(this,e),this.parseChunk=function(e,t){if(this.isFirstChunk&&M(this._config.beforeFirstChunk)){var i=this._config.beforeFirstChunk(e);void 0!==i&&(e=i);}this.isFirstChunk=!1,this._halted=!1;var r=this._partialLine+e;this._partialLine="";var n=this._handle.parse(r,this._baseIndex,!this._finished);if(!this._handle.paused()&&!this._handle.aborted()){var s=n.meta.cursor;this._finished||(this._partialLine=r.substring(s-this._baseIndex),this._baseIndex=s),n&&n.data&&(this._rowCount+=n.data.length);var a=this._finished||this._config.preview&&this._rowCount>=this._config.preview;if(o)f.postMessage({results:n,workerId:b.WORKER_ID,finished:a});else if(M(this._config.chunk)&&!t){if(this._config.chunk(n,this._handle),this._handle.paused()||this._handle.aborted())return void(this._halted=!0);n=void 0,this._completeResults=void 0;}return this._config.step||this._config.chunk||(this._completeResults.data=this._completeResults.data.concat(n.data),this._completeResults.errors=this._completeResults.errors.concat(n.errors),this._completeResults.meta=n.meta),this._completed||!a||!M(this._config.complete)||n&&n.meta.aborted||(this._config.complete(this._completeResults,this._input),this._completed=!0),a||n&&n.meta.paused||this._nextChunk(),n}this._halted=!0;},this._sendError=function(e){M(this._config.error)?this._config.error(e):o&&this._config.error&&f.postMessage({workerId:b.WORKER_ID,error:e,finished:!1});};}function l(e){var r;(e=e||{}).chunkSize||(e.chunkSize=b.RemoteChunkSize),u.call(this,e),this._nextChunk=n?function(){this._readChunk(),this._chunkLoaded();}:function(){this._readChunk();},this.stream=function(e){this._input=e,this._nextChunk();},this._readChunk=function(){if(this._finished)this._chunkLoaded();else {if(r=new XMLHttpRequest,this._config.withCredentials&&(r.withCredentials=this._config.withCredentials),n||(r.onload=v(this._chunkLoaded,this),r.onerror=v(this._chunkError,this)),r.open(this._config.downloadRequestBody?"POST":"GET",this._input,!n),this._config.downloadRequestHeaders){var e=this._config.downloadRequestHeaders;for(var t in e)r.setRequestHeader(t,e[t]);}if(this._config.chunkSize){var i=this._start+this._config.chunkSize-1;r.setRequestHeader("Range","bytes="+this._start+"-"+i);}try{r.send(this._config.downloadRequestBody);}catch(e){this._chunkError(e.message);}n&&0===r.status&&this._chunkError();}},this._chunkLoaded=function(){4===r.readyState&&(r.status<200||400<=r.status?this._chunkError():(this._start+=this._config.chunkSize?this._config.chunkSize:r.responseText.length,this._finished=!this._config.chunkSize||this._start>=function(e){var t=e.getResponseHeader("Content-Range");if(null===t)return -1;return parseInt(t.substring(t.lastIndexOf("/")+1))}(r),this.parseChunk(r.responseText)));},this._chunkError=function(e){var t=r.statusText||e;this._sendError(new Error(t));};}function c(e){var r,n;(e=e||{}).chunkSize||(e.chunkSize=b.LocalChunkSize),u.call(this,e);var s="undefined"!=typeof FileReader;this.stream=function(e){this._input=e,n=e.slice||e.webkitSlice||e.mozSlice,s?((r=new FileReader).onload=v(this._chunkLoaded,this),r.onerror=v(this._chunkError,this)):r=new FileReaderSync,this._nextChunk();},this._nextChunk=function(){this._finished||this._config.preview&&!(this._rowCount<this._config.preview)||this._readChunk();},this._readChunk=function(){var e=this._input;if(this._config.chunkSize){var t=Math.min(this._start+this._config.chunkSize,this._input.size);e=n.call(e,this._start,t);}var i=r.readAsText(e,this._config.encoding);s||this._chunkLoaded({target:{result:i}});},this._chunkLoaded=function(e){this._start+=this._config.chunkSize,this._finished=!this._config.chunkSize||this._start>=this._input.size,this.parseChunk(e.target.result);},this._chunkError=function(){this._sendError(r.error);};}function p(e){var i;u.call(this,e=e||{}),this.stream=function(e){return i=e,this._nextChunk()},this._nextChunk=function(){if(!this._finished){var e,t=this._config.chunkSize;return t?(e=i.substring(0,t),i=i.substring(t)):(e=i,i=""),this._finished=!i,this.parseChunk(e)}};}function g(e){u.call(this,e=e||{});var t=[],i=!0,r=!1;this.pause=function(){u.prototype.pause.apply(this,arguments),this._input.pause();},this.resume=function(){u.prototype.resume.apply(this,arguments),this._input.resume();},this.stream=function(e){this._input=e,this._input.on("data",this._streamData),this._input.on("end",this._streamEnd),this._input.on("error",this._streamError);},this._checkIsFinished=function(){r&&1===t.length&&(this._finished=!0);},this._nextChunk=function(){this._checkIsFinished(),t.length?this.parseChunk(t.shift()):i=!0;},this._streamData=v(function(e){try{t.push("string"==typeof e?e:e.toString(this._config.encoding)),i&&(i=!1,this._checkIsFinished(),this.parseChunk(t.shift()));}catch(e){this._streamError(e);}},this),this._streamError=v(function(e){this._streamCleanUp(),this._sendError(e);},this),this._streamEnd=v(function(){this._streamCleanUp(),r=!0,this._streamData("");},this),this._streamCleanUp=v(function(){this._input.removeListener("data",this._streamData),this._input.removeListener("end",this._streamEnd),this._input.removeListener("error",this._streamError);},this);}function i(m){var a,o,h,r=Math.pow(2,53),n=-r,s=/^\s*-?(\d+\.?|\.\d+|\d+\.\d+)([eE][-+]?\d+)?\s*$/,u=/^(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))$/,t=this,i=0,f=0,d=!1,e=!1,l=[],c={data:[],errors:[],meta:{}};if(M(m.step)){var p=m.step;m.step=function(e){if(c=e,_())g();else {if(g(),0===c.data.length)return;i+=e.data.length,m.preview&&i>m.preview?o.abort():(c.data=c.data[0],p(c,t));}};}function y(e){return "greedy"===m.skipEmptyLines?""===e.join("").trim():1===e.length&&0===e[0].length}function g(){if(c&&h&&(k("Delimiter","UndetectableDelimiter","Unable to auto-detect delimiting character; defaulted to '"+b.DefaultDelimiter+"'"),h=!1),m.skipEmptyLines)for(var e=0;e<c.data.length;e++)y(c.data[e])&&c.data.splice(e--,1);return _()&&function(){if(!c)return;function e(e,t){M(m.transformHeader)&&(e=m.transformHeader(e,t)),l.push(e);}if(Array.isArray(c.data[0])){for(var t=0;_()&&t<c.data.length;t++)c.data[t].forEach(e);c.data.splice(0,1);}else c.data.forEach(e);}(),function(){if(!c||!m.header&&!m.dynamicTyping&&!m.transform)return c;function e(e,t){var i,r=m.header?{}:[];for(i=0;i<e.length;i++){var n=i,s=e[i];m.header&&(n=i>=l.length?"__parsed_extra":l[i]),m.transform&&(s=m.transform(s,n)),s=v(n,s),"__parsed_extra"===n?(r[n]=r[n]||[],r[n].push(s)):r[n]=s;}return m.header&&(i>l.length?k("FieldMismatch","TooManyFields","Too many fields: expected "+l.length+" fields but parsed "+i,f+t):i<l.length&&k("FieldMismatch","TooFewFields","Too few fields: expected "+l.length+" fields but parsed "+i,f+t)),r}var t=1;!c.data.length||Array.isArray(c.data[0])?(c.data=c.data.map(e),t=c.data.length):c.data=e(c.data,0);m.header&&c.meta&&(c.meta.fields=l);return f+=t,c}()}function _(){return m.header&&0===l.length}function v(e,t){return i=e,m.dynamicTypingFunction&&void 0===m.dynamicTyping[i]&&(m.dynamicTyping[i]=m.dynamicTypingFunction(i)),!0===(m.dynamicTyping[i]||m.dynamicTyping)?"true"===t||"TRUE"===t||"false"!==t&&"FALSE"!==t&&(function(e){if(s.test(e)){var t=parseFloat(e);if(n<t&&t<r)return !0}return !1}(t)?parseFloat(t):u.test(t)?new Date(t):""===t?null:t):t;var i;}function k(e,t,i,r){var n={type:e,code:t,message:i};void 0!==r&&(n.row=r),c.errors.push(n);}this.parse=function(e,t,i){var r=m.quoteChar||'"';if(m.newline||(m.newline=function(e,t){e=e.substring(0,1048576);var i=new RegExp(j(t)+"([^]*?)"+j(t),"gm"),r=(e=e.replace(i,"")).split("\r"),n=e.split("\n"),s=1<n.length&&n[0].length<r[0].length;if(1===r.length||s)return "\n";for(var a=0,o=0;o<r.length;o++)"\n"===r[o][0]&&a++;return a>=r.length/2?"\r\n":"\r"}(e,r)),h=!1,m.delimiter)M(m.delimiter)&&(m.delimiter=m.delimiter(e),c.meta.delimiter=m.delimiter);else {var n=function(e,t,i,r,n){var s,a,o,h;n=n||[",","\t","|",";",b.RECORD_SEP,b.UNIT_SEP];for(var u=0;u<n.length;u++){var f=n[u],d=0,l=0,c=0;o=void 0;for(var p=new E({comments:r,delimiter:f,newline:t,preview:10}).parse(e),g=0;g<p.data.length;g++)if(i&&y(p.data[g]))c++;else {var _=p.data[g].length;l+=_,void 0!==o?0<_&&(d+=Math.abs(_-o),o=_):o=_;}0<p.data.length&&(l/=p.data.length-c),(void 0===a||d<=a)&&(void 0===h||h<l)&&1.99<l&&(a=d,s=f,h=l);}return {successful:!!(m.delimiter=s),bestDelimiter:s}}(e,m.newline,m.skipEmptyLines,m.comments,m.delimitersToGuess);n.successful?m.delimiter=n.bestDelimiter:(h=!0,m.delimiter=b.DefaultDelimiter),c.meta.delimiter=m.delimiter;}var s=w(m);return m.preview&&m.header&&s.preview++,a=e,o=new E(s),c=o.parse(a,t,i),g(),d?{meta:{paused:!0}}:c||{meta:{paused:!1}}},this.paused=function(){return d},this.pause=function(){d=!0,o.abort(),a=M(m.chunk)?"":a.substring(o.getCharIndex());},this.resume=function(){t.streamer._halted?(d=!1,t.streamer.parseChunk(a,!0)):setTimeout(t.resume,3);},this.aborted=function(){return e},this.abort=function(){e=!0,o.abort(),c.meta.aborted=!0,M(m.complete)&&m.complete(c),a="";};}function j(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function E(e){var S,O=(e=e||{}).delimiter,x=e.newline,I=e.comments,T=e.step,D=e.preview,A=e.fastMode,L=S=void 0===e.quoteChar?'"':e.quoteChar;if(void 0!==e.escapeChar&&(L=e.escapeChar),("string"!=typeof O||-1<b.BAD_DELIMITERS.indexOf(O))&&(O=","),I===O)throw new Error("Comment character same as delimiter");!0===I?I="#":("string"!=typeof I||-1<b.BAD_DELIMITERS.indexOf(I))&&(I=!1),"\n"!==x&&"\r"!==x&&"\r\n"!==x&&(x="\n");var F=0,z=!1;this.parse=function(r,t,i){if("string"!=typeof r)throw new Error("Input must be a string");var n=r.length,e=O.length,s=x.length,a=I.length,o=M(T),h=[],u=[],f=[],d=F=0;if(!r)return C();if(A||!1!==A&&-1===r.indexOf(S)){for(var l=r.split(x),c=0;c<l.length;c++){if(f=l[c],F+=f.length,c!==l.length-1)F+=x.length;else if(i)return C();if(!I||f.substring(0,a)!==I){if(o){if(h=[],k(f.split(O)),R(),z)return C()}else k(f.split(O));if(D&&D<=c)return h=h.slice(0,D),C(!0)}}return C()}for(var p=r.indexOf(O,F),g=r.indexOf(x,F),_=new RegExp(j(L)+j(S),"g"),m=r.indexOf(S,F);;)if(r[F]!==S)if(I&&0===f.length&&r.substring(F,F+a)===I){if(-1===g)return C();F=g+s,g=r.indexOf(x,F),p=r.indexOf(O,F);}else if(-1!==p&&(p<g||-1===g))f.push(r.substring(F,p)),F=p+e,p=r.indexOf(O,F);else {if(-1===g)break;if(f.push(r.substring(F,g)),w(g+s),o&&(R(),z))return C();if(D&&h.length>=D)return C(!0)}else for(m=F,F++;;){if(-1===(m=r.indexOf(S,m+1)))return i||u.push({type:"Quotes",code:"MissingQuotes",message:"Quoted field unterminated",row:h.length,index:F}),E();if(m===n-1)return E(r.substring(F,m).replace(_,S));if(S!==L||r[m+1]!==L){if(S===L||0===m||r[m-1]!==L){-1!==p&&p<m+1&&(p=r.indexOf(O,m+1)),-1!==g&&g<m+1&&(g=r.indexOf(x,m+1));var y=b(-1===g?p:Math.min(p,g));if(r[m+1+y]===O){f.push(r.substring(F,m).replace(_,S)),r[F=m+1+y+e]!==S&&(m=r.indexOf(S,F)),p=r.indexOf(O,F),g=r.indexOf(x,F);break}var v=b(g);if(r.substring(m+1+v,m+1+v+s)===x){if(f.push(r.substring(F,m).replace(_,S)),w(m+1+v+s),p=r.indexOf(O,F),m=r.indexOf(S,F),o&&(R(),z))return C();if(D&&h.length>=D)return C(!0);break}u.push({type:"Quotes",code:"InvalidQuotes",message:"Trailing quote on quoted field is malformed",row:h.length,index:F}),m++;}}else m++;}return E();function k(e){h.push(e),d=F;}function b(e){var t=0;if(-1!==e){var i=r.substring(m+1,e);i&&""===i.trim()&&(t=i.length);}return t}function E(e){return i||(void 0===e&&(e=r.substring(F)),f.push(e),F=n,k(f),o&&R()),C()}function w(e){F=e,k(f),f=[],g=r.indexOf(x,F);}function C(e){return {data:h,errors:u,meta:{delimiter:O,linebreak:x,aborted:z,truncated:!!e,cursor:d+(t||0)}}}function R(){T(C()),h=[],u=[];}},this.abort=function(){z=!0;},this.getCharIndex=function(){return F};}function _(e){var t=e.data,i=a[t.workerId],r=!1;if(t.error)i.userError(t.error,t.file);else if(t.results&&t.results.data){var n={abort:function(){r=!0,m(t.workerId,{data:[],errors:[],meta:{aborted:!0}});},pause:y,resume:y};if(M(i.userStep)){for(var s=0;s<t.results.data.length&&(i.userStep({data:t.results.data[s],errors:t.results.errors,meta:t.results.meta},n),!r);s++);delete t.results;}else M(i.userChunk)&&(i.userChunk(t.results,n,t.file),delete t.results);}t.finished&&!r&&m(t.workerId,t.results);}function m(e,t){var i=a[e];M(i.userComplete)&&i.userComplete(t),i.terminate(),delete a[e];}function y(){throw new Error("Not implemented.")}function w(e){if("object"!=typeof e||null===e)return e;var t=Array.isArray(e)?[]:{};for(var i in e)t[i]=w(e[i]);return t}function v(e,t){return function(){e.apply(t,arguments);}}function M(e){return "function"==typeof e}return o&&(f.onmessage=function(e){var t=e.data;void 0===b.WORKER_ID&&t&&(b.WORKER_ID=t.workerId);if("string"==typeof t.input)f.postMessage({workerId:b.WORKER_ID,results:b.parse(t.input,t.config),finished:!0});else if(f.File&&t.input instanceof File||t.input instanceof Object){var i=b.parse(t.input,t.config);i&&f.postMessage({workerId:b.WORKER_ID,results:i,finished:!0});}}),(l.prototype=Object.create(u.prototype)).constructor=l,(c.prototype=Object.create(u.prototype)).constructor=c,(p.prototype=Object.create(p.prototype)).constructor=p,(g.prototype=Object.create(u.prototype)).constructor=g,b});
});
function parseCSV(csv) {
const lines = Buffer.concat(csv).toString("utf-8");
return papaparse_min.parse(lines, {
dynamicTyping: false,
header: true,
skipEmptyLines: true,
}).data;
}
async function handleSqliteQuery(dbPath, query) {
return new Promise((done) => {
const stdOut = [];
const stdErr = [];
let settled = false;
const finish = (result) => {
if (!settled) {
settled = true;
done(result);
}
};
const spawned = child_process.spawn("sqlite3", ["-csv", "-header", "-readonly", dbPath, query], { detached: true });
spawned.stdout.on("data", (buffer) => {
stdOut.push(buffer);
});
spawned.stderr.on("data", (buffer) => {
stdErr.push(buffer);
});
spawned.on("error", (err) => {
stdErr.push(Buffer.from(String(err.stack), "ascii"));
});
spawned.on("close", (code) => finish({ stdErr, stdOut, code }));
spawned.on("exit", (code) => finish({ stdErr, stdOut, code }));
});
}
async function querySqliteDB(dbPath, query) {
const { stdOut, stdErr } = await handleSqliteQuery(dbPath, query);
if (stdErr.length) {
const error = Buffer.concat(stdErr).toString("utf-8");
return Promise.reject(error);
}
return parseCSV(stdOut);
}
const TASK_FETCH_LIMIT = 1000;
const DEFAULT_APPLESCRIPT_FALLBACK_LOOKBACK_DAYS = 365;
const MIN_APPLESCRIPT_FALLBACK_LOOKBACK_DAYS = 365;
const APPLESCRIPT_RECORD_SEPARATOR = "\x1e";
const STATUS_COMPLETED = "3";
// Info on how to find the Things db file here:
// https://culturedcode.com/things/support/articles/2982272/
const baseDir = THINGS_DB_PATH_START.replace("~", os.homedir());
const STATUS_CANCELLED = 2;
class ThingsSqlitePrivacyError extends Error {
constructor(message) {
super(message);
this.name = "ThingsSqlitePrivacyError";
}
}
function getThingsSqlitePath() {
let dataFiles;
try {
dataFiles = fs.readdirSync(baseDir);
}
catch (err) {
if (isFileAccessDeniedError(err)) {
throw new ThingsSqlitePrivacyError("macOS privacy is blocking direct access to the Things database");
}
throw err;
}
const dataPath = dataFiles.find((file) => file.startsWith("ThingsData")) ?? "";
const dbPath = dataPath ? path.join(baseDir, dataPath, THINGS_DB_PATH_END) : "";
if (!dbPath || !fs.existsSync(dbPath)) {
throw new Error("Things database not found");
}
return dbPath;
}
function isFileAccessDeniedError(err) {
const error = err;
return (error?.code === "EPERM" ||
error?.code === "EACCES" ||
String(error?.message || "").includes("operation not permitted"));
}
function getErrorMessage(err) {
return err instanceof Error ? err.message : String(err);
}
function buildAccessStatus(source, message, sqliteBlocked) {
return {
message,
source,
sqliteBlocked,
updatedAt: window.moment().unix(),
};
}
function getRepairCutoff(latestSyncTime, repairLookbackDays) {
const incrementalCutoff = window.moment.unix(latestSyncTime).startOf("day");
const repairCutoff = window.moment()
.subtract(Math.max(1, Number(repairLookbackDays) || 1), "days")
.startOf("day");
if (latestSyncTime > 0 && incrementalCutoff.isBefore(repairCutoff)) {
return incrementalCutoff;
}
return repairCutoff;
}
function getLastStopDate(records) {
const recordsWithStopDate = records.filter((record) => record.stopDate);
return recordsWithStopDate[recordsWithStopDate.length - 1]?.stopDate ?? null;
}
function runAppleScript(script) {
return new Promise((resolve, reject) => {
const stdOut = [];
const stdErr = [];
let settled = false;
const finish = (err) => {
if (settled) {
return;
}
settled = true;
if (err) {
reject(err);
}
else {
resolve(Buffer.concat(stdOut).toString("utf-8"));
}
};
const spawned = child_process.spawn("osascript", ["-e", script]);
spawned.stdout.on("data", (buffer) => stdOut.push(buffer));
spawned.stderr.on("data", (buffer) => stdErr.push(buffer));
spawned.on("error", finish);
spawned.on("close", (code) => {
if (code === 0) {
finish();
}
else {
finish(new Error(Buffer.concat(stdErr).toString("utf-8")));
}
});
});
}
function getAppleScriptCutoff(latestSyncTime, fallbackLookbackDays, repairLookbackDays) {
const configuredLookbackDays = Math.max(1, Number(fallbackLookbackDays) || DEFAULT_APPLESCRIPT_FALLBACK_LOOKBACK_DAYS);
const lookbackDays = Math.max(configuredLookbackDays, repairLookbackDays, MIN_APPLESCRIPT_FALLBACK_LOOKBACK_DAYS);
const lookbackCutoff = getRepairCutoff(latestSyncTime, lookbackDays);
if (latestSyncTime > 0) {
const incrementalCutoff = window.moment.unix(latestSyncTime).startOf("day");
if (lookbackCutoff.isBefore(incrementalCutoff)) {
console.debug(`[Things Toolkit] repairing the last ${lookbackDays} days from Things AppleScript.`);
return lookbackCutoff;
}
return incrementalCutoff;
}
console.warn(`[Things Toolkit] SQLite is unavailable. Falling back to the last ${lookbackDays} days from Things AppleScript instead of scanning the full Logbook.`);
return lookbackCutoff;
}
function buildThingsToolkitAppleScript(cutoff) {
return `
set cutoff to current date
set year of cutoff to ${cutoff.year()}
set month of cutoff to ${cutoff.month() + 1}
set day of cutoff to ${cutoff.date()}
set time of cutoff to ${cutoff.diff(cutoff.clone().startOf("day"), "seconds")}
set sep to ASCII character 30
set out to ""
tell application "Things3"
set completedItems to to dos of list "Logbook" whose status is completed and completion date > cutoff
repeat with taskItem in completedItems
set out to out & (_private_experimental_ json of taskItem) & sep
end repeat
set canceledItems to to dos of list "Logbook" whose status is canceled and cancellation date > cutoff
repeat with taskItem in canceledItems
set out to out & (_private_experimental_ json of taskItem) & sep
end repeat
end tell
return out
`;
}
function toUnixTime(dateString) {
if (!dateString) {
return 0;
}
return Math.floor(new Date(dateString).getTime() / 1000);
}
function buildTaskRecordsFromAppleScriptJson(records) {
return records.flatMap((record) => {
const isCancelled = record.status === "canceled";
const stopDate = toUnixTime(isCancelled ? record.cancellationDate : record.completionDate);
const tags = (record.tagNames || "")
.split(",")
.map((tag) => tag.trim())
.filter((tag) => !!tag);
const task = {
uuid: record.id,
title: record.name,
notes: record.notes || "",
area: record.area?.name,
project: record.project?.name,
startDate: toUnixTime(record.creationDate),
stopDate,
status: isCancelled ? String(STATUS_CANCELLED) : STATUS_COMPLETED,
};
if (tags.length === 0) {
return [{ ...task }];
}
return tags.map((tag) => ({ ...task, tag }));
});
}
async function getTasksFromThingsAppleScript(latestSyncTime, fallbackLookbackDays, repairLookbackDays, accessStatus) {
const cutoff = getAppleScriptCutoff(latestSyncTime, fallbackLookbackDays, repairLookbackDays);
console.debug(`[Things Toolkit] fetching tasks from Things AppleScript after ${cutoff.format()}...`);
const output = await runAppleScript(buildThingsToolkitAppleScript(cutoff));
const records = output
.split(APPLESCRIPT_RECORD_SEPARATOR)
.map((line) => line.trim())
.filter((line) => !!line)
.map((line) => JSON.parse(line));
const taskRecords = buildTaskRecordsFromAppleScriptJson(records);
console.debug(`[Things Toolkit] fetched ${records.length} tasks from Things AppleScript`);
return {
accessStatus,
taskRecords,
checklistRecords: [],
source: "applescript",
cutoffTime: cutoff.unix(),
isLimited: latestSyncTime === 0,
repairLookbackDays: window.moment().startOf("day").diff(cutoff, "days"),
};
}
function buildTasksFromSQLRecords(taskRecords, checklistRecords) {
const tasks = {};
taskRecords.forEach(({ tag, ...task }) => {
const id = task.uuid;
const { status, title, ...other } = task;
if (tasks[id]) {
if (tag && !tasks[id].tags.includes(tag)) {
tasks[id].tags.push(tag);
}
}
else {
tasks[id] = {
...other,
cancelled: STATUS_CANCELLED === Number.parseInt(status),
title: (title || "").trimEnd(),
subtasks: [],
tags: tag ? [tag] : [],
};
}
});
checklistRecords.forEach(({ taskId, title, stopDate }) => {
const task = tasks[taskId];
const subtaskTitle = (title || "").trimEnd();
if (!subtaskTitle) {
return;
}
const subtask = {
completed: !!stopDate,
title: subtaskTitle,
};
// checklist item might be completed before task
if (task) {
if (task.subtasks) {
task.subtasks.push(subtask);
}
else {
task.subtasks = [subtask];
}
}
});
return Object.values(tasks).sort((a, b) => {
const stopDateDiff = b.stopDate - a.stopDate;
if (stopDateDiff !== 0) {
return stopDateDiff;
}
return a.uuid.localeCompare(b.uuid);
});
}
async function getTasksFromThingsDb(latestSyncTime) {
return querySqliteDB(getThingsSqlitePath(), `SELECT
TMTask.uuid as uuid,
TMTask.title as title,
TMTask.notes as notes,
TMTask.startDate as startDate,
TMTask.stopDate as stopDate,
TMTask.status as status,
TMArea.title as area,
TMTag.title as tag,
TMProject.title as project
FROM
TMTask
LEFT JOIN TMTaskTag
ON TMTaskTag.tasks = TMTask.uuid
LEFT JOIN TMTag
ON TMTag.uuid = TMTaskTag.tags
LEFT JOIN TMArea
ON TMTask.area = TMArea.uuid
LEFT JOIN TMTask TMProject
ON TMProject.uuid = TMTask.project
WHERE
TMTask.trashed = 0
AND TMTask.stopDate IS NOT NULL
AND TMTask.stopDate > ${latestSyncTime}
ORDER BY
TMTask.stopDate
LIMIT ${TASK_FETCH_LIMIT}
`);
}
async function getChecklistItemsThingsDb(latestSyncTime) {
return querySqliteDB(getThingsSqlitePath(), `SELECT
task as taskId,
title as title,
stopDate as stopDate
FROM
TMChecklistItem
WHERE
stopDate > ${latestSyncTime}
AND title IS NOT ""
ORDER BY
stopDate
LIMIT ${TASK_FETCH_LIMIT}
`);
}
async function getTasksFromThingsSqlite(latestSyncTime) {
const taskRecords = [];
let isSyncCompleted = false;
let stopTime = window.moment.unix(latestSyncTime).startOf("day").unix();
while (!isSyncCompleted) {
console.debug("[Things Toolkit] fetching tasks from sqlite db...");
const batch = await getTasksFromThingsDb(stopTime);
isSyncCompleted = batch.length < TASK_FETCH_LIMIT;
const lastStopDate = getLastStopDate(batch);
if (lastStopDate) {
stopTime = lastStopDate;
}
taskRecords.push(...batch);
console.debug(`[Things Toolkit] fetched ${batch.length} tasks from sqlite db`);
}
return taskRecords;
}
async function getChecklistItemsFromThingsSqlite(latestSyncTime) {
const checklistItems = [];
let isSyncCompleted = false;
let stopTime = latestSyncTime;
while (!isSyncCompleted) {
console.debug("[Things Toolkit] fetching checklist items from sqlite db...");
const batch = await getChecklistItemsThingsDb(stopTime);
isSyncCompleted = batch.length < TASK_FETCH_LIMIT;
const lastStopDate = getLastStopDate(batch);
if (lastStopDate) {
stopTime = lastStopDate;
}
checklistItems.push(...batch);
console.debug(`[Things Toolkit] fetched ${batch.length} checklist items from sqlite db`);
}
return checklistItems;
}
async function fetchThingsToolkit(latestSyncTime, fallbackLookbackDays, accessMode, repairLookbackDays) {
const cutoff = getRepairCutoff(latestSyncTime, repairLookbackDays);
const cutoffTime = cutoff.unix();
if (accessMode === "applescript") {
return getTasksFromThingsAppleScript(latestSyncTime, fallbackLookbackDays, repairLookbackDays, buildAccessStatus("applescript", "Using Things AppleScript. Direct database access is disabled in settings.", false));
}
try {
const taskRecords = await getTasksFromThingsSqlite(cutoffTime);
const checklistRecords = await getChecklistItemsFromThingsSqlite(cutoffTime);
return {
accessStatus: buildAccessStatus("sqlite", "Using direct Things database access.", false),
taskRecords,
checklistRecords,
source: "sqlite",
cutoffTime,
isLimited: false,
repairLookbackDays: window.moment().startOf("day").diff(cutoff, "days"),
};
}
catch (err) {
if (accessMode === "sqlite") {
throw new Error(`Things SQLite access failed: ${getErrorMessage(err)}`);
}
const sqliteBlocked = err instanceof ThingsSqlitePrivacyError || isFileAccessDeniedError(err);
const message = sqliteBlocked
? "macOS privacy is blocking direct access to the Things database. Using Things AppleScript instead."
: `Direct Things database access is unavailable. Using Things AppleScript instead. (${getErrorMessage(err)})`;
console.warn("[Things Toolkit] Things SQLite DB is unavailable; falling back to Things AppleScript", err);
console.warn("[Things Toolkit] Checklist items are skipped because Things AppleScript does not expose them.");
try {
return await getTasksFromThingsAppleScript(latestSyncTime, fallbackLookbackDays, repairLookbackDays, buildAccessStatus("applescript", message, sqliteBlocked));
}
catch (appleScriptErr) {
throw new Error(`Things AppleScript access failed after SQLite was unavailable: ${getErrorMessage(appleScriptErr)}`);
}
}
}
const DEFAULT_SECTION_HEADING = "## Things";
const DEFAULT_SYNC_FREQUENCY_SECONDS = 30 * 60; // Every 30 minutes
const DEFAULT_REVIEW_WINDOW_DAYS = 365;
const DEFAULT_TAG_PREFIX = "things/";
const DEFAULT_CANCELLED_MARK = "c";
const DEFAULT_SETTINGS = Object.freeze({
hasAcceptedDisclaimer: false,
latestSyncTime: 0,
appleScriptFallbackLookbackDays: DEFAULT_APPLESCRIPT_FALLBACK_LOOKBACK_DAYS,
thingsAccessMode: "auto",
thingsAccessStatus: undefined,
reviewWindowDays: DEFAULT_REVIEW_WINDOW_DAYS,
dailyStats: {},
dailyReviews: {},
doesSyncNoteBody: true,
doesSyncProject: false,
doesAddNewlineBeforeHeadings: false,
isSyncEnabled: false,
syncInterval: DEFAULT_SYNC_FREQUENCY_SECONDS,
sectionHeading: DEFAULT_SECTION_HEADING,
tagPrefix: DEFAULT_TAG_PREFIX,
canceledMark: DEFAULT_CANCELLED_MARK
});
class ThingsToolkitSettingsTab extends obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
this.containerEl.empty();
this.addResetLastSyncSetting();
new obsidian.Setting(this.containerEl).setName("Sync Engine").setHeading();
this.addThingsAccessModeSetting();
this.addThingsAccessStatusSetting();
this.addSyncEnabledSetting();
this.addSyncIntervalSetting();
this.addAppleScriptFallbackLookbackSetting();
new obsidian.Setting(this.containerEl).setName("Daily Notes").setHeading();
this.addSectionHeadingSetting();
this.addDoesSyncNoteBodySetting();
this.addDoesSyncProjectSetting();
this.addDoesAddNewlineBeforeHeadingsSetting();
new obsidian.Setting(this.containerEl).setName("Imported Tags").setHeading();
this.addTagPrefixSetting();
this.addCanceledMarkSetting();
new obsidian.Setting(this.containerEl).setName("Review Calendar").setHeading();
this.addReviewWindowDaysSetting();
}
addSectionHeadingSetting() {
new obsidian.Setting(this.containerEl)
.setName("Section heading")
.setDesc("Markdown heading to replace or append when adding Things items to a daily note")
.addText((textfield) => {
textfield.setValue(this.plugin.options.sectionHeading);
textfield.onChange(async (rawSectionHeading) => {
const sectionHeading = this.normalizeSectionHeading(rawSectionHeading);
this.plugin.writeOptions({ sectionHeading });
});
});
}
addReviewWindowDaysSetting() {
new obsidian.Setting(this.containerEl)
.setName("Review window")
.setDesc("Number of recent days to show and repair in the review calendar")
.addText((textfield) => {
textfield.setValue(String(this.plugin.options.reviewWindowDays));
textfield.inputEl.type = "number";
textfield.inputEl.onblur = (e) => {
const reviewWindowDays = Math.max(30, Math.floor(Number(e.target.value) || DEFAULT_REVIEW_WINDOW_DAYS));
textfield.setValue(String(reviewWindowDays));
this.plugin.writeOptions({ reviewWindowDays });
};
});
}
normalizeSectionHeading(rawSectionHeading) {
const sectionHeading = rawSectionHeading.trim();
if (!sectionHeading) {
return DEFAULT_SECTION_HEADING;
}
if (/^#{1,6}\s+\S/.test(sectionHeading)) {
return sectionHeading;
}
return `## ${sectionHeading.replace(/^#+\s*/, "")}`;
}
addSyncEnabledSetting() {
new obsidian.Setting(this.containerEl)
.setName("Enable periodic syncing")
.addToggle((toggle) => {
toggle.setValue(this.plugin.options.isSyncEnabled);
toggle.onChange(async (isSyncEnabled) => {
this.plugin.writeOptions({ isSyncEnabled });
});
});
}
addThingsAccessModeSetting() {
new obsidian.Setting(this.containerEl)
.setName("Things access")
.setDesc("Auto tries the Things database first, then uses AppleScript when macOS privacy blocks direct access.")
.addDropdown((dropdown) => {
dropdown
.addOption("auto", "Auto")
.addOption("applescript", "AppleScript")
.addOption("sqlite", "SQLite only");
dropdown.setValue(this.plugin.options.thingsAccessMode);
dropdown.onChange(async (thingsAccessMode) => {
await this.plugin.writeOptions({ thingsAccessMode });
this.display();
});
});
}
addThingsAccessStatusSetting() {
const accessStatus = this.plugin.options.thingsAccessStatus;
const statusText = accessStatus
? `${accessStatus.message} Checked ${obsidian.moment.unix(accessStatus.updatedAt).fromNow()}.`
: "Not checked yet. Run Sync now to test Things access.";
new obsidian.Setting(this.containerEl)
.setName("macOS privacy status")
.setDesc(statusText)
.addButton((button) => {
button.setButtonText("Full Disk Access");
button.onClick(() => {
this.openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles");
});
})
.addButton((button) => {
button.setButtonText("Automation");
button.onClick(() => {
this.openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation");
});
});
}
openSystemSettings(url) {
child_process.spawn("open", [url]);
}
addDoesSyncNoteBodySetting() {
new obsidian.Setting(this.containerEl)
.setName("Include notes")
.setDesc('Includes MD notes of a task into the synced Obsidian document')
.addToggle((toggle) => {
toggle.setValue(this.plugin.options.doesSyncNoteBody);
toggle.onChange(async (doesSyncNoteBody) => {
this.plugin.writeOptions({ doesSyncNoteBody });
});
});
}
addDoesSyncProjectSetting() {
new obsidian.Setting(this.containerEl)
.setName("Include project")
.setDesc("If the Things task belongs to a project, use project name as header instead of area")
.addToggle((toggle) => {
toggle.setValue(this.plugin.options.doesSyncProject);
toggle.onChange(async (doesSyncProject) => {
this.plugin.writeOptions({ doesSyncProject });
});
});
}
addSyncIntervalSetting() {
new obsidian.Setting(this.containerEl)
.setName("Sync frequency")
.setDesc("Number of seconds the plugin will wait before syncing again")
.addText((textfield) => {
textfield.setValue(String(this.plugin.options.syncInterval));
textfield.inputEl.type = "number";
textfield.inputEl.onblur = (e) => {
const syncInterval = Math.max(60, Math.floor(Number(e.target.value) || DEFAULT_SYNC_FREQUENCY_SECONDS));
textfield.setValue(String(syncInterval));
this.plugin.writeOptions({ syncInterval });
};
});
}
addAppleScriptFallbackLookbackSetting() {
new obsidian.Setting(this.containerEl)
.setName("AppleScript fallback lookback")
.setDesc(`Days to repair when macOS blocks direct Things database access. The recent review window always uses at least ${MIN_APPLESCRIPT_FALLBACK_LOOKBACK_DAYS} days.`)
.addText((textfield) => {
textfield.setValue(String(this.plugin.options.appleScriptFallbackLookbackDays));
textfield.inputEl.type = "number";
textfield.inputEl.onblur = (e) => {
const appleScriptFallbackLookbackDays = Math.max(1, Math.floor(Number(e.target.value) || DEFAULT_APPLESCRIPT_FALLBACK_LOOKBACK_DAYS));
textfield.setValue(String(appleScriptFallbackLookbackDays));
this.plugin.writeOptions({ appleScriptFallbackLookbackDays });
};
});
}
addTagPrefixSetting() {
new obsidian.Setting(this.containerEl)
.setName("Tag prefix")
.setDesc("Prefix added to Things tags when imported into Obsidian (e.g. #things/work)")
.addText((textfield) => {
textfield.setValue(this.plugin.options.tagPrefix);
textfield.onChange(async (tagPrefix) => {
this.plugin.writeOptions({ tagPrefix });
});
});
}
addCanceledMarkSetting() {
new obsidian.Setting(this.containerEl)
.setName("Canceled mark")
.setDesc("Mark character to use for canceled tasks")
.addText((textfield) => {
textfield.setValue(this.plugin.options.canceledMark);
textfield.onChange(async (canceledMark) => {
this.plugin.writeOptions({ canceledMark });
});
});
}
addDoesAddNewlineBeforeHeadingsSetting() {
new obsidian.Setting(this.containerEl)
.setName("Empty line before headings")
.setDesc("When grouping tasks with headings by area or project, add an empty line before that heading")
.addToggle((toggle) => {
toggle.setValue(this.plugin.options.doesAddNewlineBeforeHeadings);
toggle.onChange(async (doesAddNewlineBeforeHeadings) => {
this.plugin.writeOptions({ doesAddNewlineBeforeHeadings });
});
});
}
addResetLastSyncSetting() {
const { latestSyncTime } = this.plugin.options;
const { syncStatus } = this.plugin;
const syncTime = latestSyncTime > 0
? obsidian.moment.unix(this.plugin.options.latestSyncTime).fromNow()
: 'Never';
new obsidian.Setting(this.containerEl)
.setDesc(createFragment(el => {
el.appendText('Last sync: ');
el.createSpan({ cls: 'u-pop', text: syncTime });
if (syncStatus.message) {
el.createEl("br");
el.appendText(syncStatus.message);
}
}))
.addButton(button => {
button.setButtonText(syncStatus.isSyncing ? 'Syncing...' : 'Sync now');
button.setClass('mod-cta');
button.setDisabled(syncStatus.isSyncing);
button.onClick(async () => {
button.setDisabled(true);
await this.plugin.syncLogbook();
this.display();
});
})
.addButton(button => {
button.setButtonText('Reset sync history');
button.setClass('mod-danger');
button.setDisabled(syncStatus.isSyncing);
button.onClick(async () => {
await this.plugin.writeOptions({ latestSyncTime: 0 });
this.display();
});
})
.addExtraButton(component => {
component.setIcon('lucide-info');
component.setTooltip('Resetting sync history will rewrite the configured Things section in matching daily notes.');
});
}
}
class ThingsToolkitPlugin extends obsidian.Plugin {
constructor() {
super(...arguments);
this.syncStatus = {
isSyncing: false,
message: "",
};
}
async onload() {
if (!isMacOS()) {
console.info("Failed to load Things Toolkit plugin. Platform not supported");
return;
}
this.scheduleNextSync = this.scheduleNextSync.bind(this);
this.syncLogbook = this.syncLogbook.bind(this);
this.tryToScheduleSync = this.tryToScheduleSync.bind(this);
this.tryToSyncLogbook = this.tryToSyncLogbook.bind(this);
this.registerView(VIEW_TYPE_THINGS_TOOLKIT_REVIEW, (leaf) => new ThingsToolkitReviewView(leaf, this));
this.addCommand({
id: "sync-things-toolkit",
name: "Sync",
callback: () => setTimeout(() => this.tryToSyncLogbook(), 20),
});
this.addCommand({
id: "open-things-toolkit-review",
name: "Open review",
callback: () => this.activateReviewView(),
});
this.addRibbonIcon("calendar-check", "Open Things toolkit review", () => this.activateReviewView());
await this.loadOptions();
this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass("things-toolkit-status");
this.statusBarEl.addEventListener("click", () => this.activateReviewView());
this.updateStatusBar();
this.app.workspace.onLayoutReady(() => {
this.refreshRecentDailyStats().then(() => {
this.updateStatusBar();
this.refreshReviewViews();
});
});
this.settingsTab = new ThingsToolkitSettingsTab(this.app, this);
this.addSettingTab(this.settingsTab);
if (this.options.hasAcceptedDisclaimer && this.options.isSyncEnabled) {
if (this.app.workspace.layoutReady) {
this.scheduleNextSync();
}
else {
this.registerEvent(this.app.workspace.on("layout-ready", this.scheduleNextSync));
}
}
}
async activateReviewView() {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_THINGS_TOOLKIT_REVIEW)[0];
if (!leaf) {
leaf = workspace.getRightLeaf(false);
if (!leaf) {
return;
}
await leaf.setViewState({
type: VIEW_TYPE_THINGS_TOOLKIT_REVIEW,
active: true,
});
}
await workspace.revealLeaf(leaf);
}
async tryToSyncLogbook() {
if (this.options.hasAcceptedDisclaimer) {
await this.syncLogbook();
}
else {
new ConfirmationModal(this.app, {
cta: "Sync",
onAccept: async () => {
await this.writeOptions({ hasAcceptedDisclaimer: true });
await this.syncLogbook();
},
text: "Enabling sync will repair the configured review window from Things3 into Obsidian. This can create or modify many daily notes. Make sure to test the plugin in a test vault before continuing.",
title: "Sync Now?",
}).open();
}
}
async tryToScheduleSync() {
if (this.options.hasAcceptedDisclaimer) {
this.scheduleNextSync();
}
else {
new ConfirmationModal(this.app, {
cta: "Sync",
onAccept: async () => {
await this.writeOptions({ hasAcceptedDisclaimer: true });
this.scheduleNextSync();
},
onCancel: async () => {
await this.writeOptions({ isSyncEnabled: false });
// update the settings tab display
this.settingsTab.display();
},
text: "Enabling sync will repair the configured review window from Things3 into Obsidian. This can create or modify many daily notes. Make sure to test the plugin in a test vault before continuing.",
title: "Sync Now?",
}).open();
}
}
async syncLogbook() {
if (this.syncStatus.isSyncing) {
new obsidian.Notice("Things Toolkit sync already running");
return;
}
this.syncStatus = {
isSyncing: true,
message: "Syncing...",
};
this.settingsTab?.display();
const logbookRenderer = new ToolkitRenderer(this.app, this.options);
const dailyNotes = getAllDailyNotes_1();
const latestSyncTime = this.options.latestSyncTime || 0;
let taskCount = 0;
let dayCount = 0;
let changedDayCount = 0;
try {
const fetchResult = await fetchThingsToolkit(latestSyncTime, this.options.appleScriptFallbackLookbackDays, this.options.thingsAccessMode, this.getReviewWindowDayCount());
const tasks = buildTasksFromSQLRecords(fetchResult.taskRecords, fetchResult.checklistRecords);
taskCount = tasks.length;
const daysToTasks = groupBy(tasks.filter((task) => task.stopDate), (task) => window.moment.unix(task.stopDate).startOf("day").format());
const dayEntries = Object.entries(daysToTasks).sort(([a], [b]) => window.moment(a).diff(window.moment(b)));
dayCount = dayEntries.length;
const dailyStats = this.buildDailyStats(dayEntries, fetchResult.source, window.moment().unix());
for (const [dateStr, tasks] of dayEntries) {
const date = window.moment(dateStr);
let dailyNote = getDailyNote_1(date, dailyNotes);
if (!dailyNote) {
dailyNote = await createDailyNote_1(date);
}
const didChange = await updateSection(this.app, dailyNote, this.options.sectionHeading, logbookRenderer.render(tasks));
if (didChange) {
changedDayCount++;
}
}
await this.writeOptions({
latestSyncTime: window.moment().unix(),
dailyStats,
thingsAccessStatus: fetchResult.accessStatus,
});
const sourceLabel = fetchResult.source === "applescript"
? `Things AppleScript, repairing last ${fetchResult.repairLookbackDays || this.options.appleScriptFallbackLookbackDays} days`
: `Things SQLite, repairing last ${fetchResult.repairLookbackDays || this.getReviewWindowDayCount()} days`;
this.syncStatus = {
isSyncing: false,
message: `Last result: ${taskCount} tasks, ${changedDayCount}/${dayCount} notes updated via ${sourceLabel}. ${fetchResult.accessStatus.message}`,
};
new obsidian.Notice(`Things Toolkit sync complete (${taskCount} tasks, ${changedDayCount} notes updated)`);
}
catch (err) {
console.error("[Things Toolkit] Sync failed", err);
const errorMessage = err instanceof Error ? err.message : String(err);
this.syncStatus = {
isSyncing: false,
message: `Last result: sync failed. ${errorMessage}`,
};
new obsidian.Notice("Things Toolkit sync failed");
}
finally {
await this.refreshRecentDailyStats();
this.updateStatusBar();
this.refreshReviewViews();
this.settingsTab?.display();
this.scheduleNextSync();
}
}
buildDailyStats(dayEntries, source, syncedAt) {
const dailyStats = { ...(this.options.dailyStats || {}) };
dayEntries.forEach(([dateStr, tasks]) => {
const dateKey = window.moment(dateStr).format("YYYY-MM-DD");
dailyStats[dateKey] = {
taskCount: tasks.length,
source,
syncedAt,
};
});
return dailyStats;
}
getTaskCountForDay(dateKey) {
return this.options.dailyStats?.[dateKey]?.taskCount || 0;
}
getReviewWindowDayCount() {
return Math.max(7, Math.floor(Number(this.options.reviewWindowDays) || 365));
}
async refreshRecentDailyStats(dayCount = this.getReviewWindowDayCount()) {
const dailyStats = { ...(this.options.dailyStats || {}) };
const dailyNotes = getAllDailyNotes_1();
const syncedAt = window.moment().unix();
const end = window.moment().startOf("day");
const start = end.clone().subtract(dayCount - 1, "days");
for (let date = start; date.isSameOrBefore(end); date.add(1, "day")) {
const dateKey = date.format("YYYY-MM-DD");
const dailyNote = getDailyNote_1(date, dailyNotes);
if (!dailyNote) {
dailyStats[dateKey] = {
taskCount: 0,
source: "daily-note",
syncedAt,
};
continue;
}
const fileContents = await this.app.vault.read(dailyNote);
dailyStats[dateKey] = {
taskCount: countThingsTasksInSection(fileContents, this.options.sectionHeading),
source: "daily-note",
syncedAt,
};
}
await this.writeOptions({ dailyStats });
}
getCurrentCompletionStreak() {
let streak = 0;
for (let date = window.moment().startOf("day"); this.getTaskCountForDay(date.format("YYYY-MM-DD")) > 0; date = date.subtract(1, "day")) {
streak++;
}
return streak;
}
async writeDayReview(dateKey, diff) {
const dailyReviews = { ...(this.options.dailyReviews || {}) };
const nextReview = {
...(dailyReviews[dateKey] || {}),
...diff,
updatedAt: window.moment().unix(),
};
if (!nextReview.rating && !nextReview.reflection) {
delete dailyReviews[dateKey];
}
else {
dailyReviews[dateKey] = nextReview;
}
await this.writeOptions({ dailyReviews });
this.refreshReviewViews();
}
async openDailyNote(dateKey) {
const date = window.moment(dateKey, "YYYY-MM-DD");
const dailyNotes = getAllDailyNotes_1();
let dailyNote = getDailyNote_1(date, dailyNotes);
if (!dailyNote) {
dailyNote = await createDailyNote_1(date);
}
await this.app.workspace.getLeaf(false).openFile(dailyNote);
}
refreshReviewViews() {
this.app.workspace
.getLeavesOfType(VIEW_TYPE_THINGS_TOOLKIT_REVIEW)
.forEach((leaf) => {
if (leaf.view instanceof ThingsToolkitReviewView) {
leaf.view.display();
}
});
}
updateStatusBar() {
if (!this.statusBarEl) {
return;
}
const todayKey = window.moment().format("YYYY-MM-DD");
const todayCount = this.getTaskCountForDay(todayKey);
const streak = this.getCurrentCompletionStreak();
this.statusBarEl.setText(`Things: ${todayCount} today | ${streak}d streak`);
this.statusBarEl.setAttribute("aria-label", "Open Things toolkit review");
}
cancelScheduledSync() {
if (this.syncTimeoutId !== undefined) {
window.clearTimeout(this.syncTimeoutId);
}
}
scheduleNextSync() {
const now = window.moment().unix();
this.cancelScheduledSync();
if (!this.options.isSyncEnabled || !this.options.syncInterval) {
console.debug("[Things Toolkit] scheduling skipped, no syncInterval set");
return;
}
const { latestSyncTime, syncInterval } = this.options;
const secondsUntilNextSync = latestSyncTime + syncInterval - now;
const nextSync = Math.max(secondsUntilNextSync * 1000, 20);
console.debug(`[Things Toolkit] next sync scheduled in ${nextSync}ms`);
this.syncTimeoutId = window.setTimeout(this.syncLogbook, nextSync);
}
async loadOptions() {
this.options = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
if (!this.options.hasAcceptedDisclaimer) {
// In case the user quits before accepting sync modal,
// this keep the settings in sync
this.options.isSyncEnabled = false;
}
}
async writeOptions(diff) {
this.options = Object.assign(this.options, diff);
// Sync toggled on/off
if (diff.isSyncEnabled !== undefined) {
if (diff.isSyncEnabled) {
this.tryToScheduleSync();
}
else {
this.cancelScheduledSync();
}
}
else if (diff.syncInterval !== undefined && this.options.isSyncEnabled) {
// reschedule if interval changed
this.tryToScheduleSync();
}
await this.saveData(this.options);
}
}
module.exports = ThingsToolkitPlugin;