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",
|
"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",
|
||||||
|
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_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
|
||||||
|
);
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
|
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;
|
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;
|
||||||
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
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 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
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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");
|
||||||
|
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,
|
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> = {
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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);
|
||||||
|
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",
|
"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
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 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];
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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(", ")}`);
|
||||||
}
|
}
|
||||||
|
@ -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
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 = ?`,
|
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) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user