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",
"internalConsoleOptions": "neverOpen",
"name": "nodemon start-server",
"program": "${workspaceFolder}/src/www",
"name": "nodemon server:start",
"program": "${workspaceFolder}/src/main",
"request": "launch",
"restart": true,
"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_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
);

View File

@ -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;

View File

@ -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) {

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

View File

@ -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;

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 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

View File

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

View File

@ -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
);
}

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.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");

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,
search: null,
text: null,
webView: null
webView: null,
taskList: 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 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
};
/**

View File

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

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",
"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
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 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];

View File

@ -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 {

View File

@ -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(", ")}`);
}

View File

@ -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
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 = ?`,
[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) {