From 70d9d8a890d000a8281b115298441ad80934e9eb Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 13 Jun 2026 21:23:11 +0200 Subject: [PATCH 01/11] feat(todotxt): add parser/serializer for todo.txt format Pure utility with Task model, line/file parse/serialize, project/context/tag extraction, due/rec segment splitting, and toggleCompleted. Round-trip tests cover priorities, dates, tag tokens, mid-word @ guards, and blank lines. --- src/utils/todotxt.spec.ts | 238 ++++++++++++++++++++++++++++++++++++++ src/utils/todotxt.ts | 231 ++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 src/utils/todotxt.spec.ts create mode 100644 src/utils/todotxt.ts diff --git a/src/utils/todotxt.spec.ts b/src/utils/todotxt.spec.ts new file mode 100644 index 0000000..4d98eb7 --- /dev/null +++ b/src/utils/todotxt.spec.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest" + +import { + contextsOf, + isBlank, + parseFile, + parseLine, + projectsOf, + removeTagFromBody, + removeTokenFromBody, + segmentBody, + serializeFile, + serializeTask, + tagsOf, + toggleCompleted +} from "./todotxt" + +describe("todotxt parseLine", () => { + it("parses a plain task with no metadata", () => { + const task = parseLine("Pick up groceries") + expect(task.completed).toBe(false) + expect(task.priority).toBeUndefined() + expect(task.completionDate).toBeUndefined() + expect(task.creationDate).toBeUndefined() + expect(task.body).toBe("Pick up groceries") + }) + + it("parses priority + creation date + body", () => { + const task = parseLine("(A) 2024-01-15 Call Mom +Family @phone") + expect(task.priority).toBe("A") + expect(task.creationDate).toBe("2024-01-15") + expect(task.body).toBe("Call Mom +Family @phone") + }) + + it("parses a completed task with completion + creation dates", () => { + const task = parseLine("x 2024-01-20 (A) 2024-01-15 Finish report") + expect(task.completed).toBe(true) + expect(task.completionDate).toBe("2024-01-20") + expect(task.priority).toBe("A") + expect(task.creationDate).toBe("2024-01-15") + expect(task.body).toBe("Finish report") + }) + + it("parses a completed task with no completion date", () => { + const task = parseLine("x Buy milk") + expect(task.completed).toBe(true) + expect(task.completionDate).toBeUndefined() + expect(task.body).toBe("Buy milk") + }) + + it("does not treat uppercase X as completion", () => { + const task = parseLine("X-ray appointment") + expect(task.completed).toBe(false) + expect(task.body).toBe("X-ray appointment") + }) + + it("does not match a lowercase priority", () => { + const task = parseLine("(a) lowercase priority is body text") + expect(task.priority).toBeUndefined() + expect(task.body).toBe("(a) lowercase priority is body text") + }) +}) + +describe("todotxt serializeTask", () => { + it("round-trips a complex task", () => { + const input = "x 2024-01-20 (A) 2024-01-15 Finish report +work @office" + expect(serializeTask(parseLine(input))).toBe(input) + }) + + it("round-trips a plain task", () => { + const input = "Buy milk" + expect(serializeTask(parseLine(input))).toBe(input) + }) + + it("round-trips priority + body", () => { + const input = "(B) Walk the dog" + expect(serializeTask(parseLine(input))).toBe(input) + }) + + it("drops completion date when task is not completed", () => { + const task = parseLine("(A) 2024-01-15 Hello") + const out = serializeTask({ ...task, completionDate: "2024-02-01" }) + expect(out).toBe("(A) 2024-01-15 Hello") + }) +}) + +describe("todotxt projectsOf / contextsOf / tagsOf", () => { + it("extracts projects", () => { + const task = parseLine("Plan trip +Travel +Family @home") + expect(projectsOf(task)).toEqual(["Travel", "Family"]) + }) + + it("extracts contexts", () => { + const task = parseLine("Call +Family @phone @home") + expect(contextsOf(task)).toEqual(["phone", "home"]) + }) + + it("extracts key:value tags", () => { + const task = parseLine("Finish report due:2024-02-01 pri:A") + expect(tagsOf(task)).toEqual({ due: "2024-02-01", pri: "A" }) + }) + + it("does not treat an email address as a context", () => { + const task = parseLine("Email boss at boss@example.com") + expect(contextsOf(task)).toEqual([]) + }) + + it("does not treat a URL scheme as a tag", () => { + const task = parseLine("Read https://example.com later") + expect(tagsOf(task)).toEqual({}) + }) +}) + +describe("todotxt segmentBody", () => { + it("splits text + project + context", () => { + expect(segmentBody("Call Mom +Family @phone")).toEqual([ + { kind: "text", value: "Call Mom " }, + { kind: "project", value: "Family" }, + { kind: "text", value: " " }, + { kind: "context", value: "phone" } + ]) + }) + + it("keeps mid-word @ as text", () => { + expect(segmentBody("Email boss@example.com")).toEqual([ + { kind: "text", value: "Email boss@example.com" } + ]) + }) + + it("handles a token at the start", () => { + expect(segmentBody("@phone Call Mom")).toEqual([ + { kind: "context", value: "phone" }, + { kind: "text", value: " Call Mom" } + ]) + }) + + it("recognizes due: and rec: as their own segments", () => { + expect(segmentBody("Pay rent due:2025-01-15 rec:1m")).toEqual([ + { kind: "text", value: "Pay rent " }, + { kind: "due", value: "2025-01-15" }, + { kind: "text", value: " " }, + { kind: "rec", value: "1m" } + ]) + }) + + it("does not match due: inside a word", () => { + expect(segmentBody("xdue:foo")).toEqual([ + { kind: "text", value: "xdue:foo" } + ]) + }) +}) + +describe("todotxt removeTagFromBody", () => { + it("removes a due tag", () => { + expect( + removeTagFromBody("Pay rent due:2025-01-15 rec:1m", "due", "2025-01-15") + ).toBe("Pay rent rec:1m") + }) + + it("removes a rec tag at the end", () => { + expect(removeTagFromBody("Pay rent rec:1m", "rec", "1m")).toBe("Pay rent") + }) +}) + +describe("todotxt removeTokenFromBody", () => { + it("removes a project token mid-body", () => { + expect( + removeTokenFromBody("Plan trip +Travel +Family @home", "+", "Travel") + ).toBe("Plan trip +Family @home") + }) + + it("removes a context token at the start", () => { + expect(removeTokenFromBody("@phone Call Mom", "@", "phone")).toBe( + "Call Mom" + ) + }) + + it("removes a token at the end", () => { + expect(removeTokenFromBody("Call Mom @phone", "@", "phone")).toBe( + "Call Mom" + ) + }) + + it("leaves untouched a token-prefix substring inside another word", () => { + expect(removeTokenFromBody("boss@example.com here", "@", "example")).toBe( + "boss@example.com here" + ) + }) +}) + +describe("todotxt parseFile / serializeFile", () => { + it("round-trips a file with trailing newline", () => { + const input = `(A) 2024-01-15 First task +work +x 2024-01-20 Second task +Plain task +` + const parsed = parseFile(input) + expect(parsed).toHaveLength(3) + expect(serializeFile(parsed)).toBe(input) + }) + + it("preserves blank lines", () => { + const input = `Task one + +Task two +` + const parsed = parseFile(input) + expect(parsed).toHaveLength(3) + expect(isBlank(parsed[1])).toBe(true) + expect(serializeFile(parsed)).toBe(input) + }) + + it("handles file with no trailing newline", () => { + const parsed = parseFile("only line") + expect(serializeFile(parsed, { trailingNewline: false })).toBe("only line") + }) +}) + +describe("todotxt toggleCompleted", () => { + it("marks a task complete with today's date", () => { + // Construct via local-time fields so the test doesn't depend on the + // runner's timezone (todayIso uses getFullYear/getMonth/getDate). + const now = new Date(2024, 2, 15, 12, 0) + const task = parseLine("(A) 2024-01-15 Call Mom") + const toggled = toggleCompleted(task, now) + expect(toggled.completed).toBe(true) + expect(toggled.completionDate).toBe("2024-03-15") + expect(serializeTask(toggled)).toBe("x 2024-03-15 (A) 2024-01-15 Call Mom") + }) + + it("uncompletes a task and drops the completion date", () => { + const task = parseLine("x 2024-01-20 (A) 2024-01-15 Call Mom") + const toggled = toggleCompleted(task) + expect(toggled.completed).toBe(false) + expect(toggled.completionDate).toBeUndefined() + expect(serializeTask(toggled)).toBe("(A) 2024-01-15 Call Mom") + }) +}) diff --git a/src/utils/todotxt.ts b/src/utils/todotxt.ts new file mode 100644 index 0000000..fe54e22 --- /dev/null +++ b/src/utils/todotxt.ts @@ -0,0 +1,231 @@ +// Parser / serializer for the todo.txt format. +// Spec reference: https://github.com/todotxt/todo.txt + +export interface Task { + raw: string + completed: boolean + priority?: string + completionDate?: string + creationDate?: string + body: string +} + +export interface BlankLine { + blank: true + raw: string +} + +export type FileLine = Task | BlankLine + +export const isBlank = (line: FileLine): line is BlankLine => + (line as BlankLine).blank === true + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const PRIORITY_RE = /^\(([A-Z])\)$/ +const PROJECT_TOKEN_RE = /(?:^|\s)\+(\S+)/g +const CONTEXT_TOKEN_RE = /(?:^|\s)@(\S+)/g +// Match key:value tags but skip URL-like values (anything containing a slash) +// to avoid grabbing the scheme of `https://example.com`. +const TAG_TOKEN_RE = /(?:^|\s)([A-Za-z][A-Za-z0-9_-]*):([^\s/]+)/g + +const tryConsume = ( + rest: string, + predicate: (token: string) => boolean +): { token: string; rest: string } | null => { + const match = rest.match(/^(\S+)(\s+|$)/) + if (!match || !predicate(match[1])) return null + return { token: match[1], rest: rest.slice(match[0].length) } +} + +export const parseLine = (line: string): Task => { + let rest = line + let completed = false + let priority: string | undefined + let completionDate: string | undefined + let creationDate: string | undefined + + if (rest.startsWith("x ")) { + completed = true + rest = rest.slice(2) + + const dateAttempt = tryConsume(rest, (t) => DATE_RE.test(t)) + if (dateAttempt) { + completionDate = dateAttempt.token + rest = dateAttempt.rest + } + } + + const priorityAttempt = tryConsume(rest, (t) => PRIORITY_RE.test(t)) + if (priorityAttempt) { + priority = priorityAttempt.token.slice(1, 2) + rest = priorityAttempt.rest + } + + const creationAttempt = tryConsume(rest, (t) => DATE_RE.test(t)) + if (creationAttempt) { + creationDate = creationAttempt.token + rest = creationAttempt.rest + } + + return { + raw: line, + completed, + priority, + completionDate, + creationDate, + body: rest + } +} + +export const serializeTask = (task: Task): string => { + const parts: string[] = [] + if (task.completed) { + parts.push("x") + if (task.completionDate) parts.push(task.completionDate) + } + if (task.priority) parts.push(`(${task.priority})`) + if (task.creationDate) parts.push(task.creationDate) + if (task.body.length) parts.push(task.body) + return parts.join(" ") +} + +export const parseFile = (text: string): FileLine[] => { + const lines = text.split("\n") + // Drop the trailing empty element produced by a final newline. + // We re-add a trailing newline in serializeFile when there was one in the source. + const trailing = lines.length > 0 && lines[lines.length - 1] === "" + const meaningful = trailing ? lines.slice(0, -1) : lines + + return meaningful.map((line) => { + if (line.trim() === "") return { blank: true, raw: line } + return parseLine(line) + }) +} + +export const serializeFile = ( + items: FileLine[], + { trailingNewline = true }: { trailingNewline?: boolean } = {} +): string => { + const lines = items.map((item) => + isBlank(item) ? item.raw : serializeTask(item) + ) + return lines.join("\n") + (trailingNewline ? "\n" : "") +} + +const collectTokens = (body: string, re: RegExp): string[] => { + const out: string[] = [] + for (const match of body.matchAll(re)) { + out.push(match[1]) + } + return out +} + +export const projectsOf = (task: Task): string[] => + collectTokens(task.body, PROJECT_TOKEN_RE) + +export const contextsOf = (task: Task): string[] => + collectTokens(task.body, CONTEXT_TOKEN_RE) + +export const tagsOf = (task: Task): Record => { + const out: Record = {} + for (const match of task.body.matchAll(TAG_TOKEN_RE)) { + out[match[1]] = match[2] + } + return out +} + +export type BodySegment = + | { kind: "text"; value: string } + | { kind: "project" | "context"; value: string } + | { kind: "due" | "rec"; value: string } + +// Split a task body into display segments. `due:VALUE` and `rec:VALUE` tags +// get their own segment kinds so the UI can render them as themed chips; +// `+project` and `@context` tokens become project/context segments. +// All tokens only count when they sit at the start of the body or after +// whitespace — so mid-word `@` (e.g. `boss@example.com`) stays as text. +export const segmentBody = (body: string): BodySegment[] => { + const segments: BodySegment[] = [] + const re = /(due|rec):(\S+)|([+@])(\S+)/g + let last = 0 + for (const match of body.matchAll(re)) { + const start = match.index ?? 0 + const prevChar = start > 0 ? body[start - 1] : " " + if (!/\s/.test(prevChar)) continue + if (start > last) { + segments.push({ kind: "text", value: body.slice(last, start) }) + } + if (match[1]) { + segments.push({ + kind: match[1] as "due" | "rec", + value: match[2] + }) + } else { + segments.push({ + kind: match[3] === "+" ? "project" : "context", + value: match[4] + }) + } + last = start + match[0].length + } + if (last < body.length) { + segments.push({ kind: "text", value: body.slice(last) }) + } + return segments +} + +const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +// Remove a `+project` or `@context` token from a task body. +// Anchored on `(^|\s)` so mid-word matches (e.g. inside email addresses) are not touched. +export const removeTokenFromBody = ( + body: string, + prefix: "+" | "@", + value: string +): string => { + const re = new RegExp( + `(?:^|\\s)\\${prefix}${escapeRegex(value)}(?=\\s|$)`, + "g" + ) + return body.replace(re, "").replace(/[ \t]+/g, " ").trim() +} + +// Remove a `key:value` tag (e.g. `due:2025-01-15`) from a task body. +export const removeTagFromBody = ( + body: string, + key: string, + value: string +): string => { + const re = new RegExp( + `(?:^|\\s)${escapeRegex(key)}:${escapeRegex(value)}(?=\\s|$)`, + "g" + ) + return body.replace(re, "").replace(/[ \t]+/g, " ").trim() +} + +const todayIso = (now: Date): string => { + const y = now.getFullYear() + const m = String(now.getMonth() + 1).padStart(2, "0") + const d = String(now.getDate()).padStart(2, "0") + return `${y}-${m}-${d}` +} + +// Toggle the completed state of a task. +// On complete: stamp today's completion date. Priority stays in place per spec. +// On uncomplete: drop completion date but leave the rest of the line as-is — +// we deliberately do not try to recover any original priority that may have +// been stashed in a `pri:X` tag. +export const toggleCompleted = (task: Task, now = new Date()): Task => { + if (task.completed) { + return { + ...task, + completed: false, + completionDate: undefined + } + } + return { + ...task, + completed: true, + completionDate: todayIso(now) + } +} From 72dee337b70b8825e8a6f660c3ed6e2e7cbf5186 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 13 Jun 2026 21:23:19 +0200 Subject: [PATCH 02/11] feat(todo): switch /todo view to todo.txt format Replace the markdown _todo/todo.md view with a structured todo.txt UI at the repo root: priority dropdown, inline body edit, project/context/due/rec chips with Tabler icons, add input as a join-suffix, project/context filter columns with TransitionGroup animation, priority-sorted list. Drops the now-unused .todo-notes markdown checkbox styles from app.css. --- src/components/TodoTxtItem.vue | 470 +++++++++++++++++++++++++++++ src/hooks/useTodoTxtCommit.hook.ts | 87 ++++++ src/styles/app.css | 9 - src/views/TodoNotes.vue | 420 ++++++++++++++++++++++---- 4 files changed, 920 insertions(+), 66 deletions(-) create mode 100644 src/components/TodoTxtItem.vue create mode 100644 src/hooks/useTodoTxtCommit.hook.ts diff --git a/src/components/TodoTxtItem.vue b/src/components/TodoTxtItem.vue new file mode 100644 index 0000000..b93e646 --- /dev/null +++ b/src/components/TodoTxtItem.vue @@ -0,0 +1,470 @@ + + + + + diff --git a/src/hooks/useTodoTxtCommit.hook.ts b/src/hooks/useTodoTxtCommit.hook.ts new file mode 100644 index 0000000..a7f2697 --- /dev/null +++ b/src/hooks/useTodoTxtCommit.hook.ts @@ -0,0 +1,87 @@ +import { useDebounceFn } from "@vueuse/core" +import { onUnmounted, Ref, ref, toValue } from "vue" + +import { useGitHubContent } from "@/hooks/useGitHubContent.hook" +import { FileLine, parseFile, serializeFile } from "@/utils/todotxt" + +export const useTodoTxtCommit = ({ + user, + repo, + path, + initialContent, + initialSha, + debounceMs = 1000 +}: { + user: string + repo: string + path: Ref | string | undefined + initialContent: Ref | string + initialSha: Ref | string + debounceMs?: number +}) => { + const { updateFile } = useGitHubContent({ user, repo }) + + const items = ref(parseFile(toValue(initialContent))) + const currentSha = ref(toValue(initialSha)) + const isCommitting = ref(false) + const hasPendingChanges = ref(false) + + // Re-seed from a freshly fetched file. We must not blow away in-flight edits. + const syncContent = (content: string, sha: string) => { + if (!hasPendingChanges.value) { + items.value = parseFile(content) + currentSha.value = sha + } + } + + const commitChanges = async () => { + const pathValue = toValue(path) + if (!pathValue || !hasPendingChanges.value) { + return + } + + if (isCommitting.value) { + debouncedCommit() + return + } + + isCommitting.value = true + + const { sha: newSha } = await updateFile({ + content: serializeFile(items.value), + path: pathValue, + sha: currentSha.value + }) + + if (newSha) { + currentSha.value = newSha + hasPendingChanges.value = false + } + + isCommitting.value = false + } + + const debouncedCommit = useDebounceFn(commitChanges, debounceMs) + + const mutate = (mutator: (current: FileLine[]) => FileLine[]) => { + items.value = mutator(items.value) + hasPendingChanges.value = true + debouncedCommit() + } + + onUnmounted(() => { + // Flush any pending edit on unmount so navigation doesn't lose work. + if (hasPendingChanges.value) { + commitChanges() + } + }) + + return { + items, + currentSha, + isCommitting, + hasPendingChanges, + syncContent, + mutate + } +} diff --git a/src/styles/app.css b/src/styles/app.css index 34d2399..657c9ab 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -336,15 +336,6 @@ iframe { height: 400px; } -/* TODO page */ -.todo-notes input[type="checkbox"] { - @apply checkbox checkbox-success; -} - -.todo-notes li:has(> input[type="checkbox"]) { - list-style: none; -} - .tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 0; margin-bottom: 0; diff --git a/src/views/TodoNotes.vue b/src/views/TodoNotes.vue index 27c1ab3..da74d0d 100644 --- a/src/views/TodoNotes.vue +++ b/src/views/TodoNotes.vue @@ -1,80 +1,180 @@ - From 5de84f135fd727f959b243b560836dcf9ecf6fbe Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 13 Jun 2026 21:42:04 +0200 Subject: [PATCH 03/11] feat(todo): add priority filter and stack filter rows inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Priorities filter column with toggleable (A)/(B)/(C)/(D)/no-priority chips. Reflows the filter section from a two-column grid into single-line strips so each filter type (Priorities · Projects · Contexts) reads on one row with a fixed-width label and wrap-only-on-overflow chips. --- src/views/TodoNotes.vue | 94 ++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/src/views/TodoNotes.vue b/src/views/TodoNotes.vue index da74d0d..9203bf0 100644 --- a/src/views/TodoNotes.vue +++ b/src/views/TodoNotes.vue @@ -89,8 +89,23 @@ const allContexts = computed(() => { return Array.from(set).sort() }) +// "none" represents tasks without a priority. +const allPriorities = computed>(() => { + const set = new Set() + items.value.forEach((line) => { + if (isBlank(line)) return + set.add(line.priority ?? "none") + }) + return Array.from(set).sort((a, b) => { + if (a === "none") return 1 + if (b === "none") return -1 + return a.localeCompare(b) + }) +}) + const activeProjects = ref>(new Set()) const activeContexts = ref>(new Set()) +const activePriorities = ref>(new Set()) const toggleProject = (p: string) => { const next = new Set(activeProjects.value) @@ -106,15 +121,39 @@ const toggleContext = (c: string) => { activeContexts.value = next } +const togglePriority = (p: string | "none") => { + const next = new Set(activePriorities.value) + if (next.has(p)) next.delete(p) + else next.add(p) + activePriorities.value = next +} + const clearFilters = () => { activeProjects.value = new Set() activeContexts.value = new Set() + activePriorities.value = new Set() } const hasFilters = computed( - () => activeProjects.value.size > 0 || activeContexts.value.size > 0 + () => + activeProjects.value.size > 0 || + activeContexts.value.size > 0 || + activePriorities.value.size > 0 ) +const priorityBadgeFilterClass = (p: string | "none"): string => { + switch (p) { + case "A": + return "badge-error" + case "B": + return "badge-warning" + case "C": + return "badge-info" + default: + return "badge-ghost" + } +} + const filteredEntries = computed(() => { if (!hasFilters.value) return taskEntries.value return taskEntries.value.filter(({ line }) => { @@ -126,7 +165,10 @@ const filteredEntries = computed(() => { const contextsOk = activeContexts.value.size === 0 || taskContexts.some((c) => activeContexts.value.has(c)) - return projectsOk && contextsOk + const prioritiesOk = + activePriorities.value.size === 0 || + activePriorities.value.has(line.priority ?? "none") + return projectsOk && contextsOk && prioritiesOk }) }) @@ -229,10 +271,33 @@ const createTodoFile = async () => {
+
+

Priorities

+
+ +
+
{ } .todo-filter-columns { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 0.35rem; } .todo-filter-column { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; min-width: 0; - text-align: center; } .todo-filter-label { font-size: 0.8rem; opacity: 0.7; - margin: 0 0 0.35rem; + margin: 0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - text-align: center; + flex: 0 0 auto; + min-width: 5.5rem; } .todo-filter-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; - justify-content: center; + flex: 1 1 auto; } .todo-filter-chip { cursor: pointer; } - @media (max-width: 480px) { - .todo-filter-columns { - grid-template-columns: 1fr; - } - } } From 03e7185abe65e9c44a03a43309e91562d8aec73b Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 13 Jun 2026 21:42:09 +0200 Subject: [PATCH 04/11] fix(todo): rebuild priority dropdown with controlled state The DaisyUI
/ dropdown didn't toggle reliably in Firefox (summary + inline-flex badge), and :focus-within left the wrong row open when TransitionGroup reordered tasks on sort. Replace with a Vue-controlled ref + onClickOutside, render options as colored badge buttons (no menu hover underline), and lift the widget by z-index: 1 only while open so the absolute menu paints above the next row. --- src/components/TodoTxtItem.vue | 124 ++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 25 deletions(-) diff --git a/src/components/TodoTxtItem.vue b/src/components/TodoTxtItem.vue index b93e646..265511e 100644 --- a/src/components/TodoTxtItem.vue +++ b/src/components/TodoTxtItem.vue @@ -1,4 +1,5 @@