aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbobzel <zzzman@gmail.com>2025-06-19 15:34:13 -0400
committerbobzel <zzzman@gmail.com>2025-06-19 15:34:13 -0400
commit21fb3a555cdd861452e2e54de3d0524e29f2bc08 (patch)
tree44b93ba0b4f9a85dafdb744ccb11dfe3386f4829
parentabdfe5b109f42cd5da4aceef1bd667c8e1f6cbbd (diff)
parent765c7a3870a1d446622a28b157ab00a4dced2879 (diff)
Merge branch 'task_nodes_aarav'
-rw-r--r--src/client/apis/GoogleAuthenticationManager.tsx84
-rw-r--r--src/client/documents/Documents.ts27
-rw-r--r--src/client/util/SettingsManager.tsx2
-rw-r--r--src/client/views/MainViewModal.tsx2
-rw-r--r--src/client/views/nodes/TaskBox.scss66
-rw-r--r--src/client/views/nodes/TaskBox.tsx578
-rw-r--r--src/client/views/nodes/calendarBox/CalendarBox.scss9
-rw-r--r--src/client/views/nodes/calendarBox/CalendarBox.tsx99
-rw-r--r--src/server/ApiManagers/FireflyManager.ts11
-rw-r--r--src/server/ApiManagers/GeneralGoogleManager.ts135
-rw-r--r--src/server/RouteManager.ts8
-rw-r--r--src/server/apis/google/GoogleApiServerUtils.ts11
-rw-r--r--src/server/apis/google/google_project_credentials.json8
-rw-r--r--src/server/authentication/DashUserModel.ts14
-rw-r--r--src/server/authentication/Passport.ts6
15 files changed, 861 insertions, 199 deletions
diff --git a/src/client/apis/GoogleAuthenticationManager.tsx b/src/client/apis/GoogleAuthenticationManager.tsx
index 94ce42d8d..67a6e01e9 100644
--- a/src/client/apis/GoogleAuthenticationManager.tsx
+++ b/src/client/apis/GoogleAuthenticationManager.tsx
@@ -1,4 +1,4 @@
-import { action, IReactionDisposer, observable, reaction, runInAction } from 'mobx';
+import { action, IReactionDisposer, makeObservable, observable, runInAction } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
import { Opt } from '../../fields/Doc';
@@ -6,12 +6,12 @@ import { Networking } from '../Network';
import { ScriptingGlobals } from '../util/ScriptingGlobals';
import { MainViewModal } from '../views/MainViewModal';
import './GoogleAuthenticationManager.scss';
+import { ObservableReactComponent } from '../views/ObservableReactComponent';
-const AuthenticationUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const prompt = 'Paste authorization code here...';
@observer
-export class GoogleAuthenticationManager extends React.Component<object> {
+export class GoogleAuthenticationManager extends ObservableReactComponent<object> {
// eslint-disable-next-line no-use-before-define
public static Instance: GoogleAuthenticationManager;
private authenticationLink: Opt<string> = undefined;
@@ -23,6 +23,12 @@ export class GoogleAuthenticationManager extends React.Component<object> {
@observable private credentials: { user_info: { name: string; picture: string }; access_token: string } | undefined = undefined;
private disposer: Opt<IReactionDisposer>;
+ constructor(props: object) {
+ super(props);
+ makeObservable(this);
+ GoogleAuthenticationManager.Instance = this;
+ }
+
private set isOpen(value: boolean) {
runInAction(() => (this.openState = value));
}
@@ -35,44 +41,51 @@ export class GoogleAuthenticationManager extends React.Component<object> {
this.openState && this.resetState(0, 0);
}
- public fetchOrGenerateAccessToken = async (displayIfFound = false) => {
+ public fetchOrGenerateAccessToken = async (): Promise<string | undefined> => {
const response = await Networking.FetchFromServer('/readGoogleAccessToken');
- // if this is an authentication url, activate the UI to register the new access token
- if (new RegExp(AuthenticationUrl).test(response)) {
- this.isOpen = true;
- this.authenticationLink = response;
- return new Promise<string>(resolve => {
- this.disposer?.();
- this.disposer = reaction(
- () => this.authenticationCode,
- async authenticationCode => {
- if (authenticationCode && /\d{1}\/[\w-]{55}/.test(authenticationCode)) {
- this.disposer?.();
- const response2 = await Networking.PostToServer('/writeGoogleAccessToken', { authenticationCode });
- runInAction(() => {
- this.success = true;
- this.credentials = response2 as { user_info: { name: string; picture: string }; access_token: string };
- });
- this.resetState();
- resolve((response2 as { access_token: string }).access_token);
- }
- }
- );
+
+ // This will return a JSON object with { access_token, user_info } if already linked
+ try {
+ const parsed = JSON.parse(response) as { access_token: string; user_info: { name: string; picture: string } };
+
+ runInAction(() => {
+ this.success = true;
+ this.credentials = parsed;
});
+
+ return parsed.access_token;
+ } catch {
+ console.warn('Not linked yet or invalid JSON. open auth...');
+ // This is an auth URL — redirect the user to /refreshGoogle
+ if (typeof response === 'string' && response.startsWith('http')) {
+ if (window.confirm('Authorize Dash to access your Google account?')) {
+ window.open(response)?.focus();
+ return undefined;
+ }
+ }
+
+ throw new Error('Unable to fetch Google access token.');
}
+ };
- // otherwise, we already have a valid, stored access token and user info
- const response2 = JSON.parse(response) as { user_info: { name: string; picture: string }; access_token: string };
- if (displayIfFound) {
+ public fetchAccessTokenSilently = async (): Promise<string | undefined> => {
+ const response = await Networking.FetchFromServer('/readGoogleAccessToken');
+
+ try {
+ const parsed = JSON.parse(response) as { access_token: string; user_info: { name: string; picture: string } };
+
runInAction(() => {
this.success = true;
- this.credentials = response2;
+ this.credentials = parsed;
});
- this.resetState(-1, -1);
- this.isOpen = true;
+
+ return parsed.access_token;
+ } catch {
+ // Do nothing — just return undefined silently
+ return undefined;
}
- return (response2 as { access_token: string }).access_token;
};
+
resetState = action((visibleForMS: number = 3000, fadesOutInMS: number = 500) => {
if (!visibleForMS && !fadesOutInMS) {
@@ -107,11 +120,6 @@ export class GoogleAuthenticationManager extends React.Component<object> {
}
});
- constructor(props: object) {
- super(props);
- GoogleAuthenticationManager.Instance = this;
- }
-
private get renderPrompt() {
return (
<div className={'authorize-container'}>
@@ -127,7 +135,7 @@ export class GoogleAuthenticationManager extends React.Component<object> {
</button>
) : null}
{this.showPasteTargetState ? <input className={'paste-target'} onChange={action(e => (this.authenticationCode = e.currentTarget.value))} placeholder={prompt} /> : null}
- {this.credentials ? (
+ {this.credentials?.user_info?.picture ? (
<>
<img className={'avatar'} src={this.credentials.user_info.picture} />
<span className={'welcome'}>Welcome to Dash, {this.credentials.user_info.name}</span>
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index aea636040..2289224cc 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -531,6 +531,9 @@ export class DocumentOptions {
$task_endTime?: DateInfo | DateField = new DateInfo('end date and time', /*filterable*/ false);
$task_allDay?: BoolInfo | boolean = new BoolInfo('whether task is all-day or not', /*filterable*/ false);
$task_completed?: BoolInfo | boolean = new BoolInfo('whether the task is completed', /*filterable*/ false);
+ $googleTaskId?: STRt = new StrInfo('Google Task ID for syncing');
+ $task_lastSyncedAt?: STRt = new StrInfo('last update time for task node');
+ $task_deleted?: BoolInfo | boolean = new BoolInfo('whether task is deleted or not', /*filterable*/ false);
_calendar_date?: DateInfo | DateField = new DateInfo('current selected date of a calendar', /*filterable*/ false);
_calendar_dateRange?: STRt = new StrInfo('date range shown on a calendar', false);
@@ -593,30 +596,6 @@ export namespace Docs {
options: { acl: '' },
},
],
-
- // AARAV ADD //
- [
- DocumentType.JOURNAL,
- {
- layout: { view: EmptyBox, dataField: 'text' },
- options: {
- title: 'Daily Journal',
- acl_Guest: SharingPermissions.View,
- },
- },
- ],
-
- [
- DocumentType.TASK,
- {
- layout: { view: EmptyBox, dataField: 'text' },
- options: {
- title: 'Task',
- acl_Guest: SharingPermissions.View,
- },
- },
- ],
- // AARAV ADD //
]);
const suffix = 'Proto';
diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx
index 9e79fd870..f54dea90c 100644
--- a/src/client/util/SettingsManager.tsx
+++ b/src/client/util/SettingsManager.tsx
@@ -40,7 +40,7 @@ export class SettingsManager extends React.Component<object> {
@observable private _activeTab = 'Accounts';
@observable private _isOpen = false;
- private googleAuthorize = action(() => GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(true));
+ private googleAuthorize = action(() => GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken());
public closeMgr = action(() => {
this._isOpen = false;
diff --git a/src/client/views/MainViewModal.tsx b/src/client/views/MainViewModal.tsx
index b05292c47..d7640dc72 100644
--- a/src/client/views/MainViewModal.tsx
+++ b/src/client/views/MainViewModal.tsx
@@ -41,7 +41,7 @@ export class MainViewModal extends React.Component<MainViewOverlayProps> {
className="overlay"
onClick={this.props?.closeOnExternalClick}
style={{
- backgroundColor: isDark(SnappingManager.userColor) ? '#DFDFDF30' : '#32323230',
+ backgroundColor: isDark(SnappingManager.userColor ?? '') ? '#DFDFDF30' : '#32323230',
...(p.overlayStyle || {}),
}}
/>
diff --git a/src/client/views/nodes/TaskBox.scss b/src/client/views/nodes/TaskBox.scss
index 0fcc2f955..beee58697 100644
--- a/src/client/views/nodes/TaskBox.scss
+++ b/src/client/views/nodes/TaskBox.scss
@@ -1,4 +1,5 @@
.task-manager-container {
+ color-scheme: light;
display: flex;
flex-direction: column;
padding: 8px;
@@ -31,7 +32,7 @@
resize: none;
line-height: 1.4;
resize: none;
- flex-grow: 1
+ flex-grow: 1;
}
.task-manager-checkboxes {
@@ -40,7 +41,8 @@
gap: 16px;
}
-.task-manager-allday, .task-manager-complete {
+.task-manager-allday,
+.task-manager-complete {
display: flex;
align-items: center;
gap: 6px;
@@ -62,7 +64,7 @@
gap: 4px;
}
-input[type="datetime-local"] {
+input[type='datetime-local'] {
width: 100%;
font-size: 0.9rem;
padding: 6px 8px;
@@ -70,3 +72,61 @@ input[type="datetime-local"] {
border-radius: 6px;
box-sizing: border-box;
}
+
+.task-manager-checkboxes {
+ display: flex;
+ flex-wrap: wrap; /* allows wrapping on small screens */
+ align-items: center;
+ gap: 16px;
+ row-gap: 8px; /* optional: tighter vertical spacing if it wraps */
+}
+
+.task-manager-google {
+ align-self: flex-start;
+ width: auto;
+ font-size: 0.85rem;
+ padding: 6px 12px;
+ border-radius: 6px;
+ background-color: #5e88c8;
+ color: white;
+ border: none;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background-color 0.2s ease;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+
+ &:hover {
+ background-color: #4773b0; // darker shade of your base blue
+ color: white;
+ transform: scale(1.01); // subtle hover feel without real size change
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+}
+
+.task-box-blur-wrapper {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.task-manager-button-row {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+}
+
+.task-manager-delete {
+ @extend .task-manager-google;
+ background-color: #182430;
+
+ &:hover {
+ background-color: #000000;
+ }
+}
diff --git a/src/client/views/nodes/TaskBox.tsx b/src/client/views/nodes/TaskBox.tsx
index 9d59746f8..ed5982c55 100644
--- a/src/client/views/nodes/TaskBox.tsx
+++ b/src/client/views/nodes/TaskBox.tsx
@@ -1,30 +1,64 @@
-import { action, makeObservable, IReactionDisposer, reaction } from 'mobx';
+import { action, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
+import { DateField } from '../../../fields/DateField';
+import { BoolCast, DateCast, DocCast, NumCast, StrCast } from '../../../fields/Types';
+import { GoogleAuthenticationManager } from '../../apis/GoogleAuthenticationManager';
import { Docs } from '../../documents/Documents';
import { DocumentType } from '../../documents/DocumentTypes';
-import { FieldView } from './FieldView';
-import { DateField } from '../../../fields/DateField';
-import { Doc } from '../../../fields/Doc';
-
+import { ViewBoxBaseComponent } from '../DocComponent';
+import { FieldView, FieldViewProps } from './FieldView';
import './TaskBox.scss';
-
-/**
- * Props (reference to document) for Task Box
- */
-
-interface TaskBoxProps {
- Document: Doc;
-}
+import { DocumentDecorations } from '../DocumentDecorations';
+import { Doc } from '../../../fields/Doc';
+import { DocumentView } from './DocumentView';
/**
* TaskBox class for adding task information + completing tasks
*/
@observer
-export class TaskBox extends React.Component<TaskBoxProps> {
+export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() {
+ _googleTaskCreateDisposer?: IReactionDisposer;
+ _heightDisposer?: IReactionDisposer;
+ _widthDisposer?: IReactionDisposer;
+ @observable _needsSync = false; // Whether the task needs to be synced with Google Tasks
+ @observable _syncing = false; // Whether the task is currently syncing with Google Tasks
+ private _isFocused = false; // Whether the task box is currently focused
+
+ // contains the last synced task information
+ private _lastSyncedTask: {
+ title: string;
+ text: string;
+ due?: string;
+ completed: boolean;
+ deleted?: boolean;
+ } = {
+ title: '',
+ text: '',
+ due: '',
+ completed: false,
+ deleted: false,
+ };
+
+ /**
+ * Getter for needsSync
+ */
+ get needsSync() {
+ return this._needsSync;
+ }
+
+ /**
+ * Constructor for the task box
+ * @param props - props containing the document reference
+ */
+ constructor(props: FieldViewProps) {
+ super(props);
+ makeObservable(this);
+ }
+
/**
- * Method to reuturn the
- * @param fieldStr
+ * Return the JSX string that will create this component
+ * @param fieldStr the Doc field that contains the primary data for this component
* @returns
*/
public static LayoutString(fieldStr: string) {
@@ -35,10 +69,9 @@ export class TaskBox extends React.Component<TaskBoxProps> {
* Method to update the task description
* @param e - event of changing the description box input
*/
-
@action
updateText = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
- this.props.Document.text = e.target.value;
+ this.Document[this.fieldKey] = e.target.value;
};
/**
@@ -47,7 +80,7 @@ export class TaskBox extends React.Component<TaskBoxProps> {
*/
@action
updateTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
- this.props.Document.title = e.target.value;
+ this.Document.title = e.target.value;
};
/**
@@ -56,11 +89,11 @@ export class TaskBox extends React.Component<TaskBoxProps> {
*/
@action
updateAllDay = (e: React.ChangeEvent<HTMLInputElement>) => {
- this.props.Document.$task_allDay = e.target.checked;
+ this.Document.$task_allDay = e.target.checked;
if (e.target.checked) {
- delete this.props.Document.$task_startTime;
- delete this.props.Document.$task_endTime;
+ delete this.Document.$task_startTime;
+ delete this.Document.$task_endTime;
}
this.setTaskDateRange();
@@ -74,16 +107,16 @@ export class TaskBox extends React.Component<TaskBoxProps> {
updateStart = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = new Date(e.target.value);
- this.props.Document.$task_startTime = new DateField(newStart);
+ this.Document.$task_startTime = new DateField(newStart);
- const endDate = this.props.Document.$task_endTime instanceof DateField ? this.props.Document.$task_endTime.date : undefined;
+ const endDate = this.Document.$task_endTime instanceof DateField ? this.Document.$task_endTime.date : undefined;
if (endDate && newStart > endDate) {
// Alert user
alert('Start time cannot be after end time. End time has been adjusted.');
// Fix end time
const adjustedEnd = new Date(newStart.getTime() + 60 * 60 * 1000);
- this.props.Document.$task_endTime = new DateField(adjustedEnd);
+ this.Document.$task_endTime = new DateField(adjustedEnd);
}
this.setTaskDateRange();
@@ -97,16 +130,16 @@ export class TaskBox extends React.Component<TaskBoxProps> {
updateEnd = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = new Date(e.target.value);
- this.props.Document.$task_endTime = new DateField(newEnd);
+ this.Document.$task_endTime = new DateField(newEnd);
- const startDate = this.props.Document.$task_startTime instanceof DateField ? this.props.Document.$task_startTime.date : undefined;
+ const startDate = this.Document.$task_startTime instanceof DateField ? this.Document.$task_startTime.date : undefined;
if (startDate && newEnd < startDate) {
// Alert user
alert('End time cannot be before start time. Start time has been adjusted.');
// Fix start time
const adjustedStart = new Date(newEnd.getTime() - 60 * 60 * 1000);
- this.props.Document.$task_startTime = new DateField(adjustedStart);
+ this.Document.$task_startTime = new DateField(adjustedStart);
}
this.setTaskDateRange();
@@ -117,10 +150,10 @@ export class TaskBox extends React.Component<TaskBoxProps> {
*/
@action
setTaskDateRange() {
- const doc = this.props.Document;
+ const doc = this.Document;
if (doc.$task_allDay) {
- const range = typeof doc.$task_dateRange === 'string' ? doc.$task_dateRange.split('|') : [];
+ const range = StrCast(doc.$task_dateRange).split('|');
const dateStr = range[0] ?? new Date().toISOString().split('T')[0]; // default to today
doc.$task_dateRange = `${dateStr}|${dateStr}`;
@@ -145,53 +178,365 @@ export class TaskBox extends React.Component<TaskBoxProps> {
@action
toggleComplete = (e: React.ChangeEvent<HTMLInputElement>) => {
- this.props.Document.$task_completed = e.target.checked;
+ this.Document.$task_completed = e.target.checked;
};
/**
- * Constructor for the task box
- * @param props - props containing the document reference
+ * Computes due date for the task (for Google Tasks API)
+ * @returns - a string representing the due date in ISO format, or undefined if no valid date is found
*/
+ private computeDueDate(): string | undefined {
+ const doc = this.Document;
+ let due: string | undefined;
+ const allDay = !!doc.$task_allDay;
- constructor(props: TaskBoxProps) {
- super(props);
- makeObservable(this);
+ if (allDay) {
+ const rawRange = StrCast(doc.$task_dateRange);
+ const datePart = rawRange.split('|')[0];
+
+ if (datePart && !isNaN(new Date(datePart).getTime())) {
+ // Set time to midnight UTC to represent the start of the all-day event
+ const baseDate = datePart.includes('T') ? datePart : datePart + 'T00:00:00Z';
+ due = new Date(baseDate).toISOString();
+ } else {
+ due = undefined;
+ }
+ } else if (doc.$task_endTime instanceof DateField && doc.$task_endTime.date) {
+ due = doc.$task_endTime.date.toISOString();
+ } else if (doc.$task_startTime instanceof DateField && doc.$task_startTime.date) {
+ due = doc.$task_startTime.date.toISOString();
+ } else {
+ due = undefined;
+ }
+
+ return due;
}
- _heightDisposer?: IReactionDisposer;
- _widthDisposer?: IReactionDisposer;
+ /**
+ * Builds the body for the Google Tasks API request
+ * @returns - an object containing the task details
+ */
+
+ private buildGoogleTaskBody(): Record<string, string | boolean | undefined> {
+ const doc = this.Document;
+ const title = StrCast(doc.title, 'Untitled Task');
+ const notes = StrCast(doc[this.fieldKey]);
+ const due = this.computeDueDate();
+ const completed = !!doc.$task_completed;
+
+ const body: Record<string, string | boolean | undefined> = {
+ title,
+ notes,
+ due,
+ status: completed ? 'completed' : 'needsAction',
+ completed: completed ? new Date().toISOString() : undefined,
+ };
+
+ if (doc.$dashDeleted === true) {
+ body.deleted = true;
+ } else if (doc.$dashDeleted === false) {
+ body.deleted = false;
+ }
+
+ return body;
+ }
+
+ /**
+ * Handles the focus event for the task box (for auto-syncing)
+ */
+ handleFocus = () => {
+ if (!this._isFocused) {
+ this._isFocused = true;
+ this.syncWithGoogleTaskBidirectional(true); // silent sync
+ }
+ };
+
+ /**
+ * Handles the blur event for the task box (for auto-syncing)
+ * @param e - the focus event
+ */
+ handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
+ // Check if focus is moving outside this component
+ if (!e.currentTarget.contains(e.relatedTarget)) {
+ this._isFocused = false;
+ this.syncWithGoogleTaskBidirectional(true);
+ }
+ };
+
+ /**
+ * Method to sync the task with Google Tasks bidirectionally
+ * (update Dash from Google and vice versa, based on which is newer)
+ * @param silent - whether to suppress UI prompts to connect to Google (default: false)
+ * @returns - a promise that resolves to true if sync was successful, false otherwise
+ */
+
+ syncWithGoogleTaskBidirectional = async (silent = false): Promise<boolean> => {
+ const doc = this.Document;
+ let token: string | undefined;
+ try {
+ token = silent ? await GoogleAuthenticationManager.Instance.fetchAccessTokenSilently() : await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken();
+ } catch (err) {
+ console.warn('Google auth failed:', err);
+ return false;
+ }
+
+ if (!token) {
+ if (!silent) {
+ const listener = () => {
+ window.removeEventListener('focusin', listener);
+ if (confirm('✅ Authorization complete. Try syncing the task again?')) {
+ // try syncing again
+ this.syncWithGoogleTaskBidirectional();
+ }
+ window.removeEventListener('focusin', listener);
+ };
+ setTimeout(() => window.addEventListener('focusin', listener), 100);
+ }
+ return false;
+ }
+
+ if (!doc.$googleTaskId) return false;
+
+ runInAction(() => {
+ this._syncing = true;
+ });
+
+ try {
+ // Fetch current version of Google Task
+ const response = await fetch(`/googleTasks/${doc.$googleTaskId}`, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ credentials: 'include',
+ });
+
+ const googleTask = await response.json();
+ const googleUpdated = new Date(googleTask.updated);
+ const dashUpdated = new Date(StrCast(doc.$task_lastSyncedAt));
+
+ const dashChanged =
+ StrCast(doc.title) !== this._lastSyncedTask.title ||
+ StrCast(doc[this.fieldKey]) !== this._lastSyncedTask.text ||
+ this.computeDueDate() !== this._lastSyncedTask.due ||
+ !!doc.$task_completed !== this._lastSyncedTask.completed ||
+ !!doc.$dashDeleted !== this._lastSyncedTask.deleted;
+
+ if (googleUpdated > dashUpdated && !dashChanged) {
+ // Google version is newer — update Dash
+ runInAction(() => {
+ doc.title = googleTask.title ?? doc.title;
+ doc[this.fieldKey] = googleTask.notes ?? doc[this.fieldKey];
+ doc.$task_completed = googleTask.status === 'completed';
+
+ if (googleTask.due && googleTask.due.split('T')[0] !== this.computeDueDate()?.split('T')[0]) {
+ const dueDate = new Date(googleTask.due);
+ doc.$task_allDay = true;
+ doc.$task_dateRange = `${dueDate.toISOString().split('T')[0]}|${dueDate.toISOString().split('T')[0]}`;
+ }
+
+ doc.$task_lastSyncedAt = googleTask.updated;
+ this._lastSyncedTask = {
+ title: StrCast(doc.title),
+ text: StrCast(doc[this.fieldKey]),
+ due: this.computeDueDate(),
+ completed: !!doc.$task_completed,
+ deleted: !!doc.$dashDeleted,
+ };
+ this._needsSync = false;
+ });
+
+ console.log('Pulled newer version from Google');
+ return true;
+ } else if (googleUpdated <= dashUpdated && !dashChanged) {
+ console.log('No changes to sync');
+ return true;
+ } else {
+ // Dash version is newer — push update to Google
+ const body = this.buildGoogleTaskBody();
+ const res = await fetch(`/googleTasks/${doc.$googleTaskId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+
+ const result = await res.json();
+ if (result?.id) {
+ doc.$task_lastSyncedAt = new Date().toISOString();
+ this._lastSyncedTask = {
+ title: StrCast(doc.title),
+ text: StrCast(doc[this.fieldKey]),
+ due: this.computeDueDate(),
+ completed: !!doc.$task_completed,
+ deleted: !!doc.$dashDeleted,
+ };
+ this._needsSync = false;
+ console.log('Pushed newer version to Google');
+ return true;
+ } else {
+ console.warn('❌ Push failed:', result);
+ return false;
+ }
+ }
+ } catch (err) {
+ console.error('❌ Sync error:', err);
+ return false;
+ } finally {
+ runInAction(() => {
+ this._syncing = false;
+ });
+ }
+ };
+
+ /**
+ * Method to set up the task box on mount
+ */
componentDidMount() {
this.setTaskDateRange();
+ const doc = this.Document;
+
+ // adding task on creation to google
+ (async () => {
+ if (!doc.$googleTaskId && doc.title) {
+ try {
+ const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken();
+ if (!token) return;
+ const body = this.buildGoogleTaskBody();
+
+ const res = await fetch('/googleTasks/create', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const result = await res.json();
+ if (result?.id) {
+ doc.$googleTaskId = result.id;
+ console.log('✅ Google Task created on mount:', result);
+ } else {
+ console.warn('❌ Google Task creation failed:', result);
+ }
+ } catch (err) {
+ console.warn('❌ Error creating Google Task:', err);
+ }
+ } else if (doc.$googleTaskId) {
+ await this.syncWithGoogleTaskBidirectional();
+ }
+ })();
- const doc = this.props.Document;
this._heightDisposer = reaction(
- () => Number(doc._height),
+ () => NumCast(doc._height),
height => {
- const minHeight = Number(doc.height_min ?? 0);
- if (!isNaN(height) && height < minHeight) {
+ const minHeight = NumCast(doc.height_min);
+ if (height < minHeight) {
doc._height = minHeight;
}
}
);
this._widthDisposer = reaction(
- () => Number(doc._width),
+ () => NumCast(doc._width),
width => {
- const minWidth = Number(doc.width_min ?? 0);
- if (!isNaN(width) && width < minWidth) {
+ const minWidth = NumCast(doc.width_min);
+ if (width < minWidth) {
doc._width = minWidth;
}
}
);
+
+ runInAction(() => {
+ const completed = BoolCast(doc.$task_completed);
+ const due = this.computeDueDate();
+
+ this._lastSyncedTask = {
+ title: StrCast(doc.title),
+ text: StrCast(doc[this.fieldKey]),
+ due,
+ completed,
+ deleted: !!doc.$dashDeleted,
+ };
+ this._needsSync = false;
+ });
+
+ if (this.Document.$dashDeleted) {
+ runInAction(() => {
+ this.Document.$dashDeleted = false;
+ });
+ }
+
+ this._googleTaskCreateDisposer = reaction(
+ () => {
+ const completed = BoolCast(doc.$task_completed);
+ const due = this.computeDueDate();
+ const dashDeleted = !!doc.$dashDeleted;
+
+ return { title: StrCast(doc.title), text: StrCast(doc[this.fieldKey]), completed, due, dashDeleted };
+ },
+ ({ title, text, completed, due, dashDeleted }) => {
+ this._needsSync = title !== this._lastSyncedTask.title || text !== this._lastSyncedTask.text || due !== this._lastSyncedTask.due || completed !== this._lastSyncedTask.completed || dashDeleted !== this._lastSyncedTask.deleted;
+ },
+ { fireImmediately: true }
+ );
}
+ /**
+ * Method to clean up the task box on unmount
+ */
componentWillUnmount() {
+ const doc = this.Document;
+ this._googleTaskCreateDisposer?.();
this._heightDisposer?.();
this._widthDisposer?.();
}
/**
+ * Method to handle task deletion
+ * @returns - a promise that resolves when the task is deleted
+ */
+ handleDeleteTask = async () => {
+ const doc = this.Document;
+ if (!doc.$googleTaskId) return;
+ if (!window.confirm('Are you sure you want to delete this task?')) return;
+
+ doc.$dashDeleted = true;
+ this._needsSync = true;
+
+ try {
+ const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken();
+ if (!token) return;
+
+ await fetch(`/googleTasks/${doc.$googleTaskId}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ const view = DocumentView.getDocumentView(this.Document);
+ if (view) {
+ DocumentView.SelectView(view, false); // select document
+ DocumentDecorations.Instance?.onCloseClick?.(true); // simulate clicking the close button
+ }
+
+ // Remove the task from the recently closed list
+ Doc.MyRecentlyClosed && Doc.RemoveDocFromList(Doc.MyRecentlyClosed, undefined, this.Document);
+ console.log(`✅ Deleted Google Task ${doc.$googleTaskId}`);
+ } catch (err) {
+ console.warn('❌ Failed to delete Google Task:', err);
+ }
+ };
+
+ /**
* Method to render the task box
* @returns - HTML with taskbox components
*/
@@ -202,71 +547,104 @@ export class TaskBox extends React.Component<TaskBoxProps> {
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
- const doc = this.props.Document;
+ const doc = this.Document;
- const taskDesc = typeof doc.text === 'string' ? doc.text : '';
- const taskTitle = typeof doc.title === 'string' ? doc.title : '';
+ const taskDesc = StrCast(doc[this.fieldKey]);
+ const taskTitle = StrCast(doc.title);
const allDay = !!doc.$task_allDay;
- const isCompleted = !!this.props.Document.$task_completed;
+ const due = this.computeDueDate();
+ const isCompleted = !!this.Document.$task_completed;
+
+ const startTime = DateCast(doc.$task_startTime) ? toLocalDateTimeString(DateCast(doc.$task_startTime)!.date) : '';
+ const endTime = DateCast(doc.$task_endTime) ? toLocalDateTimeString(DateCast(doc.$task_endTime)!.date) : '';
- const startTime = doc.$task_startTime instanceof DateField && doc.$task_startTime.date instanceof Date ? toLocalDateTimeString(doc.$task_startTime.date) : '';
+ const handleGoogleTaskSync = async () => {
+ const success = await this.syncWithGoogleTaskBidirectional();
- const endTime = doc.$task_endTime instanceof DateField && doc.$task_endTime.date instanceof Date ? toLocalDateTimeString(doc.$task_endTime.date) : '';
+ if (success) {
+ alert('✅ Task successfully synced!');
+ } else {
+ alert('❌ Task sync failed. Try reloading.');
+ }
+ };
return (
- <div className="task-manager-container">
- <input className="task-manager-title" type="text" placeholder="Task Title" value={taskTitle} onChange={this.updateTitle} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} />
-
- <textarea className="task-manager-description" placeholder="What’s your task?" value={taskDesc} onChange={this.updateText} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} />
-
- <div className="task-manager-checkboxes">
- <label className="task-manager-allday" style={{ opacity: isCompleted ? 0.7 : 1 }}>
- <input type="checkbox" checked={allDay} onChange={this.updateAllDay} disabled={isCompleted} />
- All day
- {allDay && (
- <input
- type="date"
- value={(() => {
- const rawRange = doc.$task_dateRange;
- if (typeof rawRange !== 'string') return '';
- const datePart = rawRange.split('|')[0];
- if (!datePart) return '';
- const d = new Date(datePart);
- return !isNaN(d.getTime()) ? d.toISOString().split('T')[0] : '';
- })()}
- onChange={e => {
- const newDate = new Date(e.target.value);
- if (!isNaN(newDate.getTime())) {
- const dateStr = e.target.value;
- if (dateStr) {
- doc.$task_dateRange = `${dateStr}T00:00:00|${dateStr}T00:00:00`;
+ <div className="task-box-blur-wrapper" tabIndex={0} onBlur={this.handleBlur} onFocus={this.handleFocus}>
+ <div className="task-manager-container">
+ <input className="task-manager-title" type="text" placeholder="Task Title" value={taskTitle} onChange={this.updateTitle} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} />
+
+ <textarea className="task-manager-description" placeholder="What’s your task?" value={taskDesc} onChange={this.updateText} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} />
+
+ <div className="task-manager-checkboxes">
+ <label className="task-manager-allday" style={{ opacity: isCompleted ? 0.7 : 1 }}>
+ <input type="checkbox" checked={allDay} onChange={this.updateAllDay} disabled={isCompleted} />
+ All day
+ {allDay && (
+ <input
+ type="date"
+ value={(() => {
+ const datePart = StrCast(doc.$task_dateRange).split('|')[0];
+ if (!datePart) return '';
+ const d = new Date(datePart);
+ return !isNaN(d.getTime()) ? d.toISOString().split('T')[0] : '';
+ })()}
+ onChange={e => {
+ const newDate = new Date(e.target.value);
+ if (!isNaN(newDate.getTime())) {
+ const dateStr = e.target.value;
+ if (dateStr) {
+ doc.$task_dateRange = `${dateStr}T00:00:00|${dateStr}T00:00:00`;
+ }
}
- }
- }}
- disabled={isCompleted}
- style={{ marginLeft: '8px' }}
- />
- )}
- </label>
-
- <label className="task-manager-complete">
- <input type="checkbox" checked={isCompleted} onChange={this.toggleComplete} />
- Complete
- </label>
- </div>
-
- {!allDay && (
- <div className="task-manager-times" style={{ opacity: isCompleted ? 0.7 : 1 }}>
- <label>
- Start:
- <input type="datetime-local" value={startTime} onChange={this.updateStart} disabled={isCompleted} />
+ }}
+ disabled={isCompleted}
+ style={{ marginLeft: '8px' }}
+ />
+ )}
</label>
- <label>
- End:
- <input type="datetime-local" value={endTime} onChange={this.updateEnd} disabled={isCompleted} />
+
+ <label className="task-manager-complete">
+ <input type="checkbox" checked={isCompleted} onChange={this.toggleComplete} />
+ Complete
</label>
</div>
- )}
+
+ <div className="task-manager-button-row">
+ <button
+ className="task-manager-google"
+ onClick={event => {
+ event.preventDefault();
+ handleGoogleTaskSync();
+ }}>
+ {this._syncing ? 'Syncing...' : this.needsSync ? 'Push Updates' : 'Sync Task'}
+ </button>
+
+ <button
+ className="task-manager-delete"
+ onClick={event => {
+ event.preventDefault();
+ this.handleDeleteTask();
+ }}>
+ <svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="12" height="12" viewBox="0 0 24 24" style={{ fill: 'white', marginRight: '6px', verticalAlign: 'middle', marginTop: '-2px' }}>
+ <path d="M 10 2 L 9 3 L 5 3 C 4.4 3 4 3.4 4 4 C 4 4.6 4.4 5 5 5 L 7 5 L 17 5 L 19 5 C 19.6 5 20 4.6 20 4 C 20 3.4 19.6 3 19 3 L 15 3 L 14 2 L 10 2 z M 5 7 L 5 20 C 5 21.1 5.9 22 7 22 L 17 22 C 18.1 22 19 21.1 19 20 L 19 7 L 5 7 z M 9 9 C 9.6 9 10 9.4 10 10 L 10 19 C 10 19.6 9.6 20 9 20 C 8.4 20 8 19.6 8 19 L 8 10 C 8 9.4 8.4 9 9 9 z M 15 9 C 15.6 9 16 9.4 16 10 L 16 19 C 16 19.6 15.6 20 15 20 C 14.4 20 14 19.6 14 19 L 14 10 C 14 9.4 14.4 9 15 9 z"></path>
+ </svg>
+ Delete
+ </button>
+ </div>
+
+ {!allDay && (
+ <div className="task-manager-times" style={{ opacity: isCompleted ? 0.7 : 1 }}>
+ <label>
+ Start:
+ <input type="datetime-local" value={startTime} onChange={this.updateStart} disabled={isCompleted} />
+ </label>
+ <label>
+ End:
+ <input type="datetime-local" value={endTime} onChange={this.updateEnd} disabled={isCompleted} />
+ </label>
+ </div>
+ )}
+ </div>
</div>
);
}
diff --git a/src/client/views/nodes/calendarBox/CalendarBox.scss b/src/client/views/nodes/calendarBox/CalendarBox.scss
index 891db9d90..89dc294a5 100644
--- a/src/client/views/nodes/calendarBox/CalendarBox.scss
+++ b/src/client/views/nodes/calendarBox/CalendarBox.scss
@@ -50,3 +50,12 @@
pointer-events: unset;
}
}
+
+.custom-drag-mirror {
+ transition: none !important;
+ transform: none !important;
+}
+
+.fc-event-dragging {
+ opacity: 0 !important;
+}
diff --git a/src/client/views/nodes/calendarBox/CalendarBox.tsx b/src/client/views/nodes/calendarBox/CalendarBox.tsx
index 26aed72c3..a4183a11a 100644
--- a/src/client/views/nodes/calendarBox/CalendarBox.tsx
+++ b/src/client/views/nodes/calendarBox/CalendarBox.tsx
@@ -22,6 +22,7 @@ import './CalendarBox.scss';
import { DateField } from '../../../../fields/DateField';
import { undoable } from '../../../util/UndoManager';
import { DocumentType } from '../../../documents/DocumentTypes';
+import { truncate } from 'fs/promises';
type CalendarView = 'multiMonth' | 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
@@ -74,8 +75,20 @@ export class CalendarBox extends CollectionSubView() {
@computed get calendarEvents(): EventSourceInput | undefined {
return this.childDocs.map(doc => {
- const { start, end } = dateRangeStrToDates(StrCast(doc.$task_dateRange));
- const isCompleted = BoolCast(doc.$task_completed); // AARAV ADD
+ // const { start, end } = dateRangeStrToDates(StrCast(doc.$task_dateRange));
+ const isCompleted = BoolCast(doc.$task_completed);
+
+ const rangeStr = StrCast(doc.$task_dateRange);
+ const [startStr, endStr] = rangeStr.split('|');
+ let start: string | Date, end: string | Date;
+
+ if (BoolCast(doc.$task_allDay)) {
+ start = startStr;
+ end = endStr;
+ } else {
+ ({ start, end } = dateRangeStrToDates(rangeStr));
+ }
+
return {
title: StrCast(doc.title),
start,
@@ -157,7 +170,7 @@ export class CalendarBox extends CollectionSubView() {
delete doc.$task_endTime;
} else {
doc.$task_startTime = new DateField(startDate);
- doc.task_endTime = new DateField(endDate);
+ doc.$task_endTime = new DateField(endDate);
}
}, 'change event date');
@@ -220,7 +233,7 @@ export class CalendarBox extends CollectionSubView() {
}}
initialDate={untracked(() => this.dateSelect.start)}
navLinks={true}
- editable={false}
+ editable={true}
// expandRows={true}
// handleWindowResize={true}
displayEventTime={false}
@@ -231,6 +244,7 @@ export class CalendarBox extends CollectionSubView() {
events={this.calendarEvents}
eventClick={this.handleEventClick}
eventDrop={this.handleEventDrop}
+ eventResize={this.handleEventDrop}
unselectAuto={false}
// unselect={() => {}}
select={(info: DateSelectArg) => {
@@ -284,15 +298,74 @@ export class CalendarBox extends CollectionSubView() {
ev.preventDefault();
});
}}
+
+ // for dragging and dropping (mirror)
+
+ eventDragStart={(arg) => {
+ const mirror = arg.el.cloneNode(true) as HTMLElement;
+ const rect = arg.el.getBoundingClientRect();
+
+ mirror.style.position = 'fixed';
+ mirror.style.pointerEvents = 'none';
+ mirror.style.opacity = '0.8';
+ mirror.style.zIndex = '10000';
+ mirror.classList.add('custom-drag-mirror');
+ mirror.style.width = `${rect.width}px`;
+ mirror.style.height = `${rect.height}px`;
+
+ document.body.appendChild(mirror);
+
+ const moveListener = (ev: MouseEvent) => {
+ mirror.style.left = `${ev.clientX}px`;
+ mirror.style.top = `${ev.clientY}px`;
+ };
+
+ window.addEventListener('mousemove', moveListener);
+
+ // hide the actual box
+ arg.el.style.visibility = 'hidden';
+ arg.el.style.opacity = '0';
+
+ (arg.el as any)._mirrorElement = mirror;
+ (arg.el as any)._moveListener = moveListener;
+ }}
+
+ eventDragStop={(arg) => {
+ const el = arg.el as any;
+ const mirror = el._mirrorElement;
+ const moveListener = el._moveListener;
+
+ // show the actual box
+ el.style.visibility = 'visible';
+ el.style.opacity = '1';
+
+ if (mirror) document.body.removeChild(mirror);
+ if (moveListener) window.removeEventListener('mousemove', moveListener);
+ }}
+
/>
);
}
render() {
+ const scale = this._props.ScreenToLocalTransform().Scale;
+ const scaledWidth = this._props.PanelWidth();
+ const scaledHeight = this._props.PanelHeight();
+
return (
<div
key={this.calendarViewType}
className={`calendarBox${this._props.isContentActive() ? '-interactive' : ''}`}
+ style={{
+ width: scaledWidth,
+ height: scaledHeight,
+ overflow: 'hidden',
+ position: 'relative',
+ }}
+ ref={r => {
+ this.createDashEventsTarget(r);
+ this.fixWheelEvents(r, this._props.isContentActive);
+ }}
onPointerDown={e => {
setTimeout(
action(() => {
@@ -303,17 +376,17 @@ export class CalendarBox extends CollectionSubView() {
if (cname.includes('timeGridDay')) this.dataDoc[this.calTypeFieldKey] = 'timeGridDay';
})
);
- }}
- style={{
- width: this._props.PanelWidth() / this._props.ScreenToLocalTransform().Scale,
- height: this._props.PanelHeight() / this._props.ScreenToLocalTransform().Scale,
- transform: `scale(${this._props.ScreenToLocalTransform().Scale})`,
- }}
- ref={r => {
- this.createDashEventsTarget(r);
- this.fixWheelEvents(r, this._props.isContentActive);
}}>
+ <div
+ style={{
+ transform: `scale(${scale})`,
+ transformOrigin: 'top left',
+ width: scaledWidth / scale,
+ height: scaledHeight / scale,
+ }}
+ >
{this.renderCalendar}
+ </div>
</div>
);
}
diff --git a/src/server/ApiManagers/FireflyManager.ts b/src/server/ApiManagers/FireflyManager.ts
index 07ef271a1..6393a1f74 100644
--- a/src/server/ApiManagers/FireflyManager.ts
+++ b/src/server/ApiManagers/FireflyManager.ts
@@ -23,7 +23,16 @@ export default class FireflyManager extends ApiManager {
return undefined;
});
- generateImageFromStructure = (prompt: string = 'a realistic illustration of a cat coding', width: number = 2048, height: number = 2048, structureUrl: string, strength: number = 50, styles: string[], styleUrl: string | undefined, variations: number = 4) =>
+ generateImageFromStructure = (
+ prompt: string = 'a realistic illustration of a cat coding',
+ width: number = 2048,
+ height: number = 2048,
+ structureUrl: string,
+ strength: number = 50,
+ styles: string[],
+ styleUrl: string | undefined,
+ variations: number = 4
+ ) =>
this.getBearerToken().then(response =>
response?.json().then((data: { access_token: string }) =>
//prettier-ignore
diff --git a/src/server/ApiManagers/GeneralGoogleManager.ts b/src/server/ApiManagers/GeneralGoogleManager.ts
index 12913b1ef..110701418 100644
--- a/src/server/ApiManagers/GeneralGoogleManager.ts
+++ b/src/server/ApiManagers/GeneralGoogleManager.ts
@@ -3,6 +3,7 @@ import { Method } from '../RouteManager';
import { GoogleApiServerUtils } from '../apis/google/GoogleApiServerUtils';
import RouteSubscriber from '../RouteSubscriber';
import { Database } from '../database';
+import { google } from 'googleapis';
const EndpointHandlerMap = new Map<GoogleApiServerUtils.Action, GoogleApiServerUtils.ApiRouter>([
['create', (api, params) => api.create(params)],
@@ -61,5 +62,139 @@ export default class GeneralGoogleManager extends ApiManager {
res.send(undefined);
},
});
+
+ // Task Creation
+ register({
+ method: Method.POST,
+ subscription: new RouteSubscriber('googleTasks').add('create'),
+ secureHandler: async ({ req, res, user }) => {
+ try {
+ const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id);
+
+ if (!auth) {
+ return res.status(401).send('Google credentials missing or invalid.');
+ }
+
+ const tasks = google.tasks({ version: 'v1', auth });
+
+ const { title, notes, due, status, completed, deleted } = req.body;
+ const result = await tasks.tasks.insert({
+ tasklist: '@default',
+ requestBody: { title, notes, due, status, completed, deleted },
+ });
+
+ res.status(200).send(result.data);
+ } catch (err) {
+ console.error('Google Tasks error:', err);
+ res.status(500).send('Failed to create task.');
+ }
+ },
+ });
+
+ // Task Update
+ register({
+ method: Method.PATCH,
+ subscription: new RouteSubscriber('googleTasks').add('taskId'),
+ // any way to add static params? like /update (this is not very descriptive)
+ secureHandler: async ({ req, res, user }) => {
+ try {
+ const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id);
+
+ if (!auth) {
+ return res.status(401).send('Google credentials missing or invalid.');
+ }
+
+ const tasks = google.tasks({ version: 'v1', auth });
+
+ const { taskId } = req.params;
+ const { title, notes, due, status, completed, deleted } = req.body;
+
+ const result = await tasks.tasks.patch({
+ tasklist: '@default',
+ task: taskId,
+ requestBody: { title, notes, due, status, completed, deleted},
+ });
+
+ res.status(200).send(result.data);
+ } catch (err) {
+ console.error('Google Tasks update error:', err);
+ res.status(500).send('Failed to update task.');
+ }
+ },
+ });
+
+ // Task Deletion
+ register({
+ method: Method.DELETE,
+ subscription: new RouteSubscriber('googleTasks').add('taskId'),
+ secureHandler: async ({ req, res, user }) => {
+ try {
+ const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id);
+
+ if (!auth) {
+ return res.status(401).send('Google credentials missing or invalid.');
+ }
+
+ const tasks = google.tasks({ version: 'v1', auth });
+ const { taskId } = req.params;
+
+ await tasks.tasks.delete({
+ tasklist: '@default',
+ task: taskId,
+ });
+
+ res.status(200).send({ success: true });
+ } catch (err) {
+ console.error('Google Tasks delete error:', err);
+ res.status(500).send('Failed to delete task.');
+ }
+ },
+ });
+
+ // Google Account Linking
+
+ register({
+ method: Method.GET,
+ subscription: '/refreshGoogle',
+ secureHandler: async ({ user, req, res }) =>
+ new Promise<void>(resolve =>
+ GoogleApiServerUtils.processNewUser(user.id, req.query.code as string)
+ .then(() => res.status(200).send('Google account linked successfully!'))
+ .catch(err => {
+ console.error('Failed to process Google code:', err);
+ res.status(500).send('Error linking Google account');
+ })
+ .finally(resolve)
+ ),
+ });
+
+ // Task Retrieval
+ register({
+ method: Method.GET,
+ subscription: new RouteSubscriber('googleTasks').add('taskId'),
+ secureHandler: async ({ req, res, user }) => {
+ try {
+ const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id);
+
+ if (!auth) {
+ return res.status(401).send('Google credentials missing or invalid.');
+ }
+
+ const tasks = google.tasks({ version: 'v1', auth });
+ const { taskId } = req.params;
+
+ const result = await tasks.tasks.get({
+ tasklist: '@default',
+ task: taskId,
+ });
+
+ res.status(200).send(result.data);
+ } catch (err) {
+ console.error('Google Tasks retrieval error:', err);
+ res.status(500).send('Failed to retrieve task.');
+ }
+ },
+ });
}
+
}
diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts
index 2f6cf80b5..c5d70da3d 100644
--- a/src/server/RouteManager.ts
+++ b/src/server/RouteManager.ts
@@ -8,6 +8,8 @@ import { DashUserModel } from './authentication/DashUserModel';
export enum Method {
GET,
POST,
+ PATCH,
+ DELETE,
}
export interface CoreArguments {
@@ -207,6 +209,12 @@ export default class RouteManager {
case Method.POST:
this.server.post(route, supervised);
break;
+ case Method.PATCH:
+ this.server.patch(route, supervised);
+ break;
+ case Method.DELETE:
+ this.server.delete(route, supervised);
+ break;
default:
}
}
diff --git a/src/server/apis/google/GoogleApiServerUtils.ts b/src/server/apis/google/GoogleApiServerUtils.ts
index 7373df473..ad0f0e580 100644
--- a/src/server/apis/google/GoogleApiServerUtils.ts
+++ b/src/server/apis/google/GoogleApiServerUtils.ts
@@ -13,7 +13,7 @@ import { GoogleCredentialsLoader } from './CredentialsLoader';
* This is the somewhat overkill list of what Dash requests
* from the user.
*/
-const scope = ['documents.readonly', 'documents', 'presentations', 'presentations.readonly', 'drive', 'drive.file', 'photoslibrary', 'photoslibrary.appendonly', 'photoslibrary.sharing', 'userinfo.profile'].map(
+const scope = ['tasks', 'documents.readonly', 'documents', 'presentations', 'presentations.readonly', 'drive', 'drive.file', 'photoslibrary', 'photoslibrary.appendonly', 'photoslibrary.sharing', 'userinfo.profile'].map(
relative => `https://www.googleapis.com/auth/${relative}`
);
@@ -181,7 +181,7 @@ export namespace GoogleApiServerUtils {
* @returns the newly generated url to the authentication landing page
*/
export function generateAuthenticationUrl(): string {
- return worker.generateAuthUrl({ scope, access_type: 'offline' });
+ return worker.generateAuthUrl({ scope, access_type: 'offline', prompt: 'consent' });
}
/**
@@ -203,12 +203,12 @@ export namespace GoogleApiServerUtils {
*/
export async function processNewUser(userId: string, authenticationCode: string): Promise<EnrichedCredentials> {
const credentials = await new Promise<Credentials>((resolve, reject) => {
- worker.getToken(authenticationCode, (err, credentials) => {
- if (err || !credentials) {
+ worker.getToken(authenticationCode, (err, creds) => {
+ if (err || !creds) {
reject(err);
return;
}
- resolve(credentials);
+ resolve(creds);
});
});
const enriched = injectUserInfo(credentials);
@@ -304,6 +304,7 @@ export namespace GoogleApiServerUtils {
const { access_token, expires_in } = await new Promise<{ access_token: string; expires_in: number }>(resolve => {
request.post(url, headerParameters).then(response => resolve(JSON.parse(response)));
});
+
// expires_in is in seconds, but we're building the new expiry date in milliseconds
const expiry_date = new Date().getTime() + expires_in * 1000;
await Database.Auxiliary.GoogleAccessToken.Update(userId, access_token, expiry_date);
diff --git a/src/server/apis/google/google_project_credentials.json b/src/server/apis/google/google_project_credentials.json
index 955c5a3c1..438e5157e 100644
--- a/src/server/apis/google/google_project_credentials.json
+++ b/src/server/apis/google/google_project_credentials.json
@@ -1,11 +1,11 @@
{
"installed": {
- "client_id": "343179513178-ud6tvmh275r2fq93u9eesrnc66t6akh9.apps.googleusercontent.com",
- "project_id": "quickstart-1565056383187",
+ "client_id": "740987818053-dtflji3hfkn5r9t8ad6jb8740pls8moh.apps.googleusercontent.com",
+ "project_id": "dash-web-461920",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
- "client_secret": "w8KIFSc0MQpmUYHed4qEzn8b",
- "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"]
+ "client_secret": "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",
+ "redirect_uris": ["http://localhost:1050/refreshGoogle"]
}
} \ No newline at end of file
diff --git a/src/server/authentication/DashUserModel.ts b/src/server/authentication/DashUserModel.ts
index debeef60c..6fd8dd593 100644
--- a/src/server/authentication/DashUserModel.ts
+++ b/src/server/authentication/DashUserModel.ts
@@ -2,9 +2,10 @@ import * as bcrypt from 'bcrypt-nodejs';
import * as mongoose from 'mongoose';
import { Utils } from '../../Utils';
-type comparePasswordFunction = (candidatePassword: string, cb: (err: any, isMatch: any) => void) => void;
-export type DashUserModel = mongoose.Document & {
- email: String;
+type comparePasswordFunction = (candidatePassword: string, cb: (err: Error, isMatch: boolean) => void) => void;
+type mongooseDocument = { id: string }; // & mongoose.Document;
+export type DashUserModel = mongooseDocument & {
+ email: string;
password: string;
passwordResetToken?: string;
passwordResetExpires?: Date;
@@ -65,12 +66,13 @@ const userSchema = new mongoose.Schema(
/**
* Password hash middleware.
*/
-userSchema.pre('save', function save(next) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+userSchema.pre('save', function save(next: any) {
const user = this;
if (!user.isModified('password')) {
return next();
}
- bcrypt.genSalt(10, (err: any, salt: string) => {
+ bcrypt.genSalt(10, (err: Error, salt: string) => {
if (err) {
return next(err);
}
@@ -102,7 +104,7 @@ const comparePassword: comparePasswordFunction = function (this: DashUserModel,
userSchema.methods.comparePassword = comparePassword;
-const User: any = mongoose.model('User', userSchema);
+const User = mongoose.model('User', userSchema);
export function initializeGuest() {
new User({
email: 'guest',
diff --git a/src/server/authentication/Passport.ts b/src/server/authentication/Passport.ts
index ca9e3058e..a62d38e3e 100644
--- a/src/server/authentication/Passport.ts
+++ b/src/server/authentication/Passport.ts
@@ -5,13 +5,13 @@ import User, { DashUserModel } from './DashUserModel';
const LocalStrategy = passportLocal.Strategy;
passport.serializeUser<any, any>((req, user, done) => {
- done(undefined, (user as any)?.id);
+ done(undefined, (user as DashUserModel)?.id);
});
passport.deserializeUser<any, any>((id, done) => {
User.findById(id)
.exec()
- .then((user: any) => done(undefined, user));
+ .then((user: DashUserModel) => done(undefined, user));
});
// AUTHENTICATE JUST WITH EMAIL AND PASSWORD
@@ -30,6 +30,6 @@ passport.use(
});
}
})
- .catch((error: any) => done(error));
+ .catch((error: Error) => done(error));
})
);