mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
Merge adca4e9edd into 2c1a443bc8
This commit is contained in:
commit
62e4a8d448
34 changed files with 774 additions and 15 deletions
|
|
@ -27,6 +27,7 @@ export const ENTITY_TYPES = [
|
|||
'MENU_TREE',
|
||||
'METRIC',
|
||||
'BOARD',
|
||||
'SECTION',
|
||||
'REMINDER',
|
||||
'PLUGIN_USER_DATA',
|
||||
'PLUGIN_METADATA',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@
|
|||
<mat-icon class="color-warn-i">delete_forever</mat-icon>
|
||||
<span class="text">{{ T.MH.DELETE_TAG | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (isForProject) {
|
||||
<button (click)="addSection()" mat-menu-item>
|
||||
<mat-icon>list</mat-icon>
|
||||
<span class="text">{{ T.MH.ADD_SECTION | translate }}</span>
|
||||
</button>
|
||||
|
||||
}
|
||||
@if (isForProject && contextId !== INBOX_PROJECT.id) {
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { WorkContextService } from '../../features/work-context/work-context.ser
|
|||
import { Router, RouterLink, RouterModule } from '@angular/router';
|
||||
|
||||
import { ProjectService } from '../../features/project/project.service';
|
||||
import { SectionService } from '../../features/section/section.service';
|
||||
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
|
||||
import { MatMenuItem } from '@angular/material/menu';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
|
@ -40,6 +42,7 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
private _matDialog = inject(MatDialog);
|
||||
private _tagService = inject(TagService);
|
||||
private _projectService = inject(ProjectService);
|
||||
private _sectionService = inject(SectionService);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
private _router = inject(Router);
|
||||
private _snackService = inject(SnackService);
|
||||
|
|
@ -130,6 +133,23 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
addSection(): void {
|
||||
this._matDialog
|
||||
.open(DialogPromptComponent, {
|
||||
data: {
|
||||
placeholder: T.G.TITLE,
|
||||
defaultValue: '',
|
||||
message: T.CONFIRM.ADD_SECTION,
|
||||
},
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((title: string) => {
|
||||
if (title) {
|
||||
this._sectionService.addSection(title, this.contextId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly INBOX_PROJECT = INBOX_PROJECT;
|
||||
|
||||
async shareTasksAsMarkdown(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,12 @@ export const ACTION_TYPE_TO_CODE: Record<ActionType, string> = {
|
|||
[ActionType.REPEAT_CFG_DELETE_INSTANCE]: 'RDI',
|
||||
[ActionType.REPEAT_CFG_UPSERT]: 'RX',
|
||||
|
||||
// Section
|
||||
[ActionType.SECTION_ADD]: 'S1',
|
||||
[ActionType.SECTION_DELETE]: 'S2',
|
||||
[ActionType.SECTION_UPDATE]: 'S3',
|
||||
[ActionType.SECTION_UPDATE_ORDER]: 'S4',
|
||||
|
||||
// SimpleCounter actions (S)
|
||||
[ActionType.COUNTER_ADD]: 'SA',
|
||||
[ActionType.COUNTER_UPDATE]: 'SU',
|
||||
|
|
|
|||
|
|
@ -265,3 +265,13 @@ export const moveAllProjectBacklogTasksToRegularList = createAction(
|
|||
} satisfies PersistentActionMeta,
|
||||
}),
|
||||
);
|
||||
|
||||
export const moveProjectTaskToSection = createAction(
|
||||
'[Project] Move Project Task to Section',
|
||||
props<{
|
||||
taskId: string;
|
||||
sectionId: string | null;
|
||||
afterTaskId: string | null;
|
||||
workContextId: string;
|
||||
}>(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect, ofType } from '@ngrx/effects';
|
||||
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
|
||||
import { filter, map, tap } from 'rxjs/operators';
|
||||
import { filter, map, tap, switchMap, withLatestFrom } from 'rxjs/operators';
|
||||
import {
|
||||
addProject,
|
||||
moveAllProjectBacklogTasksToRegularList,
|
||||
moveProjectTaskToSection,
|
||||
updateProject,
|
||||
} from './project.actions';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
|
|
@ -13,12 +14,16 @@ import { GlobalConfigService } from '../../config/global-config.service';
|
|||
import { T } from '../../../t.const';
|
||||
import { Project } from '../project.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectProjectFeatureState } from './project.selectors';
|
||||
import { moveItemAfterAnchor } from '../../work-context/store/work-context-meta.helper';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectEffects {
|
||||
private _actions$ = inject(LOCAL_ACTIONS);
|
||||
private _snackService = inject(SnackService);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _store$ = inject(Store);
|
||||
|
||||
/**
|
||||
* Handles non-archive cleanup when a project is deleted.
|
||||
|
|
@ -56,6 +61,42 @@ export class ProjectEffects {
|
|||
),
|
||||
);
|
||||
|
||||
moveProjectTaskToSection$: Observable<unknown> = createEffect(() =>
|
||||
this._actions$.pipe(
|
||||
ofType(moveProjectTaskToSection),
|
||||
withLatestFrom(this._store$.select(selectProjectFeatureState)),
|
||||
map(([{ taskId, sectionId, afterTaskId, workContextId }, projectState]) => {
|
||||
const project = projectState.entities[workContextId];
|
||||
if (!project) throw new Error('Project not found');
|
||||
const currentTaskIds = project.taskIds || [];
|
||||
|
||||
let newTaskIds = [...currentTaskIds];
|
||||
newTaskIds = moveItemAfterAnchor(taskId, afterTaskId, newTaskIds);
|
||||
|
||||
return {
|
||||
taskId,
|
||||
sectionId,
|
||||
workContextId,
|
||||
ids: newTaskIds,
|
||||
};
|
||||
}),
|
||||
switchMap(({ taskId, sectionId, workContextId, ids }) => [
|
||||
TaskSharedActions.updateTask({
|
||||
task: {
|
||||
id: taskId,
|
||||
changes: { sectionId },
|
||||
},
|
||||
}),
|
||||
updateProject({
|
||||
project: {
|
||||
id: workContextId,
|
||||
changes: { taskIds: ids },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// CURRENTLY NOT IMPLEMENTED
|
||||
// archiveProject: Observable<unknown> = createEffect(
|
||||
// () =>
|
||||
|
|
|
|||
11
src/app/features/section/section.model.ts
Normal file
11
src/app/features/section/section.model.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { EntityState } from '@ngrx/entity';
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SectionState extends EntityState<Section> {
|
||||
ids: string[];
|
||||
}
|
||||
65
src/app/features/section/section.service.ts
Normal file
65
src/app/features/section/section.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Section } from './section.model';
|
||||
import { addSection, deleteSection, updateSection, updateSectionOrder } from './store/section.actions';
|
||||
import { selectAllSections, selectSectionsByProjectId } from './store/section.selectors';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SectionService {
|
||||
private _store = inject(Store);
|
||||
|
||||
// Expose selectors
|
||||
sections$: Observable<Section[]> = this._store.select(selectAllSections);
|
||||
|
||||
getSectionsByProjectId$(projectId: string): Observable<Section[]> {
|
||||
return this._store.select(selectSectionsByProjectId(projectId));
|
||||
}
|
||||
|
||||
addSection(title: string, projectId: string | null = null): void {
|
||||
const id = nanoid();
|
||||
this._store.dispatch(addSection({
|
||||
section: {
|
||||
id,
|
||||
title,
|
||||
projectId,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Generate a section ID without dispatching
|
||||
generateSectionId(): string {
|
||||
return nanoid();
|
||||
}
|
||||
|
||||
// Add section with a pre-generated ID
|
||||
addSectionWithId(id: string, title: string, projectId: string | null = null): void {
|
||||
this._store.dispatch(addSection({
|
||||
section: {
|
||||
id,
|
||||
title,
|
||||
projectId,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
deleteSection(id: string): void {
|
||||
this._store.dispatch(deleteSection({ id }));
|
||||
}
|
||||
|
||||
updateSection(id: string, sectionChanges: Partial<Section>): void {
|
||||
this._store.dispatch(updateSection({
|
||||
section: {
|
||||
id,
|
||||
changes: sectionChanges,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
updateSectionOrder(ids: string[]): void {
|
||||
this._store.dispatch(updateSectionOrder({ ids }));
|
||||
}
|
||||
}
|
||||
63
src/app/features/section/store/section.actions.ts
Normal file
63
src/app/features/section/store/section.actions.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { createAction, props } from '@ngrx/store';
|
||||
import { Update } from '@ngrx/entity';
|
||||
import { Section } from '../section.model';
|
||||
import { PersistentActionMeta } from '../../../op-log/core/persistent-action.interface';
|
||||
import { OpType } from '../../../op-log/core/operation.types';
|
||||
|
||||
export const loadSections = createAction(
|
||||
'[Section] Load Sections',
|
||||
props<{ sections: Section[] }>(),
|
||||
);
|
||||
|
||||
export const addSection = createAction(
|
||||
'[Section] Add Section',
|
||||
(section: { section: Section }) => ({
|
||||
...section,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'SECTION',
|
||||
entityId: section.section.id,
|
||||
opType: OpType.Create,
|
||||
} satisfies PersistentActionMeta,
|
||||
}),
|
||||
);
|
||||
|
||||
export const deleteSection = createAction(
|
||||
'[Section] Delete Section',
|
||||
(payload: { id: string }) => ({
|
||||
...payload,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'SECTION',
|
||||
entityId: payload.id,
|
||||
opType: OpType.Delete,
|
||||
} satisfies PersistentActionMeta,
|
||||
}),
|
||||
);
|
||||
|
||||
export const updateSection = createAction(
|
||||
'[Section] Update Section',
|
||||
(payload: { section: Update<Section> }) => ({
|
||||
...payload,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'SECTION',
|
||||
entityId: payload.section.id as string,
|
||||
opType: OpType.Update,
|
||||
} satisfies PersistentActionMeta,
|
||||
}),
|
||||
);
|
||||
|
||||
export const updateSectionOrder = createAction(
|
||||
'[Section] Update Section Order',
|
||||
(payload: { ids: string[] }) => ({
|
||||
...payload,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'SECTION',
|
||||
// Uses ids[0] as random entity id reference, actual sync logic handles payload
|
||||
entityId: payload.ids[0],
|
||||
opType: OpType.Update,
|
||||
} satisfies PersistentActionMeta,
|
||||
}),
|
||||
);
|
||||
27
src/app/features/section/store/section.effects.ts
Normal file
27
src/app/features/section/store/section.effects.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { deleteSection } from './section.actions';
|
||||
import { map, switchMap, withLatestFrom } from 'rxjs/operators';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectAllTasks } from '../../tasks/store/task.selectors';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
|
||||
@Injectable()
|
||||
export class SectionEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
|
||||
deleteSection$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(deleteSection),
|
||||
withLatestFrom(this.store.select(selectAllTasks)),
|
||||
map(([{ id }, tasks]) => {
|
||||
return tasks.filter((t) => t.sectionId === id).map((t) => t.id);
|
||||
}),
|
||||
switchMap((taskIds) => {
|
||||
if (taskIds.length === 0) return [];
|
||||
return [TaskSharedActions.deleteTasks({ taskIds })];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
40
src/app/features/section/store/section.reducer.ts
Normal file
40
src/app/features/section/store/section.reducer.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { createReducer, on } from '@ngrx/store';
|
||||
import { createEntityAdapter, EntityAdapter } from '@ngrx/entity';
|
||||
import * as SectionActions from './section.actions';
|
||||
import { Section, SectionState } from '../section.model';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
|
||||
export const SECTION_FEATURE_NAME = 'section';
|
||||
|
||||
export const adapter: EntityAdapter<Section> = createEntityAdapter<Section>();
|
||||
|
||||
export const initialSectionState: SectionState = adapter.getInitialState({
|
||||
ids: [],
|
||||
});
|
||||
|
||||
export const sectionReducer = createReducer(
|
||||
initialSectionState,
|
||||
on(SectionActions.addSection, (state, { section }) => adapter.addOne(section, state)),
|
||||
on(SectionActions.deleteSection, (state, { id }) => adapter.removeOne(id, state)),
|
||||
on(SectionActions.updateSection, (state, { section }) => adapter.updateOne(section, state)),
|
||||
on(SectionActions.loadSections, (state, { sections }) => adapter.setAll(sections, state)),
|
||||
on(SectionActions.updateSectionOrder, (state, { ids }) => {
|
||||
const idsSet = new Set(ids);
|
||||
return {
|
||||
...state,
|
||||
ids: [...state.ids.filter(id => !idsSet.has(id as string)), ...ids]
|
||||
};
|
||||
}),
|
||||
on(loadAllData, (state, { appDataComplete }) =>
|
||||
appDataComplete.section
|
||||
? { ...(appDataComplete.section as SectionState) }
|
||||
: state
|
||||
),
|
||||
);
|
||||
|
||||
export const {
|
||||
selectIds,
|
||||
selectEntities,
|
||||
selectAll,
|
||||
selectTotal,
|
||||
} = adapter.getSelectors();
|
||||
12
src/app/features/section/store/section.selectors.ts
Normal file
12
src/app/features/section/store/section.selectors.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { SectionState } from '../section.model';
|
||||
import { sectionReducer, selectAll, SECTION_FEATURE_NAME } from './section.reducer';
|
||||
|
||||
export const selectSectionFeatureState = createFeatureSelector<SectionState>(SECTION_FEATURE_NAME);
|
||||
|
||||
export const selectAllSections = createSelector(selectSectionFeatureState, selectAll);
|
||||
|
||||
export const selectSectionsByProjectId = (projectId: string) => createSelector(
|
||||
selectAllSections,
|
||||
(sections) => sections.filter(section => section.projectId === projectId)
|
||||
);
|
||||
|
|
@ -5,11 +5,14 @@ import {
|
|||
parseMarkdownTasks,
|
||||
convertToMarkdownNotes,
|
||||
parseMarkdownTasksWithStructure,
|
||||
parseMarkdownWithSections,
|
||||
} from '../../util/parse-markdown-tasks';
|
||||
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
|
||||
import { T } from '../../t.const';
|
||||
import { TaskService } from './task.service';
|
||||
import { addSubTask } from './store/task.actions';
|
||||
import { SectionService } from '../section/section.service';
|
||||
import { WorkContextService } from '../work-context/work-context.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -18,6 +21,8 @@ export class MarkdownPasteService {
|
|||
private _matDialog = inject(MatDialog);
|
||||
private _taskService = inject(TaskService);
|
||||
private _store = inject(Store);
|
||||
private _sectionService = inject(SectionService);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
|
||||
async handleMarkdownPaste(
|
||||
pastedText: string,
|
||||
|
|
@ -62,6 +67,78 @@ export class MarkdownPasteService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Try to parse with sections first (for markdown with H1 headers)
|
||||
if (!selectedTaskId) {
|
||||
const sectionsData = parseMarkdownWithSections(pastedText);
|
||||
if (sectionsData && sectionsData.hasHeaders) {
|
||||
// Confirm with user
|
||||
const totalTasks = sectionsData.sections.reduce((sum, section) => sum + section.tasks.length, 0);
|
||||
const dialogRef = this._matDialog.open(DialogConfirmComponent, {
|
||||
data: {
|
||||
okTxt: T.G.CONFIRM,
|
||||
title: T.F.MARKDOWN_PASTE.DIALOG_TITLE,
|
||||
titleIcon: 'content_paste',
|
||||
message: T.F.MARKDOWN_PASTE.CONFIRM_SECTIONS,
|
||||
translateParams: {
|
||||
sectionsCount: sectionsData.sections.length,
|
||||
tasksCount: totalTasks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isConfirmed = await dialogRef.afterClosed().toPromise();
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workContextId = this._workContextService.activeWorkContextId;
|
||||
|
||||
// Create sections and tasks
|
||||
for (const section of sectionsData.sections) {
|
||||
let sectionId: string | null = null;
|
||||
|
||||
// Create section if it has a title
|
||||
if (section.sectionTitle) {
|
||||
// Generate ID locally to avoid async wait issues
|
||||
sectionId = this._sectionService.generateSectionId();
|
||||
this._sectionService.addSectionWithId(sectionId, section.sectionTitle, workContextId);
|
||||
}
|
||||
|
||||
// Create tasks for this section
|
||||
for (const task of section.tasks) {
|
||||
const taskId = this._taskService.add(
|
||||
task.title,
|
||||
false,
|
||||
{
|
||||
isDone: task.isCompleted,
|
||||
notes: task.notes,
|
||||
sectionId: sectionId,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// Create sub-tasks if any
|
||||
if (task.subTasks && task.subTasks.length > 0) {
|
||||
for (const subTask of task.subTasks) {
|
||||
const subTaskObj = this._taskService.createNewTaskWithDefaults({
|
||||
title: subTask.title,
|
||||
additional: {
|
||||
isDone: subTask.isCompleted,
|
||||
parentId: taskId,
|
||||
notes: subTask.notes,
|
||||
},
|
||||
});
|
||||
this._store.dispatch(
|
||||
addSubTask({ task: subTaskObj, parentId: taskId }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse with structure first (for creating sub-tasks when no task selected)
|
||||
if (!selectedTaskId) {
|
||||
const structure = parseMarkdownTasksWithStructure(pastedText);
|
||||
|
|
@ -177,6 +254,13 @@ export class MarkdownPasteService {
|
|||
}
|
||||
|
||||
isMarkdownTaskList(text: string): boolean {
|
||||
// Check for sections (H1 headers)
|
||||
const sectionsData = parseMarkdownWithSections(text);
|
||||
if (sectionsData && sectionsData.hasHeaders) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for regular task lists
|
||||
const parsedTasks = parseMarkdownTasks(text);
|
||||
return parsedTasks !== null && parsedTasks.length > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,12 @@ export const taskReducer = createReducer<TaskState>(
|
|||
}),
|
||||
|
||||
on(moveSubTask, (state, { taskId, srcTaskId, targetTaskId, afterTaskId }) => {
|
||||
// Guard against invalid moves (e.g. 'UNDONE' passed as ID) which might be in the op log
|
||||
if (!state.entities[srcTaskId] || !state.entities[targetTaskId]) {
|
||||
console.warn('Ignoring invalid moveSubTask action', { taskId, srcTaskId, targetTaskId });
|
||||
return state;
|
||||
}
|
||||
|
||||
let newState = state;
|
||||
const oldPar = getTaskById(srcTaskId, state);
|
||||
const newPar = getTaskById(targetTaskId, state);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
moveProjectTaskInBacklogList,
|
||||
moveProjectTaskToBacklogList,
|
||||
moveProjectTaskToRegularList,
|
||||
moveProjectTaskToSection,
|
||||
} from '../../project/store/project.actions';
|
||||
import { moveSubTask } from '../store/task.actions';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
|
|
@ -47,6 +48,7 @@ export type ListModelId = DropListModelSource | string;
|
|||
const PARENT_ALLOWED_LISTS = ['DONE', 'UNDONE', 'OVERDUE', 'BACKLOG', 'ADD_TASK_PANEL'];
|
||||
|
||||
export interface DropModelDataForList {
|
||||
listId: TaskListId;
|
||||
listModelId: ListModelId;
|
||||
allTasks: TaskWithSubTasks[];
|
||||
filteredTasks: TaskWithSubTasks[];
|
||||
|
|
@ -92,6 +94,7 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
currentTaskId = toSignal(this._taskService.currentTaskId$);
|
||||
dropModelDataForList = computed<DropModelDataForList>(() => {
|
||||
return {
|
||||
listId: this.listId(),
|
||||
listModelId: this.listModelId(),
|
||||
allTasks: this.tasks(),
|
||||
filteredTasks: this.filteredTasks(),
|
||||
|
|
@ -147,16 +150,19 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
const isSubtask = !!task.parentId;
|
||||
// TaskLog.log(drag.data.id, { isSubtask, targetModelId, drag, drop });
|
||||
// return true;
|
||||
const targetIsSectionOrProjectList =
|
||||
PARENT_ALLOWED_LISTS.includes(targetModelId) ||
|
||||
// Allow if it's not a restricted list, likely a section ID
|
||||
(!['LATER_TODAY', 'OVERDUE'].includes(targetModelId));
|
||||
|
||||
if (targetModelId === 'OVERDUE' || targetModelId === 'LATER_TODAY') {
|
||||
return false;
|
||||
} else if (isSubtask) {
|
||||
if (!PARENT_ALLOWED_LISTS.includes(targetModelId)) {
|
||||
return true;
|
||||
}
|
||||
// Subtasks can move to sections or parent lists
|
||||
if (targetIsSectionOrProjectList) return true;
|
||||
} else {
|
||||
if (PARENT_ALLOWED_LISTS.includes(targetModelId)) {
|
||||
return true;
|
||||
}
|
||||
// Main tasks can move to sections or parent lists
|
||||
if (targetIsSectionOrProjectList) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -286,6 +292,24 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
return;
|
||||
}
|
||||
|
||||
if (workContextId && this._workContextService.activeWorkContextType === WorkContextType.PROJECT) {
|
||||
// NOTE: sections are not sub task lists
|
||||
const targetIsSection = !['DONE', 'UNDONE', 'BACKLOG', 'OVERDUE', 'LATER_TODAY'].includes(target);
|
||||
const srcIsSection = !['DONE', 'UNDONE', 'BACKLOG', 'OVERDUE', 'LATER_TODAY'].includes(src);
|
||||
|
||||
if (targetIsSection || srcIsSection) {
|
||||
const sectionId = targetIsSection ? target : null;
|
||||
const afterTaskId = getAnchorFromDragDrop(taskId, newOrderedIds);
|
||||
this._store.dispatch(moveProjectTaskToSection({
|
||||
taskId,
|
||||
sectionId,
|
||||
afterTaskId,
|
||||
workContextId,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSrcRegularList && isTargetRegularList) {
|
||||
// move inside today
|
||||
const workContextType = this._workContextService
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export interface TaskCopy
|
|||
hasPlannedTime?: boolean;
|
||||
attachments: TaskAttachment[];
|
||||
reminderId?: string | null;
|
||||
sectionId?: string | null;
|
||||
|
||||
// Ensure type compatibility for internal fields
|
||||
modified?: number;
|
||||
|
|
|
|||
|
|
@ -182,6 +182,40 @@
|
|||
></task-list>
|
||||
</collapsible>
|
||||
}
|
||||
} @else if (sections().length) {
|
||||
<div class="sections-wrapper" cdkDropList (cdkDropListDropped)="dropSection($event)">
|
||||
<div class="no-section" style="margin-bottom: 24px;">
|
||||
<task-list [tasks]="undoneTasksBySection().noSection" [listId]="'PARENT'"
|
||||
[listModelId]="'UNDONE'"></task-list>
|
||||
</div>
|
||||
@for (section of sections(); track section.id) {
|
||||
<div class="section-container" style="margin-bottom: 24px;" cdkDrag [cdkDragLockAxis]="'y'">
|
||||
<collapsible [title]="section.title" [isIconBefore]="true" [isExpanded]="true">
|
||||
<ng-container actions>
|
||||
<div class="drag-handle" cdkDragHandle>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
</div>
|
||||
<button mat-icon-button [matMenuTriggerFor]="sectionMenu" (click)="$event.stopPropagation()">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #sectionMenu="matMenu">
|
||||
<button mat-menu-item (click)="editSection(section.id, section.title)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
{{ T.G.EDIT | translate }}
|
||||
</button>
|
||||
<button mat-menu-item (click)="deleteSection(section.id)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
{{ T.G.DELETE | translate }}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</ng-container>
|
||||
|
||||
<task-list [tasks]="undoneTasksBySection().dict[section.id] || []" [listId]="'PARENT'"
|
||||
[listModelId]="section.id"></task-list>
|
||||
</collapsible>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<task-list
|
||||
class="tour-undoneList"
|
||||
|
|
|
|||
|
|
@ -330,3 +330,31 @@ finish-day-btn,
|
|||
padding-bottom: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
// Ensure section titles are left aligned and actions are right aligned
|
||||
.section-container {
|
||||
::ng-deep .collapsible-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
::ng-deep .collapsible-title {
|
||||
text-align: left;
|
||||
// ensure title takes available space to push actions to right
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: move;
|
||||
margin-right: 8px;
|
||||
color: var(--text-color-secondary); // standard Material color variable often used
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-color-secondary) !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,12 @@ import {
|
|||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatMenu, MatMenuTrigger, MatMenuItem, MatMenuModule } from '@angular/material/menu';
|
||||
|
||||
import { TaskService } from '../tasks/task.service';
|
||||
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
|
||||
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
|
||||
import { expandAnimation, expandFadeAnimation } from '../../ui/animations/expand.ani';
|
||||
import { LayoutService } from '../../core-ui/layout/layout.service';
|
||||
import { TakeABreakService } from '../take-a-break/take-a-break.service';
|
||||
|
|
@ -30,6 +35,7 @@ import {
|
|||
} from 'rxjs';
|
||||
import { TaskWithSubTasks } from '../tasks/task.model';
|
||||
import { delay, filter, map, observeOn, switchMap } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { fadeAnimation } from '../../ui/animations/fade.ani';
|
||||
import { PlanningModeService } from '../planning-mode/planning-mode.service';
|
||||
import { T } from '../../t.const';
|
||||
|
|
@ -38,7 +44,16 @@ import { WorkContextService } from '../work-context/work-context.service';
|
|||
import { ProjectService } from '../project/project.service';
|
||||
import { TaskViewCustomizerService } from '../task-view-customizer/task-view-customizer.service';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CdkDropListGroup } from '@angular/cdk/drag-drop';
|
||||
import { SectionService } from '../section/section.service';
|
||||
import { Section } from '../section/section.model';
|
||||
import {
|
||||
CdkDrag,
|
||||
CdkDragDrop,
|
||||
CdkDragHandle,
|
||||
CdkDropList,
|
||||
CdkDropListGroup,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
|
@ -77,6 +92,9 @@ import { ScheduledDateGroupPipe } from '../../ui/pipes/scheduled-date-group.pipe
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CdkDropListGroup,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
CdkScrollable,
|
||||
MatTooltip,
|
||||
MatIcon,
|
||||
|
|
@ -92,15 +110,18 @@ import { ScheduledDateGroupPipe } from '../../ui/pipes/scheduled-date-group.pipe
|
|||
TranslatePipe,
|
||||
CollapsibleComponent,
|
||||
CommonModule,
|
||||
MatMenuModule,
|
||||
FinishDayBtnComponent,
|
||||
ScheduledDateGroupPipe,
|
||||
],
|
||||
})
|
||||
|
||||
export class WorkViewComponent implements OnInit, OnDestroy {
|
||||
taskService = inject(TaskService);
|
||||
takeABreakService = inject(TakeABreakService);
|
||||
planningModeService = inject(PlanningModeService);
|
||||
layoutService = inject(LayoutService);
|
||||
sectionService = inject(SectionService);
|
||||
customizerService = inject(TaskViewCustomizerService);
|
||||
workContextService = inject(WorkContextService);
|
||||
private _activatedRoute = inject(ActivatedRoute);
|
||||
|
|
@ -108,6 +129,7 @@ export class WorkViewComponent implements OnInit, OnDestroy {
|
|||
private _cd = inject(ChangeDetectorRef);
|
||||
private _store = inject(Store);
|
||||
private _snackService = inject(SnackService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
|
||||
// TODO refactor all to signals
|
||||
overdueTasks = toSignal(this._store.select(selectOverdueTasksWithSubTasks), {
|
||||
|
|
@ -141,6 +163,34 @@ export class WorkViewComponent implements OnInit, OnDestroy {
|
|||
isLaterTodayHidden = signal(!!localStorage.getItem(LS.LATER_TODAY_TASKS_HIDDEN));
|
||||
isOverdueHidden = signal(!!localStorage.getItem(LS.OVERDUE_TASKS_HIDDEN));
|
||||
|
||||
// Section Logic
|
||||
sections = toSignal(
|
||||
this.workContextService.activeWorkContextId$.pipe(
|
||||
switchMap((id) => (id ? this.sectionService.getSectionsByProjectId$(id) : of([]))),
|
||||
),
|
||||
{ initialValue: [] },
|
||||
);
|
||||
|
||||
undoneTasksBySection = computed(() => {
|
||||
const tasks = this.undoneTasks();
|
||||
const sections = this.sections();
|
||||
|
||||
const dict: Record<string, TaskWithSubTasks[]> = {};
|
||||
const noSection: TaskWithSubTasks[] = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (task.sectionId && sections.find((s) => s.id === task.sectionId)) {
|
||||
if (!dict[task.sectionId]) dict[task.sectionId] = [];
|
||||
dict[task.sectionId].push(task);
|
||||
} else {
|
||||
noSection.push(task);
|
||||
}
|
||||
});
|
||||
|
||||
return { dict, noSection };
|
||||
});
|
||||
|
||||
|
||||
isShowOverduePanel = computed(
|
||||
() => this.isOnTodayList() && this.overdueTasks().length > 0,
|
||||
);
|
||||
|
|
@ -264,6 +314,53 @@ export class WorkViewComponent implements OnInit, OnDestroy {
|
|||
this.planningModeService.enterPlanningMode();
|
||||
}
|
||||
|
||||
addSection(): void {
|
||||
this._matDialog
|
||||
.open(DialogPromptComponent, {
|
||||
data: {
|
||||
placeholder: T.WW.ADD_SECTION_TITLE,
|
||||
},
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((title: string | undefined) => {
|
||||
if (title) {
|
||||
this.sectionService.addSection(title, this.workContextService.activeWorkContextId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteSection(id: string): void {
|
||||
this._matDialog
|
||||
.open(DialogConfirmComponent, {
|
||||
data: {
|
||||
message: T.CONFIRM.DELETE_SECTION_CASCADE,
|
||||
},
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((isConfirm: boolean) => {
|
||||
if (isConfirm) {
|
||||
this.sectionService.deleteSection(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editSection(id: string, title: string): void {
|
||||
this._matDialog
|
||||
.open(DialogPromptComponent, {
|
||||
data: {
|
||||
placeholder: T.WW.ADD_SECTION_TITLE,
|
||||
val: title,
|
||||
},
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((newTitle: string | undefined) => {
|
||||
if (newTitle) {
|
||||
this.sectionService.updateSection(id, { title: newTitle });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
startWork(): void {
|
||||
this.planningModeService.leavePlanningMode();
|
||||
}
|
||||
|
|
@ -314,9 +411,26 @@ export class WorkViewComponent implements OnInit, OnDestroy {
|
|||
);
|
||||
}
|
||||
|
||||
dropSection(event: CdkDragDrop<Section[]>): void {
|
||||
const sections = this.sections();
|
||||
if (event.previousIndex === event.currentIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We can't mutate the array directly as it is from a signal/store
|
||||
// So we copy it, move the item, and then extract the IDs
|
||||
const newSections = [...sections];
|
||||
moveItemInArray(newSections, event.previousIndex, event.currentIndex);
|
||||
|
||||
// Update the section order in the store
|
||||
this.sectionService.updateSectionOrder(newSections.map((s) => s.id));
|
||||
}
|
||||
|
||||
private _initScrollTracking(): void {
|
||||
this._subs.add(
|
||||
this.upperContainerScroll$.subscribe(({ target }) => {
|
||||
this.upperContainerScroll$.subscribe(({
|
||||
target
|
||||
}) => {
|
||||
if ((target as HTMLElement).scrollTop !== 0) {
|
||||
this.layoutService.isScrolled.set(true);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { initialSectionState } from '../../features/section/store/section.reducer';
|
||||
import { initialProjectState } from '../../features/project/store/project.reducer';
|
||||
import { initialTaskState } from '../../features/tasks/store/task.reducer';
|
||||
import { initialTagState } from '../../features/tag/store/tag.reducer';
|
||||
|
|
@ -67,6 +68,7 @@ export const DEFAULT_APP_BASE_DATA: AppBaseData = {
|
|||
},
|
||||
taskRepeatCfg: initialTaskRepeatCfgState,
|
||||
note: initialNoteState,
|
||||
section: initialSectionState,
|
||||
|
||||
metric: initialMetricState,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { PlannerState } from '../../features/planner/store/planner.reducer';
|
|||
import { IssueProviderState } from '../../features/issue/issue.model';
|
||||
import { BoardsState } from '../../features/boards/store/boards.reducer';
|
||||
import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model';
|
||||
import { SectionState } from '../../features/section/section.model';
|
||||
|
||||
export interface AppBaseWithoutLastSyncModelChange {
|
||||
project: ProjectState;
|
||||
|
|
@ -30,6 +31,7 @@ export interface AppBaseWithoutLastSyncModelChange {
|
|||
tag: TagState;
|
||||
simpleCounter: SimpleCounterState;
|
||||
taskRepeatCfg: TaskRepeatCfgState;
|
||||
section: SectionState;
|
||||
}
|
||||
|
||||
export interface AppMainFileNoRevsData extends AppBaseWithoutLastSyncModelChange {
|
||||
|
|
@ -47,7 +49,7 @@ export interface AppArchiveFileData {
|
|||
}
|
||||
|
||||
export interface AppBaseData
|
||||
extends AppBaseWithoutLastSyncModelChange, AppArchiveFileData {}
|
||||
extends AppBaseWithoutLastSyncModelChange, AppArchiveFileData { }
|
||||
|
||||
export interface LocalSyncMetaForProvider {
|
||||
lastSync: number;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { selectSimpleCounterFeatureState } from '../../features/simple-counter/s
|
|||
import { selectTagFeatureState } from '../../features/tag/store/tag.reducer';
|
||||
import { selectTaskFeatureState } from '../../features/tasks/store/task.selectors';
|
||||
import { selectTaskRepeatCfgFeatureState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
|
||||
import { selectSectionFeatureState } from '../../features/section/store/section.selectors';
|
||||
import { selectTimeTrackingState } from '../../features/time-tracking/store/time-tracking.selectors';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
|
||||
|
|
@ -108,6 +109,7 @@ export class StateSnapshotService {
|
|||
this._store.select(selectPluginUserDataFeatureState),
|
||||
this._store.select(selectPluginMetadataFeatureState),
|
||||
this._store.select(selectReminderFeatureState),
|
||||
this._store.select(selectSectionFeatureState),
|
||||
]).pipe(first()),
|
||||
);
|
||||
|
||||
|
|
@ -128,6 +130,7 @@ export class StateSnapshotService {
|
|||
pluginUserData,
|
||||
pluginMetadata,
|
||||
reminders,
|
||||
section,
|
||||
] = ngRxData;
|
||||
|
||||
return {
|
||||
|
|
@ -153,6 +156,7 @@ export class StateSnapshotService {
|
|||
pluginUserData,
|
||||
pluginMetadata,
|
||||
reminders,
|
||||
section,
|
||||
archiveYoung,
|
||||
archiveOld,
|
||||
};
|
||||
|
|
@ -176,7 +180,8 @@ export class StateSnapshotService {
|
|||
let simpleCounter: unknown,
|
||||
taskRepeatCfg: unknown,
|
||||
menuTree: unknown,
|
||||
timeTracking: unknown;
|
||||
timeTracking: unknown,
|
||||
section: unknown;
|
||||
let pluginUserData: unknown, pluginMetadata: unknown, reminders: unknown;
|
||||
|
||||
// Subscribe synchronously to get current values
|
||||
|
|
@ -244,6 +249,10 @@ export class StateSnapshotService {
|
|||
.select(selectReminderFeatureState)
|
||||
.pipe(first())
|
||||
.subscribe((v) => (reminders = v));
|
||||
this._store
|
||||
.select(selectSectionFeatureState)
|
||||
.pipe(first())
|
||||
.subscribe((v) => (section = v));
|
||||
|
||||
return {
|
||||
task: {
|
||||
|
|
@ -268,6 +277,7 @@ export class StateSnapshotService {
|
|||
pluginUserData,
|
||||
pluginMetadata,
|
||||
reminders,
|
||||
section,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ export enum ActionType {
|
|||
REPEAT_CFG_DELETE_INSTANCE = '[TaskRepeatCfg] Delete Single Instance',
|
||||
REPEAT_CFG_UPSERT = '[TaskRepeatCfg] Upsert TaskRepeatCfg',
|
||||
|
||||
// Section actions (S)
|
||||
SECTION_ADD = '[Section] Add Section',
|
||||
SECTION_DELETE = '[Section] Delete Section',
|
||||
SECTION_UPDATE = '[Section] Update Section',
|
||||
SECTION_UPDATE_ORDER = '[Section] Update Section Order',
|
||||
|
||||
// SimpleCounter actions (S)
|
||||
COUNTER_ADD = '[SimpleCounter] Add SimpleCounter',
|
||||
COUNTER_UPDATE = '[SimpleCounter] Update SimpleCounter',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface AppStateSnapshot {
|
|||
pluginUserData: unknown;
|
||||
pluginMetadata: unknown;
|
||||
reminders: unknown;
|
||||
section: unknown;
|
||||
archiveYoung: ArchiveModel;
|
||||
archiveOld: ArchiveModel;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { initialMetricState } from '../../features/metric/store/metric.reducer';
|
|||
import { initialTaskState } from '../../features/tasks/store/task.reducer';
|
||||
import { initialTagState } from '../../features/tag/store/tag.reducer';
|
||||
import { initialSimpleCounterState } from '../../features/simple-counter/store/simple-counter.reducer';
|
||||
import { initialSectionState } from '../../features/section/store/section.reducer';
|
||||
import { SectionState } from '../../features/section/section.model';
|
||||
import { initialTaskRepeatCfgState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.reducer';
|
||||
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
|
||||
import { Webdav } from '../sync-providers/file-based/webdav/webdav';
|
||||
|
|
@ -70,6 +72,7 @@ export type AllModelConfig = {
|
|||
task: ModelCfg<TaskState>;
|
||||
tag: ModelCfg<TagState>;
|
||||
simpleCounter: ModelCfg<SimpleCounterState>;
|
||||
section: ModelCfg<SectionState>;
|
||||
taskRepeatCfg: ModelCfg<TaskRepeatCfgState>;
|
||||
reminders: ModelCfg<Reminder[]>;
|
||||
timeTracking: ModelCfg<TimeTrackingState>;
|
||||
|
|
@ -111,6 +114,12 @@ export const MODEL_CONFIGS: AllModelConfig = {
|
|||
validate: appDataValidators.simpleCounter,
|
||||
repair: fixEntityStateConsistency,
|
||||
},
|
||||
section: {
|
||||
defaultData: initialSectionState,
|
||||
isMainFileModel: true,
|
||||
validate: appDataValidators.section,
|
||||
repair: fixEntityStateConsistency,
|
||||
},
|
||||
note: {
|
||||
defaultData: initialNoteState,
|
||||
isMainFileModel: true,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { BOARDS_FEATURE_NAME } from '../../features/boards/store/boards.reducer'
|
|||
import { menuTreeFeatureKey } from '../../features/menu-tree/store/menu-tree.reducer';
|
||||
import { plannerFeatureKey } from '../../features/planner/store/planner.reducer';
|
||||
import { TIME_TRACKING_FEATURE_KEY } from '../../features/time-tracking/store/time-tracking.reducer';
|
||||
import { initialSectionState } from '../../features/section/store/section.reducer';
|
||||
|
||||
/**
|
||||
* Creates a minimal valid AppDataComplete state.
|
||||
|
|
@ -85,6 +86,7 @@ export const createValidAppData = (
|
|||
entities: {},
|
||||
todayOrder: [],
|
||||
},
|
||||
section: initialSectionState,
|
||||
menuTree: {
|
||||
...menuTreeInitialState,
|
||||
projectTree: [{ k: MenuTreeKind.PROJECT, id: 'INBOX' } as MenuTreeProjectNode],
|
||||
|
|
@ -327,6 +329,7 @@ export const rootStateToAppData = (
|
|||
reminders?: AppDataComplete['reminders'];
|
||||
pluginUserData?: AppDataComplete['pluginUserData'];
|
||||
pluginMetadata?: AppDataComplete['pluginMetadata'];
|
||||
section?: AppDataComplete['section'];
|
||||
} = {},
|
||||
): AppDataComplete => {
|
||||
return {
|
||||
|
|
@ -339,6 +342,7 @@ export const rootStateToAppData = (
|
|||
planner: state[plannerFeatureKey],
|
||||
boards: state[BOARDS_FEATURE_NAME],
|
||||
timeTracking: state[TIME_TRACKING_FEATURE_KEY],
|
||||
section: additionalData.section || initialSectionState,
|
||||
// These are either from additional data or defaults
|
||||
simpleCounter: additionalData.simpleCounter || initialSimpleCounterState,
|
||||
taskRepeatCfg: additionalData.taskRepeatCfg || initialTaskRepeatCfgState,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
TimeTrackingState,
|
||||
} from '../../features/time-tracking/time-tracking.model';
|
||||
import { ProjectState } from '../../features/project/project.model';
|
||||
import { SectionState } from '../../features/section/section.model';
|
||||
import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model';
|
||||
import { TaskState } from '../../features/tasks/task.model';
|
||||
import { createValidate } from 'typia';
|
||||
|
|
@ -52,6 +53,7 @@ const _validateGlobalConfig = createValidate<GlobalConfigState>();
|
|||
const _validateTimeTracking = createValidate<TimeTrackingState>();
|
||||
const _validatePluginUserData = createValidate<PluginUserDataState>();
|
||||
const _validatePluginMetadata = createValidate<PluginMetaDataState>();
|
||||
const _validateSection = createValidate<SectionState>();
|
||||
|
||||
export const validateAllData = <R>(
|
||||
d: AppDataComplete | R,
|
||||
|
|
@ -101,6 +103,7 @@ export const appDataValidators: {
|
|||
_wrapValidate(_validatePluginUserData(d)),
|
||||
pluginMetadata: <R>(d: R | PluginMetaDataState) =>
|
||||
_wrapValidate(_validatePluginMetadata(d)),
|
||||
section: <R>(d: R | SectionState) => _wrapValidate(_validateSection(d), d, true),
|
||||
} as const;
|
||||
|
||||
const validateArchiveModel = <R>(d: ArchiveModel | R): ValidationResult<ArchiveModel> => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ import {
|
|||
simpleCounterReducer,
|
||||
} from '../features/simple-counter/store/simple-counter.reducer';
|
||||
import { SimpleCounterEffects } from '../features/simple-counter/store/simple-counter.effects';
|
||||
import {
|
||||
SECTION_FEATURE_NAME,
|
||||
sectionReducer,
|
||||
} from '../features/section/store/section.reducer';
|
||||
import { SectionEffects } from '../features/section/store/section.effects';
|
||||
import { TAG_FEATURE_NAME, tagReducer } from '../features/tag/store/tag.reducer';
|
||||
import { TagEffects } from '../features/tag/store/tag.effects';
|
||||
import {
|
||||
|
|
@ -132,6 +137,10 @@ import {
|
|||
StoreModule.forFeature(SIMPLE_COUNTER_FEATURE_NAME, simpleCounterReducer),
|
||||
EffectsModule.forFeature([SimpleCounterEffects]),
|
||||
|
||||
StoreModule.forFeature(SECTION_FEATURE_NAME, sectionReducer),
|
||||
EffectsModule.forFeature([SectionEffects]),
|
||||
|
||||
|
||||
StoreModule.forFeature(TAG_FEATURE_NAME, tagReducer),
|
||||
EffectsModule.forFeature([TagEffects]),
|
||||
|
||||
|
|
@ -184,4 +193,4 @@ import {
|
|||
EffectsModule.forFeature([PluginHooksEffects]),
|
||||
],
|
||||
})
|
||||
export class FeatureStoresModule {}
|
||||
export class FeatureStoresModule { }
|
||||
|
|
|
|||
|
|
@ -20,11 +20,14 @@ const T = {
|
|||
SHOW_NOTES: 'BN.SHOW_NOTES',
|
||||
},
|
||||
CONFIRM: {
|
||||
ADD_SECTION: 'CONFIRM.ADD_SECTION',
|
||||
AUTO_FIX: 'CONFIRM.AUTO_FIX',
|
||||
RELOAD_AFTER_IDB_ERROR: 'CONFIRM.RELOAD_AFTER_IDB_ERROR',
|
||||
RESTORE_FILE_BACKUP: 'CONFIRM.RESTORE_FILE_BACKUP',
|
||||
RESTORE_FILE_BACKUP_ANDROID: 'CONFIRM.RESTORE_FILE_BACKUP_ANDROID',
|
||||
RESTORE_STRAY_BACKUP: 'CONFIRM.RESTORE_STRAY_BACKUP',
|
||||
DELETE_SECTION: 'CONFIRM.DELETE_SECTION',
|
||||
DELETE_SECTION_CASCADE: 'CONFIRM.DELETE_SECTION_CASCADE',
|
||||
},
|
||||
DATETIME_SCHEDULE: {
|
||||
PRESS_ENTER_AGAIN: 'DATETIME_SCHEDULE.PRESS_ENTER_AGAIN',
|
||||
|
|
@ -530,6 +533,7 @@ const T = {
|
|||
CONFIRM_PARENT_TASKS_WITH_SUBS: 'F.MARKDOWN_PASTE.CONFIRM_PARENT_TASKS_WITH_SUBS',
|
||||
CONFIRM_SUB_TASKS: 'F.MARKDOWN_PASTE.CONFIRM_SUB_TASKS',
|
||||
CONFIRM_SUB_TASKS_WITH_PARENT: 'F.MARKDOWN_PASTE.CONFIRM_SUB_TASKS_WITH_PARENT',
|
||||
CONFIRM_SECTIONS: 'F.MARKDOWN_PASTE.CONFIRM_SECTIONS',
|
||||
DIALOG_TITLE: 'F.MARKDOWN_PASTE.DIALOG_TITLE',
|
||||
},
|
||||
METRIC: {
|
||||
|
|
@ -2003,6 +2007,7 @@ const T = {
|
|||
},
|
||||
MH: {
|
||||
ADD_NEW_TASK: 'MH.ADD_NEW_TASK',
|
||||
ADD_SECTION: 'MH.ADD_SECTION',
|
||||
ALL_PLANNED_LIST: 'MH.ALL_PLANNED_LIST',
|
||||
BOARDS: 'MH.BOARDS',
|
||||
COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN',
|
||||
|
|
@ -2280,6 +2285,8 @@ const T = {
|
|||
WW: {
|
||||
ADD_MORE: 'WW.ADD_MORE',
|
||||
ADD_SCHEDULED_FOR_TOMORROW: 'WW.ADD_SCHEDULED_FOR_TOMORROW',
|
||||
ADD_SECTION: 'WW.ADD_SECTION',
|
||||
ADD_SECTION_TITLE: 'WW.ADD_SECTION_TITLE',
|
||||
ADD_SOME_TASKS: 'WW.ADD_SOME_TASKS',
|
||||
DONE_TASKS: 'WW.DONE_TASKS',
|
||||
DONE_TASKS_IN_ARCHIVE: 'WW.DONE_TASKS_IN_ARCHIVE',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
|
||||
<div class="collapsible-title">{{ title() }}</div>
|
||||
|
||||
<ng-content select="[actions]"></ng-content>
|
||||
|
||||
@if (!isIconBefore()) {
|
||||
<mat-icon class="collapsible-expand-icon">expand_more</mat-icon>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ export interface MarkdownTaskStructure {
|
|||
totalSubTasks: number;
|
||||
}
|
||||
|
||||
export interface SectionWithTasks {
|
||||
sectionTitle: string | null; // null for "No Section"
|
||||
tasks: ParsedMarkdownTask[];
|
||||
}
|
||||
|
||||
export interface MarkdownWithSections {
|
||||
sections: SectionWithTasks[];
|
||||
hasHeaders: boolean;
|
||||
}
|
||||
|
||||
|
||||
interface ParsedLine {
|
||||
indentLevel: number;
|
||||
content: string;
|
||||
|
|
@ -309,3 +320,81 @@ export const parseMarkdownTasks = (text: string): ParsedMarkdownTask[] | null =>
|
|||
// Return tasks only if we found at least one
|
||||
return tasks.length > 0 ? tasks : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse markdown text to detect H1 headers (#) and group tasks under sections
|
||||
* @param text - Markdown text with potential headers and task lists
|
||||
* @returns Sections with tasks, or null if not valid markdown
|
||||
*/
|
||||
export const parseMarkdownWithSections = (text: string): MarkdownWithSections | null => {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = text.split('\n');
|
||||
const sections: SectionWithTasks[] = [];
|
||||
let currentSection: SectionWithTasks | null = null;
|
||||
let hasHeaders = false;
|
||||
const pendingTaskLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Detect H1 header (only single #)
|
||||
if (trimmed.match(/^#\s+(.+)$/)) {
|
||||
hasHeaders = true;
|
||||
|
||||
// Process any pending tasks from previous section
|
||||
if (pendingTaskLines.length > 0) {
|
||||
const tasksText = pendingTaskLines.join('\n');
|
||||
const parsedTasks = parseMarkdownTasksWithStructure(tasksText);
|
||||
|
||||
if (currentSection && parsedTasks) {
|
||||
currentSection.tasks = parsedTasks.mainTasks;
|
||||
}
|
||||
pendingTaskLines.length = 0; // Clear
|
||||
}
|
||||
|
||||
// Create new section
|
||||
const headerText = trimmed.replace(/^#\s+/, '').trim();
|
||||
currentSection = {
|
||||
sectionTitle: headerText,
|
||||
tasks: []
|
||||
};
|
||||
sections.push(currentSection);
|
||||
}
|
||||
// Detect task lines
|
||||
else if (trimmed.match(/^[-*]\s+/) || trimmed.match(/^[-*]\s*\[([ x])\]/)) {
|
||||
pendingTaskLines.push(line);
|
||||
}
|
||||
// Empty lines and other content are ignored for now
|
||||
}
|
||||
|
||||
// Process remaining pending tasks
|
||||
if (pendingTaskLines.length > 0) {
|
||||
const tasksText = pendingTaskLines.join('\n');
|
||||
const parsedTasks = parseMarkdownTasksWithStructure(tasksText);
|
||||
|
||||
if (currentSection && parsedTasks) {
|
||||
// Add to current section
|
||||
currentSection.tasks = parsedTasks.mainTasks;
|
||||
} else if (parsedTasks) {
|
||||
// Tasks before any header - create "No Section"
|
||||
sections.unshift({
|
||||
sectionTitle: null,
|
||||
tasks: parsedTasks.mainTasks
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only return if we found headers
|
||||
if (!hasHeaders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sections,
|
||||
hasHeaders
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,11 +20,14 @@
|
|||
"SHOW_NOTES": "Project Notes"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"ADD_SECTION": "Add Section",
|
||||
"AUTO_FIX": "Your data seems to be damaged (\"{{validityError}}\"). Do you want to try to automatically fix it? This might result in partial data loss.",
|
||||
"RELOAD_AFTER_IDB_ERROR": "Database Error - App Will Restart\n\nSuper Productivity cannot save data. This is usually caused by:\n• Low disk space (most common)\n• App update in background\n• Linux Snap users: run 'snap set core experimental.refresh-app-awareness=true'\n\nYour recent changes may not have been saved. Please free up disk space if low. The app will restart after you close this dialog.",
|
||||
"RESTORE_FILE_BACKUP": "There seems to be NO DATA, but there are backups available at \"{{dir}}\". Do you want to restore the latest backup from {{from}}?",
|
||||
"RESTORE_FILE_BACKUP_ANDROID": "There seems to be NO DATA, but there is a backup available. Do you want to load it?",
|
||||
"RESTORE_STRAY_BACKUP": "During last sync there might have been some error. Do you want to restore the last backup?"
|
||||
"RESTORE_STRAY_BACKUP": "During last sync there might have been some error. Do you want to restore the last backup?",
|
||||
"DELETE_SECTION": "Are you sure you want to delete this section? Tasks in it will be moved to the task list.",
|
||||
"DELETE_SECTION_CASCADE": "Are you sure you want to delete this section? All tasks in this section will also be deleted."
|
||||
},
|
||||
"DATETIME_SCHEDULE": {
|
||||
"PRESS_ENTER_AGAIN": "Press enter again to save"
|
||||
|
|
@ -519,6 +522,7 @@
|
|||
"CONFIRM_PARENT_TASKS_WITH_SUBS": "Create <strong>{{tasksCount}} new tasks and {{subTasksCount}} sub-tasks</strong> from the pasted markdown list?",
|
||||
"CONFIRM_SUB_TASKS": "Create {{tasksCount}} new sub-tasks from the pasted markdown list?",
|
||||
"CONFIRM_SUB_TASKS_WITH_PARENT": "Create <strong>{{tasksCount}} new sub-tasks under \"{{parentTaskTitle}}\"</strong> from the pasted markdown list?",
|
||||
"CONFIRM_SECTIONS": "Create {{sectionsCount}} section(s) with {{tasksCount}} task(s)?",
|
||||
"DIALOG_TITLE": "Pasted Markdown List detected!"
|
||||
},
|
||||
"METRIC": {
|
||||
|
|
@ -1993,6 +1997,7 @@
|
|||
},
|
||||
"MH": {
|
||||
"ADD_NEW_TASK": "Add new Task",
|
||||
"ADD_SECTION": "Add Section",
|
||||
"ALL_PLANNED_LIST": "Planned / Repeat",
|
||||
"BOARDS": "Boards",
|
||||
"COPY_TASK_LIST_MARKDOWN": "Copy to Clipboard",
|
||||
|
|
@ -2265,6 +2270,7 @@
|
|||
"WW": {
|
||||
"ADD_MORE": "Add more",
|
||||
"ADD_SCHEDULED_FOR_TOMORROW": "Add tasks planned for tomorrow ({{nr}})",
|
||||
"ADD_SECTION_TITLE": "Add Section",
|
||||
"ADD_SOME_TASKS": "Add some tasks to plan your day!",
|
||||
"DONE_TASKS": "Done Tasks",
|
||||
"DONE_TASKS_IN_ARCHIVE": "There are currently no done tasks here, but there are some already archived.",
|
||||
|
|
@ -2284,4 +2290,4 @@
|
|||
"WORKING_TODAY": "Working today:",
|
||||
"WORKING_TODAY_ARCHIVED": "Time worked today on archived tasks"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
"SHOW_NOTES": "Mostrar notas del proyecto"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"ADD_SECTION": "Añadir Sección",
|
||||
"AUTO_FIX": "Tus datos parecen estar dañados (\"{{validityError}}\"). ¿Quieres intentar arreglarlos automáticamente? Esto podría resultar en una pérdida parcial de datos.",
|
||||
"RELOAD_AFTER_IDB_ERROR": "No se puede acceder a la base de datos :( Las causas posibles son una actualización de la aplicación en segundo plano o poco espacio en disco. Si instalaste la aplicación en Linux como snap, también querrás habilitar 'snap set core experimental.refresh-app-awareness=true' hasta que arreglen este problema por su parte. Pulsa OK para recargar la aplicación (puede requerir reiniciar manualmente la aplicación en algunas plataformas).",
|
||||
"RESTORE_FILE_BACKUP": "Parece que NO HAY DATOS, pero hay copias de seguridad disponibles en \"{{dir}}\". ¿Quieres restaurar la última copia de seguridad de {{from}}?",
|
||||
|
|
@ -2043,4 +2044,4 @@
|
|||
"WORKING_TODAY": "Trabajando hoy:",
|
||||
"WORKING_TODAY_ARCHIVED": "Tiempo trabajado hoy en tareas archivadas"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
test-sections.js
Normal file
14
test-sections.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Test parseMarkdownWithSections function
|
||||
const { parseMarkdownWithSections } = require('./src/app/util/parse-markdown-tasks.ts');
|
||||
|
||||
const testMarkdown = `# Backend Tasks
|
||||
- Setup API routes
|
||||
- Configure database
|
||||
|
||||
# Frontend Tasks
|
||||
- Create login component
|
||||
- Add authentication`;
|
||||
|
||||
console.log('Testing parseMarkdownWithSections...');
|
||||
const result = parseMarkdownWithSections(testMarkdown);
|
||||
console.log('Result:', JSON.stringify(result, null, 2));
|
||||
Loading…
Add table
Add a link
Reference in a new issue