mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
chore: checkpoint current tasknotes state
This commit is contained in:
parent
6845a59571
commit
c3863baacc
15 changed files with 1649 additions and 116 deletions
|
|
@ -11,28 +11,33 @@ remote_title: >-
|
|||
[Bug]: Default Filter on This Week View Broken for Recurring Tasks
|
||||
remote_author: bkennedy-improving
|
||||
remote_url: 'https://github.com/callumalpass/tasknotes/issues/1644'
|
||||
local_status: triaged
|
||||
local_status: done
|
||||
priority: high
|
||||
difficulty: easy
|
||||
risk: low
|
||||
summary: >-
|
||||
Recurring tasks without a `complete_instances` property are hidden from the
|
||||
This Week view because `!complete_instances.contains(...)` evaluates to
|
||||
undefined (falsy) when the property is absent, instead of treating absence as
|
||||
"not completed today."
|
||||
Fixed by updating generated default task view filters so a missing
|
||||
`complete_instances` property is treated as "not completed today," and by
|
||||
updating the default base template docs to match the generated YAML.
|
||||
notes: |-
|
||||
Root cause:
|
||||
- In `src/templates/defaultBasesFiles.ts`, all recurring task filters use the pattern `!completeInstances.contains(today().format("yyyy-MM-dd"))` without a fallback for when `complete_instances` is undefined/empty.
|
||||
- When a recurring task has never been completed, `complete_instances` is not present in the frontmatter, so the `.contains()` call on undefined returns undefined rather than true.
|
||||
- This affects the Today, This Week, Overdue, Not Blocked, and Unscheduled views -- all share the same filter pattern.
|
||||
|
||||
Suggested fix (preferred):
|
||||
- Add `complete_instances.isEmpty()` as an OR clause alongside the `!complete_instances.contains(...)` check in each recurring task filter block in `defaultBasesFiles.ts`. This matches the user's suggested fix and handles the undefined case.
|
||||
Implemented:
|
||||
- Updated `src/templates/defaultBasesFiles.ts` so generated recurring-task filters in Not Blocked, Today, Overdue, This Week, and Unscheduled use `complete_instances.isEmpty()` as a fallback alongside `!complete_instances.contains(...)`.
|
||||
- Updated `docs/views/default-base-templates.md` so the reference examples match the generator output.
|
||||
- Added a user-facing release note entry in `docs/releases/unreleased.md`.
|
||||
|
||||
Fallback options:
|
||||
- Modify the filter evaluation engine (`BasesFilterConverter` or `FilterService`) to treat `.contains()` on undefined/null as returning false (so `!undefined.contains(x)` returns true).
|
||||
command_id: triage-issue
|
||||
last_analyzed_at: '2026-03-22T00:00:00Z'
|
||||
|
||||
Verification:
|
||||
- `npm run build:test` passed.
|
||||
- `obsidian plugin:reload id=tasknotes` could not be completed from the agent environment because the CLI could not connect to the Obsidian main process.
|
||||
command_id: address-issue
|
||||
last_analyzed_at: '2026-03-23T00:00:00Z'
|
||||
sync_state: clean
|
||||
type: item_state
|
||||
---
|
||||
|
|
|
|||
360
.ops/tasknotes-agent-loop.sh
Executable file
360
.ops/tasknotes-agent-loop.sh
Executable file
|
|
@ -0,0 +1,360 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="/home/calluma/projects/tasknotes"
|
||||
OPS_DIR="$REPO_DIR/.ops"
|
||||
TASKS_DIR="$OPS_DIR/tasks"
|
||||
LOG_DIR="$OPS_DIR/logs/agent-loop"
|
||||
MODE="review"
|
||||
ITERATIONS=1
|
||||
DELAY_SECONDS=0
|
||||
MODEL=""
|
||||
BRANCH="auto-improvement"
|
||||
SWITCH_BRANCH=0
|
||||
DRY_RUN=0
|
||||
FOCUS_FILE=""
|
||||
|
||||
FOCUS_AREAS=(
|
||||
"tasks view with large datasets, grouping, counts, and virtual scrolling"
|
||||
"kanban drag and drop, status transitions, card rendering, and column counts"
|
||||
"calendar recurring tasks, ICS overlays, drag/drop, date edits, and stale rendering"
|
||||
"agenda and tasks view consistency for the same tasks across filters and grouping"
|
||||
"quick actions, context menus, and right-click interactions on task cards"
|
||||
"task creation, convert-to-task flows, and create-or-open-task behavior"
|
||||
"date editing, reminders, recurrence, and overdue/scheduled metadata rendering"
|
||||
"pomodoro and time-tracking flows, timer state, and task linkage"
|
||||
"keyboard-only navigation, focus handling, and accessibility issues"
|
||||
"large vault or pathological data behavior, including slow renders and broken UI states"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: tasknotes-agent-loop.sh [options]
|
||||
|
||||
Options:
|
||||
--mode review|improve Run bug-finding review or improvement pass. Default: review
|
||||
--iterations N Number of Codex runs. Default: 1
|
||||
--delay SECONDS Sleep between runs. Default: 0
|
||||
--model MODEL Optional model to pass to Codex
|
||||
--branch NAME Improvement branch name. Default: auto-improvement
|
||||
--switch-branch In improve mode, switch/create the branch before running
|
||||
--focus-file PATH Optional newline-delimited list of focus areas to rotate through
|
||||
--dry-run Print prompts and commands without executing Codex
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
.ops/tasknotes-agent-loop.sh --mode review --iterations 5
|
||||
.ops/tasknotes-agent-loop.sh --mode improve --iterations 3 --switch-branch
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--iterations)
|
||||
ITERATIONS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--delay)
|
||||
DELAY_SECONDS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--model)
|
||||
MODEL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--branch)
|
||||
BRANCH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--switch-branch)
|
||||
SWITCH_BRANCH=1
|
||||
shift
|
||||
;;
|
||||
--focus-file)
|
||||
FOCUS_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$MODE" != "review" && "$MODE" != "improve" ]]; then
|
||||
echo "Invalid mode: $MODE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$ITERATIONS" =~ ^[0-9]+$ ]] || [[ "$ITERATIONS" -lt 1 ]]; then
|
||||
echo "--iterations must be a positive integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$DELAY_SECONDS" =~ ^[0-9]+$ ]]; then
|
||||
echo "--delay must be a non-negative integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$REPO_DIR/.git" ]]; then
|
||||
echo "Expected git repo at $REPO_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$TASKS_DIR" "$LOG_DIR"
|
||||
|
||||
load_focus_areas() {
|
||||
local raw line
|
||||
if [[ -n "$FOCUS_FILE" ]]; then
|
||||
if [[ ! -f "$FOCUS_FILE" ]]; then
|
||||
echo "Focus file not found: $FOCUS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
mapfile -t raw < "$FOCUS_FILE"
|
||||
FOCUS_AREAS=()
|
||||
for line in "${raw[@]}"; do
|
||||
[[ -z "${line// }" ]] && continue
|
||||
[[ "${line:0:1}" == "#" ]] && continue
|
||||
FOCUS_AREAS+=("$line")
|
||||
done
|
||||
if [[ "${#FOCUS_AREAS[@]}" -eq 0 ]]; then
|
||||
echo "No usable focus areas found in $FOCUS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
current_branch() {
|
||||
git -C "$REPO_DIR" branch --show-current
|
||||
}
|
||||
|
||||
ensure_improvement_branch() {
|
||||
local branch
|
||||
branch="$(current_branch)"
|
||||
|
||||
if [[ "$SWITCH_BRANCH" -eq 1 ]]; then
|
||||
if [[ -n "$(git -C "$REPO_DIR" status --short)" ]]; then
|
||||
echo "Refusing to switch branches with a dirty worktree. Commit/stash first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if git -C "$REPO_DIR" show-ref --verify --quiet "refs/heads/$BRANCH"; then
|
||||
git -C "$REPO_DIR" switch "$BRANCH"
|
||||
else
|
||||
git -C "$REPO_DIR" switch -c "$BRANCH"
|
||||
fi
|
||||
branch="$(current_branch)"
|
||||
fi
|
||||
|
||||
if [[ "$branch" == "main" ]]; then
|
||||
echo "Improve mode must not run on main. Switch to $BRANCH or pass --switch-branch." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_review_prompt() {
|
||||
local focus="$1"
|
||||
cat <<EOF
|
||||
You are reviewing the TaskNotes Obsidian plugin for reproducible bugs, regressions, UX failures, performance/pathological issues, and code-level weaknesses that are likely to cause real problems.
|
||||
|
||||
Context:
|
||||
- Repo: $REPO_DIR
|
||||
- Use Obsidian CLI against: vault=test
|
||||
- Vault path: /home/calluma/testvault/test
|
||||
- Output task directory: $TASKS_DIR
|
||||
- Existing ops registry: $OPS_DIR
|
||||
- Plugin id: tasknotes
|
||||
|
||||
Primary goal for this run:
|
||||
- Review code related to this focus area: $focus
|
||||
- Use the Obsidian CLI to exercise the live plugin UI in vault=test
|
||||
- Prefer findings supported by both code inspection and live reproduction
|
||||
|
||||
Rules:
|
||||
1. Inspect existing files in $TASKS_DIR first to avoid duplicates.
|
||||
2. Do not modify plugin code in this phase.
|
||||
3. Do not fix issues in this phase.
|
||||
4. Do not create weak/speculative tasks.
|
||||
5. If code review suggests a likely bug, try to confirm it in the test vault before filing.
|
||||
6. If live testing finds a bug, inspect code enough to suggest likely scope and relevant files.
|
||||
7. Spend at most 15 minutes or 25 meaningful CLI interactions.
|
||||
8. Optimize for 1-2 high-confidence findings, not broad coverage.
|
||||
|
||||
Useful Obsidian CLI capabilities:
|
||||
- obsidian plugins filter=community versions format=json vault=test
|
||||
- obsidian plugin id=tasknotes vault=test
|
||||
- obsidian commands vault=test
|
||||
- obsidian command id=<command-id> vault=test
|
||||
- obsidian dev:debug on
|
||||
- obsidian dev:errors
|
||||
- obsidian dev:console level=error limit=50
|
||||
- obsidian dev:dom selector='<selector>' all
|
||||
- obsidian dev:screenshot path=<path>
|
||||
|
||||
Valid findings include:
|
||||
- reproducible bug
|
||||
- clear broken behavior
|
||||
- obvious UX failure
|
||||
- performance/pathological behavior
|
||||
- accessibility or keyboard issue
|
||||
- stale state, wrong counts, wrong navigation, broken edit flow, rendering glitch, drag/drop issue, or runtime error
|
||||
- code smells likely to cause real user-facing breakage, especially if confirmed in UI
|
||||
|
||||
For each solid issue:
|
||||
- reproduce it at least twice if feasible
|
||||
- collect evidence using CLI output, DOM inspection, screenshots, and console/errors when relevant
|
||||
- write one markdown task file in $TASKS_DIR
|
||||
|
||||
Task file requirements:
|
||||
- One file per issue
|
||||
- Use a concise slugged filename
|
||||
- Include frontmatter with at least:
|
||||
- type: task
|
||||
- title:
|
||||
- status: open
|
||||
- priority:
|
||||
- area:
|
||||
- discovered_by: codex
|
||||
- discovered_at:
|
||||
- reproduction_confidence:
|
||||
- In the body include:
|
||||
- Summary
|
||||
- Why this is a problem
|
||||
- Exact reproduction steps
|
||||
- Expected behavior
|
||||
- Actual behavior
|
||||
- Evidence
|
||||
- Relevant code paths
|
||||
- Notes on likely scope or hypotheses if useful
|
||||
- Include exact Obsidian CLI commands used
|
||||
- Include screenshot file paths if captured
|
||||
|
||||
At the end:
|
||||
- Print a short summary of what you reviewed and tested
|
||||
- List any new task files created
|
||||
- If no issue was filed, say so explicitly
|
||||
EOF
|
||||
}
|
||||
|
||||
build_improve_prompt() {
|
||||
local focus="$1"
|
||||
local branch
|
||||
branch="$(current_branch)"
|
||||
cat <<EOF
|
||||
You are implementing one small, reviewable improvement for TaskNotes.
|
||||
|
||||
Context:
|
||||
- Repo: $REPO_DIR
|
||||
- Current git branch must stay off main. Current branch: $branch
|
||||
- Preferred long-lived branch: $BRANCH
|
||||
- Ops task directory: $TASKS_DIR
|
||||
- Obsidian test vault: /home/calluma/testvault/test using vault=test
|
||||
- Focus area for this run: $focus
|
||||
|
||||
Your job:
|
||||
1. Inspect open tasks in $TASKS_DIR and choose one high-confidence task that matches or is close to the focus area.
|
||||
2. Do not start a new exploratory bug hunt unless needed to verify the chosen task.
|
||||
3. Implement a minimal fix or targeted improvement in the TaskNotes codebase.
|
||||
4. Run relevant verification.
|
||||
5. Update the chosen task markdown file with:
|
||||
- what changed
|
||||
- files touched
|
||||
- how it was verified
|
||||
- screenshots or CLI evidence if relevant
|
||||
- remaining risks or follow-up work
|
||||
|
||||
Rules:
|
||||
- Do not work on more than one task in this run.
|
||||
- Keep the change small and reviewable.
|
||||
- Prefer reproducing the issue before and after the fix when feasible.
|
||||
- Use the Obsidian CLI with vault=test for live verification when relevant.
|
||||
- You may edit code in this phase.
|
||||
- You may add or adjust tests if useful.
|
||||
- Do not switch branches.
|
||||
- Do not rewrite unrelated files.
|
||||
|
||||
At the end:
|
||||
- Print the chosen task file
|
||||
- Summarize the code changes
|
||||
- Summarize verification performed
|
||||
- Report whether the task file was updated
|
||||
EOF
|
||||
}
|
||||
|
||||
run_codex() {
|
||||
local prompt_file="$1"
|
||||
local last_msg_file="$2"
|
||||
local cmd=(
|
||||
codex exec
|
||||
--dangerously-bypass-approvals-and-sandbox
|
||||
-C "$REPO_DIR"
|
||||
--add-dir "$OPS_DIR"
|
||||
--output-last-message "$last_msg_file"
|
||||
)
|
||||
|
||||
if [[ -n "$MODEL" ]]; then
|
||||
cmd+=(--model "$MODEL")
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
printf 'DRY RUN COMMAND:'
|
||||
printf ' %q' "${cmd[@]}"
|
||||
printf ' - < %q\n' "$prompt_file"
|
||||
echo "----- PROMPT BEGIN -----"
|
||||
cat "$prompt_file"
|
||||
echo "----- PROMPT END -----"
|
||||
return 0
|
||||
fi
|
||||
|
||||
"${cmd[@]}" - < "$prompt_file"
|
||||
}
|
||||
|
||||
load_focus_areas
|
||||
|
||||
if [[ "$MODE" == "improve" ]]; then
|
||||
ensure_improvement_branch
|
||||
fi
|
||||
|
||||
for ((i = 0; i < ITERATIONS; i++)); do
|
||||
focus="${FOCUS_AREAS[$((i % ${#FOCUS_AREAS[@]}))]}"
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
run_dir="$LOG_DIR/${MODE}-${ts}-$(printf '%02d' "$((i + 1))")"
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
prompt_file="$run_dir/prompt.txt"
|
||||
last_msg_file="$run_dir/final-message.txt"
|
||||
transcript_file="$run_dir/stdout.log"
|
||||
|
||||
if [[ "$MODE" == "review" ]]; then
|
||||
build_review_prompt "$focus" > "$prompt_file"
|
||||
else
|
||||
build_improve_prompt "$focus" > "$prompt_file"
|
||||
fi
|
||||
|
||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Starting $MODE run $((i + 1))/$ITERATIONS"
|
||||
echo "Focus: $focus"
|
||||
echo "Logs: $run_dir"
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
run_codex "$prompt_file" "$last_msg_file"
|
||||
else
|
||||
run_codex "$prompt_file" "$last_msg_file" | tee "$transcript_file"
|
||||
fi
|
||||
|
||||
if [[ "$DELAY_SECONDS" -gt 0 && $((i + 1)) -lt "$ITERATIONS" ]]; then
|
||||
sleep "$DELAY_SECONDS"
|
||||
fi
|
||||
done
|
||||
945
.tmp-conformance-smoke.mjs
Normal file
945
.tmp-conformance-smoke.mjs
Normal file
|
|
@ -0,0 +1,945 @@
|
|||
// node_modules/date-fns/constants.js
|
||||
var daysInYear = 365.2425;
|
||||
var maxTime = Math.pow(10, 8) * 24 * 60 * 60 * 1e3;
|
||||
var minTime = -maxTime;
|
||||
var millisecondsInMinute = 6e4;
|
||||
var millisecondsInHour = 36e5;
|
||||
var secondsInHour = 3600;
|
||||
var secondsInDay = secondsInHour * 24;
|
||||
var secondsInWeek = secondsInDay * 7;
|
||||
var secondsInYear = secondsInDay * daysInYear;
|
||||
var secondsInMonth = secondsInYear / 12;
|
||||
var secondsInQuarter = secondsInMonth * 3;
|
||||
var constructFromSymbol = Symbol.for("constructDateFrom");
|
||||
|
||||
// node_modules/date-fns/constructFrom.js
|
||||
function constructFrom(date, value) {
|
||||
if (typeof date === "function") return date(value);
|
||||
if (date && typeof date === "object" && constructFromSymbol in date)
|
||||
return date[constructFromSymbol](value);
|
||||
if (date instanceof Date) return new date.constructor(value);
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
// node_modules/date-fns/toDate.js
|
||||
function toDate(argument, context) {
|
||||
return constructFrom(context || argument, argument);
|
||||
}
|
||||
|
||||
// node_modules/date-fns/isDate.js
|
||||
function isDate(value) {
|
||||
return value instanceof Date || typeof value === "object" && Object.prototype.toString.call(value) === "[object Date]";
|
||||
}
|
||||
|
||||
// node_modules/date-fns/isValid.js
|
||||
function isValid(date) {
|
||||
return !(!isDate(date) && typeof date !== "number" || isNaN(+toDate(date)));
|
||||
}
|
||||
|
||||
// node_modules/date-fns/parseISO.js
|
||||
function parseISO(argument, options) {
|
||||
const invalidDate = () => constructFrom(options?.in, NaN);
|
||||
const additionalDigits = options?.additionalDigits ?? 2;
|
||||
const dateStrings = splitDateString(argument);
|
||||
let date;
|
||||
if (dateStrings.date) {
|
||||
const parseYearResult = parseYear(dateStrings.date, additionalDigits);
|
||||
date = parseDate(parseYearResult.restDateString, parseYearResult.year);
|
||||
}
|
||||
if (!date || isNaN(+date)) return invalidDate();
|
||||
const timestamp = +date;
|
||||
let time = 0;
|
||||
let offset;
|
||||
if (dateStrings.time) {
|
||||
time = parseTime(dateStrings.time);
|
||||
if (isNaN(time)) return invalidDate();
|
||||
}
|
||||
if (dateStrings.timezone) {
|
||||
offset = parseTimezone(dateStrings.timezone);
|
||||
if (isNaN(offset)) return invalidDate();
|
||||
} else {
|
||||
const tmpDate = new Date(timestamp + time);
|
||||
const result = toDate(0, options?.in);
|
||||
result.setFullYear(
|
||||
tmpDate.getUTCFullYear(),
|
||||
tmpDate.getUTCMonth(),
|
||||
tmpDate.getUTCDate()
|
||||
);
|
||||
result.setHours(
|
||||
tmpDate.getUTCHours(),
|
||||
tmpDate.getUTCMinutes(),
|
||||
tmpDate.getUTCSeconds(),
|
||||
tmpDate.getUTCMilliseconds()
|
||||
);
|
||||
return result;
|
||||
}
|
||||
return toDate(timestamp + time + offset, options?.in);
|
||||
}
|
||||
var patterns = {
|
||||
dateTimeDelimiter: /[T ]/,
|
||||
timeZoneDelimiter: /[Z ]/i,
|
||||
timezone: /([Z+-].*)$/
|
||||
};
|
||||
var dateRegex = /^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/;
|
||||
var timeRegex = /^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/;
|
||||
var timezoneRegex = /^([+-])(\d{2})(?::?(\d{2}))?$/;
|
||||
function splitDateString(dateString) {
|
||||
const dateStrings = {};
|
||||
const array = dateString.split(patterns.dateTimeDelimiter);
|
||||
let timeString;
|
||||
if (array.length > 2) {
|
||||
return dateStrings;
|
||||
}
|
||||
if (/:/.test(array[0])) {
|
||||
timeString = array[0];
|
||||
} else {
|
||||
dateStrings.date = array[0];
|
||||
timeString = array[1];
|
||||
if (patterns.timeZoneDelimiter.test(dateStrings.date)) {
|
||||
dateStrings.date = dateString.split(patterns.timeZoneDelimiter)[0];
|
||||
timeString = dateString.substr(
|
||||
dateStrings.date.length,
|
||||
dateString.length
|
||||
);
|
||||
}
|
||||
}
|
||||
if (timeString) {
|
||||
const token = patterns.timezone.exec(timeString);
|
||||
if (token) {
|
||||
dateStrings.time = timeString.replace(token[1], "");
|
||||
dateStrings.timezone = token[1];
|
||||
} else {
|
||||
dateStrings.time = timeString;
|
||||
}
|
||||
}
|
||||
return dateStrings;
|
||||
}
|
||||
function parseYear(dateString, additionalDigits) {
|
||||
const regex = new RegExp(
|
||||
"^(?:(\\d{4}|[+-]\\d{" + (4 + additionalDigits) + "})|(\\d{2}|[+-]\\d{" + (2 + additionalDigits) + "})$)"
|
||||
);
|
||||
const captures = dateString.match(regex);
|
||||
if (!captures) return { year: NaN, restDateString: "" };
|
||||
const year = captures[1] ? parseInt(captures[1]) : null;
|
||||
const century = captures[2] ? parseInt(captures[2]) : null;
|
||||
return {
|
||||
year: century === null ? year : century * 100,
|
||||
restDateString: dateString.slice((captures[1] || captures[2]).length)
|
||||
};
|
||||
}
|
||||
function parseDate(dateString, year) {
|
||||
if (year === null) return /* @__PURE__ */ new Date(NaN);
|
||||
const captures = dateString.match(dateRegex);
|
||||
if (!captures) return /* @__PURE__ */ new Date(NaN);
|
||||
const isWeekDate = !!captures[4];
|
||||
const dayOfYear = parseDateUnit(captures[1]);
|
||||
const month = parseDateUnit(captures[2]) - 1;
|
||||
const day = parseDateUnit(captures[3]);
|
||||
const week = parseDateUnit(captures[4]);
|
||||
const dayOfWeek = parseDateUnit(captures[5]) - 1;
|
||||
if (isWeekDate) {
|
||||
if (!validateWeekDate(year, week, dayOfWeek)) {
|
||||
return /* @__PURE__ */ new Date(NaN);
|
||||
}
|
||||
return dayOfISOWeekYear(year, week, dayOfWeek);
|
||||
} else {
|
||||
const date = /* @__PURE__ */ new Date(0);
|
||||
if (!validateDate(year, month, day) || !validateDayOfYearDate(year, dayOfYear)) {
|
||||
return /* @__PURE__ */ new Date(NaN);
|
||||
}
|
||||
date.setUTCFullYear(year, month, Math.max(dayOfYear, day));
|
||||
return date;
|
||||
}
|
||||
}
|
||||
function parseDateUnit(value) {
|
||||
return value ? parseInt(value) : 1;
|
||||
}
|
||||
function parseTime(timeString) {
|
||||
const captures = timeString.match(timeRegex);
|
||||
if (!captures) return NaN;
|
||||
const hours = parseTimeUnit(captures[1]);
|
||||
const minutes = parseTimeUnit(captures[2]);
|
||||
const seconds = parseTimeUnit(captures[3]);
|
||||
if (!validateTime(hours, minutes, seconds)) {
|
||||
return NaN;
|
||||
}
|
||||
return hours * millisecondsInHour + minutes * millisecondsInMinute + seconds * 1e3;
|
||||
}
|
||||
function parseTimeUnit(value) {
|
||||
return value && parseFloat(value.replace(",", ".")) || 0;
|
||||
}
|
||||
function parseTimezone(timezoneString) {
|
||||
if (timezoneString === "Z") return 0;
|
||||
const captures = timezoneString.match(timezoneRegex);
|
||||
if (!captures) return 0;
|
||||
const sign = captures[1] === "+" ? -1 : 1;
|
||||
const hours = parseInt(captures[2]);
|
||||
const minutes = captures[3] && parseInt(captures[3]) || 0;
|
||||
if (!validateTimezone(hours, minutes)) {
|
||||
return NaN;
|
||||
}
|
||||
return sign * (hours * millisecondsInHour + minutes * millisecondsInMinute);
|
||||
}
|
||||
function dayOfISOWeekYear(isoWeekYear, week, day) {
|
||||
const date = /* @__PURE__ */ new Date(0);
|
||||
date.setUTCFullYear(isoWeekYear, 0, 4);
|
||||
const fourthOfJanuaryDay = date.getUTCDay() || 7;
|
||||
const diff = (week - 1) * 7 + day + 1 - fourthOfJanuaryDay;
|
||||
date.setUTCDate(date.getUTCDate() + diff);
|
||||
return date;
|
||||
}
|
||||
var daysInMonths = [31, null, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
function isLeapYearIndex(year) {
|
||||
return year % 400 === 0 || year % 4 === 0 && year % 100 !== 0;
|
||||
}
|
||||
function validateDate(year, month, date) {
|
||||
return month >= 0 && month <= 11 && date >= 1 && date <= (daysInMonths[month] || (isLeapYearIndex(year) ? 29 : 28));
|
||||
}
|
||||
function validateDayOfYearDate(year, dayOfYear) {
|
||||
return dayOfYear >= 1 && dayOfYear <= (isLeapYearIndex(year) ? 366 : 365);
|
||||
}
|
||||
function validateWeekDate(_year, week, day) {
|
||||
return week >= 1 && week <= 53 && day >= 0 && day <= 6;
|
||||
}
|
||||
function validateTime(hours, minutes, seconds) {
|
||||
if (hours === 24) {
|
||||
return minutes === 0 && seconds === 0;
|
||||
}
|
||||
return seconds >= 0 && seconds < 60 && minutes >= 0 && minutes < 60 && hours >= 0 && hours < 25;
|
||||
}
|
||||
function validateTimezone(_hours, minutes) {
|
||||
return minutes >= 0 && minutes <= 59;
|
||||
}
|
||||
|
||||
// src/utils/dateUtils.ts
|
||||
function parseDate2(dateString) {
|
||||
if (!dateString) {
|
||||
const error = new Error("Date string cannot be empty");
|
||||
console.error("Date parsing error:", { dateString, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
const trimmed = dateString.trim();
|
||||
try {
|
||||
const dateWithDayNameMatch = trimmed.match(
|
||||
/^(\d{4}-\d{2}-\d{2})\s+(Mon|Tue|Wed|Thu|Fri|Sat|Sun|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)$/i
|
||||
);
|
||||
if (dateWithDayNameMatch) {
|
||||
const dateOnly = dateWithDayNameMatch[1];
|
||||
return parseDate2(dateOnly);
|
||||
}
|
||||
if (trimmed.startsWith("T") && /^T\d{2}:\d{2}(:\d{2})?/.test(trimmed)) {
|
||||
const error = new Error(`Invalid date format - time without date: ${dateString}`);
|
||||
console.warn("Date parsing error - incomplete time format:", {
|
||||
original: dateString,
|
||||
trimmed,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (/^\d{4}-W\d{2}$/.test(trimmed)) {
|
||||
const [year, week] = trimmed.split("-W");
|
||||
const yearNum = parseInt(year, 10);
|
||||
const weekNum = parseInt(week, 10);
|
||||
if (isNaN(yearNum) || isNaN(weekNum)) {
|
||||
const error = new Error(`Invalid numeric values in ISO week format: ${dateString}`);
|
||||
console.warn("Date parsing error - invalid ISO week numbers:", {
|
||||
original: dateString,
|
||||
year,
|
||||
week,
|
||||
yearNum,
|
||||
weekNum
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (weekNum < 1 || weekNum > 53) {
|
||||
const error = new Error(
|
||||
`Invalid week number in ISO week format: ${dateString} (week must be 1-53)`
|
||||
);
|
||||
console.warn("Date parsing error - week number out of range:", {
|
||||
original: dateString,
|
||||
weekNum,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const jan4 = new Date(yearNum, 0, 4);
|
||||
const jan4Day = jan4.getDay();
|
||||
const mondayOfWeek1 = new Date(jan4);
|
||||
mondayOfWeek1.setDate(jan4.getDate() - (jan4Day === 0 ? 6 : jan4Day - 1));
|
||||
const targetWeekMonday = new Date(mondayOfWeek1);
|
||||
targetWeekMonday.setDate(mondayOfWeek1.getDate() + (weekNum - 1) * 7);
|
||||
if (!isValid(targetWeekMonday)) {
|
||||
const error = new Error(
|
||||
`Failed to calculate date from ISO week format: ${dateString}`
|
||||
);
|
||||
console.error("Date parsing error - ISO week calculation failed:", {
|
||||
original: dateString,
|
||||
yearNum,
|
||||
weekNum,
|
||||
jan4: jan4.toISOString(),
|
||||
targetWeekMonday: targetWeekMonday.toString()
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return targetWeekMonday;
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?/.test(trimmed)) {
|
||||
const isoFormat = trimmed.replace(" ", "T");
|
||||
const parsed = parseISO(isoFormat);
|
||||
if (!isValid(parsed)) {
|
||||
const error = new Error(`Invalid space-separated datetime: ${dateString}`);
|
||||
console.warn("Date parsing error - space-separated datetime invalid:", {
|
||||
original: dateString,
|
||||
converted: isoFormat,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
if (trimmed.includes("T") || trimmed.includes("Z") || trimmed.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
const parsed = parseISO(trimmed);
|
||||
if (!isValid(parsed)) {
|
||||
const error = new Error(`Invalid timezone-aware date: ${dateString}`);
|
||||
console.warn("Date parsing error - timezone-aware format invalid:", {
|
||||
original: dateString,
|
||||
trimmed,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
} else {
|
||||
const dateMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!dateMatch) {
|
||||
const error = new Error(
|
||||
`Invalid date-only string: ${dateString} (expected format: yyyy-MM-dd)`
|
||||
);
|
||||
console.warn("Date parsing error - date-only format invalid:", {
|
||||
original: dateString,
|
||||
trimmed,
|
||||
expectedFormat: "yyyy-MM-dd",
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const [, year, month, day] = dateMatch;
|
||||
const parsed = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
|
||||
if (!isValid(parsed) || parsed.getFullYear() !== parseInt(year, 10) || parsed.getMonth() !== parseInt(month, 10) - 1 || parsed.getDate() !== parseInt(day, 10)) {
|
||||
const error = new Error(`Invalid date values: ${dateString}`);
|
||||
console.warn("Date parsing error - invalid date values:", {
|
||||
original: dateString,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Invalid date")) {
|
||||
throw error;
|
||||
}
|
||||
const wrappedError = new Error(
|
||||
`Unexpected error parsing date "${dateString}": ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
console.error("Unexpected date parsing error:", {
|
||||
original: dateString,
|
||||
trimmed,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : void 0
|
||||
});
|
||||
throw wrappedError;
|
||||
}
|
||||
}
|
||||
function parseDateToUTC(dateString) {
|
||||
if (!dateString) {
|
||||
const error = new Error("Date string cannot be empty");
|
||||
console.error("Date parsing error:", { dateString, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
const trimmed = dateString.trim();
|
||||
try {
|
||||
const dateWithDayNameMatch = trimmed.match(
|
||||
/^(\d{4}-\d{2}-\d{2})\s+(Mon|Tue|Wed|Thu|Fri|Sat|Sun|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)$/i
|
||||
);
|
||||
if (dateWithDayNameMatch) {
|
||||
const dateOnly = dateWithDayNameMatch[1];
|
||||
return parseDateToUTC(dateOnly);
|
||||
}
|
||||
const dateOnlyMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (dateOnlyMatch) {
|
||||
const [, year, month, day] = dateOnlyMatch;
|
||||
const yearNum = parseInt(year, 10);
|
||||
const monthNum = parseInt(month, 10);
|
||||
const dayNum = parseInt(day, 10);
|
||||
if (monthNum < 1 || monthNum > 12) {
|
||||
throw new Error(`Invalid month in date: ${dateString}`);
|
||||
}
|
||||
if (dayNum < 1 || dayNum > 31) {
|
||||
throw new Error(`Invalid day in date: ${dateString}`);
|
||||
}
|
||||
const parsed = new Date(Date.UTC(yearNum, monthNum - 1, dayNum));
|
||||
if (parsed.getUTCFullYear() !== yearNum || parsed.getUTCMonth() !== monthNum - 1 || parsed.getUTCDate() !== dayNum) {
|
||||
throw new Error(`Invalid date values: ${dateString}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
return parseDateToLocal(trimmed);
|
||||
} catch (error) {
|
||||
const wrappedError = new Error(`Failed to parse date to UTC: ${trimmed}`);
|
||||
console.error("Date parsing error:", {
|
||||
dateString,
|
||||
trimmed,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : void 0
|
||||
});
|
||||
throw wrappedError;
|
||||
}
|
||||
}
|
||||
var parseDateToLocal = parseDate2;
|
||||
function isSameDateSafe(date1, date2) {
|
||||
try {
|
||||
const date1Part = getDatePart(date1);
|
||||
const date2Part = getDatePart(date2);
|
||||
const d1 = parseDateToUTC(date1Part);
|
||||
const d2 = parseDateToUTC(date2Part);
|
||||
return d1.getTime() === d2.getTime();
|
||||
} catch (error) {
|
||||
console.error("Error comparing dates:", { date1, date2, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function getDatePart(dateString) {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
return dateString;
|
||||
}
|
||||
const tIndex = dateString.indexOf("T");
|
||||
if (tIndex > -1) {
|
||||
return dateString.substring(0, tIndex);
|
||||
}
|
||||
const parsed = parseDateToUTC(dateString);
|
||||
return formatDateForStorage(parsed);
|
||||
} catch (error) {
|
||||
console.error("Error extracting date part:", { dateString, error });
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
function validateCompleteInstances(instances) {
|
||||
if (!Array.isArray(instances)) {
|
||||
return [];
|
||||
}
|
||||
return instances.filter((instance) => {
|
||||
if (typeof instance !== "string" || !instance.trim()) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = instance.trim();
|
||||
if (trimmed.startsWith("T") && /^T\d{2}:\d{2}(:\d{2})?/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
parseDate2(trimmed);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Invalid complete_instances entry (date parsing failed):",
|
||||
instance,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}).map((instance) => instance.trim());
|
||||
}
|
||||
function formatDateForStorage(date) {
|
||||
try {
|
||||
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
||||
console.warn("formatDateForStorage received invalid date:", date);
|
||||
return "";
|
||||
}
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch (error) {
|
||||
console.error("Error formatting date for storage:", { date, error });
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// src/utils/dependencyUtils.ts
|
||||
import { TFile as TFile2, parseLinktext as parseLinktext2 } from "obsidian";
|
||||
|
||||
// src/utils/linkUtils.ts
|
||||
import { parseLinktext } from "obsidian";
|
||||
function parseLinkToPath(linkText) {
|
||||
if (!linkText) return linkText;
|
||||
const trimmed = linkText.trim();
|
||||
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
|
||||
let inner = trimmed.slice(1, -1).trim();
|
||||
const hasMdExt = /\.md$/i.test(inner);
|
||||
try {
|
||||
inner = decodeURIComponent(inner);
|
||||
} catch (error) {
|
||||
console.debug("Failed to decode URI component:", inner, error);
|
||||
}
|
||||
const parsed = parseLinktext(inner);
|
||||
return hasMdExt ? inner : parsed.path || inner;
|
||||
}
|
||||
if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
|
||||
const inner = trimmed.slice(2, -2).trim();
|
||||
const pipeIndex = inner.indexOf("|");
|
||||
const pathOnly = pipeIndex !== -1 ? inner.substring(0, pipeIndex) : inner;
|
||||
const parsed = parseLinktext(pathOnly);
|
||||
return parsed.path;
|
||||
}
|
||||
const markdownMatch = trimmed.match(/^\[([^\]]*)\]\(([^)]+)\)$/);
|
||||
if (markdownMatch) {
|
||||
let linkPath = markdownMatch[2].trim();
|
||||
if (linkPath.startsWith("<") && linkPath.endsWith(">")) {
|
||||
linkPath = linkPath.slice(1, -1).trim();
|
||||
}
|
||||
const hasMdExt = /\.md$/i.test(linkPath);
|
||||
try {
|
||||
linkPath = decodeURIComponent(linkPath);
|
||||
} catch (error) {
|
||||
console.debug("Failed to decode URI component:", linkPath, error);
|
||||
}
|
||||
const parsed = parseLinktext(linkPath);
|
||||
return hasMdExt ? linkPath : parsed.path;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// src/utils/dependencyUtils.ts
|
||||
var DEFAULT_DEPENDENCY_RELTYPE = "FINISHTOSTART";
|
||||
var VALID_RELATIONSHIP_TYPES = [
|
||||
"FINISHTOSTART",
|
||||
"FINISHTOFINISH",
|
||||
"STARTTOSTART",
|
||||
"STARTTOFINISH"
|
||||
];
|
||||
function isValidDependencyRelType(value) {
|
||||
return VALID_RELATIONSHIP_TYPES.includes(value);
|
||||
}
|
||||
function normalizeDependencyEntry(value) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return { uid: parseLinkToPath(trimmed), reltype: DEFAULT_DEPENDENCY_RELTYPE };
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const raw = value;
|
||||
const rawUid = typeof raw.uid === "string" ? raw.uid.trim() : "";
|
||||
if (!rawUid) {
|
||||
return null;
|
||||
}
|
||||
const normalizedUid = parseLinkToPath(rawUid);
|
||||
const reltypeRaw = typeof raw.reltype === "string" ? raw.reltype.trim().toUpperCase() : "";
|
||||
const reltype = isValidDependencyRelType(reltypeRaw) ? reltypeRaw : DEFAULT_DEPENDENCY_RELTYPE;
|
||||
const gap = typeof raw.gap === "string" && raw.gap.trim().length > 0 ? raw.gap.trim() : void 0;
|
||||
return gap ? { uid: normalizedUid, reltype, gap } : { uid: normalizedUid, reltype };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function normalizeDependencyList(value) {
|
||||
if (value === null || value === void 0) {
|
||||
return void 0;
|
||||
}
|
||||
const arrayValue = Array.isArray(value) ? value : [value];
|
||||
const normalized = [];
|
||||
for (const entry of arrayValue) {
|
||||
const normalizedEntry = normalizeDependencyEntry(entry);
|
||||
if (normalizedEntry) {
|
||||
normalized.push(normalizedEntry);
|
||||
}
|
||||
}
|
||||
return normalized.length > 0 ? normalized : void 0;
|
||||
}
|
||||
function serializeDependencies(dependencies) {
|
||||
return dependencies.map((dependency) => {
|
||||
const uid = dependency.uid.startsWith("[[") ? dependency.uid : `[[${dependency.uid}]]`;
|
||||
const serialized = {
|
||||
uid,
|
||||
reltype: dependency.reltype
|
||||
};
|
||||
if (dependency.gap && dependency.gap.trim().length > 0) {
|
||||
serialized.gap = dependency.gap;
|
||||
}
|
||||
return serialized;
|
||||
});
|
||||
}
|
||||
|
||||
// src/services/FieldMapper.ts
|
||||
var FieldMapper = class {
|
||||
constructor(mapping) {
|
||||
this.mapping = mapping;
|
||||
}
|
||||
/**
|
||||
* Convert internal field name to user's property name
|
||||
*/
|
||||
toUserField(internalName) {
|
||||
return this.mapping[internalName];
|
||||
}
|
||||
/**
|
||||
* Normalize arbitrary title-like values to a string.
|
||||
* - string: return as-is
|
||||
* - number/boolean: String(value)
|
||||
* - array: join elements stringified with ', '
|
||||
* - object: return empty string (unsupported edge case)
|
||||
*/
|
||||
normalizeTitle(val) {
|
||||
if (typeof val === "string") return val;
|
||||
if (Array.isArray(val)) return val.map((v) => String(v)).join(", ");
|
||||
if (val === null || val === void 0) return void 0;
|
||||
if (typeof val === "object") return "";
|
||||
return String(val);
|
||||
}
|
||||
/**
|
||||
* Convert frontmatter object using mapping to internal task data
|
||||
*/
|
||||
mapFromFrontmatter(frontmatter, filePath, storeTitleInFilename) {
|
||||
if (!frontmatter) return {};
|
||||
const mapped = {
|
||||
path: filePath
|
||||
};
|
||||
if (frontmatter[this.mapping.title] !== void 0) {
|
||||
const rawTitle = frontmatter[this.mapping.title];
|
||||
const normalized = this.normalizeTitle(rawTitle);
|
||||
if (normalized !== void 0) {
|
||||
mapped.title = normalized;
|
||||
}
|
||||
} else if (storeTitleInFilename) {
|
||||
const filename = filePath.split("/").pop()?.replace(".md", "");
|
||||
if (filename) {
|
||||
mapped.title = filename;
|
||||
}
|
||||
}
|
||||
if (frontmatter[this.mapping.status] !== void 0) {
|
||||
const statusValue = frontmatter[this.mapping.status];
|
||||
if (typeof statusValue === "boolean") {
|
||||
mapped.status = statusValue ? "true" : "false";
|
||||
} else {
|
||||
mapped.status = statusValue;
|
||||
}
|
||||
}
|
||||
if (frontmatter[this.mapping.priority] !== void 0) {
|
||||
mapped.priority = frontmatter[this.mapping.priority];
|
||||
}
|
||||
if (frontmatter[this.mapping.due] !== void 0) {
|
||||
mapped.due = frontmatter[this.mapping.due];
|
||||
}
|
||||
if (frontmatter[this.mapping.scheduled] !== void 0) {
|
||||
mapped.scheduled = frontmatter[this.mapping.scheduled];
|
||||
}
|
||||
if (frontmatter[this.mapping.contexts] !== void 0) {
|
||||
const contexts = frontmatter[this.mapping.contexts];
|
||||
mapped.contexts = Array.isArray(contexts) ? contexts : [contexts];
|
||||
}
|
||||
if (frontmatter[this.mapping.projects] !== void 0) {
|
||||
const projects = frontmatter[this.mapping.projects];
|
||||
mapped.projects = Array.isArray(projects) ? projects : [projects];
|
||||
}
|
||||
if (frontmatter[this.mapping.timeEstimate] !== void 0) {
|
||||
mapped.timeEstimate = frontmatter[this.mapping.timeEstimate];
|
||||
}
|
||||
if (frontmatter[this.mapping.completedDate] !== void 0) {
|
||||
mapped.completedDate = frontmatter[this.mapping.completedDate];
|
||||
}
|
||||
if (frontmatter[this.mapping.recurrence] !== void 0) {
|
||||
mapped.recurrence = frontmatter[this.mapping.recurrence];
|
||||
}
|
||||
if (frontmatter[this.mapping.recurrenceAnchor] !== void 0) {
|
||||
const anchorValue = frontmatter[this.mapping.recurrenceAnchor];
|
||||
if (anchorValue === "scheduled" || anchorValue === "completion") {
|
||||
mapped.recurrence_anchor = anchorValue;
|
||||
} else {
|
||||
console.warn(`Invalid recurrence_anchor value: ${anchorValue}, defaulting to 'scheduled'`);
|
||||
mapped.recurrence_anchor = "scheduled";
|
||||
}
|
||||
}
|
||||
if (frontmatter[this.mapping.dateCreated] !== void 0) {
|
||||
mapped.dateCreated = frontmatter[this.mapping.dateCreated];
|
||||
}
|
||||
if (frontmatter[this.mapping.dateModified] !== void 0) {
|
||||
mapped.dateModified = frontmatter[this.mapping.dateModified];
|
||||
}
|
||||
if (frontmatter[this.mapping.timeEntries] !== void 0) {
|
||||
const timeEntriesValue = frontmatter[this.mapping.timeEntries];
|
||||
mapped.timeEntries = Array.isArray(timeEntriesValue) ? timeEntriesValue : [];
|
||||
}
|
||||
if (frontmatter[this.mapping.completeInstances] !== void 0) {
|
||||
mapped.complete_instances = validateCompleteInstances(
|
||||
frontmatter[this.mapping.completeInstances]
|
||||
);
|
||||
}
|
||||
if (frontmatter[this.mapping.skippedInstances] !== void 0) {
|
||||
mapped.skipped_instances = validateCompleteInstances(
|
||||
frontmatter[this.mapping.skippedInstances]
|
||||
);
|
||||
}
|
||||
if (this.mapping.blockedBy && frontmatter[this.mapping.blockedBy] !== void 0) {
|
||||
const dependencies = normalizeDependencyList(frontmatter[this.mapping.blockedBy]);
|
||||
if (dependencies) {
|
||||
mapped.blockedBy = dependencies;
|
||||
}
|
||||
}
|
||||
if (frontmatter[this.mapping.icsEventId] !== void 0) {
|
||||
const icsEventId = frontmatter[this.mapping.icsEventId];
|
||||
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
|
||||
}
|
||||
if (frontmatter[this.mapping.googleCalendarEventId] !== void 0) {
|
||||
mapped.googleCalendarEventId = frontmatter[this.mapping.googleCalendarEventId];
|
||||
}
|
||||
if (frontmatter[this.mapping.reminders] !== void 0) {
|
||||
const reminders = frontmatter[this.mapping.reminders];
|
||||
if (Array.isArray(reminders)) {
|
||||
const filteredReminders = reminders.filter((r) => r != null);
|
||||
if (filteredReminders.length > 0) {
|
||||
mapped.reminders = filteredReminders;
|
||||
}
|
||||
} else if (reminders != null) {
|
||||
mapped.reminders = [reminders];
|
||||
}
|
||||
}
|
||||
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
||||
mapped.tags = frontmatter.tags;
|
||||
mapped.archived = frontmatter.tags.includes(this.mapping.archiveTag);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
/**
|
||||
* Convert internal task data to frontmatter using mapping
|
||||
*/
|
||||
mapToFrontmatter(taskData, taskTag, storeTitleInFilename) {
|
||||
const frontmatter = {};
|
||||
if (taskData.title !== void 0) {
|
||||
frontmatter[this.mapping.title] = taskData.title;
|
||||
}
|
||||
if (taskData.status !== void 0) {
|
||||
const lower = taskData.status.toLowerCase();
|
||||
const coercedValue = lower === "true" || lower === "false" ? lower === "true" : taskData.status;
|
||||
frontmatter[this.mapping.status] = coercedValue;
|
||||
}
|
||||
if (taskData.priority !== void 0) {
|
||||
frontmatter[this.mapping.priority] = taskData.priority;
|
||||
}
|
||||
if (taskData.due !== void 0) {
|
||||
frontmatter[this.mapping.due] = taskData.due;
|
||||
}
|
||||
if (taskData.scheduled !== void 0) {
|
||||
frontmatter[this.mapping.scheduled] = taskData.scheduled;
|
||||
}
|
||||
if (taskData.contexts !== void 0 && (!Array.isArray(taskData.contexts) || taskData.contexts.length > 0)) {
|
||||
frontmatter[this.mapping.contexts] = taskData.contexts;
|
||||
}
|
||||
if (taskData.projects !== void 0 && (!Array.isArray(taskData.projects) || taskData.projects.length > 0)) {
|
||||
frontmatter[this.mapping.projects] = taskData.projects;
|
||||
}
|
||||
if (taskData.timeEstimate !== void 0) {
|
||||
frontmatter[this.mapping.timeEstimate] = taskData.timeEstimate;
|
||||
}
|
||||
if (taskData.completedDate !== void 0) {
|
||||
frontmatter[this.mapping.completedDate] = taskData.completedDate;
|
||||
}
|
||||
if (taskData.recurrence !== void 0) {
|
||||
frontmatter[this.mapping.recurrence] = taskData.recurrence;
|
||||
}
|
||||
if (taskData.recurrence_anchor !== void 0) {
|
||||
frontmatter[this.mapping.recurrenceAnchor] = taskData.recurrence_anchor;
|
||||
}
|
||||
if (taskData.dateCreated !== void 0) {
|
||||
frontmatter[this.mapping.dateCreated] = taskData.dateCreated;
|
||||
}
|
||||
if (taskData.dateModified !== void 0) {
|
||||
frontmatter[this.mapping.dateModified] = taskData.dateModified;
|
||||
}
|
||||
if (taskData.timeEntries !== void 0) {
|
||||
frontmatter[this.mapping.timeEntries] = taskData.timeEntries;
|
||||
}
|
||||
if (taskData.complete_instances !== void 0) {
|
||||
frontmatter[this.mapping.completeInstances] = taskData.complete_instances;
|
||||
}
|
||||
if (taskData.skipped_instances !== void 0 && taskData.skipped_instances.length > 0) {
|
||||
frontmatter[this.mapping.skippedInstances] = taskData.skipped_instances;
|
||||
}
|
||||
if (taskData.blockedBy !== void 0) {
|
||||
if (Array.isArray(taskData.blockedBy)) {
|
||||
const normalized = taskData.blockedBy.map((item) => normalizeDependencyEntry(item)).filter((item) => !!item);
|
||||
if (normalized.length > 0) {
|
||||
frontmatter[this.mapping.blockedBy] = serializeDependencies(normalized);
|
||||
}
|
||||
} else {
|
||||
frontmatter[this.mapping.blockedBy] = taskData.blockedBy;
|
||||
}
|
||||
}
|
||||
if (taskData.icsEventId !== void 0 && taskData.icsEventId.length > 0) {
|
||||
frontmatter[this.mapping.icsEventId] = taskData.icsEventId;
|
||||
}
|
||||
if (taskData.reminders !== void 0 && taskData.reminders.length > 0) {
|
||||
frontmatter[this.mapping.reminders] = taskData.reminders;
|
||||
}
|
||||
let tags = taskData.tags ? [...taskData.tags] : [];
|
||||
if (taskTag && !tags.includes(taskTag)) {
|
||||
tags.push(taskTag);
|
||||
}
|
||||
if (taskData.archived === true && !tags.includes(this.mapping.archiveTag)) {
|
||||
tags.push(this.mapping.archiveTag);
|
||||
} else if (taskData.archived === false) {
|
||||
tags = tags.filter((tag) => tag !== this.mapping.archiveTag);
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
frontmatter.tags = tags;
|
||||
}
|
||||
return frontmatter;
|
||||
}
|
||||
/**
|
||||
* Update mapping configuration
|
||||
*/
|
||||
updateMapping(newMapping) {
|
||||
this.mapping = newMapping;
|
||||
}
|
||||
/**
|
||||
* Get current mapping
|
||||
*/
|
||||
getMapping() {
|
||||
return { ...this.mapping };
|
||||
}
|
||||
/**
|
||||
* Look up the FieldMapping key for a given frontmatter property name.
|
||||
*
|
||||
* IMPORTANT: This returns the MAPPING KEY (e.g., "completeInstances"),
|
||||
* NOT the frontmatter property name (e.g., "complete_instances").
|
||||
*
|
||||
* Use this to check if a property is recognized/mapped, but DO NOT use
|
||||
* the returned key directly as a property identifier for TaskCard.
|
||||
*
|
||||
* @param frontmatterPropertyName - The property name from YAML (e.g., "complete_instances")
|
||||
* @returns The FieldMapping key (e.g., "completeInstances") or null if not found
|
||||
*
|
||||
* @example
|
||||
* // Given mapping: { completeInstances: "complete_instances" }
|
||||
* lookupMappingKey("complete_instances") // Returns: "completeInstances"
|
||||
* lookupMappingKey("unknown_field") // Returns: null
|
||||
*/
|
||||
lookupMappingKey(frontmatterPropertyName) {
|
||||
for (const [mappingKey, propertyName] of Object.entries(this.mapping)) {
|
||||
if (propertyName === frontmatterPropertyName) {
|
||||
return mappingKey;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Check if a frontmatter property name is a recognized/configured field.
|
||||
* Returns true if the property has a mapping, false otherwise.
|
||||
*
|
||||
* @param frontmatterPropertyName - The property name from YAML
|
||||
* @returns true if the property is recognized, false otherwise
|
||||
*/
|
||||
isRecognizedProperty(frontmatterPropertyName) {
|
||||
return this.lookupMappingKey(frontmatterPropertyName) !== null;
|
||||
}
|
||||
/**
|
||||
* Check if a property name matches a specific internal field.
|
||||
* This handles user-configured field names properly.
|
||||
*
|
||||
* @param propertyName - The property name to check (could be user-configured or internal)
|
||||
* @param internalField - The internal field key to check against
|
||||
* @returns true if the propertyName is the user's configured name for this field
|
||||
*
|
||||
* @example
|
||||
* // User has { status: "task-status" }
|
||||
* isPropertyForField("task-status", "status") // true
|
||||
* isPropertyForField("status", "status") // false
|
||||
*
|
||||
* // User has { status: "status" } (default)
|
||||
* isPropertyForField("status", "status") // true
|
||||
*/
|
||||
isPropertyForField(propertyName, internalField) {
|
||||
return this.mapping[internalField] === propertyName;
|
||||
}
|
||||
/**
|
||||
* Convert an array of internal field names to their user-configured property names.
|
||||
*
|
||||
* @param internalFields - Array of FieldMapping keys
|
||||
* @returns Array of user-configured property names
|
||||
*
|
||||
* @example
|
||||
* // User has { status: "task-status", due: "deadline" }
|
||||
* toUserFields(["status", "due", "priority"])
|
||||
* // Returns: ["task-status", "deadline", "priority"]
|
||||
*/
|
||||
toUserFields(internalFields) {
|
||||
return internalFields.map((field) => this.mapping[field]);
|
||||
}
|
||||
/**
|
||||
* @deprecated Use lookupMappingKey() instead for clarity about what is returned
|
||||
* Convert user's property name back to internal field name
|
||||
* This is the reverse of toUserField()
|
||||
*/
|
||||
fromUserField(userPropertyName) {
|
||||
return this.lookupMappingKey(userPropertyName);
|
||||
}
|
||||
/**
|
||||
* Validate that a mapping has no empty field names
|
||||
*/
|
||||
static validateMapping(mapping) {
|
||||
const errors = [];
|
||||
const fields = Object.keys(mapping);
|
||||
for (const field of fields) {
|
||||
if (!mapping[field] || mapping[field].trim() === "") {
|
||||
errors.push(`Field "${field}" cannot be empty`);
|
||||
}
|
||||
}
|
||||
const values = Object.values(mapping);
|
||||
const uniqueValues = new Set(values);
|
||||
if (values.length !== uniqueValues.size) {
|
||||
errors.push("Field mappings must have unique property names");
|
||||
}
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// src/settings/defaults.ts
|
||||
var DEFAULT_FIELD_MAPPING = {
|
||||
title: "title",
|
||||
status: "status",
|
||||
priority: "priority",
|
||||
due: "due",
|
||||
scheduled: "scheduled",
|
||||
contexts: "contexts",
|
||||
projects: "projects",
|
||||
timeEstimate: "timeEstimate",
|
||||
completedDate: "completedDate",
|
||||
dateCreated: "dateCreated",
|
||||
dateModified: "dateModified",
|
||||
recurrence: "recurrence",
|
||||
recurrenceAnchor: "recurrence_anchor",
|
||||
archiveTag: "archived",
|
||||
timeEntries: "timeEntries",
|
||||
completeInstances: "complete_instances",
|
||||
skippedInstances: "skipped_instances",
|
||||
blockedBy: "blockedBy",
|
||||
pomodoros: "pomodoros",
|
||||
icsEventId: "icsEventId",
|
||||
icsEventTag: "ics_event",
|
||||
googleCalendarEventId: "googleCalendarEventId",
|
||||
reminders: "reminders"
|
||||
};
|
||||
|
||||
// ../../../../tmp/tasknotes-conformance-smoke.ts
|
||||
console.log(parseDateToUTC("2026-02-20").toISOString().slice(0, 10));
|
||||
console.log(parseDateToLocal("2026-02-20").toISOString().slice(0, 10));
|
||||
console.log(isSameDateSafe("2026-02-20", "2026-02-20T09:00:00Z"));
|
||||
var mapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
|
||||
console.log(mapper.toUserField("title"));
|
||||
|
|
@ -45,3 +45,5 @@ npm run build # Production build (without copying to vault)
|
|||
---
|
||||
|
||||
When you make changes, update docs/releases/unreleased.md. If your changes are related to a GitHub issue or PR, include acknowledgement of the individual who opened the issue or submitted the PR. Do not update unreleased.md for the addition of tests; unreleased.md is user-facing.
|
||||
|
||||
Update .ops/ files as you work on items
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ Example:
|
|||
|
||||
## Fixed
|
||||
|
||||
- (#1651) Fixed date `is` query filtering so date-only searches also match timed `scheduled` and `due` values during TaskManager prefiltering
|
||||
- Thanks to @36mimu36 for reporting
|
||||
- (#1644) Fixed generated default task views so recurring tasks without a `complete_instances` property are still treated as incomplete and appear in views like This Week, Today, and Overdue
|
||||
- Thanks to @bkennedy-improving for reporting
|
||||
- Updated generated `_types/task.md` mdbase schema output so `dateCreated` and `dateModified` include generated values (`now` and `now_on_write`) for automatic timestamp handling on create/write
|
||||
- Fixed documentation deployment CI failures caused by `docs-builder/src/js/main.js` being excluded by a broad `.gitignore` `main.js` rule
|
||||
- Added a specific unignore rule so the docs site client script is tracked and available in GitHub Actions builds
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Default Base Templates
|
||||
description: Default base file templates for TaskNotes views
|
||||
dateModified: 2025-12-02T12:00:00+1100
|
||||
dateModified: 2026-03-23T18:00:00+1100
|
||||
---
|
||||
|
||||
# Default Base Templates
|
||||
|
|
@ -238,7 +238,7 @@ views:
|
|||
|
||||
Used by the **Tasks** command to display filtered task views.
|
||||
|
||||
This template includes multiple views: All Tasks, Not Blocked, Today, Overdue, This Week, and Unscheduled. Each view (except All Tasks) filters for incomplete tasks, handling both recurring and non-recurring tasks. The "Not Blocked" view additionally filters for tasks that are ready to work on (no incomplete blocking dependencies).
|
||||
This template includes multiple views: All Tasks, Not Blocked, Today, Overdue, This Week, and Unscheduled. Each view (except All Tasks) filters for incomplete tasks, handling both recurring and non-recurring tasks. For recurring tasks, the generated filters treat a missing `complete_instances` property as "not completed today" so newly created recurring tasks still appear by default. The "Not Blocked" view additionally filters for tasks that are ready to work on (no incomplete blocking dependencies).
|
||||
The default views cover common review horizons and can be kept, removed, or cloned with modified filters.
|
||||
|
||||
```yaml
|
||||
|
|
@ -283,7 +283,9 @@ views:
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- recurrence
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
- or:
|
||||
- complete_instances.isEmpty()
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
# Not blocked by any incomplete tasks
|
||||
- or:
|
||||
# No blocking dependencies at all
|
||||
|
|
@ -319,7 +321,9 @@ views:
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- recurrence
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
- or:
|
||||
- complete_instances.isEmpty()
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
# Due or scheduled today
|
||||
- or:
|
||||
- date(due) == today()
|
||||
|
|
@ -353,7 +357,9 @@ views:
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- recurrence
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
- or:
|
||||
- complete_instances.isEmpty()
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
# Due in the past
|
||||
- date(due) < today()
|
||||
order:
|
||||
|
|
@ -385,7 +391,9 @@ views:
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- recurrence
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
- or:
|
||||
- complete_instances.isEmpty()
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
# Due or scheduled this week
|
||||
- or:
|
||||
- and:
|
||||
|
|
@ -423,7 +431,9 @@ views:
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- recurrence
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
- or:
|
||||
- complete_instances.isEmpty()
|
||||
- "!complete_instances.contains(today().format(\"yyyy-MM-dd\"))"
|
||||
# No due date and no scheduled date
|
||||
- date(due).isEmpty()
|
||||
- date(scheduled).isEmpty()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"test:unit": "jest --testPathPatterns=unit",
|
||||
"test:performance": "jest --testPathPatterns=performance --detectOpenHandles --passWithNoTests",
|
||||
"test:build": "node -e \"console.log('Build test passed - plugin bundle created successfully')\"",
|
||||
"conformance:test": "node scripts/run-tasknotes-spec-conformance.mjs",
|
||||
"lint": "eslint src/ --ext .ts",
|
||||
"lint:fix": "eslint src/ --ext .ts --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,js,json,md}\"",
|
||||
|
|
|
|||
37
scripts/run-tasknotes-spec-conformance.mjs
Normal file
37
scripts/run-tasknotes-spec-conformance.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const tasknotesRoot = resolve(scriptDir, "..");
|
||||
const specRoot = resolve(tasknotesRoot, "../tasknotes-spec");
|
||||
const adapterPath = resolve(specRoot, "conformance/adapters/tasknotes.adapter.mjs");
|
||||
|
||||
function requirePath(path, label) {
|
||||
if (!existsSync(path)) {
|
||||
console.error(`Missing ${label}: ${path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ? { ...process.env, ...options.env } : process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
requirePath(specRoot, "tasknotes-spec repo");
|
||||
requirePath(resolve(specRoot, "package.json"), "tasknotes-spec package.json");
|
||||
|
||||
run("npm", ["run", "conformance:generate"], { cwd: specRoot });
|
||||
run("npm", ["run", "conformance:build:tasknotes-bridge"], { cwd: specRoot });
|
||||
run("npm", ["run", "conformance:test"], {
|
||||
cwd: specRoot,
|
||||
env: { TASKNOTES_ADAPTER: adapterPath },
|
||||
});
|
||||
|
|
@ -882,8 +882,8 @@ export class KanbanView extends BasesViewBase {
|
|||
visibleProperties: string[],
|
||||
cardOptions: any
|
||||
): void {
|
||||
// Make container scrollable with full viewport height
|
||||
cardsContainer.style.cssText = "overflow-y: auto; max-height: 100vh; position: relative;";
|
||||
// Use semantic class instead of inline style for easier maintenance.
|
||||
cardsContainer.addClass("kanban-view__cards--virtual");
|
||||
|
||||
// Use containerEl.ownerDocument for pop-out window support
|
||||
const doc = this.containerEl.ownerDocument;
|
||||
|
|
@ -918,8 +918,8 @@ export class KanbanView extends BasesViewBase {
|
|||
tasks: TaskInfo[],
|
||||
visibleProperties: string[]
|
||||
): Promise<void> {
|
||||
// Make container scrollable and fill the cell
|
||||
tasksContainer.style.cssText = "overflow-y: auto; height: 100%; position: relative;";
|
||||
// Use semantic class instead of inline style for easier maintenance.
|
||||
tasksContainer.addClass("kanban-view__tasks-container--virtual");
|
||||
|
||||
const cardOptions = this.getCardOptions();
|
||||
|
||||
|
|
@ -982,6 +982,9 @@ export class KanbanView extends BasesViewBase {
|
|||
const draggingClass = isSwimlaneHeader
|
||||
? "kanban-view__column-header-cell--dragging"
|
||||
: "kanban-view__column-header--dragging";
|
||||
const dragoverClass = isSwimlaneHeader
|
||||
? "kanban-view__column-header-cell--dragover"
|
||||
: "kanban-view__column-header--dragover";
|
||||
|
||||
header.addEventListener("dragstart", (e: DragEvent) => {
|
||||
if (!e.dataTransfer) return;
|
||||
|
|
@ -998,14 +1001,14 @@ export class KanbanView extends BasesViewBase {
|
|||
e.dataTransfer.dropEffect = "move";
|
||||
|
||||
// Add visual feedback for drop target
|
||||
header.classList.add("kanban-view__column-header--dragover");
|
||||
header.classList.add(dragoverClass);
|
||||
});
|
||||
|
||||
header.addEventListener("dragleave", (e: DragEvent) => {
|
||||
// Only handle column drags
|
||||
if (!e.dataTransfer?.types.includes("text/x-kanban-column")) return;
|
||||
if (e.target === header) {
|
||||
header.classList.remove("kanban-view__column-header--dragover");
|
||||
header.classList.remove(dragoverClass);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1016,7 +1019,7 @@ export class KanbanView extends BasesViewBase {
|
|||
e.stopPropagation();
|
||||
|
||||
// Remove visual feedback
|
||||
header.classList.remove("kanban-view__column-header--dragover");
|
||||
header.classList.remove(dragoverClass);
|
||||
|
||||
const draggedKey = e.dataTransfer.getData("text/x-kanban-column");
|
||||
const targetKey = header.dataset.columnKey;
|
||||
|
|
@ -1053,7 +1056,12 @@ export class KanbanView extends BasesViewBase {
|
|||
header.classList.remove(draggingClass);
|
||||
});
|
||||
|
||||
this.setupColumnHeaderTouchHandlers(header, columnKey, isSwimlaneHeader, draggingClass);
|
||||
this.setupColumnHeaderTouchHandlers(
|
||||
header,
|
||||
columnKey,
|
||||
isSwimlaneHeader,
|
||||
draggingClass
|
||||
);
|
||||
}
|
||||
|
||||
private setupColumnHeaderTouchHandlers(
|
||||
|
|
@ -1359,6 +1367,9 @@ export class KanbanView extends BasesViewBase {
|
|||
this.boardEl?.querySelectorAll(".kanban-view__column-header--dragover").forEach((el) => {
|
||||
el.classList.remove("kanban-view__column-header--dragover");
|
||||
});
|
||||
this.boardEl?.querySelectorAll(".kanban-view__column-header-cell--dragover").forEach((el) => {
|
||||
el.classList.remove("kanban-view__column-header-cell--dragover");
|
||||
});
|
||||
}
|
||||
|
||||
private updateDropTargetFeedback(x: number, y: number): void {
|
||||
|
|
@ -1370,7 +1381,11 @@ export class KanbanView extends BasesViewBase {
|
|||
} else if (target.type === "swimlane") {
|
||||
target.element.classList.add("kanban-view__swimlane-column--dragover");
|
||||
} else if (target.type === "columnHeader" && this.touchDragType === "column") {
|
||||
target.element.classList.add("kanban-view__column-header--dragover");
|
||||
if (target.element.classList.contains("kanban-view__column-header-cell")) {
|
||||
target.element.classList.add("kanban-view__column-header-cell--dragover");
|
||||
} else {
|
||||
target.element.classList.add("kanban-view__column-header--dragover");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1892,7 +1907,6 @@ export class KanbanView extends BasesViewBase {
|
|||
const doc = this.containerEl.ownerDocument;
|
||||
const empty = doc.createElement("div");
|
||||
empty.className = "tn-bases-empty";
|
||||
empty.style.cssText = "padding: 20px; text-align: center; color: var(--text-muted);";
|
||||
empty.textContent = "No TaskNotes tasks found for this Base.";
|
||||
this.boardEl.appendChild(empty);
|
||||
}
|
||||
|
|
@ -1903,7 +1917,6 @@ export class KanbanView extends BasesViewBase {
|
|||
const doc = this.containerEl.ownerDocument;
|
||||
const error = doc.createElement("div");
|
||||
error.className = "tn-bases-error";
|
||||
error.style.cssText = "padding: 20px; text-align: center; color: var(--text-error);";
|
||||
error.textContent = this.plugin.i18n.translate("views.kanban.errors.noGroupBy");
|
||||
this.boardEl.appendChild(error);
|
||||
}
|
||||
|
|
@ -1914,8 +1927,6 @@ export class KanbanView extends BasesViewBase {
|
|||
const doc = this.containerEl.ownerDocument;
|
||||
const errorEl = doc.createElement("div");
|
||||
errorEl.className = "tn-bases-error";
|
||||
errorEl.style.cssText =
|
||||
"padding: 20px; color: #d73a49; background: #ffeaea; border-radius: 4px; margin: 10px 0;";
|
||||
errorEl.textContent = `Error loading kanban: ${error.message || "Unknown error"}`;
|
||||
this.boardEl.appendChild(errorEl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,6 @@ export class MdbaseSpecService {
|
|||
this.addRoleField(lines, "recurrenceAnchor", {
|
||||
type: "enum",
|
||||
values: ["scheduled", "completion"],
|
||||
default: "scheduled",
|
||||
});
|
||||
this.addField(lines, "tags", { type: "list", items: { type: "string" }, tn_role: "tags" });
|
||||
|
||||
|
|
|
|||
|
|
@ -491,6 +491,11 @@ ${orderYaml}
|
|||
.map(status => `${statusProperty} != "${status}"`)
|
||||
.join('\n - ');
|
||||
|
||||
// Treat missing complete_instances as "not completed today" for recurring tasks.
|
||||
const recurringIncompleteFilter = `or:
|
||||
- ${completeInstancesProperty}.isEmpty()
|
||||
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"`;
|
||||
|
||||
// Generate filter condition for checking if a blocking task is incomplete
|
||||
// This is used in the "Not Blocked" view to filter out completed blocking tasks
|
||||
const blockingTaskIncompleteCondition = completedStatuses
|
||||
|
|
@ -524,7 +529,7 @@ ${orderYaml}
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- ${recurrenceProperty}
|
||||
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
|
||||
- ${recurringIncompleteFilter}
|
||||
# Not blocked by any incomplete tasks
|
||||
- or:
|
||||
# No blocking dependencies at all
|
||||
|
|
@ -549,7 +554,7 @@ ${orderYaml}
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- ${recurrenceProperty}
|
||||
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
|
||||
- ${recurringIncompleteFilter}
|
||||
# Due or scheduled today
|
||||
- or:
|
||||
- date(${dueProperty}) == today()
|
||||
|
|
@ -572,7 +577,7 @@ ${orderYaml}
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- ${recurrenceProperty}
|
||||
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
|
||||
- ${recurringIncompleteFilter}
|
||||
# Due in the past
|
||||
- date(${dueProperty}) < today()
|
||||
order:
|
||||
|
|
@ -593,7 +598,7 @@ ${orderYaml}
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- ${recurrenceProperty}
|
||||
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
|
||||
- ${recurringIncompleteFilter}
|
||||
# Due or scheduled this week
|
||||
- or:
|
||||
- and:
|
||||
|
|
@ -620,7 +625,7 @@ ${orderYaml}
|
|||
# Recurring task where today is not in complete_instances
|
||||
- and:
|
||||
- ${recurrenceProperty}
|
||||
- "!${completeInstancesProperty}.contains(today().format(\\"yyyy-MM-dd\\"))"
|
||||
- ${recurringIncompleteFilter}
|
||||
# No due date and no scheduled date
|
||||
- date(${dueProperty}).isEmpty()
|
||||
- date(${scheduledProperty}).isEmpty()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getTodayString,
|
||||
formatDateForStorage,
|
||||
isBeforeDateSafe,
|
||||
getDatePart,
|
||||
} from "./dateUtils";
|
||||
import { calculateTotalTimeSpent } from "./helpers";
|
||||
import { TaskNotesSettings } from "../types/settings";
|
||||
|
|
@ -342,6 +343,7 @@ export class TaskManager extends Events {
|
|||
getTasksForDate(date: string): string[] {
|
||||
const taskPaths: string[] = [];
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const targetDate = getDatePart(date);
|
||||
|
||||
const scheduledField = this.fieldMapper?.toUserField("scheduled") || "scheduled";
|
||||
const dueField = this.fieldMapper?.toUserField("due") || "due";
|
||||
|
|
@ -355,8 +357,15 @@ export class TaskManager extends Events {
|
|||
const scheduled = metadata.frontmatter[scheduledField];
|
||||
const due = metadata.frontmatter[dueField];
|
||||
|
||||
// Check if task is scheduled or due on this date
|
||||
if (scheduled === date || due === date) {
|
||||
const scheduledDate =
|
||||
typeof scheduled === "string" && scheduled.length > 0
|
||||
? getDatePart(scheduled)
|
||||
: undefined;
|
||||
const dueDate =
|
||||
typeof due === "string" && due.length > 0 ? getDatePart(due) : undefined;
|
||||
|
||||
// Match date-only queries against both date-only and datetime frontmatter values.
|
||||
if (scheduledDate === targetDate || dueDate === targetDate) {
|
||||
taskPaths.push(file.path);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,7 +206,8 @@
|
|||
}
|
||||
|
||||
/* Column header dragover state (for column reordering) */
|
||||
.tasknotes-plugin .kanban-view__column-header--dragover {
|
||||
.tasknotes-plugin .kanban-view__column-header--dragover,
|
||||
.tasknotes-plugin .kanban-view__column-header-cell--dragover {
|
||||
background: var(--tn-interactive-hover);
|
||||
border: 2px solid var(--tn-interactive-accent);
|
||||
transform: scale(1.02);
|
||||
|
|
@ -311,6 +312,14 @@
|
|||
color: var(--tn-text-normal);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-header .kanban-view__column-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-icon {
|
||||
|
|
@ -331,80 +340,9 @@
|
|||
line-height: 1.2;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Column Body */
|
||||
.tasknotes-plugin .kanban-view__column-body {
|
||||
padding: var(--tn-spacing-sm);
|
||||
min-height: 100px;
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tn-spacing-xs);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-body--dragover {
|
||||
background: var(--tn-interactive-hover);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-body--dragover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: color-mix(in srgb, var(--tn-interactive-accent) 0.1, transparent);
|
||||
border: 2px dashed var(--tn-interactive-accent);
|
||||
border-radius: var(--tn-radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Column Body Scrollbar */
|
||||
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar-track {
|
||||
background: var(--tn-bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar-thumb {
|
||||
background: var(--tn-border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--tn-text-muted);
|
||||
}
|
||||
|
||||
/* Empty Column */
|
||||
.tasknotes-plugin .kanban-view__column-empty {
|
||||
text-align: center;
|
||||
color: var(--tn-text-muted);
|
||||
font-style: italic;
|
||||
font-size: var(--tn-font-size-sm);
|
||||
padding: var(--tn-spacing-lg);
|
||||
border: 2px dashed var(--tn-border-color);
|
||||
border-radius: var(--tn-radius-md);
|
||||
background: var(--tn-bg-secondary);
|
||||
transition: all var(--tn-transition-fast);
|
||||
margin: var(--tn-spacing-xs);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-empty:hover {
|
||||
border-color: var(--tn-interactive-accent);
|
||||
background: var(--tn-interactive-hover);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column--dragover .kanban-view__column-empty {
|
||||
border-color: var(--tn-interactive-accent);
|
||||
background: var(--tn-interactive-accent-hover);
|
||||
color: var(--tn-text-on-accent);
|
||||
margin-left: var(--tn-spacing-xs);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
|
|
@ -421,6 +359,18 @@
|
|||
min-height: 0; /* Allow container to shrink and enable scrolling */
|
||||
}
|
||||
|
||||
/* Virtualized columns need bounded height and relative positioning. */
|
||||
.tasknotes-plugin .kanban-view__cards--virtual {
|
||||
max-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Virtualized swimlane cells should fill their container. */
|
||||
.tasknotes-plugin .kanban-view__tasks-container--virtual {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Dragging state for card wrappers */
|
||||
.tasknotes-plugin .kanban-view__card-wrapper[draggable="true"]:hover {
|
||||
cursor: grab;
|
||||
|
|
@ -483,10 +433,6 @@
|
|||
padding: var(--tn-spacing-xs) var(--tn-spacing-sm);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-body {
|
||||
padding: var(--tn-spacing-xs);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__new-task-button {
|
||||
font-size: var(--tn-font-size-xs);
|
||||
padding: 0 var(--tn-spacing-md);
|
||||
|
|
|
|||
9
tasknotes-e2e-vault/.obsidian/plugins/mdbase-obsidian/manifest.json
vendored
Normal file
9
tasknotes-e2e-vault/.obsidian/plugins/mdbase-obsidian/manifest.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "mdbase-obsidian",
|
||||
"name": "mdbase",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Schema, type, and validation tools for mdbase collections in Obsidian.",
|
||||
"author": "calluma",
|
||||
"authorUrl": "https://github.com/callumalpass"
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Reproduction tests for issue #1651.
|
||||
*
|
||||
* Reported behavior:
|
||||
* - Query condition `scheduled is 2026-02-27` should match tasks with
|
||||
* `scheduled: 2026-02-27T09:00`, but returns no results.
|
||||
* - Query condition using `is_not_empty` also returns zero results in the
|
||||
* reported API payload.
|
||||
*/
|
||||
|
||||
import { FilterService } from '../../../src/services/FilterService';
|
||||
import { TaskManager } from '../../../src/utils/TaskManager';
|
||||
import { StatusManager } from '../../../src/services/StatusManager';
|
||||
import { PriorityManager } from '../../../src/services/PriorityManager';
|
||||
import { FieldMapper } from '../../../src/services/FieldMapper';
|
||||
import {
|
||||
DEFAULT_FIELD_MAPPING,
|
||||
DEFAULT_PRIORITIES,
|
||||
DEFAULT_SETTINGS,
|
||||
DEFAULT_STATUSES,
|
||||
} from '../../../src/settings/defaults';
|
||||
import { FilterQuery, TaskInfo } from '../../../src/types';
|
||||
import { MockObsidian } from '../../__mocks__/obsidian';
|
||||
|
||||
function makeFilterService() {
|
||||
const app = MockObsidian.createMockApp();
|
||||
const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
taskIdentificationMethod: 'property',
|
||||
taskPropertyName: 'isTask',
|
||||
taskPropertyValue: 'true',
|
||||
} as any;
|
||||
|
||||
const cache = new TaskManager(app as any, settings, mapper);
|
||||
cache.initialize();
|
||||
|
||||
const statusManager = new StatusManager(DEFAULT_STATUSES);
|
||||
const priorityManager = new PriorityManager(DEFAULT_PRIORITIES);
|
||||
const plugin = { settings: { ...DEFAULT_SETTINGS, userFields: [] } } as any;
|
||||
|
||||
return {
|
||||
app,
|
||||
filterService: new FilterService(cache, statusManager, priorityManager, plugin),
|
||||
};
|
||||
}
|
||||
|
||||
async function createTaskWithTimedScheduledDate(app: any, path: string): Promise<void> {
|
||||
await app.vault.create(
|
||||
path,
|
||||
'---\n' +
|
||||
'isTask: true\n' +
|
||||
'title: Timed scheduled task\n' +
|
||||
'status: open\n' +
|
||||
'priority: normal\n' +
|
||||
'scheduled: 2026-02-27T09:00\n' +
|
||||
'---\n'
|
||||
);
|
||||
|
||||
// Keep explicit string values in metadata for deterministic comparisons.
|
||||
app.metadataCache.setCache(path, {
|
||||
frontmatter: {
|
||||
isTask: true,
|
||||
title: 'Timed scheduled task',
|
||||
status: 'open',
|
||||
priority: 'normal',
|
||||
scheduled: '2026-02-27T09:00',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTaskWithTimedDueDate(app: any, path: string): Promise<void> {
|
||||
await app.vault.create(
|
||||
path,
|
||||
'---\n' +
|
||||
'isTask: true\n' +
|
||||
'title: Timed due task\n' +
|
||||
'status: open\n' +
|
||||
'priority: normal\n' +
|
||||
'due: 2026-02-27T17:30\n' +
|
||||
'---\n'
|
||||
);
|
||||
|
||||
app.metadataCache.setCache(path, {
|
||||
frontmatter: {
|
||||
isTask: true,
|
||||
title: 'Timed due task',
|
||||
status: 'open',
|
||||
priority: 'normal',
|
||||
due: '2026-02-27T17:30',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function flattenGroups(grouped: Map<string, TaskInfo[]>): TaskInfo[] {
|
||||
return Array.from(grouped.values()).flat();
|
||||
}
|
||||
|
||||
describe('Issue #1651: Query API scheduled date matching with time components', () => {
|
||||
beforeEach(() => {
|
||||
MockObsidian.reset();
|
||||
});
|
||||
|
||||
it('matches `scheduled is YYYY-MM-DD` against `scheduled: YYYY-MM-DDTHH:mm`', async () => {
|
||||
const { app, filterService } = makeFilterService();
|
||||
const taskPath = 'Tasks/issue-1651-timed-scheduled.md';
|
||||
await createTaskWithTimedScheduledDate(app, taskPath);
|
||||
|
||||
const query: FilterQuery = {
|
||||
type: 'group',
|
||||
id: 'root',
|
||||
conjunction: 'and',
|
||||
children: [
|
||||
{
|
||||
type: 'condition',
|
||||
id: 'c1',
|
||||
property: 'scheduled',
|
||||
operator: 'is',
|
||||
value: '2026-02-27',
|
||||
},
|
||||
],
|
||||
sortKey: 'due',
|
||||
sortDirection: 'asc',
|
||||
groupKey: 'none',
|
||||
};
|
||||
|
||||
const grouped = await filterService.getGroupedTasks(query);
|
||||
const matchedTasks = flattenGroups(grouped);
|
||||
|
||||
expect(matchedTasks.map((task) => task.path)).toContain(taskPath);
|
||||
});
|
||||
|
||||
it('matches `due is YYYY-MM-DD` against `due: YYYY-MM-DDTHH:mm`', async () => {
|
||||
const { app, filterService } = makeFilterService();
|
||||
const taskPath = 'Tasks/issue-1651-timed-due.md';
|
||||
await createTaskWithTimedDueDate(app, taskPath);
|
||||
|
||||
const query: FilterQuery = {
|
||||
type: 'group',
|
||||
id: 'root',
|
||||
conjunction: 'and',
|
||||
children: [
|
||||
{
|
||||
type: 'condition',
|
||||
id: 'c1',
|
||||
property: 'due',
|
||||
operator: 'is',
|
||||
value: '2026-02-27',
|
||||
},
|
||||
],
|
||||
sortKey: 'due',
|
||||
sortDirection: 'asc',
|
||||
groupKey: 'none',
|
||||
};
|
||||
|
||||
const grouped = await filterService.getGroupedTasks(query);
|
||||
const matchedTasks = flattenGroups(grouped);
|
||||
|
||||
expect(matchedTasks.map((task) => task.path)).toContain(taskPath);
|
||||
});
|
||||
|
||||
it.skip('reproduces issue #1651 - API payload using `is_not_empty` should return tasks with scheduled values', async () => {
|
||||
const { app, filterService } = makeFilterService();
|
||||
const taskPath = 'Tasks/issue-1651-is-not-empty.md';
|
||||
await createTaskWithTimedScheduledDate(app, taskPath);
|
||||
|
||||
const query = {
|
||||
type: 'group',
|
||||
id: 'root',
|
||||
conjunction: 'and',
|
||||
children: [
|
||||
{
|
||||
type: 'condition',
|
||||
id: 'c1',
|
||||
property: 'scheduled',
|
||||
operator: 'is_not_empty',
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
sortKey: 'due',
|
||||
sortDirection: 'asc',
|
||||
groupKey: 'none',
|
||||
} as any;
|
||||
|
||||
const grouped = await filterService.getGroupedTasks(query);
|
||||
const matchedTasks = flattenGroups(grouped);
|
||||
|
||||
expect(matchedTasks.map((task) => task.path)).toContain(taskPath);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue