Merge pull request #1234 from TriliumNext/feature/task_list

Task List
This commit is contained in:
Elian Doran 2025-02-22 14:37:44 +02:00 committed by GitHub
commit 58a8821c22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 467 additions and 15 deletions

4
.vscode/launch.json vendored
View File

@ -5,8 +5,8 @@
{ {
"console": "integratedTerminal", "console": "integratedTerminal",
"internalConsoleOptions": "neverOpen", "internalConsoleOptions": "neverOpen",
"name": "nodemon start-server", "name": "nodemon server:start",
"program": "${workspaceFolder}/src/www", "program": "${workspaceFolder}/src/main",
"request": "launch", "request": "launch",
"restart": true, "restart": true,
"runtimeExecutable": "nodemon", "runtimeExecutable": "nodemon",

View 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
);

View File

@ -132,3 +132,14 @@ CREATE INDEX IDX_attachments_ownerId_role
CREATE INDEX IDX_notes_blobId on notes (blobId); CREATE INDEX IDX_notes_blobId on notes (blobId);
CREATE INDEX IDX_revisions_blobId on revisions (blobId); CREATE INDEX IDX_revisions_blobId on revisions (blobId);
CREATE INDEX IDX_attachments_blobId on attachments (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
);

View File

@ -12,6 +12,7 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "./entities/rows.js";
import BBlob from "./entities/bblob.js"; import BBlob from "./entities/bblob.js";
import BRecentNote from "./entities/brecent_note.js"; import BRecentNote from "./entities/brecent_note.js";
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
import type BTask from "./entities/btask.js";
interface AttachmentOpts { interface AttachmentOpts {
includeContentLength?: boolean; includeContentLength?: boolean;
@ -32,6 +33,7 @@ export default class Becca {
attributeIndex!: Record<string, BAttribute[]>; attributeIndex!: Record<string, BAttribute[]>;
options!: Record<string, BOption>; options!: Record<string, BOption>;
etapiTokens!: Record<string, BEtapiToken>; etapiTokens!: Record<string, BEtapiToken>;
tasks!: Record<string, BTask>;
allNoteSetCache: NoteSet | null; allNoteSetCache: NoteSet | null;
@ -48,6 +50,7 @@ export default class Becca {
this.attributeIndex = {}; this.attributeIndex = {};
this.options = {}; this.options = {};
this.etapiTokens = {}; this.etapiTokens = {};
this.tasks = {};
this.dirtyNoteSetCache(); this.dirtyNoteSetCache();
@ -213,6 +216,14 @@ export default class Becca {
return this.etapiTokens[etapiTokenId]; 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 { getEntity<T extends AbstractBeccaEntity<T>>(entityName: string, entityId: string): AbstractBeccaEntity<T> | null {
if (!entityName || !entityId) { if (!entityName || !entityId) {
return null; return null;

View File

@ -11,9 +11,10 @@ import BOption from "./entities/boption.js";
import BEtapiToken from "./entities/betapi_token.js"; import BEtapiToken from "./entities/betapi_token.js";
import cls from "../services/cls.js"; import cls from "../services/cls.js";
import entityConstructor from "../becca/entity_constructor.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 type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
import ws from "../services/ws.js"; import ws from "../services/ws.js";
import BTask from "./entities/btask.js";
const beccaLoaded = new Promise<void>(async (res, rej) => { const beccaLoaded = new Promise<void>(async (res, rej) => {
const sqlInit = (await import("../services/sql_init.js")).default; 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`)) { for (const row of sql.getRows<EtapiTokenRow>(`SELECT etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified FROM etapi_tokens WHERE isDeleted = 0`)) {
new BEtapiToken(row); 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) { for (const noteId in becca.notes) {

View 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
};
}
}

View File

@ -136,3 +136,13 @@ export interface NoteRow {
utcDateModified: string; utcDateModified: string;
content?: string | Buffer; content?: string | Buffer;
} }
export interface TaskRow {
taskId?: string;
parentNoteId: string;
title: string;
dueDate?: string;
isDone?: boolean;
isDeleted?: boolean;
utcDateModified?: string;
}

View File

@ -9,6 +9,7 @@ import BNote from "./entities/bnote.js";
import BOption from "./entities/boption.js"; import BOption from "./entities/boption.js";
import BRecentNote from "./entities/brecent_note.js"; import BRecentNote from "./entities/brecent_note.js";
import BRevision from "./entities/brevision.js"; import BRevision from "./entities/brevision.js";
import BTask from "./entities/btask.js";
type EntityClass = new (row?: any) => AbstractBeccaEntity<any>; type EntityClass = new (row?: any) => AbstractBeccaEntity<any>;
@ -21,7 +22,8 @@ const ENTITY_NAME_TO_ENTITY: Record<string, ConstructorData<any> & EntityClass>
notes: BNote, notes: BNote,
options: BOption, options: BOption,
recent_notes: BRecentNote, recent_notes: BRecentNote,
revisions: BRevision revisions: BRevision,
tasks: BTask
}; };
function getEntityFromEntityName(entityName: keyof typeof ENTITY_NAME_TO_ENTITY) { function getEntityFromEntityName(entityName: keyof typeof ENTITY_NAME_TO_ENTITY) {

View File

@ -28,7 +28,8 @@ const NOTE_TYPE_ICONS = {
doc: "bx bxs-file-doc", doc: "bx bxs-file-doc",
contentWidget: "bx bxs-widget", contentWidget: "bx bxs-widget",
mindMap: "bx bx-sitemap", 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 * end user. Those types should be used only for checking against, they are
* not for direct use. * 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 { export interface NotePathRecord {
isArchived: boolean; isArchived: boolean;

View 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;
}
}

View File

@ -6,6 +6,8 @@ import appContext from "../components/app_context.js";
import FBlob, { type FBlobRow } from "../entities/fblob.js"; import FBlob, { type FBlobRow } from "../entities/fblob.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js"; import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import type { Froca } from "./froca-interface.js"; import type { Froca } from "./froca-interface.js";
import FTask from "../entities/ftask.js";
import type { FTaskRow } from "../entities/ftask.js";
interface SubtreeResponse { interface SubtreeResponse {
notes: FNoteRow[]; notes: FNoteRow[];
@ -37,6 +39,7 @@ class FrocaImpl implements Froca {
attributes!: Record<string, FAttribute>; attributes!: Record<string, FAttribute>;
attachments!: Record<string, FAttachment>; attachments!: Record<string, FAttachment>;
blobPromises!: Record<string, Promise<void | FBlob> | null>; blobPromises!: Record<string, Promise<void | FBlob> | null>;
tasks!: Record<string, FTask>;
constructor() { constructor() {
this.initializedPromise = this.loadInitialTree(); this.initializedPromise = this.loadInitialTree();
@ -52,6 +55,7 @@ class FrocaImpl implements Froca {
this.attributes = {}; this.attributes = {};
this.attachments = {}; this.attachments = {};
this.blobPromises = {}; this.blobPromises = {};
this.tasks = {};
this.addResp(resp); 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) { async getBlob(entityType: string, entityId: string) {
// I'm not sure why we're not using blobIds directly, it would save us this composite key ... // 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 // perhaps one benefit is that we're always requesting the latest blob, not relying on perhaps faulty/slow

View File

@ -8,6 +8,8 @@ import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js"; import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import type { default as FNote, FNoteRow } from "../entities/fnote.js"; import type { default as FNote, FNoteRow } from "../entities/fnote.js";
import type { EntityChange } from "../server_types.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[]) { async function processEntityChanges(entityChanges: EntityChange[]) {
const loadResults = new LoadResults(entityChanges); const loadResults = new LoadResults(entityChanges);
@ -37,6 +39,8 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
processAttachment(loadResults, ec); processAttachment(loadResults, ec);
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") { } else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
// NOOP // NOOP
} else if (ec.entityName === "tasks") {
processTaskChange(loadResults, ec);
} else { } else {
throw new Error(`Unknown entityName '${ec.entityName}'`); throw new Error(`Unknown entityName '${ec.entityName}'`);
} }
@ -306,6 +310,35 @@ function processAttachment(loadResults: LoadResults, ec: EntityChange) {
loadResults.addAttachmentRow(attachmentEntity); 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 { export default {
processEntityChanges processEntityChanges
}; };

View File

@ -1,3 +1,4 @@
import type { TaskRow } from "../../../becca/entities/rows.js";
import type { AttributeType } from "../entities/fattribute.js"; import type { AttributeType } from "../entities/fattribute.js";
import type { EntityChange } from "../server_types.js"; import type { EntityChange } from "../server_types.js";
@ -69,6 +70,7 @@ export default class LoadResults {
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[]; private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
private optionNames: string[]; private optionNames: string[];
private attachmentRows: AttachmentRow[]; private attachmentRows: AttachmentRow[];
private taskRows: TaskRow[];
constructor(entityChanges: EntityChange[]) { constructor(entityChanges: EntityChange[]) {
const entities: Record<string, Record<string, any>> = {}; const entities: Record<string, Record<string, any>> = {};
@ -97,6 +99,8 @@ export default class LoadResults {
this.optionNames = []; this.optionNames = [];
this.attachmentRows = []; this.attachmentRows = [];
this.taskRows = [];
} }
getEntityRow<T extends EntityRowNames>(entityName: T, entityId: string): EntityRowMappings[T] { 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); 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) { addOption(name: string) {
this.optionNames.push(name); this.optionNames.push(name);
} }
@ -199,6 +211,14 @@ export default class LoadResults {
return this.attachmentRows; 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) * @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 * 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.revisionRows.length === 0 &&
this.contentNoteIdToComponentId.length === 0 && this.contentNoteIdToComponentId.length === 0 &&
this.optionNames.length === 0 && this.optionNames.length === 0 &&
this.attachmentRows.length === 0 this.attachmentRows.length === 0 &&
this.taskRows.length === 0
); );
} }

View File

@ -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.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.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.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"); const templateNoteIds = await server.get<string[]>("search-templates");

View 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`);
}

View File

@ -27,7 +27,8 @@ const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
render: null, render: null,
search: null, search: null,
text: null, text: null,
webView: null webView: null,
taskList: null
}; };
const byBookType: Record<ViewTypeOptions, string | null> = { const byBookType: Record<ViewTypeOptions, string | null> = {

View File

@ -35,6 +35,7 @@ import GeoMapTypeWidget from "./type_widgets/geo_map.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import type { NoteType } from "../entities/fnote.js"; import type { NoteType } from "../entities/fnote.js";
import type TypeWidget from "./type_widgets/type_widget.js"; import type TypeWidget from "./type_widgets/type_widget.js";
import TaskListWidget from "./type_widgets/task_list.js";
const TPL = ` const TPL = `
<div class="note-detail"> <div class="note-detail">
@ -72,7 +73,8 @@ const typeWidgetClasses = {
attachmentDetail: AttachmentDetailTypeWidget, attachmentDetail: AttachmentDetailTypeWidget,
attachmentList: AttachmentListTypeWidget, attachmentList: AttachmentListTypeWidget,
mindMap: MindMapWidget, mindMap: MindMapWidget,
geoMap: GeoMapTypeWidget geoMap: GeoMapTypeWidget,
taskList: TaskListWidget
}; };
/** /**

View File

@ -48,7 +48,8 @@ const NOTE_TYPES: NoteTypeMapping[] = [
{ type: "image", title: t("note_types.image"), selectable: false }, { type: "image", title: t("note_types.image"), selectable: false },
{ type: "launcher", mime: "", title: t("note_types.launcher"), selectable: false }, { type: "launcher", mime: "", title: t("note_types.launcher"), selectable: false },
{ type: "noteMap", mime: "", title: t("note_types.note-map"), 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); const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => !nt.selectable).map((nt) => nt.type);

View 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();
}
}
}

View File

@ -1418,7 +1418,8 @@
"widget": "Widget", "widget": "Widget",
"confirm-change": "It is not recommended to change note type when note content is not empty. Do you want to continue anyway?", "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", "geo-map": "Geo Map",
"beta-feature": "Beta" "beta-feature": "Beta",
"task-list": "To-Do List"
}, },
"protect_note": { "protect_note": {
"toggle-on": "Protect the note", "toggle-on": "Protect the note",

20
src/routes/api/tasks.ts Normal file
View 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);
}

View File

@ -72,6 +72,7 @@ import etapiSpecRoute from "../etapi/spec.js";
import etapiBackupRoute from "../etapi/backup.js"; import etapiBackupRoute from "../etapi/backup.js";
import apiDocsRoute from "./api_docs.js"; import apiDocsRoute from "./api_docs.js";
import * as tasksRoute from "./api/tasks.js";
const MAX_ALLOWED_FILE_SIZE_MB = 250; const MAX_ALLOWED_FILE_SIZE_MB = 250;
const GET = "get", const GET = "get",
@ -279,6 +280,10 @@ function register(app: express.Application) {
apiRoute(PATCH, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.patchToken); apiRoute(PATCH, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.patchToken);
apiRoute(DEL, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.deleteToken); 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 // in case of local electron, local calls are allowed unauthenticated, for server they need auth
const clipperMiddleware = isElectron ? [] : [auth.checkEtapiToken]; const clipperMiddleware = isElectron ? [] : [auth.checkEtapiToken];

View File

@ -5,8 +5,8 @@ import build from "./build.js";
import packageJson from "../../package.json" with { type: "json" }; import packageJson from "../../package.json" with { type: "json" };
import dataDir from "./data_dir.js"; import dataDir from "./data_dir.js";
const APP_DB_VERSION = 228; const APP_DB_VERSION = 229;
const SYNC_VERSION = 34; const SYNC_VERSION = 35;
const CLIPPER_PROTOCOL_VERSION = "1.0"; const CLIPPER_PROTOCOL_VERSION = "1.0";
export default { export default {

View File

@ -888,7 +888,7 @@ class ConsistencyChecks {
return `${tableName}: ${count}`; 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(", ")}`); log.info(`Table counts: ${tables.map((tableName) => getTableRowCount(tableName)).join(", ")}`);
} }

View File

@ -15,7 +15,8 @@ const noteTypes = [
{ type: "doc", defaultMime: "" }, { type: "doc", defaultMime: "" },
{ type: "contentWidget", defaultMime: "" }, { type: "contentWidget", defaultMime: "" },
{ type: "mindMap", defaultMime: "application/json" }, { type: "mindMap", defaultMime: "application/json" },
{ type: "geoMap", defaultMime: "application/json" } { type: "geoMap", defaultMime: "application/json" },
{ type: "taskList", defaultMime: "" }
]; ];
function getDefaultMimeForNoteType(typeName: string) { function getDefaultMimeForNoteType(typeName: string) {

28
src/services/tasks.ts Normal file
View 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();
}

View File

@ -188,6 +188,12 @@ function fillInAdditionalProperties(entityChange: EntityChange) {
WHERE attachmentId = ?`, WHERE attachmentId = ?`,
[entityChange.entityId] [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) { if (entityChange.entity instanceof AbstractBeccaEntity) {