mirror of
https://github.com/yangcht/obsidian-things-toolkit.git
synced 2026-07-22 07:45:44 +00:00
1749 lines
86 KiB
JavaScript
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;
|