2025-02-22 10:56:10 +02:00
import type { Calendar , DateSelectArg , EventChangeArg , EventDropArg , EventInput , EventSourceFunc , EventSourceFuncArg , EventSourceInput , PluginDef } from "@fullcalendar/core" ;
2025-02-15 10:43:46 +02:00
import froca from "../../services/froca.js" ;
2025-02-15 10:13:47 +02:00
import ViewMode , { type ViewModeArgs } from "./view_mode.js" ;
2025-02-15 11:13:44 +02:00
import type FNote from "../../entities/fnote.js" ;
2025-02-15 12:05:35 +02:00
import server from "../../services/server.js" ;
import ws from "../../services/ws.js" ;
2025-02-15 20:15:54 +02:00
import { t } from "../../services/i18n.js" ;
2025-02-15 20:18:27 +02:00
import options from "../../services/options.js" ;
2025-02-15 23:48:06 +02:00
import dialogService from "../../services/dialog.js" ;
import attributes from "../../services/attributes.js" ;
2025-02-16 13:22:44 +02:00
import type { EventData } from "../../components/app_context.js" ;
2025-02-21 18:40:54 +02:00
import utils from "../../services/utils.js" ;
2025-02-22 10:12:36 +02:00
import date_notes from "../../services/date_notes.js" ;
import appContext from "../../components/app_context.js" ;
2025-02-13 23:46:20 +02:00
const TPL = `
< div class = "calendar-view" >
< style >
. calendar - view {
overflow : hidden ;
position : relative ;
height : 100 % ;
2025-02-15 10:23:57 +02:00
user - select : none ;
2025-02-15 10:35:14 +02:00
padding : 10px ;
2025-02-13 23:46:20 +02:00
}
2025-02-15 10:24:40 +02:00
. calendar - view a {
color : unset ;
}
2025-02-15 14:07:39 +02:00
. calendar - container {
height : 100 % ;
}
2025-02-15 14:15:43 +02:00
. calendar - container . fc - toolbar . fc - header - toolbar {
margin - bottom : 0.5em ;
}
2025-02-16 19:20:59 +02:00
body.desktop :not ( . zen ) . calendar - container . fc - toolbar . fc - header - toolbar {
2025-02-16 18:09:01 +02:00
padding - right : 5em ;
}
2025-02-15 14:15:43 +02:00
. calendar - container . fc - toolbar - title {
font - size : 1.3rem ;
font - weight : normal ;
}
. calendar - container . fc - button {
padding : 0.2em 0.5 em ;
}
2025-02-13 23:46:20 +02:00
< / style >
2025-02-15 10:23:33 +02:00
< div class = "calendar-container" >
< / div >
2025-02-13 23:46:20 +02:00
< / div >
` ;
2025-02-15 23:48:06 +02:00
// TODO: Deduplicate
interface CreateChildResponse {
note : {
noteId : string ;
}
}
2025-02-13 23:46:20 +02:00
export default class CalendarView extends ViewMode {
private $root : JQuery < HTMLElement > ;
2025-02-15 10:23:33 +02:00
private $calendarContainer : JQuery < HTMLElement > ;
2025-02-15 10:43:46 +02:00
private noteIds : string [ ] ;
2025-02-15 23:48:06 +02:00
private parentNote : FNote ;
2025-02-16 13:22:44 +02:00
private calendar? : Calendar ;
2025-02-22 10:00:18 +02:00
private isCalendarRoot : boolean ;
2025-02-13 23:46:20 +02:00
2025-02-15 10:13:47 +02:00
constructor ( args : ViewModeArgs ) {
super ( args ) ;
2025-02-13 23:46:20 +02:00
this . $root = $ ( TPL ) ;
2025-02-15 10:23:33 +02:00
this . $calendarContainer = this . $root . find ( ".calendar-container" ) ;
2025-02-15 10:43:46 +02:00
this . noteIds = args . noteIds ;
2025-02-15 23:48:06 +02:00
this . parentNote = args . parentNote ;
2025-02-22 10:00:18 +02:00
this . isCalendarRoot = false ;
2025-02-15 10:13:47 +02:00
args . $parent . append ( this . $root ) ;
2025-02-13 23:46:20 +02:00
}
2025-02-21 17:17:53 +02:00
get isFullHeight ( ) : boolean {
return true ;
}
2025-02-13 23:46:20 +02:00
async renderList ( ) : Promise < JQuery < HTMLElement > | undefined > {
2025-02-22 10:57:40 +02:00
this . isCalendarRoot = this . parentNote . hasLabel ( "calendarRoot" ) || this . parentNote . hasLabel ( "workspaceCalendarRoot" ) ;
2025-02-22 10:03:38 +02:00
const isEditable = ! this . isCalendarRoot ;
2025-02-15 12:05:35 +02:00
2025-02-15 10:23:33 +02:00
const { Calendar } = await import ( "@fullcalendar/core" ) ;
2025-02-15 12:05:35 +02:00
const plugins : PluginDef [ ] = [ ] ;
plugins . push ( ( await import ( "@fullcalendar/daygrid" ) ) . default ) ;
2025-02-22 10:12:36 +02:00
if ( isEditable || this . isCalendarRoot ) {
2025-02-15 12:05:35 +02:00
plugins . push ( ( await import ( "@fullcalendar/interaction" ) ) . default ) ;
}
2025-02-15 10:23:33 +02:00
2025-02-22 10:56:10 +02:00
let eventBuilder : EventSourceFunc ;
if ( ! this . isCalendarRoot ) {
eventBuilder = async ( ) = > await this . # buildEvents ( this . noteIds )
} else {
eventBuilder = async ( e : EventSourceFuncArg ) = > await this . # buildEventsForCalendar ( e ) ;
}
2025-02-15 10:23:33 +02:00
const calendar = new Calendar ( this . $calendarContainer [ 0 ] , {
2025-02-15 12:05:35 +02:00
plugins ,
2025-02-15 10:43:46 +02:00
initialView : "dayGridMonth" ,
2025-02-22 10:56:10 +02:00
events : eventBuilder ,
2025-02-15 23:48:06 +02:00
editable : isEditable ,
selectable : isEditable ,
select : ( e ) = > this . # onCalendarSelection ( e ) ,
2025-02-15 12:26:58 +02:00
eventChange : ( e ) = > this . # onEventMoved ( e ) ,
2025-02-15 21:45:53 +02:00
firstDay : options.getInt ( "firstDayOfWeek" ) ? ? 0 ,
2025-02-21 17:52:11 +02:00
weekends : ! this . parentNote . hasAttribute ( "label" , "calendar:hideWeekends" ) ,
2025-02-21 17:54:13 +02:00
weekNumbers : this.parentNote.hasAttribute ( "label" , "calendar:weekNumbers" ) ,
2025-02-21 17:17:53 +02:00
locale : await CalendarView . # getLocale ( ) ,
2025-02-21 18:40:54 +02:00
height : "100%" ,
eventContent : ( e = > {
let html = "" ;
const iconClass = e . event . extendedProps . iconClass ;
if ( iconClass ) {
html += ` <span class=" ${ iconClass } "></span> ` ;
}
html += utils . escapeHtml ( e . event . title ) ;
return { html } ;
2025-02-22 10:12:36 +02:00
} ) ,
dateClick : async ( e ) = > {
if ( ! this . isCalendarRoot ) {
return ;
}
const note = await date_notes . getDayNote ( e . dateStr ) ;
if ( note ) {
appContext . tabManager . getActiveContext ( ) . setNote ( note . noteId ) ;
}
}
2025-02-15 12:26:58 +02:00
} ) ;
calendar . render ( ) ;
2025-02-16 13:22:44 +02:00
this . calendar = calendar ;
2025-02-15 12:05:35 +02:00
2025-02-15 12:26:58 +02:00
return this . $root ;
}
2025-02-15 12:05:35 +02:00
2025-02-15 21:45:53 +02:00
static async # getLocale() {
const locale = options . get ( "locale" ) ;
// Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages.
switch ( locale ) {
case "de" :
return ( await import ( "@fullcalendar/core/locales/de" ) ) . default ;
case "es" :
return ( await import ( "@fullcalendar/core/locales/es" ) ) . default ;
case "fr" :
return ( await import ( "@fullcalendar/core/locales/fr" ) ) . default ;
case "cn" :
return ( await import ( "@fullcalendar/core/locales/zh-cn" ) ) . default ;
case "tw" :
return ( await import ( "@fullcalendar/core/locales/zh-tw" ) ) . default ;
case "ro" :
return ( await import ( "@fullcalendar/core/locales/ro" ) ) . default ;
case "en" :
default :
return undefined ;
}
}
2025-02-15 23:48:06 +02:00
async # onCalendarSelection ( e : DateSelectArg ) {
const startDate = CalendarView . # formatDateToLocalISO ( e . start ) ;
if ( ! startDate ) {
return ;
}
const endDate = CalendarView . # formatDateToLocalISO ( CalendarView . # offsetDate ( e . end , - 1 ) ) ;
const title = await dialogService . prompt ( { message : t ( "relation_map.enter_title_of_new_note" ) , defaultValue : t ( "relation_map.default_new_note_title" ) } ) ;
if ( ! title ? . trim ( ) ) {
return ;
}
const { note } = await server . post < CreateChildResponse > ( ` notes/ ${ this . parentNote . noteId } /children?target=into ` , {
title ,
content : "" ,
type : "text"
} ) ;
attributes . setLabel ( note . noteId , "startDate" , startDate ) ;
if ( endDate ) {
attributes . setLabel ( note . noteId , "endDate" , endDate ) ;
}
}
2025-02-15 12:26:58 +02:00
async # onEventMoved ( e : EventChangeArg ) {
const startDate = CalendarView . # formatDateToLocalISO ( e . event . start ) ;
2025-02-16 19:57:44 +02:00
// Fullcalendar end date is exclusive, not inclusive but we store it the other way around.
2025-02-15 23:48:06 +02:00
let endDate = CalendarView . # formatDateToLocalISO ( CalendarView . # offsetDate ( e . event . end , - 1 ) ) ;
2025-02-15 12:26:58 +02:00
const noteId = e . event . extendedProps . noteId ;
2025-02-15 12:05:35 +02:00
2025-02-15 12:26:58 +02:00
// Don't store the end date if it's empty.
if ( endDate === startDate ) {
endDate = undefined ;
}
2025-02-15 10:23:33 +02:00
2025-02-15 12:26:58 +02:00
// Update start date
const note = await froca . getNote ( noteId ) ;
if ( ! note ) {
return ;
}
CalendarView . # setAttribute ( note , "label" , "startDate" , startDate ) ;
CalendarView . # setAttribute ( note , "label" , "endDate" , endDate ) ;
2025-02-13 23:46:20 +02:00
}
2025-02-21 17:52:11 +02:00
onEntitiesReloaded ( { loadResults } : EventData < "entitiesReloaded" > ) {
2025-02-16 13:22:44 +02:00
// Refresh note IDs if they got changed.
2025-02-21 17:52:11 +02:00
if ( loadResults . getBranchRows ( ) . some ( ( branch ) = > branch . parentNoteId === this . parentNote . noteId ) ) {
2025-02-16 13:22:44 +02:00
this . noteIds = this . parentNote . getChildNoteIds ( ) ;
}
2025-02-21 17:52:11 +02:00
// Refresh calendar on attribute change.
if ( loadResults . getAttributeRows ( ) . some ( ( attribute ) = > attribute . noteId === this . parentNote . noteId && attribute . name ? . startsWith ( "calendar:" ) ) ) {
return true ;
}
// Refresh dataset on subnote change.
2025-02-16 13:22:44 +02:00
if ( this . calendar && loadResults . getAttributeRows ( ) . some ( ( a ) = > this . noteIds . includes ( a . noteId ? ? "" ) ) ) {
this . calendar . refetchEvents ( ) ;
}
}
2025-02-22 10:56:10 +02:00
async # buildEventsForCalendar ( e : EventSourceFuncArg ) {
const events = [ ] ;
// Gather all the required date note IDs.
const dateRange = utils . getMonthsInDateRange ( e . startStr , e . endStr ) ;
let allDateNoteIds : string [ ] = [ ] ;
for ( const month of dateRange ) {
// TODO: Deduplicate get type.
2025-02-22 11:31:26 +02:00
const dateNotesForMonth = await server . get < Record < string , string > > ( ` special-notes/notes-for-month/ ${ month } ?calendarRoot= ${ this . parentNote . noteId } ` ) ;
2025-02-22 10:56:10 +02:00
const dateNoteIds = Object . values ( dateNotesForMonth ) ;
allDateNoteIds = [ . . . allDateNoteIds , . . . dateNoteIds ] ;
}
// Request all the date notes.
const dateNotes = await froca . getNotes ( allDateNoteIds ) ;
const childNoteToDateMapping : Record < string , string > = { } ;
for ( const dateNote of dateNotes ) {
const startDate = dateNote . getLabelValue ( "dateNote" ) ;
if ( ! startDate ) {
continue ;
}
events . push ( await CalendarView . # buildEvent ( dateNote , startDate ) ) ;
if ( dateNote . hasChildren ( ) ) {
const childNoteIds = dateNote . getChildNoteIds ( ) ;
for ( const childNoteId of childNoteIds ) {
childNoteToDateMapping [ childNoteId ] = startDate ;
}
}
}
// Request all child notes of date notes in a single run.
const childNoteIds = Object . keys ( childNoteToDateMapping ) ;
const childNotes = await froca . getNotes ( childNoteIds ) ;
for ( const childNote of childNotes ) {
const startDate = childNoteToDateMapping [ childNote . noteId ] ;
const event = await CalendarView . # buildEvent ( childNote , startDate ) ;
events . push ( event ) ;
}
return events . flat ( ) ;
}
async # buildEvents ( noteIds : string [ ] ) {
2025-02-15 10:43:46 +02:00
const notes = await froca . getNotes ( noteIds ) ;
const events : EventSourceInput = [ ] ;
for ( const note of notes ) {
2025-02-22 23:34:14 +01:00
const startDate = note . getLabelValue ( "startDate" ) ;
2025-02-15 11:13:44 +02:00
2025-02-22 09:47:48 +02:00
if ( note . hasChildren ( ) ) {
2025-02-22 10:56:10 +02:00
const childrenEventData = await this . # buildEvents ( note . getChildNoteIds ( ) ) ;
2025-02-22 09:47:48 +02:00
if ( childrenEventData . length > 0 ) {
events . push ( childrenEventData ) ;
}
}
2025-02-23 18:39:34 +02:00
if ( ! startDate ) {
continue ;
}
2025-02-15 10:43:46 +02:00
2025-02-22 10:56:10 +02:00
const endDate = note . getAttributeValue ( "label" , "endDate" ) ;
events . push ( await CalendarView . # buildEvent ( note , startDate , endDate ) ) ;
2025-02-15 10:43:46 +02:00
}
2025-02-22 09:47:48 +02:00
return events . flat ( ) ;
2025-02-15 10:43:46 +02:00
}
2025-02-22 10:56:10 +02:00
static async # buildEvent ( note : FNote , startDate : string , endDate? : string | null ) {
const customTitle = note . getLabelValue ( "calendar:title" ) ;
const titles = await CalendarView . # parseCustomTitle ( customTitle , note ) ;
const color = note . getLabelValue ( "calendar:color" ) ? ? note . getLabelValue ( "color" ) ;
const events : EventInput [ ] = [ ] ;
2025-02-22 23:34:14 +01:00
// the user can specify one or multiple attributes to be promoted onto the calendar view by setting `#calendar:promotedAttributes` at the note level
// their values will then be rentered into the event title and appear as "[eventIcon] $eventTitle [#promotedAttributeX=valueX] [#promotedAttributeY=valueY]"
const promotedAttrs = note
. getAttributes ( )
. filter ( ( attr ) = > attr . type == "label" && attr . name == "calendar:promotedAttribute" )
. map ( ( attr ) = > attr . value . substring ( 1 ) ) ;
let titleExtended = "" ;
if ( promotedAttrs && promotedAttrs . length ) {
const promotedValues = note
. getAttributes ( )
. filter ( ( attr ) = > promotedAttrs . includes ( attr . name ) )
. map ( ( attr ) = > [ attr . name , attr . value ] ) ;
for ( const defined of promotedValues ) titleExtended = titleExtended + ` [# ${ defined [ 0 ] } =" ${ defined [ 1 ] } "] ` ;
}
2025-02-22 10:56:10 +02:00
for ( const title of titles ) {
const eventData : EventInput = {
2025-02-22 23:34:14 +01:00
title : title + titleExtended ,
2025-02-22 10:56:10 +02:00
start : startDate ,
url : ` # ${ note . noteId } ` ,
noteId : note.noteId ,
color : color ? ? undefined ,
iconClass : note.getLabelValue ( "iconClass" )
} ;
const endDateOffset = CalendarView . # offsetDate ( endDate ? ? startDate , 1 ) ;
if ( endDateOffset ) {
eventData . end = CalendarView . # formatDateToLocalISO ( endDateOffset ) ;
}
events . push ( eventData ) ;
}
return events ;
}
2025-02-15 11:41:08 +02:00
static async # parseCustomTitle ( customTitleValue : string | null , note : FNote , allowRelations = true ) : Promise < string [ ] > {
if ( customTitleValue ) {
const attributeName = customTitleValue . substring ( 1 ) ;
if ( customTitleValue . startsWith ( "#" ) ) {
const labelValue = note . getAttributeValue ( "label" , attributeName ) ;
if ( labelValue ) {
return [ labelValue ] ;
}
} else if ( allowRelations && customTitleValue . startsWith ( "~" ) ) {
const relations = note . getRelations ( attributeName ) ;
if ( relations . length > 0 ) {
const noteIds = relations . map ( ( r ) = > r . targetNoteId ) ;
const notesFromRelation = await froca . getNotes ( noteIds ) ;
const titles = [ ] ;
for ( const targetNote of notesFromRelation ) {
const targetCustomTitleValue = targetNote . getAttributeValue ( "label" , "calendar:title" ) ;
const targetTitles = await CalendarView . # parseCustomTitle ( targetCustomTitleValue , targetNote , false ) ;
titles . push ( targetTitles . flat ( ) ) ;
}
return titles . flat ( ) ;
}
2025-02-15 11:13:44 +02:00
}
}
2025-02-15 11:41:08 +02:00
return [ note . title ] ;
2025-02-15 11:13:44 +02:00
}
2025-02-15 12:05:35 +02:00
static async # setAttribute ( note : FNote , type : "label" | "relation" , name : string , value : string | null | undefined ) {
if ( value ) {
// Create or update the attribute.
await server . put ( ` notes/ ${ note . noteId } /set-attribute ` , { type , name , value } ) ;
} else {
// Remove the attribute if it exists on the server but we don't define a value for it.
2025-02-15 12:26:58 +02:00
const attributeId = note . getAttribute ( type , name ) ? . attributeId ;
2025-02-15 12:05:35 +02:00
if ( attributeId ) {
await server . remove ( ` notes/ ${ note . noteId } /attributes/ ${ attributeId } ` ) ;
}
}
await ws . waitForMaxKnownEntityChangeId ( ) ;
}
2025-02-15 12:26:58 +02:00
static # formatDateToLocalISO ( date : Date | null | undefined ) {
if ( ! date ) {
return undefined ;
}
const offset = date . getTimezoneOffset ( ) ;
const localDate = new Date ( date . getTime ( ) - offset * 60 * 1000 ) ;
return localDate . toISOString ( ) . split ( 'T' ) [ 0 ] ;
}
2025-02-15 23:48:06 +02:00
static # offsetDate ( date : Date | string | null | undefined , offset : number ) {
if ( ! date ) {
return undefined ;
}
const newDate = new Date ( date ) ;
newDate . setDate ( newDate . getDate ( ) + offset ) ;
return newDate ;
}
2025-02-13 23:46:20 +02:00
}