Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,10 @@ New subcommand? Copy a sibling in the target group, wire it in that group's
- **`refs.ts`** — `isIdRef`, `extractId`, `looksLikeRawId`, `lenientIdRef`,
`resolveTaskRef`, `resolveProjectRef`, `resolveProjectId`,
`resolveSectionId`, `resolveParentTaskId`, `resolveWorkspaceRef`,
`resolveFolderRef`, `resolveAppRef`, `resolveGoalRef`, `parseTodoistUrl`,
`classifyTodoistUrl`
`resolveFolderRef`, `resolveAppRef`, `resolveGoalRef`, `resolveFromList`,
`parseTodoistUrl`, `classifyTodoistUrl`
- **`reorder.ts`** — `validateReorderPlacement()` for shared
`--before` / `--after` / `--position` validation.
- **`urls.ts`** — `taskUrl`, `projectUrl`, `labelUrl`, `sectionUrl`,
`commentUrl`, `filterUrl`
- **`task-list.ts`** — `fetchProjects`, `filterByWorkspaceOrPersonal`,
Expand Down
4 changes: 4 additions & 0 deletions skills/todoist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ td section list --search "Planning"
td section list --search "Planning" --project "Roadmap"
td section create --project "Roadmap" --name "In Progress"
td section update id:123 --name "Done"
td section reorder "Review" --project "Roadmap" --before "Done"
td section reorder "Review" --project "Roadmap" --after "In Progress"
td section reorder --section "Review" --project "Roadmap" --position 0 --dry-run
td section reorder "Review" --project "Roadmap" --position 2 --json
td section archive id:123
td section unarchive id:123
td section delete id:123 --yes
Expand Down
17 changes: 2 additions & 15 deletions src/commands/project/reorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CliError } from '../../lib/errors.js'
import { isQuiet } from '../../lib/global-args.js'
import { formatJson } from '../../lib/output.js'
import { resolveProjectRef } from '../../lib/refs.js'
import { validateReorderPlacement } from '../../lib/reorder.js'
import { loadPersonalProjects, resolvePersonalFromList } from './helpers.js'

export type ReorderOptions = {
Expand All @@ -16,21 +17,7 @@ export type ReorderOptions = {
}

export async function reorderProject(ref: string, options: ReorderOptions): Promise<void> {
const flagCount = [options.before, options.after, options.position].filter(
(v) => v !== undefined,
).length
if (flagCount === 0) {
throw new CliError(
'INVALID_OPTIONS',
'Specify exactly one of --before <ref>, --after <ref>, or --position <n>.',
)
}
if (flagCount > 1) {
throw new CliError(
'INVALID_OPTIONS',
'--before, --after, and --position are mutually exclusive.',
)
}
validateReorderPlacement(options)

const api = await getApi()
const target = await resolveProjectRef(api, ref)
Expand Down
36 changes: 36 additions & 0 deletions src/commands/section/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Command } from 'commander'
import { CliError } from '../../lib/errors.js'
import type { PaginatedViewOptions } from '../../lib/options.js'
import { parseOrderArg } from '../../lib/order.js'
import { archiveSection } from './archive.js'
import { browseSection } from './browse.js'
import { createSection } from './create.js'
import { deleteSection } from './delete.js'
import { listSections } from './list.js'
import { reorderSection } from './reorder.js'
import { unarchiveSection } from './unarchive.js'
import { updateSection } from './update.js'

Expand Down Expand Up @@ -85,6 +87,40 @@ export function registerSectionCommand(program: Command): void {
return updateSection(id, options)
})

const reorderCmd = section
.command('reorder [ref]')
.description('Reorder a section within a project')
.option('--section <ref>', 'Section name or id:xxx')
.option('--project <ref>', 'Project name or id:xxx (required)')
.option('--before <ref>', 'Place before this sibling section')
.option('--after <ref>', 'Place after this sibling section')
.option('--position <n>', 'Move to a 0-indexed position within the project', parseOrderArg)
.option('--json', 'Output the new ordering as JSON')
.option('--dry-run', 'Preview what would happen without executing')
.addHelpText(
'after',
`
Examples:
td section reorder "Review" --project "Roadmap" --before "Done"
td section reorder "Review" --project "Roadmap" --after "In Progress"
td section reorder --section "Review" --project "Roadmap" --position 0 --dry-run
td section reorder "Review" --project "Roadmap" --position 2 --json`,
)
.action((ref, options) => {
if (ref && options.section) {
throw new CliError(
'CONFLICTING_OPTIONS',
'Cannot specify section both as argument and --section flag',
)
}
const sectionRef = ref || options.section
if (!sectionRef) {
reorderCmd.help()
return
}
return reorderSection(sectionRef, options)
})

const archiveCmd = section
.command('archive [id]')
.description('Archive a section')
Expand Down
137 changes: 137 additions & 0 deletions src/commands/section/reorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Section, TodoistApi } from '@doist/todoist-sdk'
import { getApi } from '../../lib/api/core.js'
import { reorderSections } from '../../lib/api/sections-sync.js'
import { CliError } from '../../lib/errors.js'
import { isQuiet } from '../../lib/global-args.js'
import { formatJson } from '../../lib/output.js'
import { resolveFromList, resolveProjectId } from '../../lib/refs.js'
import { validateReorderPlacement } from '../../lib/reorder.js'

export type ReorderSectionOptions = {
section?: string
project?: string
before?: string
after?: string
position?: number
json?: boolean
dryRun?: boolean
}

async function loadProjectSections(api: TodoistApi, projectId: string): Promise<Section[]> {
const sections: Section[] = []
let cursor: string | null = null

do {
const { results, nextCursor } = await api.getSections({
projectId,
cursor: cursor ?? undefined,
limit: 200,
})
sections.push(...results)
cursor = nextCursor ?? null
} while (cursor)

return sections
}

function resolveSectionFromList(sections: Section[], ref: string): Section {
Comment thread
rmartins90 marked this conversation as resolved.
if (!ref.trim()) {
throw new CliError('INVALID_SECTION', 'section reference cannot be empty.')
}

return resolveFromList(ref, sections, (section) => section.name, 'section', 'in project')
}

export async function reorderSection(ref: string, options: ReorderSectionOptions): Promise<void> {
if (!options.project) {
throw new CliError('MISSING_PROJECT', 'Specify --project <ref> to reorder a section.', [
'Section names are scoped to a project.',
])
}

validateReorderPlacement(options)

const api = await getApi()
const projectId = await resolveProjectId(api, options.project)
const sections = (await loadProjectSections(api, projectId)).sort(
(a, b) => a.sectionOrder - b.sectionOrder,
)

const target = resolveSectionFromList(sections, ref)
const oldIndex = sections.findIndex((section) => section.id === target.id)
if (oldIndex === -1) {
throw new CliError('SECTION_NOT_FOUND', 'Target section not found in project.')
}

let newIndex: number
if (options.position !== undefined) {
newIndex = Math.min(options.position, sections.length - 1)
} else {
const siblingRef = (options.before ?? options.after) as string
const sibling = resolveSectionFromList(sections, siblingRef)
if (sibling.id === target.id) {
throw new CliError('INVALID_OPTIONS', 'Cannot reorder a section relative to itself.')
}

const siblingIndex = sections.findIndex((section) => section.id === sibling.id)
const adjusted = siblingIndex > oldIndex ? siblingIndex - 1 : siblingIndex
newIndex = options.before !== undefined ? adjusted : adjusted + 1
}

if (newIndex === oldIndex) {
if (options.json) {
console.log(
formatJson(sections.map((section, index) => sectionPosition(section, index))),
)
return
}
if (!isQuiet()) {
console.log(`No change: "${target.name}" already at position ${oldIndex}.`)
}
return
}

const newOrder = [...sections]
const [removed] = newOrder.splice(oldIndex, 1)
newOrder.splice(newIndex, 0, removed)

const items = newOrder.map((section, index) => ({
id: section.id,
sectionOrder: index + 1,
}))

if (options.dryRun) {
console.log(`Would reorder "${target.name}": position ${oldIndex} → ${newIndex}`)
printNewSectionOrder(newOrder, target.id)
return
}

await reorderSections(items)

if (options.json) {
console.log(formatJson(newOrder.map((section, index) => sectionPosition(section, index))))
return
}

if (!isQuiet()) {
console.log(
`Reordered "${target.name}" (id:${target.id}): position ${oldIndex} → ${newIndex} of ${sections.length - 1}.`,
)
printNewSectionOrder(newOrder, target.id)
}
}

function printNewSectionOrder(newOrder: Section[], targetId: string): void {
console.log('New section order:')
for (let index = 0; index < newOrder.length; index++) {
const marker = newOrder[index].id === targetId ? '→' : ' '
console.log(` ${marker} ${index}: ${newOrder[index].name} (id:${newOrder[index].id})`)
}
}

function sectionPosition(
section: Section,
position: number,
): { id: string; name: string; position: number } {
return { id: section.id, name: section.name, position }
}
Loading
Loading