mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	
							
								
								
									
										4
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							| @@ -5,8 +5,8 @@ | ||||
|     { | ||||
|       "console": "integratedTerminal", | ||||
|       "internalConsoleOptions": "neverOpen", | ||||
|       "name": "nodemon start-server", | ||||
|       "program": "${workspaceFolder}/src/www", | ||||
|       "name": "nodemon server:start", | ||||
|       "program": "${workspaceFolder}/src/main", | ||||
|       "request": "launch", | ||||
|       "restart": true, | ||||
|       "runtimeExecutable": "nodemon", | ||||
|   | ||||
							
								
								
									
										10
									
								
								db/migrations/0229__tasks.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								db/migrations/0229__tasks.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| CREATE TABLE IF NOT EXISTS "tasks" | ||||
| ( | ||||
| 	"taskId"	TEXT NOT NULL PRIMARY KEY, | ||||
| 	"parentNoteId"	TEXT NOT NULL, | ||||
| 	"title"	TEXT NOT NULL DEFAULT "", | ||||
| 	"dueDate"	INTEGER, | ||||
| 	"isDone"	INTEGER NOT NULL DEFAULT 0, | ||||
| 	"isDeleted"	INTEGER NOT NULL DEFAULT 0, | ||||
| 	"utcDateModified"	TEXT NOT NULL | ||||
| ); | ||||
| @@ -132,3 +132,14 @@ CREATE INDEX IDX_attachments_ownerId_role | ||||
| CREATE INDEX IDX_notes_blobId on notes (blobId); | ||||
| CREATE INDEX IDX_revisions_blobId on revisions (blobId); | ||||
| CREATE INDEX IDX_attachments_blobId on attachments (blobId); | ||||
|  | ||||
| CREATE TABLE IF NOT EXISTS "tasks" | ||||
| ( | ||||
| 	"taskId"	TEXT NOT NULL PRIMARY KEY, | ||||
| 	"parentNoteId"	TEXT NOT NULL, | ||||
| 	"title"	TEXT NOT NULL DEFAULT "", | ||||
| 	"dueDate"	INTEGER, | ||||
| 	"isDone"	INTEGER NOT NULL DEFAULT 0, | ||||
| 	"isDeleted"	INTEGER NOT NULL DEFAULT 0, | ||||
| 	"utcDateModified"	TEXT NOT NULL | ||||
| ); | ||||
| @@ -12,6 +12,7 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "./entities/rows.js"; | ||||
| import BBlob from "./entities/bblob.js"; | ||||
| import BRecentNote from "./entities/brecent_note.js"; | ||||
| import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; | ||||
| import type BTask from "./entities/btask.js"; | ||||
|  | ||||
| interface AttachmentOpts { | ||||
|     includeContentLength?: boolean; | ||||
| @@ -32,6 +33,7 @@ export default class Becca { | ||||
|     attributeIndex!: Record<string, BAttribute[]>; | ||||
|     options!: Record<string, BOption>; | ||||
|     etapiTokens!: Record<string, BEtapiToken>; | ||||
|     tasks!: Record<string, BTask>; | ||||
|  | ||||
|     allNoteSetCache: NoteSet | null; | ||||
|  | ||||
| @@ -48,6 +50,7 @@ export default class Becca { | ||||
|         this.attributeIndex = {}; | ||||
|         this.options = {}; | ||||
|         this.etapiTokens = {}; | ||||
|         this.tasks = {}; | ||||
|  | ||||
|         this.dirtyNoteSetCache(); | ||||
|  | ||||
| @@ -213,6 +216,14 @@ export default class Becca { | ||||
|         return this.etapiTokens[etapiTokenId]; | ||||
|     } | ||||
|  | ||||
|     getTasks(): BTask[] { | ||||
|         return Object.values(this.tasks); | ||||
|     } | ||||
|  | ||||
|     getTask(taskId: string): BTask | null { | ||||
|         return this.tasks[taskId]; | ||||
|     } | ||||
|  | ||||
|     getEntity<T extends AbstractBeccaEntity<T>>(entityName: string, entityId: string): AbstractBeccaEntity<T> | null { | ||||
|         if (!entityName || !entityId) { | ||||
|             return null; | ||||
|   | ||||
| @@ -11,9 +11,10 @@ import BOption from "./entities/boption.js"; | ||||
| import BEtapiToken from "./entities/betapi_token.js"; | ||||
| import cls from "../services/cls.js"; | ||||
| import entityConstructor from "../becca/entity_constructor.js"; | ||||
| import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "./entities/rows.js"; | ||||
| import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow, TaskRow } from "./entities/rows.js"; | ||||
| import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; | ||||
| import ws from "../services/ws.js"; | ||||
| import BTask from "./entities/btask.js"; | ||||
|  | ||||
| const beccaLoaded = new Promise<void>(async (res, rej) => { | ||||
|     const sqlInit = (await import("../services/sql_init.js")).default; | ||||
| @@ -63,6 +64,10 @@ function load() { | ||||
|         for (const row of sql.getRows<EtapiTokenRow>(`SELECT etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified FROM etapi_tokens WHERE isDeleted = 0`)) { | ||||
|             new BEtapiToken(row); | ||||
|         } | ||||
|  | ||||
|         for (const row of sql.getRows<TaskRow>(`SELECT taskId, parentNoteId, title, dueDate, isDone, isDeleted FROM tasks WHERE isDeleted = 0`)) { | ||||
|             new BTask(row); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     for (const noteId in becca.notes) { | ||||
|   | ||||
							
								
								
									
										84
									
								
								src/becca/entities/btask.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/becca/entities/btask.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| import date_utils from "../../services/date_utils.js"; | ||||
| import AbstractBeccaEntity from "./abstract_becca_entity.js"; | ||||
| import type BOption from "./boption.js"; | ||||
| import type { TaskRow } from "./rows.js"; | ||||
|  | ||||
| export default class BTask extends AbstractBeccaEntity<BOption> { | ||||
|  | ||||
|     static get entityName() { | ||||
|         return "tasks"; | ||||
|     } | ||||
|  | ||||
|     static get primaryKeyName() { | ||||
|         return "taskId"; | ||||
|     } | ||||
|  | ||||
|     static get hashedProperties() { | ||||
|         return [ "taskId", "parentNoteId", "title", "dueDate", "isDone", "isDeleted" ]; | ||||
|     } | ||||
|  | ||||
|     taskId?: string; | ||||
|     parentNoteId!: string; | ||||
|     title!: string; | ||||
|     dueDate?: string; | ||||
|     isDone!: boolean; | ||||
|     private _isDeleted?: boolean; | ||||
|  | ||||
|     constructor(row?: TaskRow) { | ||||
|         super(); | ||||
|  | ||||
|         if (!row) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.updateFromRow(row); | ||||
|         this.init(); | ||||
|     } | ||||
|  | ||||
|     get isDeleted() { | ||||
|         return !!this._isDeleted; | ||||
|     } | ||||
|  | ||||
|     updateFromRow(row: TaskRow) { | ||||
|         this.taskId = row.taskId; | ||||
|         this.parentNoteId = row.parentNoteId; | ||||
|         this.title = row.title; | ||||
|         this.dueDate = row.dueDate; | ||||
|         this.isDone = !!row.isDone; | ||||
|         this._isDeleted = !!row.isDeleted; | ||||
|         this.utcDateModified = row.utcDateModified; | ||||
|  | ||||
|         if (this.taskId) { | ||||
|             this.becca.tasks[this.taskId] = this; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     init() { | ||||
|         if (this.taskId) { | ||||
|             this.becca.tasks[this.taskId] = this; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     protected beforeSaving(opts?: {}): void { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         this.utcDateModified = date_utils.utcNowDateTime(); | ||||
|  | ||||
|         if (this.taskId) { | ||||
|             this.becca.tasks[this.taskId] = this; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     getPojo() { | ||||
|         return { | ||||
|             taskId: this.taskId, | ||||
|             parentNoteId: this.parentNoteId, | ||||
|             title: this.title, | ||||
|             dueDate: this.dueDate, | ||||
|             isDone: this.isDone, | ||||
|             isDeleted: this.isDeleted, | ||||
|             utcDateModified: this.utcDateModified | ||||
|         }; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -136,3 +136,13 @@ export interface NoteRow { | ||||
|     utcDateModified: string; | ||||
|     content?: string | Buffer; | ||||
| } | ||||
|  | ||||
| export interface TaskRow { | ||||
|     taskId?: string; | ||||
|     parentNoteId: string; | ||||
|     title: string; | ||||
|     dueDate?: string; | ||||
|     isDone?: boolean; | ||||
|     isDeleted?: boolean; | ||||
|     utcDateModified?: string; | ||||
| } | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import BNote from "./entities/bnote.js"; | ||||
| import BOption from "./entities/boption.js"; | ||||
| import BRecentNote from "./entities/brecent_note.js"; | ||||
| import BRevision from "./entities/brevision.js"; | ||||
| import BTask from "./entities/btask.js"; | ||||
|  | ||||
| type EntityClass = new (row?: any) => AbstractBeccaEntity<any>; | ||||
|  | ||||
| @@ -21,7 +22,8 @@ const ENTITY_NAME_TO_ENTITY: Record<string, ConstructorData<any> & EntityClass> | ||||
|     notes: BNote, | ||||
|     options: BOption, | ||||
|     recent_notes: BRecentNote, | ||||
|     revisions: BRevision | ||||
|     revisions: BRevision, | ||||
|     tasks: BTask | ||||
| }; | ||||
|  | ||||
| function getEntityFromEntityName(entityName: keyof typeof ENTITY_NAME_TO_ENTITY) { | ||||
|   | ||||
| @@ -28,7 +28,8 @@ const NOTE_TYPE_ICONS = { | ||||
|     doc: "bx bxs-file-doc", | ||||
|     contentWidget: "bx bxs-widget", | ||||
|     mindMap: "bx bx-sitemap", | ||||
|     geoMap: "bx bx-map-alt" | ||||
|     geoMap: "bx bx-map-alt", | ||||
|     taskList: "bx bx-list-check" | ||||
| }; | ||||
|  | ||||
| /** | ||||
| @@ -36,7 +37,7 @@ const NOTE_TYPE_ICONS = { | ||||
|  * end user. Those types should be used only for checking against, they are | ||||
|  * not for direct use. | ||||
|  */ | ||||
| export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "geoMap"; | ||||
| export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "geoMap" | "taskList"; | ||||
|  | ||||
| export interface NotePathRecord { | ||||
|     isArchived: boolean; | ||||
|   | ||||
							
								
								
									
										34
									
								
								src/public/app/entities/ftask.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/public/app/entities/ftask.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| import type { Froca } from "../services/froca-interface.js"; | ||||
|  | ||||
| export interface FTaskRow { | ||||
|     taskId: string; | ||||
|     parentNoteId: string; | ||||
|     title: string; | ||||
|     dueDate?: string; | ||||
|     isDone?: boolean; | ||||
|     utcDateModified: string; | ||||
| } | ||||
|  | ||||
| export default class FTask { | ||||
|     private froca: Froca; | ||||
|     taskId!: string; | ||||
|     parentNoteId!: string; | ||||
|     title!: string; | ||||
|     dueDate?: string; | ||||
|     isDone!: boolean; | ||||
|     utcDateModified!: string; | ||||
|  | ||||
|     constructor(froca: Froca, row: FTaskRow) { | ||||
|         this.froca = froca; | ||||
|         this.update(row); | ||||
|     } | ||||
|  | ||||
|     update(row: FTaskRow) { | ||||
|         this.taskId = row.taskId; | ||||
|         this.parentNoteId = row.parentNoteId; | ||||
|         this.title = row.title; | ||||
|         this.dueDate = row.dueDate; | ||||
|         this.isDone = !!row.isDone; | ||||
|         this.utcDateModified = row.utcDateModified; | ||||
|     } | ||||
| } | ||||
| @@ -6,6 +6,8 @@ import appContext from "../components/app_context.js"; | ||||
| import FBlob, { type FBlobRow } from "../entities/fblob.js"; | ||||
| import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js"; | ||||
| import type { Froca } from "./froca-interface.js"; | ||||
| import FTask from "../entities/ftask.js"; | ||||
| import type { FTaskRow } from "../entities/ftask.js"; | ||||
|  | ||||
| interface SubtreeResponse { | ||||
|     notes: FNoteRow[]; | ||||
| @@ -37,6 +39,7 @@ class FrocaImpl implements Froca { | ||||
|     attributes!: Record<string, FAttribute>; | ||||
|     attachments!: Record<string, FAttachment>; | ||||
|     blobPromises!: Record<string, Promise<void | FBlob> | null>; | ||||
|     tasks!: Record<string, FTask>; | ||||
|  | ||||
|     constructor() { | ||||
|         this.initializedPromise = this.loadInitialTree(); | ||||
| @@ -52,6 +55,7 @@ class FrocaImpl implements Froca { | ||||
|         this.attributes = {}; | ||||
|         this.attachments = {}; | ||||
|         this.blobPromises = {}; | ||||
|         this.tasks = {}; | ||||
|  | ||||
|         this.addResp(resp); | ||||
|     } | ||||
| @@ -368,6 +372,20 @@ class FrocaImpl implements Froca { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async getTasks(parentNoteId: string) { | ||||
|         const taskRows = await server.get<FTaskRow[]>(`tasks/${parentNoteId}`); | ||||
|         return this.processTaskRow(taskRows); | ||||
|     } | ||||
|  | ||||
|     processTaskRow(taskRows: FTaskRow[]): FTask[] { | ||||
|         return taskRows.map((taskRow) => { | ||||
|             const task = new FTask(this, taskRow); | ||||
|             this.tasks[task.taskId] = task; | ||||
|  | ||||
|             return task; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async getBlob(entityType: string, entityId: string) { | ||||
|         // I'm not sure why we're not using blobIds directly, it would save us this composite key ... | ||||
|         // perhaps one benefit is that we're always requesting the latest blob, not relying on perhaps faulty/slow | ||||
|   | ||||
| @@ -8,6 +8,8 @@ import FAttribute, { type FAttributeRow } from "../entities/fattribute.js"; | ||||
| import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js"; | ||||
| import type { default as FNote, FNoteRow } from "../entities/fnote.js"; | ||||
| import type { EntityChange } from "../server_types.js"; | ||||
| import type { FTaskRow } from "../entities/ftask.js"; | ||||
| import FTask from "../entities/ftask.js"; | ||||
|  | ||||
| async function processEntityChanges(entityChanges: EntityChange[]) { | ||||
|     const loadResults = new LoadResults(entityChanges); | ||||
| @@ -37,6 +39,8 @@ async function processEntityChanges(entityChanges: EntityChange[]) { | ||||
|                 processAttachment(loadResults, ec); | ||||
|             } else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") { | ||||
|                 // NOOP | ||||
|             } else if (ec.entityName === "tasks") { | ||||
|                 processTaskChange(loadResults, ec); | ||||
|             } else { | ||||
|                 throw new Error(`Unknown entityName '${ec.entityName}'`); | ||||
|             } | ||||
| @@ -306,6 +310,35 @@ function processAttachment(loadResults: LoadResults, ec: EntityChange) { | ||||
|     loadResults.addAttachmentRow(attachmentEntity); | ||||
| } | ||||
|  | ||||
| function processTaskChange(loadResults: LoadResults, ec: EntityChange) { | ||||
|     if (ec.isErased && ec.entityId in froca.tasks) { | ||||
|         utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     let task = froca.tasks[ec.entityId]; | ||||
|     const taskEntity = ec.entity as FTaskRow; | ||||
|  | ||||
|     if (ec.isErased || (ec.entity as any)?.isDeleted) { | ||||
|         if (task) { | ||||
|             delete froca.tasks[ec.entityId]; | ||||
|         } | ||||
|  | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (ec.entity) { | ||||
|         if (task) { | ||||
|             task.update(ec.entity as FTaskRow); | ||||
|         } else { | ||||
|             task = new FTask(froca, ec.entity as FTaskRow); | ||||
|             froca.tasks[task.taskId] = task; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     loadResults.addTaskRow(taskEntity); | ||||
| } | ||||
|  | ||||
| export default { | ||||
|     processEntityChanges | ||||
| }; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import type { TaskRow } from "../../../becca/entities/rows.js"; | ||||
| import type { AttributeType } from "../entities/fattribute.js"; | ||||
| import type { EntityChange } from "../server_types.js"; | ||||
|  | ||||
| @@ -69,6 +70,7 @@ export default class LoadResults { | ||||
|     private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[]; | ||||
|     private optionNames: string[]; | ||||
|     private attachmentRows: AttachmentRow[]; | ||||
|     private taskRows: TaskRow[]; | ||||
|  | ||||
|     constructor(entityChanges: EntityChange[]) { | ||||
|         const entities: Record<string, Record<string, any>> = {}; | ||||
| @@ -97,6 +99,8 @@ export default class LoadResults { | ||||
|         this.optionNames = []; | ||||
|  | ||||
|         this.attachmentRows = []; | ||||
|  | ||||
|         this.taskRows = []; | ||||
|     } | ||||
|  | ||||
|     getEntityRow<T extends EntityRowNames>(entityName: T, entityId: string): EntityRowMappings[T] { | ||||
| @@ -179,6 +183,14 @@ export default class LoadResults { | ||||
|         return this.contentNoteIdToComponentId.find((l) => l.noteId === noteId && l.componentId !== componentId); | ||||
|     } | ||||
|  | ||||
|     isTaskListReloaded(parentNoteId: string) { | ||||
|         if (!parentNoteId) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return !!this.taskRows.find((tr) => tr.parentNoteId === parentNoteId); | ||||
|     } | ||||
|  | ||||
|     addOption(name: string) { | ||||
|         this.optionNames.push(name); | ||||
|     } | ||||
| @@ -199,6 +211,14 @@ export default class LoadResults { | ||||
|         return this.attachmentRows; | ||||
|     } | ||||
|  | ||||
|     addTaskRow(task: TaskRow) { | ||||
|         this.taskRows.push(task); | ||||
|     } | ||||
|  | ||||
|     getTaskRows() { | ||||
|         return this.taskRows; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {boolean} true if there are changes which could affect the attributes (including inherited ones) | ||||
|      *          notably changes in note itself should not have any effect on attributes | ||||
| @@ -216,7 +236,8 @@ export default class LoadResults { | ||||
|             this.revisionRows.length === 0 && | ||||
|             this.contentNoteIdToComponentId.length === 0 && | ||||
|             this.optionNames.length === 0 && | ||||
|             this.attachmentRows.length === 0 | ||||
|             this.attachmentRows.length === 0 && | ||||
|             this.taskRows.length === 0 | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -20,6 +20,7 @@ async function getNoteTypeItems(command?: NoteTypeCommandNames) { | ||||
|         { title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" }, | ||||
|         { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" }, | ||||
|         { title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" }, | ||||
|         { title: t("note_types.task-list"), command, type: "taskList", uiIcon: "bx bx-list-check" } | ||||
|     ]; | ||||
|  | ||||
|     const templateNoteIds = await server.get<string[]>("search-templates"); | ||||
|   | ||||
							
								
								
									
										17
									
								
								src/public/app/services/tasks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/public/app/services/tasks.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import server from "./server.js"; | ||||
|  | ||||
| interface CreateNewTasksOpts { | ||||
|     parentNoteId: string; | ||||
|     title: string; | ||||
| } | ||||
|  | ||||
| export async function createNewTask({ parentNoteId, title }: CreateNewTasksOpts) { | ||||
|     await server.post(`tasks`, { | ||||
|         parentNoteId, | ||||
|         title | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export async function toggleTaskDone(taskId: string) { | ||||
|     await server.post(`tasks/${taskId}/toggle`); | ||||
| } | ||||
| @@ -27,7 +27,8 @@ const byNoteType: Record<Exclude<NoteType, "book">, string | null> = { | ||||
|     render: null, | ||||
|     search: null, | ||||
|     text: null, | ||||
|     webView: null | ||||
|     webView: null, | ||||
|     taskList: null | ||||
| }; | ||||
|  | ||||
| const byBookType: Record<ViewTypeOptions, string | null> = { | ||||
|   | ||||
| @@ -35,6 +35,7 @@ import GeoMapTypeWidget from "./type_widgets/geo_map.js"; | ||||
| import utils from "../services/utils.js"; | ||||
| import type { NoteType } from "../entities/fnote.js"; | ||||
| import type TypeWidget from "./type_widgets/type_widget.js"; | ||||
| import TaskListWidget from "./type_widgets/task_list.js"; | ||||
|  | ||||
| const TPL = ` | ||||
| <div class="note-detail"> | ||||
| @@ -72,7 +73,8 @@ const typeWidgetClasses = { | ||||
|     attachmentDetail: AttachmentDetailTypeWidget, | ||||
|     attachmentList: AttachmentListTypeWidget, | ||||
|     mindMap: MindMapWidget, | ||||
|     geoMap: GeoMapTypeWidget | ||||
|     geoMap: GeoMapTypeWidget, | ||||
|     taskList: TaskListWidget | ||||
| }; | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -48,7 +48,8 @@ const NOTE_TYPES: NoteTypeMapping[] = [ | ||||
|     { type: "image", title: t("note_types.image"), selectable: false }, | ||||
|     { type: "launcher", mime: "", title: t("note_types.launcher"), selectable: false }, | ||||
|     { type: "noteMap", mime: "", title: t("note_types.note-map"), selectable: false }, | ||||
|     { type: "search", title: t("note_types.saved-search"), selectable: false } | ||||
|     { type: "search", title: t("note_types.saved-search"), selectable: false }, | ||||
|     { type: "taskList", title: t("note_types.task-list"), selectable: false } | ||||
| ]; | ||||
|  | ||||
| const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => !nt.selectable).map((nt) => nt.type); | ||||
|   | ||||
							
								
								
									
										129
									
								
								src/public/app/widgets/type_widgets/task_list.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/public/app/widgets/type_widgets/task_list.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | ||||
| import type FNote from "../../entities/fnote.js"; | ||||
| import type FTask from "../../entities/ftask.js"; | ||||
| import froca from "../../services/froca.js"; | ||||
| import TypeWidget from "./type_widget.js"; | ||||
| import * as taskService from "../../services/tasks.js"; | ||||
| import type { EventData } from "../../components/app_context.js"; | ||||
|  | ||||
| const TPL = ` | ||||
| <div class="note-detail-task-list note-detail-printable"> | ||||
|  | ||||
|     <header> | ||||
|         <input type="text" placeholder="Add a new task" class="add-new-task" /> | ||||
|     </header> | ||||
|  | ||||
|     <ol class="task-container"> | ||||
|     </ol> | ||||
|  | ||||
|     <style> | ||||
|         .note-detail-task-list { | ||||
|             height: 100%; | ||||
|             contain: none; | ||||
|             padding: 10px; | ||||
|         } | ||||
|  | ||||
|         .note-detail-task-list header { | ||||
|             position: sticky; | ||||
|             top: 0; | ||||
|             z-index: 100; | ||||
|             margin: 0; | ||||
|             padding: 0.5em 0; | ||||
|             background-color: var(--main-background-color); | ||||
|         } | ||||
|  | ||||
|         .note-detail-task-list .add-new-task { | ||||
|             width: 100%; | ||||
|             padding: 0.25em 0.5em; | ||||
|         } | ||||
|  | ||||
|         .note-detail-task-list .task-container { | ||||
|             list-style-type: none; | ||||
|             margin: 0; | ||||
|             padding: 0; | ||||
|             border-radius: var(--bs-border-radius); | ||||
|             overflow: hidden; | ||||
|         } | ||||
|  | ||||
|         .note-detail-task-list .task-container li { | ||||
|             background: var(--input-background-color); | ||||
|             border-bottom: 1px solid var(--main-background-color); | ||||
|             padding: 0.5em 1em; | ||||
|         } | ||||
|  | ||||
|         .note-detail-task-list .task-container li .check { | ||||
|             margin-right: 0.5em; | ||||
|         } | ||||
|     </style> | ||||
| </div> | ||||
| `; | ||||
|  | ||||
| function buildTask(task: FTask) { | ||||
|     return `\ | ||||
| <li class="task"> | ||||
|     <input type="checkbox" class="check" data-task-id="${task.taskId}" ${task.isDone ? "checked" : ""} /> ${task.title} | ||||
| </li>`; | ||||
| } | ||||
|  | ||||
| export default class TaskListWidget extends TypeWidget { | ||||
|  | ||||
|     private $taskContainer!: JQuery<HTMLElement>; | ||||
|     private $addNewTask!: JQuery<HTMLElement>; | ||||
|  | ||||
|     static getType() { return "taskList" } | ||||
|  | ||||
|     doRender() { | ||||
|         this.$widget = $(TPL); | ||||
|         this.$addNewTask = this.$widget.find(".add-new-task"); | ||||
|         this.$taskContainer = this.$widget.find(".task-container"); | ||||
|  | ||||
|         this.$addNewTask.on("keydown", (e) => { | ||||
|             if (e.key === "Enter") { | ||||
|                 this.#createNewTask(String(this.$addNewTask.val())); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         this.$taskContainer.on("change", "input", (e) => { | ||||
|             const target = e.target as HTMLInputElement; | ||||
|             const taskId = target.dataset.taskId; | ||||
|  | ||||
|             if (!taskId) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             taskService.toggleTaskDone(taskId); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async #createNewTask(title: string) { | ||||
|         if (!title || !this.noteId) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await taskService.createNewTask({ | ||||
|             title, | ||||
|             parentNoteId: this.noteId | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async doRefresh(note: FNote) { | ||||
|         this.$widget.show(); | ||||
|  | ||||
|         if (!this.note || !this.noteId) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.$taskContainer.html(""); | ||||
|  | ||||
|         const tasks = await froca.getTasks(this.noteId); | ||||
|         for (const task of tasks) { | ||||
|             this.$taskContainer.append($(buildTask(task))); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||
|         if (this.noteId && loadResults.isTaskListReloaded(this.noteId)) { | ||||
|             this.refresh(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1418,7 +1418,8 @@ | ||||
|     "widget": "Widget", | ||||
|     "confirm-change": "It is not recommended to change note type when note content is not empty. Do you want to continue anyway?", | ||||
|     "geo-map": "Geo Map", | ||||
|     "beta-feature": "Beta" | ||||
|     "beta-feature": "Beta", | ||||
|     "task-list": "To-Do List" | ||||
|   }, | ||||
|   "protect_note": { | ||||
|     "toggle-on": "Protect the note", | ||||
|   | ||||
							
								
								
									
										20
									
								
								src/routes/api/tasks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/routes/api/tasks.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import type { Request } from "express"; | ||||
| import * as tasksService from "../../services/tasks.js"; | ||||
|  | ||||
| export function getTasks(req: Request) { | ||||
|     const { parentNoteId } = req.params; | ||||
|     return tasksService.getTasks(parentNoteId); | ||||
| } | ||||
|  | ||||
| export function createNewTask(req: Request) { | ||||
|     return tasksService.createNewTask(req.body); | ||||
| } | ||||
|  | ||||
| export function toggleTaskDone(req: Request) { | ||||
|     const { taskId } = req.params; | ||||
|     if (!taskId) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     return tasksService.toggleTaskDone(taskId); | ||||
| } | ||||
| @@ -72,6 +72,7 @@ import etapiSpecRoute from "../etapi/spec.js"; | ||||
| import etapiBackupRoute from "../etapi/backup.js"; | ||||
|  | ||||
| import apiDocsRoute from "./api_docs.js"; | ||||
| import * as tasksRoute from "./api/tasks.js"; | ||||
|  | ||||
| const MAX_ALLOWED_FILE_SIZE_MB = 250; | ||||
| const GET = "get", | ||||
| @@ -279,6 +280,10 @@ function register(app: express.Application) { | ||||
|     apiRoute(PATCH, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.patchToken); | ||||
|     apiRoute(DEL, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.deleteToken); | ||||
|  | ||||
|     apiRoute(GET, "/api/tasks/:parentNoteId", tasksRoute.getTasks); | ||||
|     apiRoute(PST, "/api/tasks", tasksRoute.createNewTask); | ||||
|     apiRoute(PST, "/api/tasks/:taskId/toggle", tasksRoute.toggleTaskDone); | ||||
|  | ||||
|     // in case of local electron, local calls are allowed unauthenticated, for server they need auth | ||||
|     const clipperMiddleware = isElectron ? [] : [auth.checkEtapiToken]; | ||||
|  | ||||
|   | ||||
| @@ -5,8 +5,8 @@ import build from "./build.js"; | ||||
| import packageJson from "../../package.json" with { type: "json" }; | ||||
| import dataDir from "./data_dir.js"; | ||||
|  | ||||
| const APP_DB_VERSION = 228; | ||||
| const SYNC_VERSION = 34; | ||||
| const APP_DB_VERSION = 229; | ||||
| const SYNC_VERSION = 35; | ||||
| const CLIPPER_PROTOCOL_VERSION = "1.0"; | ||||
|  | ||||
| export default { | ||||
|   | ||||
| @@ -888,7 +888,7 @@ class ConsistencyChecks { | ||||
|             return `${tableName}: ${count}`; | ||||
|         } | ||||
|  | ||||
|         const tables = ["notes", "revisions", "attachments", "branches", "attributes", "etapi_tokens", "blobs"]; | ||||
|         const tables = ["notes", "revisions", "attachments", "branches", "attributes", "etapi_tokens", "blobs", "tasks"]; | ||||
|  | ||||
|         log.info(`Table counts: ${tables.map((tableName) => getTableRowCount(tableName)).join(", ")}`); | ||||
|     } | ||||
|   | ||||
| @@ -15,7 +15,8 @@ const noteTypes = [ | ||||
|     { type: "doc", defaultMime: "" }, | ||||
|     { type: "contentWidget", defaultMime: "" }, | ||||
|     { type: "mindMap", defaultMime: "application/json" }, | ||||
|     { type: "geoMap", defaultMime: "application/json" } | ||||
|     { type: "geoMap", defaultMime: "application/json" }, | ||||
|     { type: "taskList", defaultMime: "" } | ||||
| ]; | ||||
|  | ||||
| function getDefaultMimeForNoteType(typeName: string) { | ||||
|   | ||||
							
								
								
									
										28
									
								
								src/services/tasks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/services/tasks.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import becca from "../becca/becca.js"; | ||||
| import BTask from "../becca/entities/btask.js"; | ||||
|  | ||||
| export function getTasks(parentNoteId: string) { | ||||
|     return becca.getTasks() | ||||
|         .filter((task) => task.parentNoteId === parentNoteId && !task.isDone); | ||||
| } | ||||
|  | ||||
| interface CreateTaskParams { | ||||
|     parentNoteId: string; | ||||
|     title: string; | ||||
|     dueDate?: string; | ||||
| } | ||||
|  | ||||
| export function createNewTask(params: CreateTaskParams) { | ||||
|     const task = new BTask(params); | ||||
|     task.save(); | ||||
|  | ||||
|     return { | ||||
|         task | ||||
|     } | ||||
| } | ||||
|  | ||||
| export function toggleTaskDone(taskId: string) { | ||||
|     const task = becca.tasks[taskId]; | ||||
|     task.isDone = !task.isDone; | ||||
|     task.save(); | ||||
| } | ||||
| @@ -188,6 +188,12 @@ function fillInAdditionalProperties(entityChange: EntityChange) { | ||||
|                                                 WHERE attachmentId = ?`, | ||||
|             [entityChange.entityId] | ||||
|         ); | ||||
|     } else if (entityChange.entityName === "tasks") { | ||||
|         entityChange.entity = becca.getTask(entityChange.entity); | ||||
|  | ||||
|         if (!entityChange.entity) { | ||||
|             entityChange.entity = sql.getRow(`SELECT * FROM tasks WHERE taskId = ?`, [entityChange.entityId]); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (entityChange.entity instanceof AbstractBeccaEntity) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user