mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-27 18:12:29 +08:00
commit
58a8821c22
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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user