From 902cfaaa54cd3e96d03bd6b3745f4e1ef26bc06e Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 19:23:02 +0200 Subject: [PATCH 01/10] :sparkles: Add Attribute View calendar backend support https://github.com/siyuan-note/siyuan/issues/13740 --- kernel/api/av.go | 82 +- kernel/api/router.go | 1 + kernel/av/av.go | 59 +- kernel/av/layout.go | 3 + kernel/av/layout_calendar.go | 224 +++++ kernel/model/attribute_view.go | 864 +++++++++++++++++-- kernel/model/attribute_view_calendar_test.go | 654 ++++++++++++++ kernel/model/attribute_view_new_item.go | 559 ++++++++---- kernel/model/attribute_view_render.go | 9 + kernel/model/attribute_view_update_item.go | 194 +++++ kernel/model/calendar_item_journal.go | 203 +++++ kernel/model/conf.go | 6 + kernel/model/crypto.go | 4 + kernel/model/export.go | 7 + kernel/model/publish_access.go | 26 + kernel/model/transaction.go | 151 +++- kernel/model/transaction_test.go | 269 +++++- kernel/sql/av.go | 79 +- kernel/sql/av_calendar.go | 136 +++ kernel/sql/av_calendar_test.go | 81 ++ kernel/sql/av_table.go | 5 + 21 files changed, 3375 insertions(+), 241 deletions(-) create mode 100644 kernel/av/layout_calendar.go create mode 100644 kernel/model/attribute_view_calendar_test.go create mode 100644 kernel/model/attribute_view_update_item.go create mode 100644 kernel/model/calendar_item_journal.go create mode 100644 kernel/sql/av_calendar.go create mode 100644 kernel/sql/av_calendar_test.go diff --git a/kernel/api/av.go b/kernel/api/av.go index cc75265693f..0d5032f7eb0 100644 --- a/kernel/api/av.go +++ b/kernel/api/av.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/88250/gulu" "github.com/gin-gonic/gin" @@ -415,13 +416,17 @@ func getAttributeViewPrimaryKeyValues(c *gin.Context) { page := 1 pageArg := arg["page"] if nil != pageArg { - page = int(pageArg.(float64)) + if pageFloat, ok := pageArg.(float64); ok { + page = int(pageFloat) + } } pageSize := -1 pageSizeArg := arg["pageSize"] if nil != pageSizeArg { - pageSize = int(pageSizeArg.(float64)) + if pageSizeFloat, ok := pageSizeArg.(float64); ok { + pageSize = int(pageSizeFloat) + } } keyword := "" @@ -882,7 +887,7 @@ func createAttributeViewItem(c *gin.Context) { if !ok { return } - var avID, blockID, viewID, templateID, previousID, groupID, app, session string + var avID, blockID, viewID, templateID, previousID, groupID, primaryKey, app, session string if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("avID", &avID, true, false), util.BindJsonArg("blockID", &blockID, true, false), @@ -890,12 +895,37 @@ func createAttributeViewItem(c *gin.Context) { util.BindJsonArg("templateID", &templateID, false, false), util.BindJsonArg("previousID", &previousID, false, false), util.BindJsonArg("groupID", &groupID, false, false), + util.BindJsonArg("primaryKey", &primaryKey, false, false), util.BindJsonArg("app", &app, false, false), util.BindJsonArg("session", &session, false, false), ) { return } - result, err := model.CreateAttributeViewItem(avID, blockID, viewID, templateID, previousID, groupID) + + // fieldValues 是 {keyID: IAVCellValue} 形式的对象,BindJsonArg 只处理标量,这里单独反序列化。 + fieldValues := map[string]*av.Value{} + if rawFieldValues, exists := arg["fieldValues"]; exists && nil != rawFieldValues { + rawMap, isMap := rawFieldValues.(map[string]any) + if !isMap { + ret.Code = -1 + ret.Msg = "fieldValues must be an object" + return + } + data, marshalErr := gulu.JSON.MarshalJSON(rawMap) + if nil != marshalErr { + ret.Code = -1 + ret.Msg = marshalErr.Error() + return + } + if unmarshalErr := gulu.JSON.UnmarshalJSON(data, &fieldValues); nil != unmarshalErr { + ret.Code = -1 + ret.Msg = unmarshalErr.Error() + return + } + } + + result, err := model.CreateAttributeViewItem(avID, blockID, viewID, templateID, previousID, groupID, + &model.CreateItemOptions{PrimaryKey: primaryKey, FieldValues: fieldValues}) if nil != err { if errors.Is(err, model.ErrBoxNotFound) { ret.Code = 1 @@ -968,6 +998,50 @@ func createAttributeViewItemDocs(c *gin.Context) { } } +func updateAttributeViewItem(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + var avID, blockID, itemID, boundBlockID, primaryKey, app, session string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("avID", &avID, true, false), + util.BindJsonArg("blockID", &blockID, true, false), + util.BindJsonArg("itemID", &itemID, true, false), + util.BindJsonArg("boundBlockID", &boundBlockID, true, false), + util.BindJsonArg("primaryKey", &primaryKey, false, false), + util.BindJsonArg("app", &app, false, false), + util.BindJsonArg("session", &session, false, false)) { + return + } + fieldValues := map[string]*av.Value{} + if raw := arg["fieldValues"]; raw != nil { + data, err := gulu.JSON.MarshalJSON(raw) + if err != nil { + ret.Code = -1 + ret.Msg = err.Error() + return + } + if err = gulu.JSON.UnmarshalJSON(data, &fieldValues); err != nil { + ret.Code = -1 + ret.Msg = err.Error() + return + } + } + tx := &model.Transaction{DoOperations: []*model.Operation{{Action: "updateAttributeViewItem", AvID: avID, ID: itemID, Data: &model.AttributeViewItemUpdateData{AvID: avID, ItemID: itemID, BoundBlockID: boundBlockID, PrimaryKey: primaryKey, FieldValues: fieldValues}}}, Timestamp: time.Now().UnixMilli()} + tx.MarkFromAPI() + if err := model.PerformTxSync(tx); err != nil { + ret.Code = -1 + ret.Msg = err.Error() + return + } + ret.Data = map[string]any{"itemID": itemID, "blockID": boundBlockID, "content": primaryKey} + model.FlushTxQueue() + pushTransactions(app, session, []*model.Transaction{tx}) +} + func searchAttributeView(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) diff --git a/kernel/api/router.go b/kernel/api/router.go index f5868eb39fe..b0af3e09679 100644 --- a/kernel/api/router.go +++ b/kernel/api/router.go @@ -584,6 +584,7 @@ func ServeAPI(ginServer *gin.Engine) { ginServer.Handle("POST", "/api/av/getUnusedAttributeViews", model.CheckAuth, model.CheckAdminRole, getUnusedAttributeViews) ginServer.Handle("POST", "/api/av/createAttributeViewItem", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, createAttributeViewItem) ginServer.Handle("POST", "/api/av/createAttributeViewItemDocs", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, createAttributeViewItemDocs) + ginServer.Handle("POST", "/api/av/updateAttributeViewItem", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, updateAttributeViewItem) ginServer.Handle("POST", "/api/av/removeUnusedAttributeViews", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeUnusedAttributeViews) ginServer.Handle("POST", "/api/av/removeUnusedAttributeView", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeUnusedAttributeView) diff --git a/kernel/av/av.go b/kernel/av/av.go index 3229029cc16..67eb12a395c 100644 --- a/kernel/av/av.go +++ b/kernel/av/av.go @@ -292,19 +292,20 @@ var widthValuePattern = regexp.MustCompile(`^\d+(\.\d+)?(px|em|rem|%)$`) // View 描述了视图的结构。 type View struct { - ID string `json:"id"` // 视图 ID - Icon string `json:"icon"` // 视图图标 - Name string `json:"name"` // 视图名称 - HideAttrViewName bool `json:"hideAttrViewName"` // 是否隐藏属性视图名称 - Desc string `json:"desc"` // 视图描述 - Filters []*ViewFilter `json:"filters,omitempty"` // 过滤规则 - Sorts []*ViewSort `json:"sorts,omitempty"` // 排序规则 - PageSize int `json:"pageSize"` // 每页条目数 - LayoutType LayoutType `json:"type"` // 当前布局类型 - Table *LayoutTable `json:"table,omitempty"` // 表格布局 - Gallery *LayoutGallery `json:"gallery,omitempty"` // 卡片布局 - Kanban *LayoutKanban `json:"kanban,omitempty"` // 看板布局 - ItemIDs []string `json:"itemIds,omitempty"` // 项目 ID 列表,用于维护所有项目 + ID string `json:"id"` // 视图 ID + Icon string `json:"icon"` // 视图图标 + Name string `json:"name"` // 视图名称 + HideAttrViewName bool `json:"hideAttrViewName"` // 是否隐藏属性视图名称 + Desc string `json:"desc"` // 视图描述 + Filters []*ViewFilter `json:"filters,omitempty"` // 过滤规则 + Sorts []*ViewSort `json:"sorts,omitempty"` // 排序规则 + PageSize int `json:"pageSize"` // 每页条目数 + LayoutType LayoutType `json:"type"` // 当前布局类型 + Table *LayoutTable `json:"table,omitempty"` // 表格布局 + Gallery *LayoutGallery `json:"gallery,omitempty"` // 卡片布局 + Kanban *LayoutKanban `json:"kanban,omitempty"` // 看板布局 + Calendar *LayoutCalendar `json:"calendar,omitempty"` // 日历布局 + ItemIDs []string `json:"itemIds,omitempty"` // 项目 ID 列表,用于维护所有项目 Group *ViewGroup `json:"group,omitempty"` // 分组规则 GroupCreated int64 `json:"groupCreated"` // 分组生成时间戳 @@ -405,9 +406,10 @@ type GroupCalc struct { type LayoutType string const ( - LayoutTypeTable LayoutType = "table" // 属性视图类型 - 表格 - LayoutTypeGallery LayoutType = "gallery" // 属性视图类型 - 卡片 - LayoutTypeKanban LayoutType = "kanban" // 属性视图类型 - 看板 + LayoutTypeTable LayoutType = "table" // 属性视图类型 - 表格 + LayoutTypeGallery LayoutType = "gallery" // 属性视图类型 - 卡片 + LayoutTypeKanban LayoutType = "kanban" // 属性视图类型 - 看板 + LayoutTypeCalendar LayoutType = "calendar" // 属性视图类型 - 日历 ) const ( @@ -469,6 +471,18 @@ func NewKanbanView() (ret *View) { } } +func NewCalendarView() (ret *View) { + return &View{ + ID: ast.NewNodeID(), + Name: GetAttributeViewI18n("calendar"), + Filters: []*ViewFilter{}, + Sorts: []*ViewSort{}, + PageSize: ViewDefaultPageSize, + LayoutType: LayoutTypeCalendar, + Calendar: NewLayoutCalendar(), + } +} + // Viewable 描述了视图的接口。 type Viewable interface { @@ -1267,6 +1281,19 @@ func (av *AttributeView) Clone() (ret *AttributeView) { for _, field := range view.Kanban.Fields { field.ID = keyIDMap[field.ID] } + case LayoutTypeCalendar: + view.Calendar.ID = ast.NewNodeID() + view.Calendar.DateFieldID = keyIDMap[view.Calendar.DateFieldID] + for _, field := range view.Calendar.Fields { + field.ID = keyIDMap[field.ID] + } + if nil != view.Calendar.FieldMapping { + view.Calendar.FieldMapping.RecurrenceFieldID = keyIDMap[view.Calendar.FieldMapping.RecurrenceFieldID] + view.Calendar.FieldMapping.ExceptionFieldID = keyIDMap[view.Calendar.FieldMapping.ExceptionFieldID] + view.Calendar.FieldMapping.LocationFieldID = keyIDMap[view.Calendar.FieldMapping.LocationFieldID] + view.Calendar.FieldMapping.DescriptionFieldID = keyIDMap[view.Calendar.FieldMapping.DescriptionFieldID] + view.Calendar.FieldMapping.ColorFieldID = keyIDMap[view.Calendar.FieldMapping.ColorFieldID] + } } view.ItemIDs = []string{} } diff --git a/kernel/av/layout.go b/kernel/av/layout.go index 8d05b57f2d0..77054526839 100644 --- a/kernel/av/layout.go +++ b/kernel/av/layout.go @@ -84,6 +84,9 @@ func NewViewBaseInstance(view *View) *BaseInstance { case LayoutTypeKanban: showIcon = view.Kanban.ShowIcon wrapField = view.Kanban.WrapField + case LayoutTypeCalendar: + showIcon = view.Calendar.ShowIcon + wrapField = view.Calendar.WrapField } return &BaseInstance{ ID: view.ID, diff --git a/kernel/av/layout_calendar.go b/kernel/av/layout_calendar.go new file mode 100644 index 00000000000..e85b767827a --- /dev/null +++ b/kernel/av/layout_calendar.go @@ -0,0 +1,224 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package av + +import "github.com/88250/lute/ast" + +type ViewMode int + +const ( + ViewModeMonth ViewMode = iota + ViewModeWeek + ViewModeDay + ViewModeSchedule + ViewModeYear + ViewModeFiveDay +) + +type WeekStart int + +const ( + WeekStartSunday WeekStart = iota + WeekStartMonday +) + +// CalendarNewItemTarget 描述日历视图新建条目时创建的目标。 +// - 空字符串是历史视图的零值,等同于 CalendarNewItemTargetRow(只建游离行,不建文档), +// 这样升级到本版本的既有视图行为保持不变。 +// - CalendarNewItemTargetDocument 表示每个条目都是一篇真正的 SiYuan 文档(页面)。 +type CalendarNewItemTarget = string + +const ( + CalendarNewItemTargetRow CalendarNewItemTarget = "row" + CalendarNewItemTargetDocument CalendarNewItemTarget = "document" +) + +// IsCalendarNewItemTargetValid 校验 setAttrViewCalendarNewItemTarget 的取值。 +// 允许空字符串,表示回到历史默认(只建行)。 +func IsCalendarNewItemTargetValid(target string) bool { + switch target { + case "", CalendarNewItemTargetRow, CalendarNewItemTargetDocument: + return true + } + return false +} + +type LayoutCalendar struct { + *BaseLayout + + DateFieldID string `json:"dateFieldID"` + ViewMode ViewMode `json:"viewMode"` + WeekStart WeekStart `json:"weekStart"` + Fields []*ViewCalendarCardField `json:"fields"` + // NewItemTarget 记录该视图新建条目时是创建文档(页面)还是只创建游离行。 + // 零值 "" 表示历史视图,只建行。 + NewItemTarget CalendarNewItemTarget `json:"newItemTarget,omitempty"` + FieldMapping *CalendarFieldMapping `json:"fieldMapping"` +} + +type CalendarFieldMapping struct { + RecurrenceFieldID string `json:"recurrenceFieldID,omitempty"` + ExceptionFieldID string `json:"exceptionFieldID,omitempty"` + LocationFieldID string `json:"locationFieldID,omitempty"` + DescriptionFieldID string `json:"descriptionFieldID,omitempty"` + ColorFieldID string `json:"colorFieldID,omitempty"` +} + +type ViewCalendarCardField struct { + *BaseField +} + +type Calendar struct { + *BaseInstance + + DateFieldID string `json:"dateFieldID"` + ViewMode ViewMode `json:"viewMode"` + WeekStart WeekStart `json:"weekStart"` + NewItemTarget CalendarNewItemTarget `json:"newItemTarget"` + Fields []*CalendarField `json:"fields"` + Cards []*CalendarCard `json:"cards"` + CardCount int `json:"cardCount"` + FieldMapping *CalendarFieldMapping `json:"fieldMapping"` +} + +type CalendarCard struct { + ID string `json:"id"` + Values []*CalendarFieldValue `json:"values"` +} + +type CalendarField struct { + *BaseInstanceField +} + +type CalendarFieldValue struct { + *BaseValue +} + +func NewLayoutCalendar() (ret *LayoutCalendar) { + return &LayoutCalendar{ + BaseLayout: &BaseLayout{ + Spec: CurrentSpec, + ID: ast.NewNodeID(), + ShowIcon: true, + WrapField: false, + }, + ViewMode: ViewModeMonth, + Fields: []*ViewCalendarCardField{}, + // 新建的日历视图默认「每个条目是一个页面」;磁盘上已有的视图解析出来是零值 "",保持只建行。 + NewItemTarget: CalendarNewItemTargetDocument, + } +} + +func (card *CalendarCard) GetID() string { + return card.ID +} + +func (card *CalendarCard) GetBlockValue() (ret *Value) { + for _, v := range card.Values { + if KeyTypeBlock == v.ValueType { + ret = v.Value + break + } + } + return +} + +func (card *CalendarCard) GetValues() (ret []*Value) { + ret = []*Value{} + for _, v := range card.Values { + ret = append(ret, v.Value) + } + return +} + +func (card *CalendarCard) GetValue(keyID string) (ret *Value) { + for _, value := range card.Values { + if nil != value.Value && keyID == value.Value.KeyID { + ret = value.Value + break + } + } + return +} + +func (calendar *Calendar) GetItems() (ret []Item) { + ret = []Item{} + for _, card := range calendar.Cards { + ret = append(ret, card) + } + return +} + +func (calendar *Calendar) SetItems(items []Item) { + calendar.Cards = []*CalendarCard{} + for _, item := range items { + if card, ok := item.(*CalendarCard); ok { + calendar.Cards = append(calendar.Cards, card) + } + } +} + +func (calendar *Calendar) GetType() LayoutType { + return LayoutTypeCalendar +} + +func (calendar *Calendar) CountItems() int { + return len(calendar.Cards) +} + +func (calendar *Calendar) GetFields() []Field { + ret := []Field{} + for _, field := range calendar.Fields { + ret = append(ret, field) + } + return ret +} + +func (calendar *Calendar) GetField(id string) (ret Field, fieldIndex int) { + for i, field := range calendar.Fields { + if field.ID == id { + ret = field + fieldIndex = i + return + } + } + return nil, -1 +} + +func (calendar *Calendar) GetValue(itemID, keyID string) (ret *Value) { + for _, card := range calendar.Cards { + if card.ID == itemID { + ret = card.GetValue(keyID) + return + } + } + return +} + +func (calendar *Calendar) GetSorts() []*ViewSort { + if nil == calendar.BaseInstance { + return []*ViewSort{} + } + return calendar.BaseInstance.Sorts +} + +func (calendar *Calendar) GetFilters() []*ViewFilter { + if nil == calendar.BaseInstance { + return []*ViewFilter{} + } + return calendar.BaseInstance.Filters +} diff --git a/kernel/model/attribute_view.go b/kernel/model/attribute_view.go index 63c6cec7f74..e0aabdb1db6 100644 --- a/kernel/model/attribute_view.go +++ b/kernel/model/attribute_view.go @@ -865,7 +865,7 @@ func syncAttrViewTableColWidth(operation *Operation) (err error) { break } } - case av.LayoutTypeGallery, av.LayoutTypeKanban: + case av.LayoutTypeGallery, av.LayoutTypeKanban, av.LayoutTypeCalendar: return } @@ -1364,7 +1364,7 @@ func changeAttrViewLayout(attrView *av.AttributeView, view *av.View, newLayout a switch newLayout { case av.LayoutTypeTable: - if view.Name == av.GetAttributeViewI18n("gallery") || view.Name == av.GetAttributeViewI18n("kanban") { + if view.Name == av.GetAttributeViewI18n("gallery") || view.Name == av.GetAttributeViewI18n("kanban") || view.Name == av.GetAttributeViewI18n("calendar") { view.Name = av.GetAttributeViewI18n("table") } @@ -1382,9 +1382,13 @@ func changeAttrViewLayout(attrView *av.AttributeView, view *av.View, newLayout a for _, field := range view.Kanban.Fields { view.Table.Columns = append(view.Table.Columns, &av.ViewTableColumn{BaseField: &av.BaseField{ID: field.ID}}) } + case av.LayoutTypeCalendar: + for _, field := range view.Calendar.Fields { + view.Table.Columns = append(view.Table.Columns, &av.ViewTableColumn{BaseField: &av.BaseField{ID: field.ID}}) + } } case av.LayoutTypeGallery: - if view.Name == av.GetAttributeViewI18n("table") || view.Name == av.GetAttributeViewI18n("kanban") { + if view.Name == av.GetAttributeViewI18n("table") || view.Name == av.GetAttributeViewI18n("kanban") || view.Name == av.GetAttributeViewI18n("calendar") { view.Name = av.GetAttributeViewI18n("gallery") } @@ -1402,9 +1406,13 @@ func changeAttrViewLayout(attrView *av.AttributeView, view *av.View, newLayout a for _, field := range view.Kanban.Fields { view.Gallery.CardFields = append(view.Gallery.CardFields, &av.ViewGalleryCardField{BaseField: &av.BaseField{ID: field.ID}}) } + case av.LayoutTypeCalendar: + for _, field := range view.Calendar.Fields { + view.Gallery.CardFields = append(view.Gallery.CardFields, &av.ViewGalleryCardField{BaseField: &av.BaseField{ID: field.ID}}) + } } case av.LayoutTypeKanban: - if view.Name == av.GetAttributeViewI18n("table") || view.Name == av.GetAttributeViewI18n("gallery") { + if view.Name == av.GetAttributeViewI18n("table") || view.Name == av.GetAttributeViewI18n("gallery") || view.Name == av.GetAttributeViewI18n("calendar") { view.Name = av.GetAttributeViewI18n("kanban") } @@ -1422,6 +1430,10 @@ func changeAttrViewLayout(attrView *av.AttributeView, view *av.View, newLayout a for _, field := range view.Gallery.CardFields { view.Kanban.Fields = append(view.Kanban.Fields, &av.ViewKanbanField{BaseField: &av.BaseField{ID: field.ID}}) } + case av.LayoutTypeCalendar: + for _, field := range view.Calendar.Fields { + view.Kanban.Fields = append(view.Kanban.Fields, &av.ViewKanbanField{BaseField: &av.BaseField{ID: field.ID}}) + } } if !view.IsGroupView() { @@ -1429,6 +1441,32 @@ func changeAttrViewLayout(attrView *av.AttributeView, view *av.View, newLayout a group := &av.ViewGroup{Field: preferredGroupKey.ID} setAttributeViewGroup(attrView, view, group) } + case av.LayoutTypeCalendar: + if view.Name == av.GetAttributeViewI18n("table") || view.Name == av.GetAttributeViewI18n("gallery") || view.Name == av.GetAttributeViewI18n("kanban") { + view.Name = av.GetAttributeViewI18n("calendar") + } + + if nil == view.Calendar { + view.Calendar = av.NewLayoutCalendar() + switch oldLayout { + case av.LayoutTypeTable: + for _, col := range view.Table.Columns { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: col.ID}}) + } + case av.LayoutTypeGallery: + for _, field := range view.Gallery.CardFields { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: field.ID}}) + } + case av.LayoutTypeKanban: + for _, field := range view.Kanban.Fields { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: field.ID}}) + } + } + } + setDefaultCalendarDateField(attrView, view) + if av.CalendarNewItemTargetDocument == view.Calendar.NewItemTarget { + ensureCalendarNewItemDocumentTemplate(attrView) + } } return } @@ -1469,6 +1507,11 @@ func setAttrViewWrapField(operation *Operation) (err error) { for _, field := range view.Kanban.Fields { field.Wrap = allFieldWrap } + case av.LayoutTypeCalendar: + view.Calendar.WrapField = allFieldWrap + for _, field := range view.Calendar.Fields { + field.Wrap = allFieldWrap + } } err = av.SaveAttributeView(attrView) @@ -1501,6 +1544,8 @@ func setAttrViewShowIcon(operation *Operation) (err error) { view.Gallery.ShowIcon = operation.Data.(bool) case av.LayoutTypeKanban: view.Kanban.ShowIcon = operation.Data.(bool) + case av.LayoutTypeCalendar: + view.Calendar.ShowIcon = operation.Data.(bool) } err = av.SaveAttributeView(attrView) @@ -1581,6 +1626,8 @@ func setAttrViewDisplayFieldName(operation *Operation) (err error) { view.Gallery.DisplayFieldName = operation.Data.(bool) case av.LayoutTypeKanban: view.Kanban.DisplayFieldName = operation.Data.(bool) + case av.LayoutTypeCalendar: + return } err = av.SaveAttributeView(attrView) @@ -1903,6 +1950,105 @@ func (tx *Transaction) doSetAttrViewCardCoverPosition(operation *Operation) (ret return } +func (tx *Transaction) doSetAttrViewCalendarDateField(operation *Operation) (ret *TxErr) { + err := setAttrViewCalendarDateField(operation) + if err != nil { + return &TxErr{code: TxErrHandleAttributeView, id: operation.AvID, msg: err.Error()} + } + return +} + +// resolveAttrViewViewByOperation 优先使用 operation.ViewID 定位视图,这样同一个数据库块存在多个视图时写入不会落到错误的视图上。 +// 旧版前端只发送 blockID,此时回退到按块上的当前视图解析。 +func resolveAttrViewViewByOperation(attrView *av.AttributeView, operation *Operation) (ret *av.View, err error) { + if "" != operation.ViewID { + ret = attrView.GetView(operation.ViewID) + if nil == ret { + return nil, av.ErrViewNotFound + } + return + } + return getAttrViewViewByBlockID(attrView, operation.BlockID) +} + +func setAttrViewCalendarDateField(operation *Operation) (err error) { + attrView, err := av.ParseAttributeView(operation.AvID) + if err != nil { + return + } + + view, err := resolveAttrViewViewByOperation(attrView, operation) + if err != nil { + return + } + + if av.LayoutTypeCalendar != view.LayoutType || nil == view.Calendar { + return fmt.Errorf("view is not a calendar layout") + } + + dateFieldID, err := calendarDateFieldFromOperationData(attrView, operation) + if err != nil { + return + } + if "" == dateFieldID { + view.Calendar.DateFieldID = "" + err = av.SaveAttributeView(attrView) + ReloadAttrView(attrView.ID) + return + } + + view.Calendar.DateFieldID = dateFieldID + err = av.SaveAttributeView(attrView) + ReloadAttrView(attrView.ID) + return +} + +func calendarDateFieldFromOperationData(attrView *av.AttributeView, operation *Operation) (dateFieldID string, err error) { + dateFieldID = operation.KeyID + if "" == dateFieldID { + dataStr, ok := operation.Data.(string) + if !ok { + return "", fmt.Errorf("calendar date field data must be a string") + } + dateFieldID = dataStr + } + if "" == dateFieldID { + return + } + key, getErr := attrView.GetKey(dateFieldID) + if nil != getErr { + return "", fmt.Errorf("calendar date field [%s] not found: %w", dateFieldID, getErr) + } + if av.KeyTypeDate != key.Type { + return "", fmt.Errorf("calendar date field [%s] is not a date type", dateFieldID) + } + return +} + +func setDefaultCalendarDateField(attrView *av.AttributeView, view *av.View) { + if nil == view.Calendar || "" != view.Calendar.DateFieldID { + return + } + for _, field := range view.Calendar.Fields { + key, err := attrView.GetKey(field.ID) + if nil != err { + continue + } + if av.KeyTypeDate == key.Type { + view.Calendar.DateFieldID = key.ID + return + } + } +} + +func (tx *Transaction) doSetAttrViewCalendarViewMode(operation *Operation) (ret *TxErr) { + err := setAttrViewCalendarViewMode(operation) + if err != nil { + return &TxErr{code: TxErrHandleAttributeView, id: operation.AvID, msg: err.Error()} + } + return +} + func setAttrViewCardCoverPosition(operation *Operation) (err error) { dataJSON, err := json.Marshal(operation.Data) if nil != err { @@ -1961,6 +2107,326 @@ func setAttrViewCardCoverPosition(operation *Operation) (err error) { return } +func setAttrViewCalendarViewMode(operation *Operation) (err error) { + attrView, err := av.ParseAttributeView(operation.AvID) + if err != nil { + return + } + + view, err := resolveAttrViewViewByOperation(attrView, operation) + if err != nil { + return + } + + if av.LayoutTypeCalendar != view.LayoutType || nil == view.Calendar { + return fmt.Errorf("view is not a calendar layout") + } + + viewMode, err := calendarViewModeFromOperationData(operation.Data) + if err != nil { + return + } + + view.Calendar.ViewMode = viewMode + err = av.SaveAttributeView(attrView) + ReloadAttrView(attrView.ID) + return +} + +func calendarNumberFromOperationData(data any, name string) (number int, err error) { + if dataFloat, ok := data.(float64); ok { + number = int(dataFloat) + if dataFloat != float64(number) { + return number, fmt.Errorf("calendar %s data must be an integer", name) + } + return + } + if dataInt, ok := data.(int); ok { + number = dataInt + return + } + return number, fmt.Errorf("calendar %s data must be a number", name) +} + +func calendarViewModeFromOperationData(data any) (viewMode av.ViewMode, err error) { + number, err := calendarNumberFromOperationData(data, "view mode") + if err != nil { + return + } + viewMode = av.ViewMode(number) + if av.ViewModeMonth != viewMode && av.ViewModeWeek != viewMode && av.ViewModeDay != viewMode && av.ViewModeSchedule != viewMode && + av.ViewModeYear != viewMode && av.ViewModeFiveDay != viewMode { + return viewMode, fmt.Errorf("calendar view mode [%d] is invalid", viewMode) + } + return +} + +func (tx *Transaction) doSetAttrViewCalendarWeekStart(operation *Operation) (ret *TxErr) { + err := setAttrViewCalendarWeekStart(operation) + if err != nil { + return &TxErr{code: TxErrHandleAttributeView, id: operation.AvID, msg: err.Error()} + } + return +} + +func setAttrViewCalendarWeekStart(operation *Operation) (err error) { + attrView, err := av.ParseAttributeView(operation.AvID) + if err != nil { + return + } + + view, err := resolveAttrViewViewByOperation(attrView, operation) + if err != nil { + return + } + + if av.LayoutTypeCalendar != view.LayoutType || nil == view.Calendar { + return fmt.Errorf("view is not a calendar layout") + } + + weekStart, err := calendarWeekStartFromOperationData(operation.Data) + if err != nil { + return + } + + view.Calendar.WeekStart = weekStart + err = av.SaveAttributeView(attrView) + ReloadAttrView(attrView.ID) + return +} + +func calendarWeekStartFromOperationData(data any) (weekStart av.WeekStart, err error) { + number, err := calendarNumberFromOperationData(data, "week start") + if err != nil { + return + } + weekStart = av.WeekStart(number) + if av.WeekStartSunday != weekStart && av.WeekStartMonday != weekStart { + return weekStart, fmt.Errorf("calendar week start [%d] is invalid", weekStart) + } + return +} + +func (tx *Transaction) doSetAttrViewCalendarNewItemTarget(operation *Operation) (ret *TxErr) { + err := setAttrViewCalendarNewItemTarget(operation) + if err != nil { + return &TxErr{code: TxErrHandleAttributeView, id: operation.AvID, msg: err.Error()} + } + return +} + +// setAttrViewCalendarNewItemTarget 设置日历视图新建条目的目标:创建页面(文档)还是只创建游离行。 +func setAttrViewCalendarNewItemTarget(operation *Operation) (err error) { + attrView, err := av.ParseAttributeView(operation.AvID) + if err != nil { + return + } + + view, err := resolveAttrViewViewByOperation(attrView, operation) + if err != nil { + return + } + + if av.LayoutTypeCalendar != view.LayoutType || nil == view.Calendar { + return fmt.Errorf("view is not a calendar layout") + } + + target, err := calendarNewItemTargetFromOperationData(operation.Data) + if err != nil { + return + } + + view.Calendar.NewItemTarget = target + if av.CalendarNewItemTargetDocument == target { + ensureCalendarNewItemDocumentTemplate(attrView) + } + err = av.SaveAttributeView(attrView) + ReloadAttrView(attrView.ID) + return +} + +func calendarNewItemTargetFromOperationData(data any) (target av.CalendarNewItemTarget, err error) { + dataStr, ok := data.(string) + if !ok { + return "", fmt.Errorf("calendar new item target data must be a string") + } + if !av.IsCalendarNewItemTargetValid(dataStr) { + return "", fmt.Errorf("calendar new item target [%s] is invalid", dataStr) + } + return dataStr, nil +} + +// calendarNewItemTemplateName 返回日历条目页面模板的名称。 +// 只复用已有的 _attrView.calendar 词条,避免为了一个模板名去改动 21 个 langs 文件。 +func calendarNewItemTemplateName() string { + if langs, ok := util.AttrViewLangs[util.Lang]; ok { + if name, ok := langs["calendar"].(string); ok && "" != strings.TrimSpace(name) { + return strings.TrimSpace(name) + } + } + return "Calendar" +} + +// ensureCalendarNewItemDocumentTemplate 在数据库还没有任何「文档」类型的新增条目模板时补一个, +// 这样日历视图新建的条目就是一篇真正的页面。空 BoxID/PathTemplate 会解析成数据库块自己的笔记本与根文档, +// 也就是说条目页面成为放置该日历的文档的子文档。已有的文档类型模板不会被覆盖。 +func ensureCalendarNewItemDocumentTemplate(attrView *av.AttributeView) (ret *av.NewItemTemplate) { + if nil == attrView { + return + } + for _, itemTemplate := range attrView.NewItemTemplates { + if nil != itemTemplate && av.NewItemTargetDocument == itemTemplate.TargetType { + return itemTemplate + } + } + + ret = &av.NewItemTemplate{ + ID: ast.NewNodeID(), + Name: calendarNewItemTemplateName(), + TargetType: av.NewItemTargetDocument, + SaveLocation: &av.NewItemSaveLocation{BoxID: "", PathTemplate: ""}, + } + attrView.NewItemTemplates = append(attrView.NewItemTemplates, ret) + return +} + +func (tx *Transaction) doSetAttrViewCalendarFieldMapping(operation *Operation) (ret *TxErr) { + err := setAttrViewCalendarFieldMapping(operation) + if err != nil { + return &TxErr{code: TxErrHandleAttributeView, id: operation.AvID, msg: err.Error()} + } + return +} + +func setAttrViewCalendarFieldMapping(operation *Operation) (err error) { + attrView, err := av.ParseAttributeView(operation.AvID) + if err != nil { + return + } + + view, err := resolveAttrViewViewByOperation(attrView, operation) + if err != nil { + return + } + if av.LayoutTypeCalendar != view.LayoutType || nil == view.Calendar { + return fmt.Errorf("view is not a calendar layout") + } + + mapping, err := calendarFieldMappingFromOperationData(attrView, view.Calendar.FieldMapping, operation.Data) + if err != nil { + return + } + + view.Calendar.FieldMapping = mapping + err = av.SaveAttributeView(attrView) + ReloadAttrView(attrView.ID) + return +} + +func calendarFieldMappingFromOperationData(attrView *av.AttributeView, existing *av.CalendarFieldMapping, data any) (mapping *av.CalendarFieldMapping, err error) { + next := av.CalendarFieldMapping{} + if nil != existing { + next = *existing + } + dataMap, ok := data.(map[string]any) + if !ok { + return nil, fmt.Errorf("calendar field mapping data must be an object") + } + if val, exists := dataMap["recurrenceFieldID"]; exists { + if fieldID, ok := val.(string); ok { + if err = validateCalendarMappingField(attrView, fieldID, "recurrenceFieldID", av.KeyTypeText); err != nil { + return + } + next.RecurrenceFieldID = fieldID + } else if nil != val { + return nil, fmt.Errorf("recurrenceFieldID must be a string") + } + } + if val, exists := dataMap["exceptionFieldID"]; exists { + if fieldID, ok := val.(string); ok { + if err = validateCalendarMappingField(attrView, fieldID, "exceptionFieldID", av.KeyTypeText); err != nil { + return + } + next.ExceptionFieldID = fieldID + } else if nil != val { + return nil, fmt.Errorf("exceptionFieldID must be a string") + } + } + if val, exists := dataMap["locationFieldID"]; exists { + if fieldID, ok := val.(string); ok { + if err = validateCalendarMappingField(attrView, fieldID, "locationFieldID", av.KeyTypeText); err != nil { + return + } + next.LocationFieldID = fieldID + } else if nil != val { + return nil, fmt.Errorf("locationFieldID must be a string") + } + } + if val, exists := dataMap["descriptionFieldID"]; exists { + if fieldID, ok := val.(string); ok { + if err = validateCalendarMappingField(attrView, fieldID, "descriptionFieldID", av.KeyTypeText); err != nil { + return + } + next.DescriptionFieldID = fieldID + } else if nil != val { + return nil, fmt.Errorf("descriptionFieldID must be a string") + } + } + if val, exists := dataMap["colorFieldID"]; exists { + if fieldID, ok := val.(string); ok { + if err = validateCalendarMappingField(attrView, fieldID, "colorFieldID", av.KeyTypeSelect, av.KeyTypeMSelect); err != nil { + return + } + next.ColorFieldID = fieldID + } else if nil != val { + return nil, fmt.Errorf("colorFieldID must be a string") + } + } + if err = validateCalendarFieldMappingUnique(&next); err != nil { + return nil, err + } + return &next, nil +} + +func validateCalendarFieldMappingUnique(mapping *av.CalendarFieldMapping) (err error) { + if nil == mapping { + return + } + seen := map[string]string{} + fields := map[string]string{ + "recurrenceFieldID": mapping.RecurrenceFieldID, + "exceptionFieldID": mapping.ExceptionFieldID, + "locationFieldID": mapping.LocationFieldID, + "descriptionFieldID": mapping.DescriptionFieldID, + } + for name, fieldID := range fields { + if "" == fieldID { + continue + } + if previous, exists := seen[fieldID]; exists { + return fmt.Errorf("calendar metadata field [%s] duplicates [%s]", name, previous) + } + seen[fieldID] = name + } + return +} + +func validateCalendarMappingField(attrView *av.AttributeView, fieldID, fieldName string, allowedTypes ...av.KeyType) (err error) { + if "" == fieldID { + return + } + key, err := attrView.GetKey(fieldID) + if nil != err { + return fmt.Errorf("calendar mapping field [%s=%s] not found: %w", fieldName, fieldID, err) + } + for _, allowedType := range allowedTypes { + if key.Type == allowedType { + return nil + } + } + return fmt.Errorf("calendar mapping field [%s=%s] has invalid type [%s]", fieldName, fieldID, key.Type) +} + func AppendAttributeViewDetachedBlocksWithValues(avID string, blocksValues [][]*av.Value) (err error) { attrView, err := av.ParseAttributeView(avID) if err != nil { @@ -3687,6 +4153,11 @@ func genAttrViewGroups(view *av.View, attrView *av.AttributeView) { return } + if av.LayoutTypeCalendar == view.LayoutType { + // 日历视图不支持分组,静默忽略而不是反复输出警告日志 + return + } + groupStates := getAttrViewGroupStates(view) group := view.Group @@ -3873,6 +4344,9 @@ func genAttrViewGroups(view *av.View, attrView *av.AttributeView) { case av.LayoutTypeKanban: v = av.NewKanbanView() v.Kanban = av.NewLayoutKanban() + case av.LayoutTypeCalendar: + v = av.NewCalendarView() + v.Calendar = av.NewLayoutCalendar() default: logging.LogWarnf("unknown layout type [%s] for group view", view.LayoutType) return @@ -4405,6 +4879,8 @@ func updateAttributeViewColRelation(operation *Operation) (err error) { v.Gallery.CardFields = append(v.Gallery.CardFields, &av.ViewGalleryCardField{BaseField: &av.BaseField{ID: operation.BackRelationKeyID}}) case av.LayoutTypeKanban: v.Kanban.Fields = append(v.Kanban.Fields, &av.ViewKanbanField{BaseField: &av.BaseField{ID: operation.BackRelationKeyID}}) + case av.LayoutTypeCalendar: + v.Calendar.Fields = append(v.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: operation.BackRelationKeyID}}) } } @@ -4709,30 +5185,10 @@ func (tx *Transaction) doDuplicateAttrViewView(operation *Operation) (ret *TxErr return &TxErr{code: TxErrHandleAttributeView, id: operation.AvID} } - visibleViewIDs := attrView.GetVisibleViewIDs(node.IALAttr(av.NodeAttrVisibleViewIDs)) - visibleViewIDs = append(visibleViewIDs, operation.ID) - if err = freezeOtherAttrViewBlockVisibleViews(attrView, operation.BlockID, tree); nil != err { - return &TxErr{code: TxErrHandleAttributeView, id: operation.AvID, msg: err.Error()} - } - - attrs := parse.IAL2Map(node.KramdownIAL) - attrs[av.NodeAttrView] = operation.ID - attrs[av.NodeAttrVisibleViewIDs] = strings.Join(visibleViewIDs, ",") - node.AttributeViewType = string(masterView.LayoutType) - err = setNodeAttrs(node, tree, attrs) - if err != nil { - logging.LogWarnf("set node [%s] attrs failed: %s", operation.BlockID, err) - return - } - - var view *av.View - switch masterView.LayoutType { - case av.LayoutTypeTable: - view = av.NewTableView() - case av.LayoutTypeGallery: - view = av.NewGalleryView() - case av.LayoutTypeKanban: - view = av.NewKanbanView() + view := newAttrViewViewByLayoutType(masterView.LayoutType) + if nil == view { + logging.LogErrorf("wrong layout type [%s] for attribute view [%s]", masterView.LayoutType, avID) + return &TxErr{code: TxErrHandleAttributeView, id: avID, msg: av.ErrWrongLayoutType.Error()} } view.ID = operation.ID @@ -4756,6 +5212,55 @@ func (tx *Transaction) doDuplicateAttrViewView(operation *Operation) (ret *TxErr }) } + copyAttrViewViewLayout(view, masterView) + + view.ItemIDs = masterView.ItemIDs + + if nil != masterView.Group { + view.Group = &av.ViewGroup{} + if copyErr := copier.Copy(view.Group, masterView.Group); nil != copyErr { + logging.LogErrorf("copy group failed: %s", copyErr) + return &TxErr{code: TxErrHandleAttributeView, id: avID, msg: copyErr.Error()} + } + + view.GroupItemIDs = masterView.GroupItemIDs + regenAttrViewGroups(attrView) + } + + // 视图构建成功后才把块指向新视图,否则失败时块会指向一个不存在的视图 + attrs := parse.IAL2Map(node.KramdownIAL) + attrs[av.NodeAttrView] = operation.ID + node.AttributeViewType = string(masterView.LayoutType) + err = setNodeAttrs(node, tree, attrs) + if err != nil { + logging.LogWarnf("set node [%s] attrs failed: %s", operation.BlockID, err) + return + } + + if err = av.SaveAttributeView(attrView); err != nil { + logging.LogErrorf("save attribute view [%s] failed: %s", avID, err) + return &TxErr{code: TxErrHandleAttributeView, msg: err.Error(), id: avID} + } + return +} + +// newAttrViewViewByLayoutType 按布局类型创建视图,未知布局返回 nil。 +func newAttrViewViewByLayoutType(layoutType av.LayoutType) (ret *av.View) { + switch layoutType { + case av.LayoutTypeTable: + ret = av.NewTableView() + case av.LayoutTypeGallery: + ret = av.NewGalleryView() + case av.LayoutTypeKanban: + ret = av.NewKanbanView() + case av.LayoutTypeCalendar: + ret = av.NewCalendarView() + } + return +} + +// copyAttrViewViewLayout 把主视图的布局配置复制到新视图上。 +func copyAttrViewViewLayout(view, masterView *av.View) { switch masterView.LayoutType { case av.LayoutTypeTable: for _, col := range masterView.Table.Columns { @@ -4826,26 +5331,31 @@ func (tx *Transaction) doDuplicateAttrViewView(operation *Operation) (ret *TxErr view.Kanban.FillColBackgroundColor = masterView.Kanban.FillColBackgroundColor view.Kanban.ShowIcon = masterView.Kanban.ShowIcon view.Kanban.WrapField = masterView.Kanban.WrapField - } - - view.ItemIDs = masterView.ItemIDs - - if nil != masterView.Group { - view.Group = &av.ViewGroup{} - if copyErr := copier.Copy(view.Group, masterView.Group); nil != copyErr { - logging.LogErrorf("copy group failed: %s", copyErr) - return &TxErr{code: TxErrHandleAttributeView, id: avID, msg: copyErr.Error()} + case av.LayoutTypeCalendar: + for _, field := range masterView.Calendar.Fields { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{ + BaseField: &av.BaseField{ + ID: field.ID, + Wrap: field.Wrap, + Hidden: field.Hidden, + Desc: field.Desc, + }, + }) } - view.GroupItemIDs = masterView.GroupItemIDs - regenAttrViewGroups(attrView) - } - - if err = av.SaveAttributeView(attrView); err != nil { - logging.LogErrorf("save attribute view [%s] failed: %s", avID, err) - return &TxErr{code: TxErrHandleAttributeView, msg: err.Error(), id: avID} + view.Calendar.DateFieldID = masterView.Calendar.DateFieldID + view.Calendar.ViewMode = masterView.Calendar.ViewMode + view.Calendar.WeekStart = masterView.Calendar.WeekStart + // 复制视图沿用源视图的「新建条目目标」,包括源视图是历史视图时的 ""(只建行)。 + view.Calendar.NewItemTarget = masterView.Calendar.NewItemTarget + if nil != masterView.Calendar.FieldMapping { + // 复制值而不是指针,避免复制出来的视图和原视图共享映射 https://github.com/siyuan-note/siyuan/issues/15587 + mapping := *masterView.Calendar.FieldMapping + view.Calendar.FieldMapping = &mapping + } + view.Calendar.ShowIcon = masterView.Calendar.ShowIcon + view.Calendar.WrapField = masterView.Calendar.WrapField } - return } func (tx *Transaction) doAddAttrViewView(operation *Operation) (ret *TxErr) { @@ -4897,6 +5407,10 @@ func addAttrViewView(avID, viewID, blockID string, layout av.LayoutType) (err er for _, field := range firstView.Kanban.Fields { view.Table.Columns = append(view.Table.Columns, &av.ViewTableColumn{BaseField: &av.BaseField{ID: field.ID}}) } + case av.LayoutTypeCalendar: + for _, field := range firstView.Calendar.Fields { + view.Table.Columns = append(view.Table.Columns, &av.ViewTableColumn{BaseField: &av.BaseField{ID: field.ID}}) + } } case av.LayoutTypeGallery: view = av.NewGalleryView() @@ -4913,6 +5427,10 @@ func addAttrViewView(avID, viewID, blockID string, layout av.LayoutType) (err er for _, field := range firstView.Kanban.Fields { view.Gallery.CardFields = append(view.Gallery.CardFields, &av.ViewGalleryCardField{BaseField: &av.BaseField{ID: field.ID}}) } + case av.LayoutTypeCalendar: + for _, field := range firstView.Calendar.Fields { + view.Gallery.CardFields = append(view.Gallery.CardFields, &av.ViewGalleryCardField{BaseField: &av.BaseField{ID: field.ID}}) + } } case av.LayoutTypeKanban: view = av.NewKanbanView() @@ -4929,6 +5447,36 @@ func addAttrViewView(avID, viewID, blockID string, layout av.LayoutType) (err er for _, field := range firstView.Kanban.Fields { view.Kanban.Fields = append(view.Kanban.Fields, &av.ViewKanbanField{BaseField: &av.BaseField{ID: field.ID}}) } + case av.LayoutTypeCalendar: + for _, field := range firstView.Calendar.Fields { + view.Kanban.Fields = append(view.Kanban.Fields, &av.ViewKanbanField{BaseField: &av.BaseField{ID: field.ID}}) + } + } + case av.LayoutTypeCalendar: + view = av.NewCalendarView() + switch firstView.LayoutType { + case av.LayoutTypeTable: + for _, col := range firstView.Table.Columns { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: col.ID}}) + } + case av.LayoutTypeGallery: + for _, field := range firstView.Gallery.CardFields { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: field.ID}}) + } + case av.LayoutTypeKanban: + for _, field := range firstView.Kanban.Fields { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: field.ID}}) + } + case av.LayoutTypeCalendar: + for _, field := range firstView.Calendar.Fields { + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: &av.BaseField{ID: field.ID}}) + } + if nil != firstView.Calendar.FieldMapping { + mapping := *firstView.Calendar.FieldMapping + view.Calendar.FieldMapping = &mapping + } + view.Calendar.ViewMode = firstView.Calendar.ViewMode + view.Calendar.WeekStart = firstView.Calendar.WeekStart } default: err = av.ErrWrongLayoutType @@ -4958,6 +5506,21 @@ func addAttrViewView(avID, viewID, blockID string, layout av.LayoutType) (err er setAttributeViewGroup(attrView, view, group) } + if av.LayoutTypeCalendar == layout { + if "" == view.Calendar.DateFieldID { + view.Calendar.DateFieldID = getCalendarPreferredDateKey(attrView).ID + } + if av.CalendarNewItemTargetDocument == view.Calendar.NewItemTarget { + ensureCalendarNewItemDocumentTemplate(attrView) + } + } + + node, tree, _ := getNodeByBlockID(nil, blockID) + if nil == node { + logging.LogErrorf("get node by block ID [%s] failed", blockID) + return + } + node.AttributeViewType = string(view.LayoutType) attrs := parse.IAL2Map(node.KramdownIAL) attrs[av.NodeAttrView] = viewID @@ -5003,6 +5566,50 @@ func getKanbanPreferredGroupKey(attrView *av.AttributeView) (ret *av.Key) { newField.Wrap = view.Kanban.WrapField view.Kanban.Fields = append(view.Kanban.Fields, &av.ViewKanbanField{BaseField: newField}) } + + if nil != view.Calendar { + newField.Wrap = view.Calendar.WrapField + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: newField}) + } + } + } + return +} + +// getCalendarPreferredDateKey 返回日历布局可用的日期字段,没有时新建一个并同步到所有视图。 +func getCalendarPreferredDateKey(attrView *av.AttributeView) (ret *av.Key) { + for _, kv := range attrView.KeyValues { + if av.KeyTypeDate == kv.Key.Type { + ret = kv.Key + break + } + } + + if nil == ret { + name := av.GetAttributeViewI18n("date") + ret = av.NewKey(ast.NewNodeID(), name, "", av.KeyTypeDate) + attrView.KeyValues = append(attrView.KeyValues, &av.KeyValues{Key: ret}) + for _, view := range attrView.Views { + newField := &av.BaseField{ID: ret.ID} + if nil != view.Table { + newField.Wrap = view.Table.WrapField + view.Table.Columns = append(view.Table.Columns, &av.ViewTableColumn{BaseField: newField}) + } + + if nil != view.Gallery { + newField.Wrap = view.Gallery.WrapField + view.Gallery.CardFields = append(view.Gallery.CardFields, &av.ViewGalleryCardField{BaseField: newField}) + } + + if nil != view.Kanban { + newField.Wrap = view.Kanban.WrapField + view.Kanban.Fields = append(view.Kanban.Fields, &av.ViewKanbanField{BaseField: newField}) + } + + if nil != view.Calendar { + newField.Wrap = view.Calendar.WrapField + view.Calendar.Fields = append(view.Calendar.Fields, &av.ViewCalendarCardField{BaseField: newField}) + } } } return @@ -6252,6 +6859,22 @@ func duplicateAttributeViewKey(operation *Operation) (err error) { break } } + case av.LayoutTypeCalendar: + for i, field := range view.Calendar.Fields { + if field.ID == key.ID { + view.Calendar.Fields = append(view.Calendar.Fields[:i+1], append([]*av.ViewCalendarCardField{ + { + BaseField: &av.BaseField{ + ID: copyKey.ID, + Wrap: field.Wrap, + Hidden: field.Hidden, + Desc: field.Desc, + }, + }, + }, view.Calendar.Fields[i+1:]...)...) + break + } + } } } @@ -6420,6 +7043,14 @@ func setAttributeViewColWrap(operation *Operation) (err error) { allFieldWrap = allFieldWrap && field.Wrap } view.Kanban.WrapField = allFieldWrap + case av.LayoutTypeCalendar: + for _, field := range view.Calendar.Fields { + if field.ID == operation.ID { + field.Wrap = newWrap + } + allFieldWrap = allFieldWrap && field.Wrap + } + view.Calendar.WrapField = allFieldWrap } err = av.SaveAttributeView(attrView) @@ -6520,6 +7151,13 @@ func getAttributeViewField(view *av.View, keyID string) (ret *av.BaseField) { return field.BaseField } } + case av.LayoutTypeCalendar: + for _, field := range view.Calendar.Fields { + if field.ID == operation.ID { + field.Hidden = operation.Data.(bool) + break + } + } } return } @@ -6820,6 +7458,27 @@ func SortAttributeViewViewKey(avID, blockID, keyID, previousKeyID string) (err e } } view.Kanban.Fields = util.InsertElem(view.Kanban.Fields, previousIndex, field) + case av.LayoutTypeCalendar: + var field *av.ViewCalendarCardField + for i, calendarField := range view.Calendar.Fields { + if calendarField.ID == keyID { + field = calendarField + curIndex = i + break + } + } + if nil == field { + return + } + + view.Calendar.Fields = append(view.Calendar.Fields[:curIndex], view.Calendar.Fields[curIndex+1:]...) + for i, calendarField := range view.Calendar.Fields { + if calendarField.ID == previousKeyID { + previousIndex = i + 1 + break + } + } + view.Calendar.Fields = util.InsertElem(view.Calendar.Fields, previousIndex, field) } err = av.SaveAttributeView(attrView) @@ -7012,6 +7671,10 @@ func AddAttributeViewKey(avID, keyID, keyName, keyType, keyIcon, previousKeyID s } } } + + if nil != view.Calendar { + addCalendarField(view.Calendar, newField, previousKeyID) + } } default: return fmt.Errorf("unsupported attribute view key type [%s]", keyType) @@ -7021,6 +7684,21 @@ func AddAttributeViewKey(avID, keyID, keyName, keyType, keyIcon, previousKeyID s return } +func addCalendarField(calendar *av.LayoutCalendar, field *av.BaseField, previousKeyID string) { + field.Wrap = calendar.WrapField + if "" == previousKeyID { + calendar.Fields = append(calendar.Fields, &av.ViewCalendarCardField{BaseField: field}) + return + } + for i, existing := range calendar.Fields { + if existing.ID == previousKeyID { + calendar.Fields = append(calendar.Fields[:i+1], append([]*av.ViewCalendarCardField{{BaseField: field}}, calendar.Fields[i+1:]...)...) + return + } + } + calendar.Fields = append(calendar.Fields, &av.ViewCalendarCardField{BaseField: field}) +} + func (tx *Transaction) doUpdateAttrViewColTemplate(operation *Operation) (ret *TxErr) { err := updateAttributeViewColTemplate(operation) if err != nil { @@ -7168,6 +7846,9 @@ func updateAttributeViewColumn(operation *Operation) (err error) { removeAttributeViewGroup0(view) } } + if nil != view.Calendar { + pruneCalendarFieldReferencesByType(view.Calendar, operation.ID, colType) + } } removeAttrViewColumnFromFieldFilters(attrView, attrView.ID, operation.ID) } @@ -7349,6 +8030,10 @@ func RemoveAttributeViewKey(avID, keyID string, removeRelationDest bool) (err er } } } + + if nil != view.Calendar { + removeCalendarFieldReferences(view.Calendar, keyID) + } } for _, view := range attrView.Views { @@ -7396,6 +8081,68 @@ func RemoveAttributeViewKey(avID, keyID string, removeRelationDest bool) (err er return } +// pruneCalendarFieldReferencesByType 在字段类型变更后清理日历视图中不再兼容的字段引用, +// 避免残留的映射阻塞后续 setAttrViewCalendarFieldMapping 校验。 +func pruneCalendarFieldReferencesByType(calendar *av.LayoutCalendar, keyID string, keyType av.KeyType) { + if nil == calendar { + return + } + if calendar.DateFieldID == keyID && av.KeyTypeDate != keyType { + calendar.DateFieldID = "" + } + if nil == calendar.FieldMapping { + return + } + // 模板字段由内核计算,不能作为可写的日历元数据映射目标。 + textLike := av.KeyTypeText == keyType + if calendar.FieldMapping.RecurrenceFieldID == keyID && !textLike { + calendar.FieldMapping.RecurrenceFieldID = "" + } + if calendar.FieldMapping.ExceptionFieldID == keyID && !textLike { + calendar.FieldMapping.ExceptionFieldID = "" + } + if calendar.FieldMapping.LocationFieldID == keyID && !textLike { + calendar.FieldMapping.LocationFieldID = "" + } + if calendar.FieldMapping.DescriptionFieldID == keyID && !textLike { + calendar.FieldMapping.DescriptionFieldID = "" + } + selectLike := av.KeyTypeSelect == keyType || av.KeyTypeMSelect == keyType + if calendar.FieldMapping.ColorFieldID == keyID && !selectLike { + calendar.FieldMapping.ColorFieldID = "" + } +} + +func removeCalendarFieldReferences(calendar *av.LayoutCalendar, keyID string) { + if calendar.DateFieldID == keyID { + calendar.DateFieldID = "" + } + for i, field := range calendar.Fields { + if field.ID == keyID { + calendar.Fields = append(calendar.Fields[:i], calendar.Fields[i+1:]...) + break + } + } + if nil == calendar.FieldMapping { + return + } + if calendar.FieldMapping.RecurrenceFieldID == keyID { + calendar.FieldMapping.RecurrenceFieldID = "" + } + if calendar.FieldMapping.ExceptionFieldID == keyID { + calendar.FieldMapping.ExceptionFieldID = "" + } + if calendar.FieldMapping.LocationFieldID == keyID { + calendar.FieldMapping.LocationFieldID = "" + } + if calendar.FieldMapping.DescriptionFieldID == keyID { + calendar.FieldMapping.DescriptionFieldID = "" + } + if calendar.FieldMapping.ColorFieldID == keyID { + calendar.FieldMapping.ColorFieldID = "" + } +} + func (tx *Transaction) doReplaceAttrViewBlock(operation *Operation) (ret *TxErr) { targetItemID, duplicate, err := replaceAttributeViewBlock(operation.AvID, operation.PreviousID, operation.NextID, operation.IsDetached, tx) if err != nil { @@ -7559,9 +8306,18 @@ func BatchUpdateAttributeViewCells(tx *Transaction, avID string, values []any) ( } func UpdateAttributeViewCell(tx *Transaction, avID, keyID, itemID string, valueData any) (val *av.Value, err error) { - attrView, err := av.ParseAttributeView(avID) - if err != nil { - return + var attrView *av.AttributeView + if tx != nil && tx.deferAttrViewSave { + attrView = tx.deferredAttrViews[avID] + } + if attrView == nil { + attrView, err = av.ParseAttributeView(avID) + if err != nil { + return + } + if tx != nil && tx.deferAttrViewSave { + tx.deferredAttrViews[avID] = attrView + } } val, err = updateAttributeViewValue(tx, attrView, keyID, itemID, valueData, true) @@ -7627,6 +8383,13 @@ func updateAttributeViewValue(tx *Transaction, attrView *av.AttributeView, keyID logging.LogErrorf("marshal value [%+v] failed: %s", valueData, err) return } + // Decode into a clean value. JSON omits empty slices/pointers, so decoding + // directly over the existing cell would preserve stale nested data and make + // undoing a first-time field value impossible. + *val = av.Value{ + ID: val.ID, KeyID: keyID, BlockID: itemID, Type: val.Type, + CreatedAt: val.CreatedAt, UpdatedAt: val.UpdatedAt, + } if err = gulu.JSON.UnmarshalJSON(data, &val); err != nil { logging.LogErrorf("unmarshal data [%s] failed: %s", data, err) return @@ -7757,7 +8520,10 @@ func updateAttributeViewValue(tx *Transaction, attrView *av.AttributeView, keyID if save { regenAttrViewGroups(attrView) - if err = av.SaveAttributeView(attrView); nil != err { + if tx != nil && tx.deferAttrViewSave { + tx.deferredAttrViews[avID] = attrView + tx.relatedAvIDs = append(tx.relatedAvIDs, avID) + } else if err = av.SaveAttributeView(attrView); nil != err { return } } diff --git a/kernel/model/attribute_view_calendar_test.go b/kernel/model/attribute_view_calendar_test.go new file mode 100644 index 00000000000..d0d2c2be7a8 --- /dev/null +++ b/kernel/model/attribute_view_calendar_test.go @@ -0,0 +1,654 @@ +package model + +import ( + "errors" + "testing" + + "github.com/siyuan-note/siyuan/kernel/av" + "github.com/siyuan-note/siyuan/kernel/util" +) + +func TestValidateCalendarMappingField(t *testing.T) { + attrView := &av.AttributeView{ + KeyValues: []*av.KeyValues{ + {Key: &av.Key{ID: "text", Type: av.KeyTypeText}}, + {Key: &av.Key{ID: "template", Type: av.KeyTypeTemplate}}, + {Key: &av.Key{ID: "select", Type: av.KeyTypeSelect}}, + {Key: &av.Key{ID: "mSelect", Type: av.KeyTypeMSelect}}, + {Key: &av.Key{ID: "date", Type: av.KeyTypeDate}}, + }, + } + + if err := validateCalendarMappingField(attrView, "", "empty", av.KeyTypeText); nil != err { + t.Fatalf("empty field should clear mapping without error: %v", err) + } + if err := validateCalendarMappingField(attrView, "text", "recurrenceFieldID", av.KeyTypeText); nil != err { + t.Fatalf("text field should be accepted: %v", err) + } + // 可写元数据映射只允许纯文本字段:模板单元格每次渲染都会被重算,写入会丢失 + if err := validateCalendarMappingField(attrView, "template", "descriptionFieldID", av.KeyTypeText); nil == err { + t.Fatal("template field should be rejected for writable metadata mappings") + } + if err := validateCalendarMappingField(attrView, "select", "colorFieldID", av.KeyTypeSelect, av.KeyTypeMSelect); nil != err { + t.Fatalf("select field should be accepted for color mapping: %v", err) + } + if err := validateCalendarMappingField(attrView, "mSelect", "colorFieldID", av.KeyTypeSelect, av.KeyTypeMSelect); nil != err { + t.Fatalf("mSelect field should be accepted for color mapping: %v", err) + } + if err := validateCalendarMappingField(attrView, "date", "colorFieldID", av.KeyTypeSelect, av.KeyTypeMSelect); nil == err { + t.Fatal("date field should be rejected for color mapping") + } + if err := validateCalendarMappingField(attrView, "missing", "locationFieldID", av.KeyTypeText); nil == err { + t.Fatal("missing field should be rejected") + } +} + +func TestCalendarDateFieldFromOperationData(t *testing.T) { + attrView := &av.AttributeView{ + KeyValues: []*av.KeyValues{ + {Key: &av.Key{ID: "date", Type: av.KeyTypeDate}}, + {Key: &av.Key{ID: "text", Type: av.KeyTypeText}}, + }, + } + + dateFieldID, err := calendarDateFieldFromOperationData(attrView, &Operation{KeyID: "date"}) + if err != nil { + t.Fatalf("date keyID should be accepted: %v", err) + } + if dateFieldID != "date" { + t.Fatalf("expected date field from keyID, got %s", dateFieldID) + } + + dateFieldID, err = calendarDateFieldFromOperationData(attrView, &Operation{Data: "date"}) + if err != nil { + t.Fatalf("date data should be accepted: %v", err) + } + if dateFieldID != "date" { + t.Fatalf("expected date field from data, got %s", dateFieldID) + } + + dateFieldID, err = calendarDateFieldFromOperationData(attrView, &Operation{Data: ""}) + if err != nil { + t.Fatalf("empty data should clear date field without error: %v", err) + } + if dateFieldID != "" { + t.Fatalf("expected empty date field, got %s", dateFieldID) + } + + if _, err = calendarDateFieldFromOperationData(attrView, &Operation{Data: float64(1)}); err == nil { + t.Fatal("non-string data should be rejected") + } + if _, err = calendarDateFieldFromOperationData(attrView, &Operation{Data: "text"}); err == nil { + t.Fatal("non-date field should be rejected") + } + if _, err = calendarDateFieldFromOperationData(attrView, &Operation{Data: "missing"}); err == nil { + t.Fatal("missing field should be rejected") + } +} + +func TestValidateCalendarFieldMappingUnique(t *testing.T) { + if err := validateCalendarFieldMappingUnique(nil); err != nil { + t.Fatalf("nil mapping should be accepted: %v", err) + } + if err := validateCalendarFieldMappingUnique(&av.CalendarFieldMapping{ + RecurrenceFieldID: "recurrence", + ExceptionFieldID: "exception", + LocationFieldID: "location", + DescriptionFieldID: "description", + ColorFieldID: "recurrence", + }); err != nil { + t.Fatalf("color mapping may reuse text metadata field IDs because it has a different key type: %v", err) + } + if err := validateCalendarFieldMappingUnique(&av.CalendarFieldMapping{ + RecurrenceFieldID: "metadata", + ExceptionFieldID: "metadata", + }); err == nil { + t.Fatal("duplicate text metadata fields should be rejected") + } +} + +func TestCalendarFieldMappingFromOperationDataMergesExisting(t *testing.T) { + attrView := &av.AttributeView{ + KeyValues: []*av.KeyValues{ + {Key: &av.Key{ID: "recurrence", Type: av.KeyTypeText}}, + {Key: &av.Key{ID: "exception", Type: av.KeyTypeText}}, + {Key: &av.Key{ID: "location", Type: av.KeyTypeText}}, + {Key: &av.Key{ID: "description", Type: av.KeyTypeTemplate}}, + {Key: &av.Key{ID: "color", Type: av.KeyTypeSelect}}, + {Key: &av.Key{ID: "newColor", Type: av.KeyTypeMSelect}}, + }, + } + existing := &av.CalendarFieldMapping{ + RecurrenceFieldID: "recurrence", + ExceptionFieldID: "exception", + LocationFieldID: "location", + DescriptionFieldID: "description", + ColorFieldID: "color", + } + + mapping, err := calendarFieldMappingFromOperationData(attrView, existing, map[string]any{ + "colorFieldID": "newColor", + }) + if err != nil { + t.Fatalf("partial mapping update should be accepted: %v", err) + } + if mapping.RecurrenceFieldID != "recurrence" || mapping.ExceptionFieldID != "exception" || + mapping.LocationFieldID != "location" || mapping.DescriptionFieldID != "description" || + mapping.ColorFieldID != "newColor" { + t.Fatalf("partial update should preserve existing mapping fields: %#v", mapping) + } + + mapping, err = calendarFieldMappingFromOperationData(attrView, existing, map[string]any{ + "locationFieldID": "", + }) + if err != nil { + t.Fatalf("empty field should clear only that mapping: %v", err) + } + if mapping.LocationFieldID != "" || mapping.RecurrenceFieldID != "recurrence" { + t.Fatalf("empty update should clear only requested mapping: %#v", mapping) + } +} + +func TestCalendarWeekStartFromOperationData(t *testing.T) { + weekStart, err := calendarWeekStartFromOperationData(float64(0)) + if err != nil { + t.Fatalf("float sunday should be accepted: %v", err) + } + if weekStart != av.WeekStartSunday { + t.Fatalf("expected sunday, got %d", weekStart) + } + + weekStart, err = calendarWeekStartFromOperationData(1) + if err != nil { + t.Fatalf("int monday should be accepted: %v", err) + } + if weekStart != av.WeekStartMonday { + t.Fatalf("expected monday, got %d", weekStart) + } + + if _, err = calendarWeekStartFromOperationData(float64(2)); err == nil { + t.Fatal("invalid week start should be rejected") + } + if _, err = calendarWeekStartFromOperationData(float64(1.5)); err == nil { + t.Fatal("fractional week start should be rejected") + } + if _, err = calendarWeekStartFromOperationData("1"); err == nil { + t.Fatal("non-number week start should be rejected") + } +} + +func TestCalendarViewModeFromOperationData(t *testing.T) { + viewMode, err := calendarViewModeFromOperationData(float64(0)) + if err != nil { + t.Fatalf("float month should be accepted: %v", err) + } + if viewMode != av.ViewModeMonth { + t.Fatalf("expected month, got %d", viewMode) + } + + viewMode, err = calendarViewModeFromOperationData(3) + if err != nil { + t.Fatalf("int schedule should be accepted: %v", err) + } + if viewMode != av.ViewModeSchedule { + t.Fatalf("expected schedule, got %d", viewMode) + } + + viewMode, err = calendarViewModeFromOperationData(float64(4)) + if err != nil { + t.Fatalf("float year should be accepted: %v", err) + } + if viewMode != av.ViewModeYear { + t.Fatalf("expected year, got %d", viewMode) + } + + viewMode, err = calendarViewModeFromOperationData(5) + if err != nil { + t.Fatalf("int five-day should be accepted: %v", err) + } + if viewMode != av.ViewModeFiveDay { + t.Fatalf("expected five-day, got %d", viewMode) + } + + if _, err = calendarViewModeFromOperationData(float64(6)); err == nil { + t.Fatal("invalid view mode should be rejected") + } + if _, err = calendarViewModeFromOperationData(float64(1.5)); err == nil { + t.Fatal("fractional view mode should be rejected") + } + if _, err = calendarViewModeFromOperationData("1"); err == nil { + t.Fatal("non-number view mode should be rejected") + } +} + +func TestAddCalendarField(t *testing.T) { + calendar := &av.LayoutCalendar{ + BaseLayout: &av.BaseLayout{WrapField: true}, + Fields: []*av.ViewCalendarCardField{ + {BaseField: &av.BaseField{ID: "first"}}, + {BaseField: &av.BaseField{ID: "second"}}, + }, + } + + addCalendarField(calendar, &av.BaseField{ID: "inserted"}, "first") + if len(calendar.Fields) != 3 { + t.Fatalf("expected 3 fields, got %d", len(calendar.Fields)) + } + if calendar.Fields[1].ID != "inserted" { + t.Fatalf("expected inserted field after first, got %s", calendar.Fields[1].ID) + } + if !calendar.Fields[1].Wrap { + t.Fatal("inserted field should inherit calendar wrap setting") + } + + addCalendarField(calendar, &av.BaseField{ID: "fallback"}, "missing") + if calendar.Fields[len(calendar.Fields)-1].ID != "fallback" { + t.Fatalf("missing previous field should append, got %s", calendar.Fields[len(calendar.Fields)-1].ID) + } +} + +func TestRemoveCalendarFieldReferences(t *testing.T) { + calendar := &av.LayoutCalendar{ + DateFieldID: "date", + Fields: []*av.ViewCalendarCardField{ + {BaseField: &av.BaseField{ID: "date"}}, + {BaseField: &av.BaseField{ID: "recurrence"}}, + {BaseField: &av.BaseField{ID: "color"}}, + }, + FieldMapping: &av.CalendarFieldMapping{ + RecurrenceFieldID: "recurrence", + ExceptionFieldID: "exception", + LocationFieldID: "location", + DescriptionFieldID: "description", + ColorFieldID: "color", + }, + } + + removeCalendarFieldReferences(calendar, "date") + if calendar.DateFieldID != "" { + t.Fatalf("date field should be cleared, got %s", calendar.DateFieldID) + } + if len(calendar.Fields) != 2 || calendar.Fields[0].ID != "recurrence" { + t.Fatalf("date field should be removed from fields: %#v", calendar.Fields) + } + + removeCalendarFieldReferences(calendar, "recurrence") + if calendar.FieldMapping.RecurrenceFieldID != "" { + t.Fatalf("recurrence mapping should be cleared, got %s", calendar.FieldMapping.RecurrenceFieldID) + } + removeCalendarFieldReferences(calendar, "color") + if calendar.FieldMapping.ColorFieldID != "" { + t.Fatalf("color mapping should be cleared, got %s", calendar.FieldMapping.ColorFieldID) + } +} + +func TestPruneCalendarFieldReferencesByType(t *testing.T) { + newCalendar := func() *av.LayoutCalendar { + return &av.LayoutCalendar{ + DateFieldID: "date", + FieldMapping: &av.CalendarFieldMapping{ + RecurrenceFieldID: "recurrence", + ExceptionFieldID: "exception", + LocationFieldID: "location", + DescriptionFieldID: "description", + ColorFieldID: "color", + }, + } + } + + calendar := newCalendar() + pruneCalendarFieldReferencesByType(calendar, "date", av.KeyTypeNumber) + if calendar.DateFieldID != "" { + t.Fatalf("date field should be cleared after type change, got %s", calendar.DateFieldID) + } + + calendar = newCalendar() + pruneCalendarFieldReferencesByType(calendar, "recurrence", av.KeyTypeNumber) + if calendar.FieldMapping.RecurrenceFieldID != "" { + t.Fatalf("recurrence mapping should be cleared after type change, got %s", calendar.FieldMapping.RecurrenceFieldID) + } + + // 模板字段由内核计算,写入会被丢弃,所以改成模板类型后映射必须清除。 + calendar = newCalendar() + pruneCalendarFieldReferencesByType(calendar, "recurrence", av.KeyTypeTemplate) + if calendar.FieldMapping.RecurrenceFieldID != "" { + t.Fatalf("recurrence mapping should be cleared when the key becomes a template, got %s", calendar.FieldMapping.RecurrenceFieldID) + } + + calendar = newCalendar() + pruneCalendarFieldReferencesByType(calendar, "description", av.KeyTypeText) + if calendar.FieldMapping.DescriptionFieldID != "description" { + t.Fatalf("text stays valid for description mapping, got %s", calendar.FieldMapping.DescriptionFieldID) + } + + calendar = newCalendar() + pruneCalendarFieldReferencesByType(calendar, "color", av.KeyTypeText) + if calendar.FieldMapping.ColorFieldID != "" { + t.Fatalf("color mapping should be cleared after type change, got %s", calendar.FieldMapping.ColorFieldID) + } + + calendar = newCalendar() + pruneCalendarFieldReferencesByType(calendar, "color", av.KeyTypeMSelect) + if calendar.FieldMapping.ColorFieldID != "color" { + t.Fatalf("mSelect stays valid for color mapping, got %s", calendar.FieldMapping.ColorFieldID) + } + + calendar = newCalendar() + pruneCalendarFieldReferencesByType(calendar, "unrelated", av.KeyTypeNumber) + if calendar.DateFieldID != "date" || calendar.FieldMapping.LocationFieldID != "location" { + t.Fatalf("unrelated key must not touch calendar references: %#v", calendar.FieldMapping) + } +} + +// seedCalendarI18n 让 av.NewCalendarView() 在单元测试里可用(它会读取 i18n 词条)。 +func seedCalendarI18n(t *testing.T) { + t.Helper() + if _, ok := util.AttrViewLangs[util.Lang]["calendar"]; ok { + return + } + prevLang := util.Lang + util.Lang = "test" + if nil == util.AttrViewLangs { + util.AttrViewLangs = map[string]map[string]any{} + } + util.AttrViewLangs["test"] = map[string]any{"calendar": "Calendar"} + t.Cleanup(func() { + delete(util.AttrViewLangs, "test") + util.Lang = prevLang + }) +} + +// K1: 复制日历视图不能空指针,并且必须保留日历专有配置。 +func TestDuplicateCalendarViewLayout(t *testing.T) { + seedCalendarI18n(t) + + masterView := av.NewCalendarView() + masterView.Calendar.Fields = []*av.ViewCalendarCardField{ + {BaseField: &av.BaseField{ID: "date", Wrap: true, Hidden: false, Desc: "when"}}, + {BaseField: &av.BaseField{ID: "location", Wrap: false, Hidden: true, Desc: "where"}}, + } + masterView.Calendar.DateFieldID = "date" + masterView.Calendar.ViewMode = av.ViewModeWeek + masterView.Calendar.WeekStart = av.WeekStartMonday + masterView.Calendar.ShowIcon = false + masterView.Calendar.WrapField = true + masterView.Calendar.FieldMapping = &av.CalendarFieldMapping{ + RecurrenceFieldID: "recurrence", + ExceptionFieldID: "exception", + LocationFieldID: "location", + DescriptionFieldID: "description", + ColorFieldID: "color", + } + + view := newAttrViewViewByLayoutType(masterView.LayoutType) + if nil == view { + t.Fatal("duplicating a calendar view must not yield a nil view") + } + if nil == view.Calendar { + t.Fatal("duplicated calendar view must have a calendar layout") + } + + copyAttrViewViewLayout(view, masterView) + + if len(view.Calendar.Fields) != 2 { + t.Fatalf("expected 2 copied fields, got %d", len(view.Calendar.Fields)) + } + if view.Calendar.Fields[0].ID != "date" || !view.Calendar.Fields[0].Wrap || view.Calendar.Fields[0].Desc != "when" { + t.Fatalf("first field not copied faithfully: %#v", view.Calendar.Fields[0].BaseField) + } + if view.Calendar.Fields[1].ID != "location" || !view.Calendar.Fields[1].Hidden { + t.Fatalf("second field not copied faithfully: %#v", view.Calendar.Fields[1].BaseField) + } + if view.Calendar.Fields[0] == masterView.Calendar.Fields[0] { + t.Fatal("copied fields must be new structs, not shared pointers") + } + if view.Calendar.DateFieldID != "date" { + t.Fatalf("date field ID not copied, got %s", view.Calendar.DateFieldID) + } + if view.Calendar.ViewMode != av.ViewModeWeek { + t.Fatalf("view mode not copied, got %d", view.Calendar.ViewMode) + } + if view.Calendar.WeekStart != av.WeekStartMonday { + t.Fatalf("week start not copied, got %d", view.Calendar.WeekStart) + } + if view.Calendar.ShowIcon || !view.Calendar.WrapField { + t.Fatalf("showIcon/wrapField not copied: showIcon=%v wrapField=%v", view.Calendar.ShowIcon, view.Calendar.WrapField) + } + if nil == view.Calendar.FieldMapping { + t.Fatal("field mapping not copied") + } + if view.Calendar.FieldMapping == masterView.Calendar.FieldMapping { + t.Fatal("field mapping must be copied by value, not by pointer") + } + if *view.Calendar.FieldMapping != *masterView.Calendar.FieldMapping { + t.Fatalf("field mapping content differs: %#v", view.Calendar.FieldMapping) + } + + // 改动副本不得影响原视图 + view.Calendar.FieldMapping.ColorFieldID = "other" + if masterView.Calendar.FieldMapping.ColorFieldID != "color" { + t.Fatal("mutating the duplicate must not affect the master view mapping") + } + + // 未知布局仍然返回 nil,由调用方报错而不是空指针 + if nil != newAttrViewViewByLayoutType(av.LayoutType("bogus")) { + t.Fatal("unknown layout type should yield nil so the caller can report an error") + } +} + +// K5: 显式 ViewID 必须命中对应视图,而不是块上的当前视图。 +func TestCalendarSetterResolvesViewByOperationViewID(t *testing.T) { + seedCalendarI18n(t) + + first := av.NewCalendarView() + first.ID = "20240101000000-view0001" + second := av.NewCalendarView() + second.ID = "20240101000000-view0002" + attrView := &av.AttributeView{ + ID: "20240101000000-avavav1", + ViewID: first.ID, + Views: []*av.View{first, second}, + } + + view, err := resolveAttrViewViewByOperation(attrView, &Operation{ViewID: second.ID}) + if err != nil { + t.Fatalf("explicit view ID should resolve: %v", err) + } + if view != second { + t.Fatalf("explicit view ID must target the second view, got %s", view.ID) + } + + view, err = resolveAttrViewViewByOperation(attrView, &Operation{ViewID: first.ID}) + if err != nil { + t.Fatalf("explicit view ID should resolve: %v", err) + } + if view != first { + t.Fatalf("explicit view ID must target the first view, got %s", view.ID) + } + + if _, err = resolveAttrViewViewByOperation(attrView, &Operation{ViewID: "20240101000000-missing"}); !errors.Is(err, av.ErrViewNotFound) { + t.Fatalf("unknown explicit view ID should return ErrViewNotFound, got %v", err) + } + + // 旧版载荷不带 ViewID,回退到当前视图 + view, err = resolveAttrViewViewByOperation(attrView, &Operation{}) + if err != nil { + t.Fatalf("legacy payload without view ID should fall back: %v", err) + } + if view != first { + t.Fatalf("legacy fallback should return the current view, got %s", view.ID) + } +} + +// K7: 模板字段是计算字段,写入会被渲染覆盖,不能再作为可写元数据映射。 +func TestCalendarMetadataMappingRejectsTemplate(t *testing.T) { + attrView := &av.AttributeView{ + KeyValues: []*av.KeyValues{ + {Key: &av.Key{ID: "text", Type: av.KeyTypeText}}, + {Key: &av.Key{ID: "template", Type: av.KeyTypeTemplate}}, + }, + } + + for _, fieldName := range []string{"recurrenceFieldID", "exceptionFieldID", "locationFieldID", "descriptionFieldID"} { + if _, err := calendarFieldMappingFromOperationData(attrView, nil, map[string]any{fieldName: "template"}); err == nil { + t.Fatalf("%s must reject a template field because template cells are recomputed on render", fieldName) + } + if _, err := calendarFieldMappingFromOperationData(attrView, nil, map[string]any{fieldName: "text"}); err != nil { + t.Fatalf("%s must still accept a text field: %v", fieldName, err) + } + } +} + +// K4: 新建的日历视图默认「每个条目是一个页面」,磁盘上已有的视图(零值)保持只建行。 +func TestCalendarNewItemTargetDefaults(t *testing.T) { + seedCalendarI18n(t) + + fresh := av.NewCalendarView() + if av.CalendarNewItemTargetDocument != fresh.Calendar.NewItemTarget { + t.Fatalf("a freshly created calendar view must default to document, got %q", fresh.Calendar.NewItemTarget) + } + + // 解析磁盘上已有的视图得到的是零值,必须继续走只建行的老行为 + legacy := &av.LayoutCalendar{BaseLayout: &av.BaseLayout{}} + if "" != legacy.NewItemTarget { + t.Fatalf("a legacy calendar layout must keep the zero value, got %q", legacy.NewItemTarget) + } + if av.CalendarNewItemTargetDocument == legacy.NewItemTarget { + t.Fatal("a legacy calendar layout must not be treated as document target") + } +} + +// K4: setAttrViewCalendarNewItemTarget 的取值校验。 +func TestCalendarNewItemTargetFromOperationData(t *testing.T) { + for _, valid := range []string{"", "row", "document"} { + target, err := calendarNewItemTargetFromOperationData(valid) + if err != nil { + t.Fatalf("%q should be accepted: %v", valid, err) + } + if target != valid { + t.Fatalf("expected %q, got %q", valid, target) + } + } + for _, invalid := range []any{"page", "Document", 1, 1.0, true, nil, map[string]any{}} { + if _, err := calendarNewItemTargetFromOperationData(invalid); err == nil { + t.Fatalf("%#v should be rejected", invalid) + } + } +} + +// K3: 日历视图按需补一个文档类型的新增条目模板,且不覆盖已有的。 +func TestEnsureCalendarNewItemDocumentTemplate(t *testing.T) { + seedCalendarI18n(t) + + attrView := &av.AttributeView{} + created := ensureCalendarNewItemDocumentTemplate(attrView) + if nil == created { + t.Fatal("a document template should have been seeded") + } + if 1 != len(attrView.NewItemTemplates) { + t.Fatalf("expected exactly one template, got %d", len(attrView.NewItemTemplates)) + } + if av.NewItemTargetDocument != created.TargetType { + t.Fatalf("seeded template must target a document, got %q", created.TargetType) + } + if "" == created.Name { + t.Fatal("seeded template must have a name, SetNewItemTemplates rejects empty names") + } + if nil == created.SaveLocation || "" != created.SaveLocation.BoxID || "" != created.SaveLocation.PathTemplate { + // 空 BoxID/PathTemplate 才会解析成数据库块自己的笔记本 + 以其根文档为父 + t.Fatalf("seeded template save location must be empty, got %#v", created.SaveLocation) + } + + // 幂等:已经有文档模板时不再追加,也不覆盖用户改过的保存位置 + created.SaveLocation.PathTemplate = "/custom" + again := ensureCalendarNewItemDocumentTemplate(attrView) + if again != created { + t.Fatal("an existing document template must be reused, not replaced") + } + if 1 != len(attrView.NewItemTemplates) { + t.Fatalf("expected no extra template, got %d", len(attrView.NewItemTemplates)) + } + if "/custom" != created.SaveLocation.PathTemplate { + t.Fatalf("an existing template must not be overwritten, got %q", created.SaveLocation.PathTemplate) + } + + // 只有游离模板时仍然需要补一个文档模板 + detachedOnly := &av.AttributeView{NewItemTemplates: []*av.NewItemTemplate{ + {ID: "20240101000000-detached", Name: "Row", TargetType: av.NewItemTargetDetached}, + }} + if nil == ensureCalendarNewItemDocumentTemplate(detachedOnly) { + t.Fatal("a detached-only AV should still get a document template") + } + if 2 != len(detachedOnly.NewItemTemplates) { + t.Fatalf("expected two templates, got %d", len(detachedOnly.NewItemTemplates)) + } +} + +// K1: 调用方传入的字段值必须校验后才写入,计算字段与主键一律拒绝。 +func TestResolveCallerItemFieldValuesForCalendar(t *testing.T) { + attrView := &av.AttributeView{ + KeyValues: []*av.KeyValues{ + {Key: &av.Key{ID: "block", Type: av.KeyTypeBlock}}, + {Key: &av.Key{ID: "date", Type: av.KeyTypeDate}}, + {Key: &av.Key{ID: "text", Type: av.KeyTypeText}}, + {Key: &av.Key{ID: "template", Type: av.KeyTypeTemplate}}, + {Key: &av.Key{ID: "rollup", Type: av.KeyTypeRollup}}, + {Key: &av.Key{ID: "created", Type: av.KeyTypeCreated}}, + }, + } + + empty, err := resolveCallerItemFieldValues(attrView, nil) + if err != nil { + t.Fatalf("nil field values should be accepted: %v", err) + } + if 0 != len(empty) { + t.Fatalf("nil field values should resolve to nothing, got %d", len(empty)) + } + + resolved, err := resolveCallerItemFieldValues(attrView, map[string]*av.Value{ + "date": {Type: av.KeyTypeDate, Date: &av.ValueDate{Content: 1, IsNotEmpty: true}}, + "text": {Text: &av.ValueText{Content: "Room A"}}, + }) + if err != nil { + t.Fatalf("valid field values should be accepted: %v", err) + } + if 2 != len(resolved) { + t.Fatalf("expected two resolved values, got %d", len(resolved)) + } + if av.KeyTypeText != resolved["text"].Type { + t.Fatalf("a value with no type must inherit the field type, got %q", resolved["text"].Type) + } + if "" != resolved["date"].ID || "" != resolved["date"].BlockID { + t.Fatalf("caller supplied identifiers must be stripped: %#v", resolved["date"]) + } + + for _, keyID := range []string{"block", "template", "rollup", "created"} { + if _, err = resolveCallerItemFieldValues(attrView, map[string]*av.Value{ + keyID: {Text: &av.ValueText{Content: "x"}}, + }); err == nil { + t.Fatalf("field %s must be rejected as not writable", keyID) + } + } + if _, err = resolveCallerItemFieldValues(attrView, map[string]*av.Value{ + "missing": {Text: &av.ValueText{Content: "x"}}, + }); err == nil { + t.Fatal("an unknown field must be rejected") + } + if _, err = resolveCallerItemFieldValues(attrView, map[string]*av.Value{ + "date": {Type: av.KeyTypeText, Text: &av.ValueText{Content: "x"}}, + }); err == nil { + t.Fatal("a value whose type contradicts the field must be rejected") + } +} + +// K1: 调用方给的标题优先于模板的 primaryKeyTemplate;为空时回退到模板。 +func TestCreateItemOptionsPrimaryKeyOverride(t *testing.T) { + merged := mergeCreateItemOptions([]*CreateItemOptions{nil, {PrimaryKey: "Standup"}}) + if "Standup" != merged.PrimaryKey { + t.Fatalf("expected the supplied primary key, got %q", merged.PrimaryKey) + } + merged = mergeCreateItemOptions(nil) + if "" != merged.PrimaryKey || 0 != len(merged.FieldValues) { + t.Fatalf("no options must merge to the zero value, got %#v", merged) + } +} diff --git a/kernel/model/attribute_view_new_item.go b/kernel/model/attribute_view_new_item.go index e1a136a064d..f80b01c1f60 100644 --- a/kernel/model/attribute_view_new_item.go +++ b/kernel/model/attribute_view_new_item.go @@ -19,16 +19,18 @@ package model import ( "errors" "fmt" - "path" - "strings" - "sync" - "time" - "github.com/88250/lute/ast" "github.com/88250/lute/parse" + "github.com/siyuan-note/filelock" "github.com/siyuan-note/siyuan/kernel/av" "github.com/siyuan-note/siyuan/kernel/treenode" "github.com/siyuan-note/siyuan/kernel/util" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" ) // NewItemTemplatePreview 描述新增条目模板在当前数据库实例中的解析结果。 @@ -67,104 +69,6 @@ type CreateAttributeViewItemDocsResult struct { Transaction *Transaction `json:"-"` } -// CreateAttributeViewItem 按指定模板创建一个数据库条目。templateID 为空时创建空白游离条目。 -func CreateAttributeViewItem(avID, blockID, viewID, templateID, previousID, groupID string) (*CreateAttributeViewItemResult, error) { - attrView, err := av.ParseAttributeView(avID) - if nil != err { - return nil, err - } - createdAt := time.Now() - itemTemplate := attrView.GetNewItemTemplate(templateID) - var prunedOptions []*av.PrunedNewItemTemplateOption - if "" != templateID && nil == itemTemplate { - return nil, fmt.Errorf("new item template [%s] not found", templateID) - } - if nil == itemTemplate { - itemTemplate = &av.NewItemTemplate{TargetType: av.NewItemTargetDetached} - } else { - cloned := *attrView - prunedOptions = cloned.PruneInvalidNewItemTemplateFieldValues() - if err = cloned.SetNewItemTemplates(&av.NewItemTemplatesConfig{Templates: []*av.NewItemTemplate{itemTemplate}}); nil != err { - return nil, err - } - attrView = &cloned - itemTemplate = cloned.NewItemTemplates[0] - } - preview, err := resolveAttributeViewNewItemTemplate(blockID, itemTemplate, createdAt) - if nil != err { - return nil, err - } - for _, prunedOption := range prunedOptions { - if templateID == prunedOption.TemplateID { - preview.Warnings = append(preview.Warnings, fmt.Sprintf(Conf.Language(353), - prunedOption.KeyID, strings.Join(prunedOption.Values, ", "))) - } - } - - itemID := ast.NewNodeID() - fieldValues, err := resolveNewItemFieldValues(attrView, itemTemplate, createdAt) - if nil != err { - return nil, err - } - dbTree, err := LoadTreeByBlockID(blockID) - if nil != err { - return nil, err - } - dbNode := treenode.GetNodeInTree(dbTree, blockID) - if nil == dbNode { - return nil, ErrBlockNotFound - } - boundBlockID := itemID - isDetached := av.NewItemTargetDocument != itemTemplate.TargetType - var createdTree *parse.Tree - if !isDetached { - boundBlockID, createdTree, err = createAttributeViewItemDocument(preview, itemTemplate) - if nil != err { - return nil, err - } - } - - doOperations := []*Operation{} - if nil != createdTree { - doOperations = append(doOperations, &Operation{Action: "restoreCreatedDoc", ID: boundBlockID, Tree: createdTree}) - } - doOperations = append(doOperations, &Operation{ - Action: "insertAttrViewBlock", AvID: avID, BlockID: blockID, ViewID: viewID, GroupID: groupID, - PreviousID: previousID, IgnoreDefaultFill: false, - Srcs: []map[string]any{{"itemID": itemID, "id": boundBlockID, "content": preview.PrimaryKey, "isDetached": isDetached}}, - }) - fieldOperations := buildNewItemFieldValueOperations(attrView, fieldValues, itemID) - doOperations = append(doOperations, fieldOperations...) - doOperations = append(doOperations, &Operation{Action: "doUpdateUpdated", ID: blockID, Data: util.CurrentTimeSecondsStr()}) - - undoOperations := []*Operation{{Action: "removeAttrViewBlock", AvID: avID, SrcIDs: []string{itemID}}} - if nil != createdTree { - undoOperations = append(undoOperations, &Operation{Action: "removeCreatedDoc", ID: boundBlockID, Tree: createdTree}) - } - undoOperations = append(undoOperations, &Operation{Action: "doUpdateUpdated", ID: blockID, Data: dbNode.IALAttr("updated")}) - tx := &Transaction{DoOperations: doOperations, UndoOperations: undoOperations, Timestamp: createdAt.UnixMilli()} - tx.MarkFromAPI() - if err = PerformTxSync(tx); nil != err { - cleanupErr := RemoveAttributeViewBlock([]string{itemID}, avID) - if nil != createdTree { - cleanupErr = errors.Join(cleanupErr, removeCreatedNewItemDoc(boundBlockID)) - } - return nil, newItemCreationError(err, cleanupErr) - } - - content := preview.PrimaryKey - if !isDetached { - if blockTree := treenode.GetBlockTree(boundBlockID); nil != blockTree { - content = path.Base(blockTree.HPath) - } - } - return &CreateAttributeViewItemResult{ - ItemID: itemID, BlockID: boundBlockID, Content: content, IsDetached: isDetached, - Warnings: preview.Warnings, Transaction: tx, - }, nil -} - -// CreateAttributeViewItemDocs 将指定条目中的游离条目批量创建为文档并绑定。 func CreateAttributeViewItemDocs(avID, blockID, saveMode string, itemIDs []string) (*CreateAttributeViewItemDocsResult, error) { unlock := lockAttributeViewItemDocs(avID) defer unlock() @@ -291,18 +195,6 @@ func CreateAttributeViewItemDocs(avID, blockID, saveMode string, itemIDs []strin return ret, nil } -func newBoundAttributeViewItemValue(original *av.Value, docID, icon string) (*av.Value, error) { - bound := original.Clone() - if nil == bound || nil == bound.Block { - return nil, errors.New("clone attribute view item failed") - } - bound.IsDetached = false - bound.Block.ID = docID - bound.Block.Content = "" - bound.Block.Icon = icon - return bound, nil -} - func lockAttributeViewItemDocs(avID string) func() { lockValue, _ := attributeViewItemDocsLocks.LoadOrStore(avID, &sync.Mutex{}) lock := lockValue.(*sync.Mutex) @@ -328,28 +220,33 @@ func attributeViewItemDocumentTemplate(attrView *av.AttributeView, saveMode stri } } -func resolveAttributeViewNewItemTemplate(blockID string, itemTemplate *av.NewItemTemplate, createdAt time.Time) (*NewItemTemplatePreview, error) { - boxID := "" - if blockTree := treenode.GetBlockTree(blockID); blockTree != nil { - boxID = blockTree.BoxID - } else { - for _, encBoxID := range treenode.GetOpenedEncryptedBoxIDs() { - if blockTree := treenode.GetBlockTreeInBox(blockID, encBoxID); blockTree != nil { - boxID = encBoxID - break - } +func createAttributeViewItemDocument(preview *NewItemTemplatePreview, itemTemplate *av.NewItemTemplate) (docID string, tree *parse.Tree, err error) { + docID = ast.NewNodeID() + arg := map[string]any{"titleEmpty": "" == preview.PrimaryKey} + docID, err = CreateWithMarkdown("", preview.BoxID, preview.HPath, "", preview.parentID, docID, false, "", arg) + if nil != err { + return + } + if "" != itemTemplate.ContentTemplatePath { + if err = applyNewItemContentTemplate(itemTemplate.ContentTemplatePath, docID); nil != err { + err = newItemCreationError(err, removeCreatedNewItemDoc(docID)) + return } } - primary, err := RenderGoTemplateAtInBox(itemTemplate.PrimaryKeyTemplate, createdAt, boxID) + tree, err = LoadTreeByBlockID(docID) if nil != err { - return nil, err + err = newItemCreationError(err, removeCreatedNewItemDoc(docID)) + return } - primary = strings.TrimSpace(primary) - preview := &NewItemTemplatePreview{PrimaryKey: primary} - if av.NewItemTargetDocument != itemTemplate.TargetType { - return preview, nil + if "" != itemTemplate.Icon { + tree.Root.SetIALAttr("icon", itemTemplate.Icon) + if err = indexWriteTreeUpsertQueue(tree); nil != err { + err = newItemCreationError(err, removeCreatedNewItemDoc(docID)) + return + } + FlushTxQueue() } - return resolveAttributeViewItemDocument(blockID, primary, itemTemplate, createdAt) + return } func resolveAttributeViewItemDocument(blockID, primary string, itemTemplate *av.NewItemTemplate, createdAt time.Time) (*NewItemTemplatePreview, error) { @@ -413,33 +310,262 @@ func resolveAttributeViewItemDocument(blockID, primary string, itemTemplate *av. return preview, nil } -func createAttributeViewItemDocument(preview *NewItemTemplatePreview, itemTemplate *av.NewItemTemplate) (docID string, tree *parse.Tree, err error) { - docID = ast.NewNodeID() - arg := map[string]any{"titleEmpty": "" == preview.PrimaryKey} - docID, err = CreateWithMarkdown("", preview.BoxID, preview.HPath, "", preview.parentID, docID, false, "", arg) +func newBoundAttributeViewItemValue(original *av.Value, docID, icon string) (*av.Value, error) { + bound := original.Clone() + if nil == bound || nil == bound.Block { + return nil, errors.New("clone attribute view item failed") + } + bound.IsDetached = false + bound.Block.ID = docID + bound.Block.Content = "" + bound.Block.Icon = icon + return bound, nil +} + +// CreateItemOptions 描述调用方对新增条目的额外要求,全部可选。 +type CreateItemOptions struct { + // PrimaryKey 调用方指定的主键内容(文档类型模板下即文档标题)。 + // 为空(或仅空白)时回退到模板的 primaryKeyTemplate,与旧行为一致。 + PrimaryKey string + // FieldValues 需要与插入操作在同一个事务中写入的字段值,键为字段 ID。 + // 与模板自带的字段默认值合并,调用方提供的值优先。 + FieldValues map[string]*av.Value +} + +// defaultDocumentNewItemTemplate 在调用方没有指定模板、而目标视图是「新条目即页面」的日历视图时, +// 返回一个文档类型的新增条目模板。优先复用数据库上已有的文档模板(用户可以在上游的模板编辑器里改保存位置), +// 没有时就地合成一个等价的(空 BoxID/PathTemplate = 数据库块所在笔记本 + 以其根文档为父)。 +// 返回 nil 表示保持上游默认(游离条目)。 +func defaultDocumentNewItemTemplate(attrView *av.AttributeView, blockID, viewID string) *av.NewItemTemplate { + var view *av.View + if "" != viewID { + view = attrView.GetView(viewID) + } + if nil == view { + view, _ = getAttrViewViewByBlockID(attrView, blockID) + } + if nil == view || av.LayoutTypeCalendar != view.LayoutType || nil == view.Calendar { + return nil + } + if av.CalendarNewItemTargetDocument != view.Calendar.NewItemTarget { + return nil + } + + for _, itemTemplate := range attrView.NewItemTemplates { + if nil != itemTemplate && av.NewItemTargetDocument == itemTemplate.TargetType { + return itemTemplate + } + } + return &av.NewItemTemplate{ + ID: ast.NewNodeID(), + Name: calendarNewItemTemplateName(), + TargetType: av.NewItemTargetDocument, + SaveLocation: &av.NewItemSaveLocation{BoxID: "", PathTemplate: ""}, + } +} + +func mergeCreateItemOptions(options []*CreateItemOptions) (ret *CreateItemOptions) { + ret = &CreateItemOptions{} + for _, option := range options { + if nil == option { + continue + } + if "" != option.PrimaryKey { + ret.PrimaryKey = option.PrimaryKey + } + if 0 < len(option.FieldValues) { + ret.FieldValues = option.FieldValues + } + } + return +} + +// CreateAttributeViewItem 按指定模板创建一个数据库条目。templateID 为空时创建空白游离条目。 +// options 可选,用于覆盖主键内容以及在同一个事务中写入字段值。 +func CreateAttributeViewItem(avID, blockID, viewID, templateID, previousID, groupID string, options ...*CreateItemOptions) (*CreateAttributeViewItemResult, error) { + opts := mergeCreateItemOptions(options) + attrView, err := av.ParseAttributeView(avID) if nil != err { - return + return nil, err } - if "" != itemTemplate.ContentTemplatePath { - if err = applyNewItemContentTemplate(itemTemplate.ContentTemplatePath, docID); nil != err { - err = newItemCreationError(err, removeCreatedNewItemDoc(docID)) - return + createdAt := time.Now() + itemTemplate := attrView.GetNewItemTemplate(templateID) + if "" != templateID && nil == itemTemplate { + return nil, fmt.Errorf("new item template [%s] not found", templateID) + } + if nil == itemTemplate && "" == templateID { + // 没有显式指定模板时,若目标视图是「每个条目是一个页面」的日历视图,则默认按文档模板创建。 + itemTemplate = defaultDocumentNewItemTemplate(attrView, blockID, viewID) + } + if nil == itemTemplate { + itemTemplate = &av.NewItemTemplate{TargetType: av.NewItemTargetDetached} + } else { + cloned := *attrView + cloned.PruneInvalidNewItemTemplateFieldValues() + if err = cloned.SetNewItemTemplates(&av.NewItemTemplatesConfig{Templates: []*av.NewItemTemplate{itemTemplate}}); nil != err { + return nil, err } + attrView = &cloned + itemTemplate = cloned.NewItemTemplates[0] } - tree, err = LoadTreeByBlockID(docID) + preview, err := resolveAttributeViewNewItemTemplate(blockID, itemTemplate, createdAt, opts.PrimaryKey) if nil != err { - err = newItemCreationError(err, removeCreatedNewItemDoc(docID)) - return + return nil, err } - if "" != itemTemplate.Icon { - tree.Root.SetIALAttr("icon", itemTemplate.Icon) - if err = indexWriteTreeUpsertQueue(tree); nil != err { - err = newItemCreationError(err, removeCreatedNewItemDoc(docID)) - return + + itemID := ast.NewNodeID() + fieldValues, err := resolveNewItemFieldValues(attrView, itemTemplate, createdAt) + if nil != err { + return nil, err + } + callerFieldValues, err := resolveCallerItemFieldValues(attrView, opts.FieldValues) + if nil != err { + return nil, err + } + for keyID, value := range callerFieldValues { + fieldValues[keyID] = value + } + dbTree, err := LoadTreeByBlockID(blockID) + if nil != err { + return nil, err + } + dbNode := treenode.GetNodeInTree(dbTree, blockID) + if nil == dbNode { + return nil, ErrBlockNotFound + } + boundBlockID := itemID + isDetached := av.NewItemTargetDocument != itemTemplate.TargetType + var createdTree *parse.Tree + if !isDetached { + boundBlockID = ast.NewNodeID() + arg := map[string]any{"titleEmpty": "" == preview.PrimaryKey} + createdID, createErr := CreateWithMarkdown("", preview.BoxID, preview.HPath, "", preview.parentID, boundBlockID, false, "", arg) + if nil != createErr { + return nil, createErr + } + boundBlockID = createdID + if "" != itemTemplate.ContentTemplatePath { + if applyErr := applyNewItemContentTemplate(itemTemplate.ContentTemplatePath, boundBlockID); nil != applyErr { + return nil, newItemCreationError(applyErr, removeCreatedNewItemDoc(boundBlockID)) + } + } + createdTree, err = LoadTreeByBlockID(boundBlockID) + if nil != err { + return nil, newItemCreationError(err, removeCreatedNewItemDoc(boundBlockID)) + } + if "" != itemTemplate.Icon { + createdTree.Root.SetIALAttr("icon", itemTemplate.Icon) + if err = indexWriteTreeUpsertQueue(createdTree); nil != err { + return nil, newItemCreationError(err, removeCreatedNewItemDoc(boundBlockID)) + } + FlushTxQueue() } - FlushTxQueue() } - return + + doOperations := []*Operation{} + if nil != createdTree { + doOperations = append(doOperations, &Operation{Action: "restoreCreatedDoc", ID: boundBlockID, Tree: createdTree}) + } + doOperations = append(doOperations, &Operation{ + Action: "insertAttrViewBlock", AvID: avID, BlockID: blockID, ViewID: viewID, GroupID: groupID, + PreviousID: previousID, IgnoreDefaultFill: false, + Srcs: []map[string]any{{"itemID": itemID, "id": boundBlockID, "content": preview.PrimaryKey, "isDetached": isDetached}}, + }) + fieldOperations := buildNewItemFieldValueOperations(attrView, fieldValues, itemID) + doOperations = append(doOperations, fieldOperations...) + doOperations = append(doOperations, &Operation{Action: "doUpdateUpdated", ID: blockID, Data: util.CurrentTimeSecondsStr()}) + + undoOperations := []*Operation{{Action: "removeAttrViewBlock", AvID: avID, SrcIDs: []string{itemID}}} + if nil != createdTree { + undoOperations = append(undoOperations, &Operation{Action: "removeCreatedDoc", ID: boundBlockID, Tree: createdTree}) + } + undoOperations = append(undoOperations, &Operation{Action: "doUpdateUpdated", ID: blockID, Data: dbNode.IALAttr("updated")}) + tx := &Transaction{DoOperations: doOperations, UndoOperations: undoOperations, Timestamp: createdAt.UnixMilli()} + tx.MarkFromAPI() + if err = PerformTxSync(tx); nil != err { + cleanupErr := RemoveAttributeViewBlock([]string{itemID}, avID) + if nil != createdTree { + cleanupErr = errors.Join(cleanupErr, removeCreatedNewItemDoc(boundBlockID)) + } + return nil, newItemCreationError(err, cleanupErr) + } + + content := preview.PrimaryKey + if !isDetached { + if blockTree := treenode.GetBlockTree(boundBlockID); nil != blockTree { + content = path.Base(blockTree.HPath) + } + } + return &CreateAttributeViewItemResult{ + ItemID: itemID, BlockID: boundBlockID, Content: content, IsDetached: isDetached, + Warnings: preview.Warnings, Transaction: tx, + }, nil +} + +// resolveAttributeViewNewItemTemplate 解析新增条目模板。primaryKeyOverride 非空时取代模板渲染出来的主键内容, +// 其余处理(normalizeDocTitle、空标题回退、保存位置解析)与模板路径完全一致。 +func resolveAttributeViewNewItemTemplate(blockID string, itemTemplate *av.NewItemTemplate, createdAt time.Time, primaryKeyOverride string) (*NewItemTemplatePreview, error) { + blockTree := treenode.GetBlockTree(blockID) + if nil == blockTree { + return nil, ErrBlockNotFound + } + primary := strings.TrimSpace(primaryKeyOverride) + if "" == primary { + rendered, err := RenderGoTemplateAt(itemTemplate.PrimaryKeyTemplate, createdAt) + if nil != err { + return nil, err + } + primary = strings.TrimSpace(rendered) + } + preview := &NewItemTemplatePreview{PrimaryKey: primary} + if av.NewItemTargetDocument != itemTemplate.TargetType { + return preview, nil + } + primary = normalizeDocTitle(primary) + preview.PrimaryKey = primary + + boxID, pathTemplate, inherited, err := resolveNewItemSaveConfig(blockTree.BoxID, itemTemplate.SaveLocation) + if nil != err { + return nil, err + } + renderedPath, err := RenderGoTemplateAt(pathTemplate, createdAt) + if nil != err { + return nil, err + } + renderedPath = util.TrimSpaceInPath(strings.TrimSpace(renderedPath)) + if boxID != blockTree.BoxID && "" != renderedPath && !strings.HasPrefix(renderedPath, "/") { + renderedPath = "/" + renderedPath + } + if "" == primary { + primary = newItemTitleFromPath(renderedPath) + preview.PrimaryKey = primary + } + parentTemplate := newItemParentPathTemplate(renderedPath) + baseHPath := "/" + if boxID == blockTree.BoxID && !strings.HasPrefix(parentTemplate, "/") { + baseHPath = blockTree.HPath + preview.parentID = blockTree.RootID + } + parentHPath := path.Clean(path.Join(baseHPath, parentTemplate)) + if "." == parentHPath || "" == parentHPath { + parentHPath = "/" + } + if !strings.HasPrefix(parentHPath, "/") { + parentHPath = "/" + parentHPath + } + if "" == primary { + preview.HPath = strings.TrimSuffix(parentHPath, "/") + "/" + if "/" == parentHPath { + preview.HPath = "/" + } + } else { + preview.HPath = path.Join(parentHPath, primary) + } + preview.BoxID = boxID + if inherited && boxID != blockTree.BoxID { + preview.parentID = "" + } + return preview, nil } func resolveNewItemSaveConfig(currentBoxID string, location *av.NewItemSaveLocation) (boxID, pathTemplate string, inherited bool, err error) { @@ -550,6 +676,65 @@ func resolveNewItemFieldValues(attrView *av.AttributeView, itemTemplate *av.NewI return } +// resolveCallerItemFieldValues 校验调用方直接传入的字段值,返回可以安全写入的副本。 +// 主键(block)以及模板/汇总/创建时间/更新时间/行号这些计算字段一律拒绝: +// 绑定文档的条目主键由内核根据文档标题派生,计算字段每次渲染都会被覆盖。 +func resolveCallerItemFieldValues(attrView *av.AttributeView, fieldValues map[string]*av.Value) (ret map[string]*av.Value, err error) { + ret = map[string]*av.Value{} + if 0 == len(fieldValues) { + return + } + for keyID, value := range fieldValues { + if nil == value { + continue + } + key, getErr := attrView.GetKey(keyID) + if nil != getErr || nil == key { + return nil, fmt.Errorf("new item field [%s] not found", keyID) + } + if !isCallerWritableKeyType(key.Type) { + return nil, fmt.Errorf("new item field [%s] type [%s] is not writable", keyID, key.Type) + } + if "" != value.Type && value.Type != key.Type { + return nil, fmt.Errorf("new item field [%s] value type [%s] does not match field type [%s]", keyID, value.Type, key.Type) + } + cloned := value.Clone() + if nil == cloned { + return nil, fmt.Errorf("new item field [%s] value is invalid", keyID) + } + cloned.ID = "" + cloned.KeyID = "" + cloned.BlockID = "" + cloned.Type = key.Type + cloned.Block, cloned.Template, cloned.Created, cloned.Updated, cloned.Rollup = nil, nil, nil, nil, nil + cloned.IsDetached = false + cloned.IsRenderAutoFill = false + if nil != cloned.Number { + cloned.Number.Format = key.NumberFormat + cloned.Number.FormattedContent = "" + } + if av.KeyTypeRelation == key.Type { + filterNewItemTemplateRelationValue(attrView, key, cloned) + // nil means the caller supplied no relation value. A non-nil relation + // with zero targets is an explicit clear and must survive undo/replay. + if nil == cloned.Relation { + continue + } + } + ret[keyID] = cloned + } + return +} + +func isCallerWritableKeyType(keyType av.KeyType) bool { + switch keyType { + case av.KeyTypeText, av.KeyTypeNumber, av.KeyTypeDate, av.KeyTypeSelect, av.KeyTypeMSelect, av.KeyTypeURL, + av.KeyTypeEmail, av.KeyTypePhone, av.KeyTypeMAsset, av.KeyTypeCheckbox, av.KeyTypeRelation: + return true + } + return false +} + func filterNewItemTemplateRelationValue(attrView *av.AttributeView, key *av.Key, value *av.Value) { if nil == key.Relation || nil == value.Relation { return @@ -592,7 +777,77 @@ func buildNewItemFieldValueOperations(attrView *av.AttributeView, fieldValues ma } func applyNewItemContentTemplate(templatePath, docID string) error { - return applyDocContentTemplateAfterIndex(templatePath, docID) + absPath, err := resolveNewItemContentTemplatePath(templatePath) + if nil != err { + return err + } + templateTree, templateDOM, err := RenderTemplate(absPath, docID, false) + if nil != err { + return err + } + if "" == templateDOM { + return nil + } + tree, err := LoadTreeByBlockID(docID) + if nil != err { + return err + } + if nil != tree.Root.FirstChild { + tree.Root.FirstChild.Unlink() + } + newTree := util.NewLute().BlockDOM2Tree(templateDOM) + var children []*ast.Node + for child := newTree.Root.FirstChild; nil != child; child = child.Next { + children = append(children, child) + } + for _, child := range children { + tree.Root.AppendChild(child) + } + templateIALs := parse.IAL2Map(templateTree.Root.KramdownIAL) + for key, value := range templateIALs { + if "name" == key || "alias" == key || "bookmark" == key || "memo" == key || "icon" == key || strings.HasPrefix(key, "custom-") { + tree.Root.SetIALAttr(key, value) + } + } + tree.Root.SetIALAttr("updated", util.CurrentTimeSecondsStr()) + if err = indexWriteTreeUpsertQueue(tree); nil != err { + return err + } + FlushTxQueue() + return nil +} + +func resolveNewItemContentTemplatePath(templatePath string) (string, error) { + cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimSpace(templatePath))) + if "" == cleanPath || "." == cleanPath || filepath.IsAbs(cleanPath) || ".." == cleanPath || strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) { + return "", errors.New("invalid content template path") + } + templateRoot := filepath.Join(util.DataDir, "templates") + absPath := filepath.Join(templateRoot, cleanPath) + rel, err := filepath.Rel(templateRoot, absPath) + if nil != err || ".." == rel || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New("content template path is outside templates directory") + } + if !filelock.IsExist(absPath) { + return "", fmt.Errorf("content template [%s] not found", templatePath) + } + realRoot, err := filepath.EvalSymlinks(templateRoot) + if nil != err { + return "", err + } + realPath, err := filepath.EvalSymlinks(absPath) + if nil != err { + return "", err + } + info, err := os.Stat(realPath) + if nil != err || !info.Mode().IsRegular() { + return "", fmt.Errorf("content template [%s] is not a regular file", templatePath) + } + realRel, err := filepath.Rel(realRoot, realPath) + if nil != err || ".." == realRel || strings.HasPrefix(realRel, ".."+string(os.PathSeparator)) { + return "", errors.New("content template path is outside templates directory") + } + return realPath, nil } func removeCreatedNewItemDoc(docID string) error { diff --git a/kernel/model/attribute_view_render.go b/kernel/model/attribute_view_render.go index 980bfd4f63b..6a41e7b5f85 100644 --- a/kernel/model/attribute_view_render.go +++ b/kernel/model/attribute_view_render.go @@ -643,6 +643,8 @@ func renderAttributeViewGroups(viewable av.Viewable, attrView *av.AttributeView, groupView.Gallery.CardFields = nil case av.LayoutTypeKanban: groupView.Kanban.Fields = nil + case av.LayoutTypeCalendar: + groupView.Calendar.Fields = nil } } viewable.SetGroups(groups) @@ -897,6 +899,13 @@ func renderViewableInstance(viewable av.Viewable, view *av.View, attrView *av.At targetOffset = start } kanban.Cards = kanban.Cards[start:end] + case av.LayoutTypeCalendar: + calendar := viewable.(*av.Calendar) + // 日历不分页(前端始终请求整个数据库),但仍要解析定位目标, + // 否则 siyuan://blocks/...?avItemID= 跳转拿不到 target 状态。 + targetIndex = findAttributeViewTargetIndex(targetItemID, len(calendar.Cards), func(index int) string { return calendar.Cards[index].ID }) + calendar.CardCount = len(calendar.Cards) + calendar.PageSize = view.PageSize } return } diff --git a/kernel/model/attribute_view_update_item.go b/kernel/model/attribute_view_update_item.go new file mode 100644 index 00000000000..fc1cf1af609 --- /dev/null +++ b/kernel/model/attribute_view_update_item.go @@ -0,0 +1,194 @@ +package model + +import ( + "errors" + "fmt" + "path" + + "github.com/88250/gulu" + "github.com/siyuan-note/siyuan/kernel/av" + "github.com/siyuan-note/siyuan/kernel/util" +) + +// AttributeViewItemUpdateData is the single-operation payload used by the +// calendar bound-page update endpoint. It deliberately keeps the document +// rename and AV field writes behind one kernel request. +type AttributeViewItemUpdateData struct { + AvID string + ItemID string + BoundBlockID string + PrimaryKey string + FieldValues map[string]*av.Value +} + +func (tx *Transaction) doUpdateAttributeViewItem(operation *Operation) (ret *TxErr) { + data, err := decodeAttributeViewItemUpdateData(operation.Data) + if err != nil { + return &TxErr{code: TxErrHandleAttributeView, msg: "invalid attribute view item update"} + } + if err = UpdateAttributeViewItem(tx, data); err != nil { + return &TxErr{code: TxErrHandleAttributeView, id: data.AvID, msg: err.Error()} + } + return nil +} + +func decodeAttributeViewItemUpdateData(value any) (*AttributeViewItemUpdateData, error) { + if data, ok := value.(*AttributeViewItemUpdateData); ok && data != nil { + return data, nil + } + raw, err := gulu.JSON.MarshalJSON(value) + if err != nil { + return nil, err + } + data := &AttributeViewItemUpdateData{} + if err = gulu.JSON.UnmarshalJSON(raw, data); err != nil { + return nil, err + } + if data.AvID == "" || data.ItemID == "" || data.BoundBlockID == "" { + return nil, errors.New("invalid attribute view item update") + } + return data, nil +} + +func emptyAttributeViewValue(keyType av.KeyType) *av.Value { + value := &av.Value{Type: keyType} + switch keyType { + case av.KeyTypeText: + value.Text = &av.ValueText{} + case av.KeyTypeNumber: + value.Number = &av.ValueNumber{} + case av.KeyTypeDate: + value.Date = &av.ValueDate{} + case av.KeyTypeSelect, av.KeyTypeMSelect: + value.MSelect = []*av.ValueSelect{} + case av.KeyTypeURL: + value.URL = &av.ValueURL{} + case av.KeyTypeEmail: + value.Email = &av.ValueEmail{} + case av.KeyTypePhone: + value.Phone = &av.ValuePhone{} + case av.KeyTypeMAsset: + value.MAsset = []*av.ValueAsset{} + case av.KeyTypeCheckbox: + value.Checkbox = &av.ValueCheckbox{} + case av.KeyTypeRelation: + value.Relation = &av.ValueRelation{BlockIDs: []string{}, Contents: []*av.Value{}} + } + return value +} + +// UpdateAttributeViewItem updates a bound calendar page and its AV fields as +// one application-level operation. The page rename is performed first so a +// validation or field-write error can never expose the new fields under the +// old page title. If a later field write fails, the original page title is +// restored before the error is returned. +// +// This is intentionally not exposed as two frontend requests. The endpoint +// owns the ordering and compensation boundary, so callers cannot observe the +// old split rename-then-transactions protocol. +func UpdateAttributeViewItem(tx *Transaction, data *AttributeViewItemUpdateData) error { + if data == nil || data.AvID == "" || data.ItemID == "" || data.BoundBlockID == "" { + return errors.New("attribute view item update requires avID, itemID and boundBlockID") + } + + attrView, err := av.ParseAttributeView(data.AvID) + if err != nil { + return err + } + blockValues := attrView.GetBlockKeyValues() + if blockValues == nil { + return errors.New("attribute view has no block field") + } + + var boundValue *av.Value + for _, value := range blockValues.Values { + if value != nil && value.BlockID == data.ItemID { + boundValue = value + break + } + } + if boundValue == nil || boundValue.Block == nil || boundValue.Block.ID != data.BoundBlockID || boundValue.IsDetached { + return fmt.Errorf("attribute view item [%s] is not bound to document [%s]", data.ItemID, data.BoundBlockID) + } + + resolved, err := resolveCallerItemFieldValues(attrView, data.FieldValues) + if err != nil { + return err + } + + originalTree, err := LoadTreeByBlockID(data.BoundBlockID) + if err != nil { + return err + } + tree, err := LoadTreeByBlockID(data.BoundBlockID) + if err != nil { + return err + } + tx.originalTrees[data.BoundBlockID] = originalTree + oldTitle := tree.Root.IALAttr("title") + nextTitle := normalizeDocTitle(data.PrimaryKey) + if nextTitle == "" { + nextTitle = oldTitle + } + + if nextTitle != oldTitle { + tree.HPath = path.Join(path.Dir(tree.HPath), nextTitle) + tree.Root.SetIALAttr("title", nextTitle) + tree.Root.RemoveIALAttr(NodeAttrTitleEmpty) + tree.Root.SetIALAttr("updated", util.CurrentTimeSecondsStr()) + tx.writeTree(tree) + tx.renamedTrees[data.BoundBlockID] = tree + } + + tx.deferAttrViewSave = true + defer func() { tx.deferAttrViewSave = false }() + // Keep the block primary key and every mapped field on the same deferred AV + // snapshot. Without seeding this map, UpdateAttributeViewCell parses a second + // copy and the new document title never reaches the persisted AV block value. + boundValue.Block.Content = nextTitle + boundValue.UpdatedAt = util.CurrentTimeMillis() + tx.deferredAttrViews[data.AvID] = attrView + oldFieldValues := map[string]*av.Value{} + for keyID := range resolved { + for _, keyValues := range attrView.KeyValues { + if keyValues.Key.ID != keyID { + continue + } + found := false + for _, value := range keyValues.Values { + if value.BlockID == data.ItemID { + copiedData, copyErr := gulu.JSON.MarshalJSON(value) + if copyErr != nil { + return copyErr + } + copied := &av.Value{} + if copyErr = gulu.JSON.UnmarshalJSON(copiedData, copied); copyErr != nil { + return copyErr + } + oldFieldValues[keyID] = copied + found = true + break + } + } + if !found { + oldFieldValues[keyID] = emptyAttributeViewValue(keyValues.Key.Type) + } + break + } + } + for keyID, value := range resolved { + if _, err = UpdateAttributeViewCell(tx, data.AvID, keyID, data.ItemID, value); err != nil { + return err + } + } + if !tx.isReplay { + tx.UndoOperations = append(tx.UndoOperations, &Operation{ + Action: "updateAttributeViewItem", AvID: data.AvID, ID: data.ItemID, + Data: &AttributeViewItemUpdateData{AvID: data.AvID, ItemID: data.ItemID, BoundBlockID: data.BoundBlockID, PrimaryKey: oldTitle, FieldValues: oldFieldValues}, + }) + } + + // Make the operation visible to the transaction's AV refresh path. + tx.relatedAvIDs = append(tx.relatedAvIDs, data.AvID) + return nil +} diff --git a/kernel/model/calendar_item_journal.go b/kernel/model/calendar_item_journal.go new file mode 100644 index 00000000000..7ea8160a2c9 --- /dev/null +++ b/kernel/model/calendar_item_journal.go @@ -0,0 +1,203 @@ +package model + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/88250/gulu" + "github.com/siyuan-note/filelock" + "github.com/siyuan-note/siyuan/kernel/av" + "github.com/siyuan-note/siyuan/kernel/cache" + "github.com/siyuan-note/siyuan/kernel/filesys" + "github.com/siyuan-note/siyuan/kernel/sql" + "github.com/siyuan-note/siyuan/kernel/treenode" + "github.com/siyuan-note/siyuan/kernel/util" +) + +var buildCalendarItemCommitJournal = buildCalendarItemCommitJournal0 +var persistCalendarItemCommitJournal = persistCalendarItemJournal +var clearCalendarItemCommitJournal = clearCalendarItemJournal +var reindexRecoveredCalendarItemTree = reindexRecoveredCalendarItemTree0 +var flushCalendarItemIndexQueue = sql.FlushQueue +var recoverCalendarItemJournal = recoverCalendarItemCommitJournal + +type calendarItemCommitJournal struct { + BoxID string `json:"boxID"` + DocPath string `json:"docPath"` + NewDocPath string `json:"newDocPath,omitempty"` + DocData []byte `json:"docData"` + AvID string `json:"avID"` + AvBoxID string `json:"avBoxID"` + AvPath string `json:"avPath"` + AvData []byte `json:"avData"` +} + +func calendarItemJournalPath(boxID string) string { + if boxID != "" { + return filepath.Join(util.DataDir, boxID, ".siyuan", "calendar-item-transaction.json") + } + return filepath.Join(util.ConfDir, "calendar-item-transaction.json") +} + +func persistCalendarItemJournal(journal *calendarItemCommitJournal) error { + if journal == nil { + return nil + } + data, err := gulu.JSON.MarshalJSON(journal) + if err != nil { + return err + } + if journal.BoxID != "" && IsEncryptedBox(journal.BoxID) { + dek, dekErr := GetDEKIfUnlocked(journal.BoxID) + if dekErr != nil { + return dekErr + } + key := util.DeriveSubKey(dek, "siyuan/calendar-item-transaction") + data, err = util.EncryptWithAAD(key, data, []byte("siyuan:v1:calendar-item-transaction:"+journal.BoxID)) + if err != nil { + return err + } + } + journalPath := calendarItemJournalPath(journal.BoxID) + if err = os.MkdirAll(filepath.Dir(journalPath), 0755); err != nil { + return err + } + return filelock.WriteFile(journalPath, data) +} + +func clearCalendarItemJournal(boxID string) error { + err := filelock.Remove(calendarItemJournalPath(boxID)) + if os.IsNotExist(err) { + return nil + } + return err +} + +func finalizeCalendarItemCommitJournal(boxID string) error { + flushCalendarItemIndexQueue() + return clearCalendarItemCommitJournal(boxID) +} + +func recoverCalendarItemCommitJournal(boxID string) error { + journalPath := calendarItemJournalPath(boxID) + data, err := filelock.ReadFile(journalPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if boxID != "" && IsEncryptedBox(boxID) { + if !IsBoxUnlocked(boxID) { + // Leave the encrypted journal untouched until UnlockBox has loaded the DEK. + return nil + } + dek, dekErr := GetDEKIfUnlocked(boxID) + if dekErr != nil { + return dekErr + } + key := util.DeriveSubKey(dek, "siyuan/calendar-item-transaction") + data, err = util.DecryptWithAAD(key, data, []byte("siyuan:v1:calendar-item-transaction:"+boxID)) + if err != nil { + return err + } + } + journal := &calendarItemCommitJournal{} + if err = gulu.JSON.UnmarshalJSON(data, journal); err != nil { + return err + } + if journal.BoxID != boxID || journal.DocPath == "" || journal.AvID == "" { + return errors.New("invalid calendar item transaction journal") + } + docRelative := strings.TrimPrefix(journal.DocPath, "/") + cleanDocRelative := filepath.Clean(docRelative) + if docRelative == journal.DocPath || cleanDocRelative == "." || cleanDocRelative == ".." || + strings.HasPrefix(cleanDocRelative, ".."+string(filepath.Separator)) || cleanDocRelative != docRelative || filepath.Ext(cleanDocRelative) != ".sy" { + return errors.New("invalid calendar item document path") + } + journal.DocPath = "/" + cleanDocRelative + if journal.NewDocPath != "" { + newDocRelative := strings.TrimPrefix(journal.NewDocPath, "/") + cleanNewDocRelative := filepath.Clean(newDocRelative) + if newDocRelative == journal.NewDocPath || cleanNewDocRelative == "." || cleanNewDocRelative == ".." || + strings.HasPrefix(cleanNewDocRelative, ".."+string(filepath.Separator)) || cleanNewDocRelative != newDocRelative || filepath.Ext(cleanNewDocRelative) != ".sy" { + return errors.New("invalid calendar item replacement document path") + } + journal.NewDocPath = "/" + cleanNewDocRelative + } + expectedAvPath, expectedAvBoxID := av.FindAttributeViewPathInBox(journal.AvID, journal.AvBoxID) + if expectedAvPath == "" || filepath.Clean(expectedAvPath) != filepath.Clean(journal.AvPath) || expectedAvBoxID != journal.AvBoxID { + return errors.New("invalid calendar item attribute view path") + } + if journal.NewDocPath != "" && journal.NewDocPath != journal.DocPath { + if err = filelock.Remove(filepath.Join(util.DataDir, journal.BoxID, journal.NewDocPath)); err != nil && !os.IsNotExist(err) { + return err + } + } + if len(journal.DocData) > 0 { + if err = filelock.WriteFile(filepath.Join(util.DataDir, journal.BoxID, journal.DocPath), journal.DocData); err != nil { + return err + } + } + if len(journal.AvData) > 0 && journal.AvPath != "" { + if err = filelock.WriteFile(journal.AvPath, journal.AvData); err != nil { + return err + } + } + cache.RemoveTreeData(util.GetTreeID(journal.DocPath)) + cache.RemoveAVDataInBox(journal.AvID, journal.AvBoxID) + if err = reindexRecoveredCalendarItemTree(journal.BoxID, journal.DocPath); err != nil { + return err + } + ReloadAttrView(journal.AvID) + ReloadFiletree() + return finalizeCalendarItemCommitJournal(boxID) +} + +func reindexRecoveredCalendarItemTree0(boxID, docPath string) error { + tree, err := filesys.LoadTree(boxID, docPath, util.NewLute()) + if err != nil { + return err + } + treenode.SetBlockTreePath(tree) + sql.RenameTreeQueue(tree) + return nil +} + +func buildCalendarItemCommitJournal0(tx *Transaction) (*calendarItemCommitJournal, error) { + if len(tx.originalTrees) != 1 || len(tx.deferredAttrViews) != 1 { + return nil, errors.New("calendar item transaction requires exactly one document and one attribute view") + } + var treeID string + var originalTreePath string + for id, tree := range tx.originalTrees { + treeID = id + originalTreePath = tree.Path + break + } + var avID string + for id := range tx.deferredAttrViews { + avID = id + break + } + docPath := filepath.Join(util.DataDir, tx.originalTrees[treeID].Box, originalTreePath) + docData, err := filelock.ReadFile(docPath) + if err != nil { + return nil, err + } + avPath, avBoxID := av.FindAttributeViewPath(avID) + if avPath == "" { + return nil, errors.New("attribute view file not found") + } + avData, err := filelock.ReadFile(avPath) + if err != nil { + return nil, err + } + newDocPath := "" + if renamed, ok := tx.renamedTrees[treeID]; ok && renamed.Path != originalTreePath { + newDocPath = renamed.Path + } + return &calendarItemCommitJournal{BoxID: tx.originalTrees[treeID].Box, DocPath: originalTreePath, NewDocPath: newDocPath, DocData: docData, AvID: avID, AvBoxID: avBoxID, AvPath: avPath, AvData: avData}, nil +} diff --git a/kernel/model/conf.go b/kernel/model/conf.go index 01043bed264..7047309792f 100644 --- a/kernel/model/conf.go +++ b/kernel/model/conf.go @@ -1183,7 +1183,13 @@ func (conf *AppConf) language(num int) (ret string) { func InitBoxes() { blockCount := treenode.CountBlocks() initialized := 0 < blockCount + if err := recoverCalendarItemCommitJournal(""); err != nil { + logging.LogErrorf("recover global calendar item transaction failed: %s", err) + } for _, box := range Conf.GetOpenedBoxes() { + if err := recoverCalendarItemCommitJournal(box.ID); err != nil { + logging.LogErrorf("recover calendar item transaction for box [%s] failed: %s", box.ID, err) + } if _, err := EnsureBoxDoc(box.ID); nil != err { logging.LogErrorf("ensure box document [%s] failed: %s", box.ID, err) } diff --git a/kernel/model/crypto.go b/kernel/model/crypto.go index 31872295611..c39dc87b0f6 100644 --- a/kernel/model/crypto.go +++ b/kernel/model/crypto.go @@ -1459,6 +1459,10 @@ func unlockBoxHeld(boxID string, password string, boxEnc *conf.BoxEncryption) (e } } finalState = EncryptedBoxStateUnlocked + if err = recoverCalendarItemCommitJournal(boxID); err != nil { + logging.LogErrorf("recover calendar item transaction after unlocking box [%s] failed: %s", boxID, err) + return err + } return nil } diff --git a/kernel/model/export.go b/kernel/model/export.go index 64a991eaa50..2ed5defb2b5 100644 --- a/kernel/model/export.go +++ b/kernel/model/export.go @@ -4489,6 +4489,13 @@ func getAttrViewTable(attrView *av.AttributeView, view *av.View, query string) ( for _, field := range view.Kanban.Fields { view.Table.Columns = append(view.Table.Columns, &av.ViewTableColumn{BaseField: &av.BaseField{ID: field.ID}}) } + case av.LayoutTypeCalendar: + view.Table = av.NewLayoutTable() + if nil != view.Calendar { + for _, field := range view.Calendar.Fields { + view.Table.Columns = append(view.Table.Columns, &av.ViewTableColumn{BaseField: &av.BaseField{ID: field.ID}}) + } + } } depth := 1 diff --git a/kernel/model/publish_access.go b/kernel/model/publish_access.go index 31817965767..4107dfd9c8a 100644 --- a/kernel/model/publish_access.go +++ b/kernel/model/publish_access.go @@ -575,6 +575,32 @@ func FilterViewByPublishAccess(c *gin.Context, publishAccess PublishAccess, view kanban.Groups[i] = FilterViewByPublishAccess(c, publishAccess, viewable) } } + case av.LayoutTypeCalendar: + calendar := ret.(*av.Calendar) + filteredCards := []*av.CalendarCard{} + for _, card := range calendar.Cards { + // 默认第一个属性是文档块 + var bt *treenode.BlockTree + if len(card.Values) > 0 && nil != card.Values[0].Value { + if card.Values[0].Value.Block != nil { + id := card.Values[0].Value.Block.ID + if id != "" { + bt = treenode.GetBlockTree(id) + } + } + } + if bt != nil { + // 不显示禁止文档 + if !CheckPathAccessableByPublishIgnore(bt.BoxID, bt.Path, publishIgnore) { + card = nil + } + } + if card != nil { + filteredCards = append(filteredCards, card) + } + } + calendar.Cards = filteredCards + calendar.CardCount = len(calendar.Cards) } return } diff --git a/kernel/model/transaction.go b/kernel/model/transaction.go index 7cead58b3a3..50bfb4e46d8 100644 --- a/kernel/model/transaction.go +++ b/kernel/model/transaction.go @@ -68,9 +68,25 @@ func FlushTxQueue() { // 失败时返回原始错误(不转成推送消息),调用方据此回滚撤销栈状态。 func PerformTxSync(tx *Transaction) (err error) { defer logging.Recover() + // 初始化事务互斥锁(異步路徑 PerformTransactions 在入隊前初始化,同步路徑這裡補上) + if nil == tx.m { + tx.m = &sync.Mutex{} + } flushLock.Lock() isFlushing.Store(true) defer func() { + if recovered := recover(); recovered != nil { + if tx.calendarJournalBoxID != "" { + if recoverErr := recoverCalendarItemCommitJournal(tx.calendarJournalBoxID); recoverErr != nil { + logging.LogErrorf("recover panicked calendar item transaction for box [%s] failed: %s", tx.calendarJournalBoxID, recoverErr) + } + } + state := tx.state.Load() + if 1 == state { + tx.rollback() + } + err = txErrFromPanic(state, recovered) + } isFlushing.Store(false) flushLock.Unlock() }() @@ -231,14 +247,22 @@ func performTx(tx *Transaction) (ret *TxErr) { defer func() { if e := recover(); nil != e { - msg := fmt.Sprintf("PANIC RECOVERED: %v\n\t%s\n", e, logging.ShortStack()) + msg := fmt.Sprintf("PANIC RECOVERED: %v\n %s\n", e, logging.ShortStack()) logging.LogError(msg) + recoverErr := recoverCalendarItemTransactionAfterPanic(tx) + if tx.calendarJournalBoxID != "" && recoverErr != nil { + logging.LogErrorf("recover panicked calendar item transaction for box [%s] failed: %s", tx.calendarJournalBoxID, recoverErr) + } state := tx.state.Load() if 1 == state { tx.rollback() } - ret = txErrFromPanic(state, e) + if recoverErr != nil { + ret = &TxErr{code: TxErrCodePushMsg, msg: fmt.Sprintf("transaction panic: %v; recovery failed: %s", e, recoverErr)} + } else { + ret = txErrFromPanic(state, e) + } } }() @@ -346,6 +370,9 @@ func performTx(tx *Transaction) (ret *TxErr) { ret = tx.doBatchUpdateAttrViewCells(operations) operationIndex += len(operations) - 1 } + ret = tx.doUpdateAttrViewCell(op) + case "updateAttributeViewItem": + ret = tx.doUpdateAttributeViewItem(op) case "updateAttrViewColOptions": ret = tx.doUpdateAttrViewColOptions(op) case "removeAttrViewColOption": @@ -448,6 +475,16 @@ func performTx(tx *Transaction) (ret *TxErr) { ret = tx.doRemoveAttrViewGroup(op) case "sortAttrViewGroup": ret = tx.doSortAttrViewGroup(op) + case "setAttrViewCalendarDateField": + ret = tx.doSetAttrViewCalendarDateField(op) + case "setAttrViewCalendarViewMode": + ret = tx.doSetAttrViewCalendarViewMode(op) + case "setAttrViewCalendarWeekStart": + ret = tx.doSetAttrViewCalendarWeekStart(op) + case "setAttrViewCalendarFieldMapping": + ret = tx.doSetAttrViewCalendarFieldMapping(op) + case "setAttrViewCalendarNewItemTarget": + ret = tx.doSetAttrViewCalendarNewItemTarget(op) } if nil != ret { @@ -464,6 +501,11 @@ func performTx(tx *Transaction) (ret *TxErr) { if cr := tx.commit(); nil != cr { logging.LogErrorf("commit tx failed: %s", cr) + if tx.calendarJournalBoxID != "" { + if recoverErr := recoverCalendarItemCommitJournal(tx.calendarJournalBoxID); recoverErr != nil { + logging.LogErrorf("recover failed calendar item transaction for box [%s] failed: %s", tx.calendarJournalBoxID, recoverErr) + } + } if 1 == tx.state.Load() { tx.rollback() } @@ -479,6 +521,13 @@ func txErrFromPanic(state int32, recovered any) *TxErr { return &TxErr{code: TxErrCodePushMsg, msg: fmt.Sprintf("transaction panic: %v", recovered)} } +func recoverCalendarItemTransactionAfterPanic(tx *Transaction) error { + if tx == nil || tx.calendarJournalBoxID == "" { + return nil + } + return recoverCalendarItemJournal(tx.calendarJournalBoxID) +} + func (tx *Transaction) processLargeDelete() bool { opSize := len(tx.DoOperations) if 32 > opSize { @@ -1314,18 +1363,40 @@ func deleteAttrView(n *ast.Node, changedAvIDs []string) []string { continue } + removedItemID := "" for i, blockValue := range blockValues.Values { if nil == blockValue.Block { continue } if blockValue.Block.ID == n.ID { + removedItemID = blockValue.BlockID blockValues.Values = append(blockValues.Values[:i], blockValues.Values[i+1:]...) changedAv = true break } } + if "" != removedItemID { + // 条目 ID 与绑定块 ID 自 v3.7.3 起不同,只删主键值会把该条目其余字段的值留成孤儿: + // 它们不再属于任何行,界面上看不见也无法恢复。这里一并清掉。 + for _, keyValues := range attrView.KeyValues { + if nil == keyValues || keyValues == blockValues { + continue + } + values := keyValues.Values[:0] + for i, value := range keyValues.Values { + if nil == value || value.BlockID != removedItemID { + values = append(values, keyValues.Values[i]) + } + } + keyValues.Values = values + } + for _, view := range attrView.Views { + view.ItemIDs = gulu.Str.RemoveElem(view.ItemIDs, removedItemID) + } + } + if changedAv { regenAttrViewGroups(attrView) av.SaveAttributeView(attrView) @@ -2169,11 +2240,16 @@ type Transaction struct { changedRootIDs []string // 变更的树 ID 列表(包含了变更定义块后影响的动态锚文本所在的树) boxIcons map[string]string // 事务提交后需要同步的笔记本图标 - isGlobalAssetsInit bool // 是否初始化过全局资源判断 - isGlobalAssets bool // 是否属于全局资源 - assetsDir string // 资源目录路径 - removedCreatedDocs []*parse.Tree - restoredCreatedDocs []*parse.Tree + isGlobalAssetsInit bool // 是否初始化过全局资源判断 + isGlobalAssets bool // 是否属于全局资源 + assetsDir string // 资源目录路径 + removedCreatedDocs []*parse.Tree + restoredCreatedDocs []*parse.Tree + deferredAttrViews map[string]*av.AttributeView + deferAttrViewSave bool + originalTrees map[string]*parse.Tree + renamedTrees map[string]*parse.Tree + calendarJournalBoxID string fromAPI bool // 是否来自 /api/transactions HTTP 入口(用于撤销日志捕获判别) isReplay bool // 是否为 undo/redo 重放构造的事务(重放不再进入撤销日志) @@ -2214,6 +2290,11 @@ func (tx *Transaction) GetMutatedRootIDs() (ret []string) { for t := range tx.trees { ret = append(ret, t) } + // Atomic bound AV updates may only change calendar fields while keeping the + // document title unchanged. They still belong to that document's undo stack. + for t := range tx.originalTrees { + ret = append(ret, t) + } ret = gulu.Str.RemoveDuplicatedElem(ret) return } @@ -2236,6 +2317,11 @@ func (tx *Transaction) begin() (err error) { tx.restoredCreatedDocs = nil tx.listItemFoldCandidates = nil tx.listItemFoldCandidateIDs = map[string]struct{}{} + tx.deferredAttrViews = map[string]*av.AttributeView{} + tx.deferAttrViewSave = false + tx.originalTrees = map[string]*parse.Tree{} + tx.renamedTrees = map[string]*parse.Tree{} + tx.calendarJournalBoxID = "" tx.luteEngine = util.NewLute() tx.m.Lock() tx.state.Store(1) @@ -2243,6 +2329,17 @@ func (tx *Transaction) begin() (err error) { } func (tx *Transaction) commit() (err error) { + var calendarJournal *calendarItemCommitJournal + if len(tx.originalTrees) > 0 { + calendarJournal, err = buildCalendarItemCommitJournal(tx) + if err != nil { + return err + } + if err = persistCalendarItemCommitJournal(calendarJournal); err != nil { + return err + } + tx.calendarJournalBoxID = calendarJournal.BoxID + } for _, tree := range tx.trees { if err = writeTreeUpsertQueue(tree); err != nil { return @@ -2270,15 +2367,46 @@ func (tx *Transaction) commit() (err error) { tx.relatedAvIDs = gulu.Str.RemoveDuplicatedElem(tx.relatedAvIDs) for _, avID := range tx.relatedAvIDs { - destAv, _ := av.ParseAttributeView(avID) + destAv := tx.deferredAttrViews[avID] + if nil == destAv { + destAv, _ = av.ParseAttributeView(avID) + } if nil == destAv { continue } regenAttrViewGroups(destAv) - av.SaveAttributeView(destAv) + if err = av.SaveAttributeView(destAv); err != nil { + return err + } ReloadAttrView(avID) } + for _, tree := range tx.renamedTrees { + box := Conf.Box(tree.Box) + if box == nil { + return ErrBoxNotFound + } + for _, subFile := range box.ListFiles(tree.Path) { + if !strings.HasSuffix(subFile.path, ".sy") { + continue + } + subTree, loadErr := filesys.LoadTree(box.ID, subFile.path, tx.luteEngine) + if loadErr != nil { + continue + } + treenode.SetBlockTreePath(subTree) + sql.RenameTreeQueue(subTree) + } + updateRefTextRenameDoc(tree) + evt := util.NewCmdResult("rename", 0, util.PushModeBroadcast) + evt.Data = map[string]any{ + "box": tree.Box, "id": tree.Root.ID, "path": tree.Path, + "title": tree.Root.IALAttr("title"), + "empty": tree.Root.IALAttr(NodeAttrTitleEmpty) == "true", + "refText": getNodeRefText(tree.Root), + } + util.PushEvent(evt) + } for _, tree := range tx.removedCreatedDocs { box := Conf.Box(tree.Box) if nil == box { @@ -2300,6 +2428,11 @@ func (tx *Transaction) commit() (err error) { } IncSync() + if calendarJournal != nil { + if err = finalizeCalendarItemCommitJournal(calendarJournal.BoxID); err != nil { + return err + } + } tx.state.Store(2) // 已提交且 trees 稳定后记录到全局撤销日志(rollback 不记录) GlobalUndoLog.Record(tx) diff --git a/kernel/model/transaction_test.go b/kernel/model/transaction_test.go index dba0789c8b5..320edec0ba0 100644 --- a/kernel/model/transaction_test.go +++ b/kernel/model/transaction_test.go @@ -16,7 +16,19 @@ package model -import "testing" +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/88250/lute/parse" + "github.com/siyuan-note/filelock" + "github.com/siyuan-note/siyuan/kernel/av" + "github.com/siyuan-note/siyuan/kernel/cache" + "github.com/siyuan-note/siyuan/kernel/util" +) func TestDoUpdateRejectsInvalidData(t *testing.T) { tests := []any{nil, 1, ""} @@ -40,3 +52,258 @@ func TestTxErrFromPanic(t *testing.T) { t.Fatal("expected a committed transaction panic to preserve the committed result") } } + +func TestRecoverCalendarItemTransactionAfterPanic(t *testing.T) { + oldRecover := recoverCalendarItemJournal + t.Cleanup(func() { recoverCalendarItemJournal = oldRecover }) + calledWith := "" + recoverCalendarItemJournal = func(boxID string) error { + calledWith = boxID + return nil + } + tx := &Transaction{calendarJournalBoxID: "20260728000000-boxabcd"} + if err := recoverCalendarItemTransactionAfterPanic(tx); err != nil { + t.Fatal(err) + } + if calledWith != tx.calendarJournalBoxID { + t.Fatalf("panic recovery did not restore the calendar journal: got %q", calledWith) + } +} + +func TestDecodeAttributeViewItemUpdateDataFromReplayedJSON(t *testing.T) { + data, err := decodeAttributeViewItemUpdateData(map[string]any{ + "AvID": "20260728000000-avabcde", "ItemID": "20260728000001-itemabc", + "BoundBlockID": "20260728000002-docabcd", "PrimaryKey": "Restored title", + "FieldValues": map[string]any{}, + }) + if err != nil { + t.Fatal(err) + } + if data.AvID != "20260728000000-avabcde" || data.ItemID != "20260728000001-itemabc" || + data.BoundBlockID != "20260728000002-docabcd" || data.PrimaryKey != "Restored title" { + t.Fatalf("replayed payload decoded incorrectly: %+v", data) + } +} + +func TestEmptyAttributeViewValueClearsWritableTypes(t *testing.T) { + text := emptyAttributeViewValue(av.KeyTypeText) + if text.Text == nil || text.Text.Content != "" { + t.Fatalf("empty text value is invalid: %+v", text) + } + date := emptyAttributeViewValue(av.KeyTypeDate) + if date.Date == nil || date.Date.IsNotEmpty || date.Date.IsNotEmpty2 { + t.Fatalf("empty date value is invalid: %+v", date) + } + relation := emptyAttributeViewValue(av.KeyTypeRelation) + if relation.Relation == nil || len(relation.Relation.BlockIDs) != 0 { + t.Fatalf("empty relation value is invalid: %+v", relation) + } + attrView := &av.AttributeView{ID: "20260728000000-avabcde", KeyValues: []*av.KeyValues{{ + Key: &av.Key{ID: "20260728000001-relabcd", Type: av.KeyTypeRelation, Relation: &av.Relation{AvID: "20260728000000-avabcde"}}, + }}} + resolved, err := resolveCallerItemFieldValues(attrView, map[string]*av.Value{"20260728000001-relabcd": relation}) + resolvedRelation := resolved["20260728000001-relabcd"] + if err != nil || resolvedRelation == nil || resolvedRelation.Relation == nil { + t.Fatalf("explicit empty relation must remain writable: value=%+v err=%v", resolved, err) + } +} + +func TestAtomicAttributeViewOnlyUpdateBelongsToDocumentUndoStack(t *testing.T) { + tx := &Transaction{trees: map[string]*parse.Tree{}, originalTrees: map[string]*parse.Tree{ + "20260728000002-docabcd": {ID: "20260728000002-docabcd"}, + }} + ids := tx.GetMutatedRootIDs() + if len(ids) != 1 || ids[0] != "20260728000002-docabcd" { + t.Fatalf("atomic AV-only update lost its document undo root: %v", ids) + } +} + +func TestCalendarItemJournalRestoresBothPersistentFiles(t *testing.T) { + oldReindex := reindexRecoveredCalendarItemTree + reindexRecoveredCalendarItemTree = func(string, string) error { return nil } + t.Cleanup(func() { reindexRecoveredCalendarItemTree = oldReindex }) + workspace := t.TempDir() + oldDataDir, oldConfDir := util.DataDir, util.ConfDir + util.DataDir, util.ConfDir = filepath.Join(workspace, "data"), filepath.Join(workspace, "conf") + t.Cleanup(func() { util.DataDir, util.ConfDir = oldDataDir, oldConfDir }) + + boxID := "20260727000000-boxabcd" + avID := "20260727000001-avabcde" + docPath := "/20260727000002-docabcd.sy" + docFile := filepath.Join(util.DataDir, boxID, docPath) + avFile := filepath.Join(util.DataDir, "storage", "av", avID+".json") + if err := os.MkdirAll(filepath.Dir(docFile), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(avFile), 0755); err != nil { + t.Fatal(err) + } + if err := filelock.WriteFile(docFile, []byte("new-document")); err != nil { + t.Fatal(err) + } + if err := filelock.WriteFile(avFile, []byte("new-av")); err != nil { + t.Fatal(err) + } + + journal := &calendarItemCommitJournal{BoxID: boxID, DocPath: docPath, DocData: []byte("old-document"), AvID: avID, AvPath: avFile, AvData: []byte("old-av")} + if err := persistCalendarItemCommitJournal(journal); err != nil { + t.Fatal(err) + } + if err := recoverCalendarItemCommitJournal(boxID); err != nil { + t.Fatal(err) + } + + docData, _ := filelock.ReadFile(docFile) + avData, _ := filelock.ReadFile(avFile) + if !bytes.Equal(docData, journal.DocData) || !bytes.Equal(avData, journal.AvData) { + t.Fatalf("journal recovery must restore both files, got doc=%q av=%q", docData, avData) + } + if _, err := os.Stat(calendarItemJournalPath(boxID)); !os.IsNotExist(err) { + t.Fatalf("journal should be removed after recovery, stat error: %v", err) + } + cache.RemoveTreeData(util.GetTreeID(docPath)) + cache.RemoveAVDataInBox(avID, "") +} + +func TestCalendarItemJournalRemovesPartialRenamedDocument(t *testing.T) { + oldReindex := reindexRecoveredCalendarItemTree + reindexRecoveredCalendarItemTree = func(string, string) error { return nil } + t.Cleanup(func() { reindexRecoveredCalendarItemTree = oldReindex }) + workspace := t.TempDir() + oldDataDir, oldConfDir := util.DataDir, util.ConfDir + util.DataDir, util.ConfDir = filepath.Join(workspace, "data"), filepath.Join(workspace, "conf") + t.Cleanup(func() { util.DataDir, util.ConfDir = oldDataDir, oldConfDir }) + + boxID := "20260727000000-boxabcd" + docPath := "/20260727000002-docabcd.sy" + newDocPath := "/20260727000002-newname.sy" + avID := "20260727000001-avabcde" + oldFile := filepath.Join(util.DataDir, boxID, docPath) + newFile := filepath.Join(util.DataDir, boxID, newDocPath) + avFile := filepath.Join(util.DataDir, "storage", "av", avID+".json") + if err := os.MkdirAll(filepath.Dir(oldFile), 0755); err != nil { + t.Fatal(err) + } + if err := filelock.WriteFile(oldFile, []byte("old")); err != nil { + t.Fatal(err) + } + if err := filelock.WriteFile(newFile, []byte("new")); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(avFile), 0755); err != nil { + t.Fatal(err) + } + if err := filelock.WriteFile(avFile, []byte("new-av")); err != nil { + t.Fatal(err) + } + j := &calendarItemCommitJournal{BoxID: boxID, DocPath: docPath, NewDocPath: newDocPath, DocData: []byte("old"), AvID: avID, AvPath: avFile, AvData: []byte("av")} + if err := persistCalendarItemCommitJournal(j); err != nil { + t.Fatal(err) + } + if err := recoverCalendarItemCommitJournal(boxID); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(newFile); !os.IsNotExist(err) { + t.Fatalf("partial renamed document remains: %v", err) + } + data, err := filelock.ReadFile(oldFile) + if err != nil || !bytes.Equal(data, []byte("old")) { + t.Fatalf("old document was not restored: %q %v", data, err) + } +} + +func TestCalendarItemJournalRejectsTraversalPath(t *testing.T) { + workspace := t.TempDir() + oldDataDir, oldConfDir := util.DataDir, util.ConfDir + util.DataDir, util.ConfDir = filepath.Join(workspace, "data"), filepath.Join(workspace, "conf") + t.Cleanup(func() { util.DataDir, util.ConfDir = oldDataDir, oldConfDir }) + boxID := "20260727000000-boxabcd" + avID := "20260727000001-avabcde" + avFile := filepath.Join(util.DataDir, "storage", "av", avID+".json") + if err := os.MkdirAll(filepath.Dir(avFile), 0755); err != nil { + t.Fatal(err) + } + if err := filelock.WriteFile(avFile, []byte("av")); err != nil { + t.Fatal(err) + } + j := &calendarItemCommitJournal{BoxID: boxID, DocPath: "/../outside.sy", DocData: []byte("old"), AvID: avID, AvPath: avFile, AvData: []byte("av")} + if err := persistCalendarItemCommitJournal(j); err != nil { + t.Fatal(err) + } + if err := recoverCalendarItemCommitJournal(boxID); err == nil { + t.Fatal("journal traversal path must fail closed") + } +} + +func TestCalendarItemJournalRejectsCorruptRecordWithoutOverwritingData(t *testing.T) { + workspace := t.TempDir() + oldDataDir, oldConfDir := util.DataDir, util.ConfDir + util.DataDir, util.ConfDir = filepath.Join(workspace, "data"), filepath.Join(workspace, "conf") + t.Cleanup(func() { util.DataDir, util.ConfDir = oldDataDir, oldConfDir }) + + boxID := "20260727000000-boxabcd" + journalPath := calendarItemJournalPath(boxID) + if err := os.MkdirAll(filepath.Dir(journalPath), 0755); err != nil { + t.Fatal(err) + } + if err := filelock.WriteFile(journalPath, []byte("not-json")); err != nil { + t.Fatal(err) + } + if err := recoverCalendarItemCommitJournal(boxID); err == nil { + t.Fatal("corrupt journal must fail closed") + } + if _, err := os.Stat(journalPath); err != nil { + t.Fatalf("corrupt journal must remain for operator recovery: %v", err) + } +} + +func TestCalendarItemJournalPersistsBeforeAnyReplacementWrite(t *testing.T) { + oldBuild := buildCalendarItemCommitJournal + oldPersist, oldClear := persistCalendarItemCommitJournal, clearCalendarItemCommitJournal + t.Cleanup(func() { + buildCalendarItemCommitJournal = oldBuild + persistCalendarItemCommitJournal, clearCalendarItemCommitJournal = oldPersist, oldClear + }) + buildCalendarItemCommitJournal = func(*Transaction) (*calendarItemCommitJournal, error) { + return &calendarItemCommitJournal{BoxID: "box", DocData: []byte("old-document"), AvData: []byte("old-av")}, nil + } + persisted := false + persistCalendarItemCommitJournal = func(journal *calendarItemCommitJournal) error { + persisted = journal != nil && len(journal.DocData) > 0 && len(journal.AvData) > 0 + return errors.New("stop after journal") + } + clearCalendarItemCommitJournal = func(string) error { return nil } + + tx := &Transaction{originalTrees: map[string]*parse.Tree{"doc": {Box: "box"}}, deferredAttrViews: map[string]*av.AttributeView{"av": {ID: "av"}}} + if err := tx.commit(); err == nil || !persisted { + t.Fatalf("commit must persist both old files before any replacement write, persisted=%v err=%v", persisted, err) + } +} + +func TestCalendarItemJournalClearsOnlyAfterIndexFlush(t *testing.T) { + oldBuild := buildCalendarItemCommitJournal + oldPersist := persistCalendarItemCommitJournal + oldClear := clearCalendarItemCommitJournal + oldFlush := flushCalendarItemIndexQueue + t.Cleanup(func() { + buildCalendarItemCommitJournal = oldBuild + persistCalendarItemCommitJournal = oldPersist + clearCalendarItemCommitJournal = oldClear + flushCalendarItemIndexQueue = oldFlush + }) + buildCalendarItemCommitJournal = func(*Transaction) (*calendarItemCommitJournal, error) { + return &calendarItemCommitJournal{BoxID: "box", DocData: []byte("old-document"), AvData: []byte("old-av")}, nil + } + persistCalendarItemCommitJournal = func(*calendarItemCommitJournal) error { return nil } + flushed := false + flushCalendarItemIndexQueue = func() { flushed = true } + clearCalendarItemCommitJournal = func(string) error { + if !flushed { + return errors.New("journal cleared before index flush") + } + return nil + } + if err := finalizeCalendarItemCommitJournal("box"); err != nil { + t.Fatal(err) + } +} diff --git a/kernel/sql/av.go b/kernel/sql/av.go index 11ffaf3d94f..9e819cc964e 100644 --- a/kernel/sql/av.go +++ b/kernel/sql/av.go @@ -37,7 +37,14 @@ import ( func RenderGroupView(attrView *av.AttributeView, view, groupView *av.View, query string) (ret av.Viewable) { var err error - switch groupView.LayoutType { + // Group layout follows the parent view. Older Calendar groups were generated + // as tables before Calendar grouping existed, which made the API return rows + // instead of cards and left every grouped Calendar visually empty. + if view.LayoutType == av.LayoutTypeCalendar && groupView.LayoutType != av.LayoutTypeCalendar { + groupView.LayoutType = av.LayoutTypeCalendar + groupView.Calendar = av.NewLayoutCalendar() + } + switch view.LayoutType { case av.LayoutTypeTable: // 这里需要使用深拷贝,因为字段上可能会带有计算(FieldCalc),每个分组视图的计算结果都需要分别存储在不同的字段实例上 err = copier.CopyWithOption(&groupView.Table.Columns, &view.Table.Columns, copier.Option{DeepCopy: true}) @@ -74,6 +81,18 @@ func RenderGroupView(attrView *av.AttributeView, view, groupView *av.View, query groupView.Kanban.DisplayFieldName = view.Kanban.DisplayFieldName groupView.Kanban.DisplayEmptyFields = view.Kanban.DisplayEmptyFields groupView.Kanban.FillColBackgroundColor = view.Kanban.FillColBackgroundColor + case av.LayoutTypeCalendar: + err = copier.CopyWithOption(&groupView.Calendar.Fields, &view.Calendar.Fields, copier.Option{DeepCopy: true}) + groupView.Calendar.ShowIcon = view.Calendar.ShowIcon + groupView.Calendar.WrapField = view.Calendar.WrapField + groupView.Calendar.DateFieldID = view.Calendar.DateFieldID + groupView.Calendar.ViewMode = view.Calendar.ViewMode + groupView.Calendar.WeekStart = view.Calendar.WeekStart + groupView.Calendar.NewItemTarget = view.Calendar.NewItemTarget + if nil != view.Calendar.FieldMapping { + mapping := *view.Calendar.FieldMapping + groupView.Calendar.FieldMapping = &mapping + } } if nil != err { logging.LogErrorf("copy view fields [%s] to group [%s] failed: %s", view.ID, groupView.ID, err) @@ -84,6 +103,8 @@ func RenderGroupView(attrView *av.AttributeView, view, groupView *av.View, query groupView.Gallery.CardFields = view.Gallery.CardFields case av.LayoutTypeKanban: groupView.Kanban.Fields = view.Kanban.Fields + case av.LayoutTypeCalendar: + groupView.Calendar.Fields = view.Calendar.Fields } } @@ -119,6 +140,8 @@ func renderView(attrView *av.AttributeView, view *av.View, query string, depth * ret = RenderAttributeViewGallery(attrView, view, query, depth, cachedAttrViews, ignoreRows) case av.LayoutTypeKanban: ret = RenderAttributeViewKanban(attrView, view, query, depth, cachedAttrViews, ignoreRows) + case av.LayoutTypeCalendar: + ret = RenderAttributeViewCalendar(attrView, view, query, depth, cachedAttrViews, ignoreRows) } return } @@ -318,13 +341,17 @@ func generateAttrViewItems(attrView *av.AttributeView, view *av.View) (ret map[s return } +// filterNotFoundAttrViewItems 从 itemID -> 键值 的映射中剔除绑定块已经不存在的条目。 +// keyValuesMap 的键是条目 ID(item id),而绑定块 ID(blockValue.Block.ID)自 v3.7.3 起与条目 ID 不同, +// 所以存在性检查必须按绑定块 ID 做、删除必须按条目 ID 做,否则文档被删掉的条目会一直以幽灵卡片留在视图里。 func filterNotFoundAttrViewItems(keyValuesMap map[string][]*av.KeyValues) { var notFound []string var toCheckBlockIDs []string - for blockID, keyValues := range keyValuesMap { + itemIDsByBlockID := map[string][]string{} + for itemID, keyValues := range keyValuesMap { blockValue := getBlockValue(keyValues) if nil == blockValue || nil == blockValue.Block { - notFound = append(notFound, blockID) + notFound = append(notFound, itemID) continue } @@ -332,21 +359,25 @@ func filterNotFoundAttrViewItems(keyValuesMap map[string][]*av.KeyValues) { continue } - if "" == blockValue.Block.ID { - notFound = append(notFound, blockID) + boundBlockID := blockValue.Block.ID + if "" == boundBlockID { + notFound = append(notFound, itemID) continue } - toCheckBlockIDs = append(toCheckBlockIDs, blockValue.Block.ID) + if _, ok := itemIDsByBlockID[boundBlockID]; !ok { + toCheckBlockIDs = append(toCheckBlockIDs, boundBlockID) + } + itemIDsByBlockID[boundBlockID] = append(itemIDsByBlockID[boundBlockID], itemID) } checkRet := treenode.ExistBlockTrees(toCheckBlockIDs) - for blockID, exist := range checkRet { + for boundBlockID, exist := range checkRet { if !exist { - notFound = append(notFound, blockID) + notFound = append(notFound, itemIDsByBlockID[boundBlockID]...) } } - for _, blockID := range notFound { - delete(keyValuesMap, blockID) + for _, itemID := range notFound { + delete(keyValuesMap, itemID) } } @@ -915,6 +946,34 @@ func removeMissingField(attrView *av.AttributeView, view *av.View, missingKeyID } } + if nil != view.Calendar { + for i, calendarField := range view.Calendar.Fields { + if calendarField.ID == missingKeyID { + view.Calendar.Fields = append(view.Calendar.Fields[:i], view.Calendar.Fields[i+1:]...) + changed = true + break + } + } + if view.Calendar.DateFieldID == missingKeyID { + view.Calendar.DateFieldID = "" + changed = true + } + if nil != view.Calendar.FieldMapping { + for _, fieldID := range []*string{ + &view.Calendar.FieldMapping.RecurrenceFieldID, + &view.Calendar.FieldMapping.ExceptionFieldID, + &view.Calendar.FieldMapping.LocationFieldID, + &view.Calendar.FieldMapping.DescriptionFieldID, + &view.Calendar.FieldMapping.ColorFieldID, + } { + if *fieldID == missingKeyID { + *fieldID = "" + changed = true + } + } + } + } + if changed { av.SaveAttributeView(attrView) } diff --git a/kernel/sql/av_calendar.go b/kernel/sql/av_calendar.go new file mode 100644 index 00000000000..0549f724cf7 --- /dev/null +++ b/kernel/sql/av_calendar.go @@ -0,0 +1,136 @@ +package sql + +import ( + "fmt" + + "github.com/88250/lute/ast" + "github.com/siyuan-note/siyuan/kernel/av" + "github.com/siyuan-note/siyuan/kernel/util" +) + +func RenderAttributeViewCalendar(attrView *av.AttributeView, view *av.View, query string, depth *int, cachedAttrViews map[string]*av.AttributeView, ignoreRows bool) (ret *av.Calendar) { + viewable := attrView.RenderedViewables[view.ID] + if nil != viewable { + ret = viewable.(*av.Calendar) + return + } + + ret = &av.Calendar{ + BaseInstance: av.NewViewBaseInstance(view), + Fields: []*av.CalendarField{}, + Cards: []*av.CalendarCard{}, + } + + if nil != view.Calendar { + ret.DateFieldID = view.Calendar.DateFieldID + ret.ViewMode = view.Calendar.ViewMode + ret.WeekStart = view.Calendar.WeekStart + ret.NewItemTarget = view.Calendar.NewItemTarget + ret.FieldMapping = view.Calendar.FieldMapping + + fields := make([]*av.ViewCalendarCardField, len(view.Calendar.Fields)) + copy(fields, view.Calendar.Fields) + for _, field := range fields { + key, getErr := attrView.GetKey(field.ID) + if nil != getErr { + // 找不到字段则在视图中删除(元数据查询场景不写盘) + if !ignoreRows { + removeMissingField(attrView, view, field.ID) + } + continue + } + + ret.Fields = append(ret.Fields, &av.CalendarField{ + BaseInstanceField: &av.BaseInstanceField{ + ID: key.ID, + Name: key.Name, + Type: key.Type, + Icon: key.Icon, + Wrap: field.Wrap, + Hidden: field.Hidden, + Desc: key.Desc, + Calc: field.Calc, + Options: key.Options, + NumberFormat: key.NumberFormat, + Template: key.Template, + Relation: key.Relation, + Rollup: key.Rollup, + Date: key.Date, + Created: key.Created, + Updated: key.Updated, + }, + }) + } + } + + // 菜单等只需要字段/视图元数据的场景,跳过全部卡片处理 + if ignoreRows { + return + } + + cardsValues := generateAttrViewItems(attrView, view) + filterNotFoundAttrViewItems(cardsValues) + + for cardID, cardValues := range cardsValues { + var calendarCard av.CalendarCard + calendarCard.ID = cardID + for _, field := range ret.Fields { + var fieldValue *av.CalendarFieldValue + for _, keyValues := range cardValues { + if keyValues.Key.ID == field.ID { + if 0 < len(keyValues.Values) { + fieldValue = &av.CalendarFieldValue{ + BaseValue: &av.BaseValue{ + ID: keyValues.Values[0].ID, + Value: keyValues.Values[0], + ValueType: field.Type, + }, + } + } + break + } + } + if nil == fieldValue { + fieldValue = &av.CalendarFieldValue{ + BaseValue: &av.BaseValue{ + ID: cardID[:14] + ast.NewNodeID()[14:], + ValueType: field.Type, + }, + } + } + + fieldDateIsTime := false + if nil != field.Date { + fieldDateIsTime = field.Date.FillSpecificTime + } + fillAttributeViewBaseValue(fieldValue.BaseValue, field.ID, cardID, field.NumberFormat, field.Template, fieldDateIsTime) + calendarCard.Values = append(calendarCard.Values, fieldValue) + } + + ret.Cards = append(ret.Cards, &calendarCard) + } + + fillAttributeViewKeyValues(attrView, ret) + + var ialIDs []string + for _, card := range ret.Cards { + blockVal := card.GetBlockValue() + if nil != blockVal && !blockVal.IsDetached && nil != blockVal.Block { + ialIDs = append(ialIDs, blockVal.Block.ID) + } + } + ials := BatchGetBlockAttrs(ialIDs) + + fillAttributeViewAutoGeneratedValues(attrView, ret, ials, depth, cachedAttrViews) + + renderTemplateErr := fillAttributeViewTemplateValues(attrView, view, ret, ials) + if nil != renderTemplateErr { + util.PushErrMsg(fmt.Sprintf(util.Langs[util.Lang][44], util.EscapeHTML(renderTemplateErr.Error())), 30000) + } + + filterByQuery(query, ret) + manualSort(view, ret) + + attrView.RenderedViewables[view.ID] = ret + return +} diff --git a/kernel/sql/av_calendar_test.go b/kernel/sql/av_calendar_test.go new file mode 100644 index 00000000000..9cd4398fd1a --- /dev/null +++ b/kernel/sql/av_calendar_test.go @@ -0,0 +1,81 @@ +package sql + +import ( + "testing" + + "github.com/siyuan-note/siyuan/kernel/av" +) + +// itemKeyValues 组装一个条目的键值:主键(block)+ 一个日期字段。 +// itemID 是条目 ID,boundBlockID 是绑定的文档根块 ID —— 自 v3.7.3 起两者不同。 +func itemKeyValues(itemID, boundBlockID string, detached bool) []*av.KeyValues { + blockValue := &av.Value{ + ID: "v-" + itemID, KeyID: "block", BlockID: itemID, + Type: av.KeyTypeBlock, IsDetached: detached, + Block: &av.ValueBlock{ID: boundBlockID, Content: itemID}, + } + return []*av.KeyValues{ + {Key: &av.Key{ID: "block", Type: av.KeyTypeBlock}, Values: []*av.Value{blockValue}}, + {Key: &av.Key{ID: "date", Type: av.KeyTypeDate}, Values: []*av.Value{ + {ID: "d-" + itemID, KeyID: "date", BlockID: itemID, Type: av.KeyTypeDate, + Date: &av.ValueDate{Content: 1, IsNotEmpty: true}}, + }}, + } +} + +// K5: 日历条目就是文档以后,文档被删掉的条目必须从渲染里消失。 +// 这里没有初始化 blocktree 库,treenode.ExistBlockTrees 对任何 ID 都返回 false, +// 也就是「绑定的文档都不存在了」这个场景。 +func TestFilterNotFoundAttrViewItemsForCalendar(t *testing.T) { + const ( + boundItemID = "20260101000000-item001" + boundBlockID = "20260101000000-doc001" + sharedItemA = "20260101000000-item002" + sharedItemB = "20260101000000-item003" + sharedBlockID = "20260101000000-doc002" + detachedItemID = "20260101000000-item004" + emptyBlockItem = "20260101000000-item005" + noBlockKeyItem = "20260101000000-item006" + nilBlockValItem = "20260101000000-item007" + ) + + keyValuesMap := map[string][]*av.KeyValues{ + // 绑定文档已被删除:必须按条目 ID 删除,而不是按绑定块 ID + boundItemID: itemKeyValues(boundItemID, boundBlockID, false), + // 两个条目绑定同一篇已删除的文档:两个都要消失 + sharedItemA: itemKeyValues(sharedItemA, sharedBlockID, false), + sharedItemB: itemKeyValues(sharedItemB, sharedBlockID, false), + // 游离条目没有文档,永远保留 + detachedItemID: itemKeyValues(detachedItemID, detachedItemID, true), + // 绑定块 ID 为空 = 数据损坏,去掉 + emptyBlockItem: itemKeyValues(emptyBlockItem, "", false), + // 没有主键值 = 不是一行,去掉 + noBlockKeyItem: { + {Key: &av.Key{ID: "date", Type: av.KeyTypeDate}, Values: []*av.Value{ + {ID: "d", KeyID: "date", BlockID: noBlockKeyItem, Type: av.KeyTypeDate}, + }}, + }, + // 主键值的 Block 为 nil,同样不是一行 + nilBlockValItem: { + {Key: &av.Key{ID: "block", Type: av.KeyTypeBlock}, Values: []*av.Value{ + {ID: "v", KeyID: "block", BlockID: nilBlockValItem, Type: av.KeyTypeBlock}, + }}, + }, + } + + filterNotFoundAttrViewItems(keyValuesMap) + + for _, itemID := range []string{boundItemID, sharedItemA, sharedItemB, emptyBlockItem, noBlockKeyItem, nilBlockValItem} { + if _, ok := keyValuesMap[itemID]; ok { + t.Fatalf("item %s whose bound document is gone must not be rendered; "+ + "filterNotFoundAttrViewItems collected the BOUND BLOCK id but deleted by ITEM id, "+ + "and since v3.7.3 those differ, which leaves a permanent ghost event on the calendar", itemID) + } + } + if _, ok := keyValuesMap[detachedItemID]; !ok { + t.Fatal("a detached item has no document and must always survive the filter") + } + if 1 != len(keyValuesMap) { + t.Fatalf("expected only the detached item to survive, got %d entries", len(keyValuesMap)) + } +} diff --git a/kernel/sql/av_table.go b/kernel/sql/av_table.go index a8c425e17e6..36f58af14a5 100644 --- a/kernel/sql/av_table.go +++ b/kernel/sql/av_table.go @@ -35,6 +35,11 @@ func RenderAttributeViewTable(attrView *av.AttributeView, view *av.View, query s Rows: []*av.TableRow{}, } + if nil == view.Table { + // 非表格布局(如日历)在导出等场景下会临时合成表格布局,缺失时兜底避免空指针 + view.Table = av.NewLayoutTable() + } + // 组装列 for _, col := range view.Table.Columns { key, getErr := attrView.GetKey(col.ID) From 3ea574ff70285860c1ddb98112f2eaf28eb7d676 Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 19:23:02 +0200 Subject: [PATCH 02/10] :sparkles: Add Attribute View calendar view https://github.com/siyuan-note/siyuan/issues/13740 --- app/src/assets/scss/business/_av.scss | 1876 ++++++++++++++++ app/src/constants.ts | 6 + app/src/protyle/render/av/action.ts | 15 +- .../render/av/calendar/context-menu.ts | 373 ++++ app/src/protyle/render/av/calendar/drafts.ts | 518 +++++ .../protyle/render/av/calendar/event-chip.ts | 181 ++ .../render/av/calendar/event-dialog.ts | 972 ++++++++ app/src/protyle/render/av/calendar/ics.ts | 310 +++ .../render/av/calendar/interactions.ts | 780 +++++++ app/src/protyle/render/av/calendar/keymap.ts | 261 +++ .../render/av/calendar/layout-overlap.ts | 162 ++ .../render/av/calendar/mapped-fields.ts | 78 + .../protyle/render/av/calendar/mini-month.ts | 177 ++ app/src/protyle/render/av/calendar/model.ts | 127 ++ .../protyle/render/av/calendar/normalize.ts | 134 ++ .../render/av/calendar/now-indicator.ts | 118 + .../render/av/calendar/quick-create.ts | 142 ++ .../render/av/calendar/recurrence-storage.ts | 75 + .../render/av/calendar/recurrence-summary.ts | 243 ++ .../protyle/render/av/calendar/recurrence.ts | 225 ++ app/src/protyle/render/av/calendar/render.ts | 1985 +++++++++++++++++ .../render/av/calendar/time-geometry.ts | 244 ++ .../protyle/render/av/calendar/time-grid.ts | 248 ++ .../render/av/calendar/transactions.ts | 1266 +++++++++++ app/src/protyle/render/av/col.ts | 3 +- app/src/protyle/render/av/gallery/render.ts | 12 + app/src/protyle/render/av/kanban/render.ts | 12 + app/src/protyle/render/av/layout.ts | 312 ++- app/src/protyle/render/av/locate.ts | 77 +- app/src/protyle/render/av/openMenuPanel.ts | 9 +- app/src/protyle/render/av/render.ts | 15 +- app/src/protyle/render/av/row.ts | 3 +- app/src/protyle/render/av/view.ts | 35 +- app/src/protyle/wysiwyg/transaction.ts | 4 +- app/src/types/index.d.ts | 31 +- 35 files changed, 10995 insertions(+), 34 deletions(-) create mode 100644 app/src/protyle/render/av/calendar/context-menu.ts create mode 100644 app/src/protyle/render/av/calendar/drafts.ts create mode 100644 app/src/protyle/render/av/calendar/event-chip.ts create mode 100644 app/src/protyle/render/av/calendar/event-dialog.ts create mode 100644 app/src/protyle/render/av/calendar/ics.ts create mode 100644 app/src/protyle/render/av/calendar/interactions.ts create mode 100644 app/src/protyle/render/av/calendar/keymap.ts create mode 100644 app/src/protyle/render/av/calendar/layout-overlap.ts create mode 100644 app/src/protyle/render/av/calendar/mapped-fields.ts create mode 100644 app/src/protyle/render/av/calendar/mini-month.ts create mode 100644 app/src/protyle/render/av/calendar/model.ts create mode 100644 app/src/protyle/render/av/calendar/normalize.ts create mode 100644 app/src/protyle/render/av/calendar/now-indicator.ts create mode 100644 app/src/protyle/render/av/calendar/quick-create.ts create mode 100644 app/src/protyle/render/av/calendar/recurrence-storage.ts create mode 100644 app/src/protyle/render/av/calendar/recurrence-summary.ts create mode 100644 app/src/protyle/render/av/calendar/recurrence.ts create mode 100644 app/src/protyle/render/av/calendar/render.ts create mode 100644 app/src/protyle/render/av/calendar/time-geometry.ts create mode 100644 app/src/protyle/render/av/calendar/time-grid.ts create mode 100644 app/src/protyle/render/av/calendar/transactions.ts diff --git a/app/src/assets/scss/business/_av.scss b/app/src/assets/scss/business/_av.scss index 0b220898eb4..36b28233285 100644 --- a/app/src/assets/scss/business/_av.scss +++ b/app/src/assets/scss/business/_av.scss @@ -3,6 +3,10 @@ box-sizing: border-box; display: flex; max-width: 100%; + + &[data-av-type="calendar"] > .av__container { + width: 100%; + } margin-top: 4px !important; margin-bottom: 4px !important; @@ -2285,3 +2289,1875 @@ img.av__cellassetimg { white-space: nowrap; } } + +.sy__calendar { + min-width: 260px; + + .av__calendar-dock-content { + overflow: auto; + padding: 8px; + } + + .av__calendar-dock-month { + display: grid; + gap: 4px; + } + + .av__calendar-dock-database-picker { + margin-bottom: 8px; + position: relative; + + > button { + align-items: center; + display: flex; + gap: 6px; + justify-content: space-between; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + svg { + flex: 0 0 auto; + height: 12px; + width: 12px; + } + } + } + + .av__calendar-dock-sources { + background: var(--b3-menu-background); + border: 1px solid var(--b3-border-color); + border-radius: var(--b3-border-radius); + box-shadow: var(--b3-dialog-shadow); + display: grid; + gap: 4px; + max-height: 240px; + overflow: auto; + padding: 4px; + position: absolute; + width: 100%; + z-index: 4; + } + + .b3-list-item__text { + min-width: 0; + + small { + color: var(--b3-theme-on-surface-light); + display: block; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + .av__calendar-dock-views { + display: grid; + gap: 4px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 8px; + + .b3-button { + justify-content: center; + min-width: 0; + padding-left: 6px; + padding-right: 6px; + } + } + + .av__calendar-dock-period { + color: var(--b3-theme-on-surface); + font-size: 12px; + font-weight: 600; + margin: 4px 0 8px; + text-align: center; + } + + .av__calendar-dock-events, + .av__calendar-dock-agenda { + display: grid; + gap: 4px; + } + + .av__calendar-dock-event { + align-items: flex-start; + border: 0; + height: auto; + min-height: 38px; + text-align: left; + width: 100%; + + .b3-list-item__text { + display: block; + -webkit-line-clamp: unset; + overflow: visible; + overflow-wrap: anywhere; + white-space: normal; + text-overflow: clip; + } + + .av__calendar-dock-event-title { + overflow-wrap: anywhere; + } + } + + .av__calendar-dock-event-dot { + flex: 0 0 auto; + font-size: 10px; + line-height: 20px; + } + + .av__calendar-dock-event-date { + color: var(--b3-theme-on-surface-light); + margin-right: 4px; + } + + .av__calendar-dock-date { + align-items: center; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: grid; + gap: 2px; + grid-template-columns: 1fr auto; + margin-bottom: 8px; + padding: 8px; + text-align: left; + width: 100%; + + &:hover { + background-color: var(--b3-list-hover); + } + + span, + small { + color: var(--b3-theme-on-surface-light); + } + + strong { + font-size: 32px; + grid-row: 1 / span 2; + line-height: 1; + } + } + + .av__calendar-dock-mini { + margin-bottom: 8px; + + .av__calendar-mini-day { + aspect-ratio: 1 / 1; + height: 24px; + justify-self: center; + min-height: 24px; + padding: 0; + width: 24px; + } + + .av__calendar-mini-day--has-events::after { + background-color: currentColor; + border-radius: 50%; + bottom: 2px; + content: ""; + height: 3px; + position: absolute; + width: 3px; + } + } + + .av__calendar-dock-selected-day { + border-top: 1px solid var(--b3-border-color); + margin: 8px 0 4px; + padding-top: 8px; + } + + .av__calendar-dock-agenda-day { + display: grid; + gap: 3px; + } + + .av__calendar-dock-agenda-date { + background: transparent; + border: 0; + color: var(--b3-theme-on-surface); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 6px 8px 2px; + text-align: left; + } + + .av__calendar-dock-agenda-today { + border-top: 1px solid var(--b3-theme-primary); + margin-top: 4px; + padding-top: 4px; + + .av__calendar-dock-agenda-date { + color: var(--b3-theme-primary); + } + } + + .av__calendar-dock-empty { + padding: 18px 8px; + text-align: center; + } +} + +.av__calendar { + min-width: 320px; + padding: 8px; + + &:focus-visible { + outline: 2px solid var(--b3-theme-primary); + outline-offset: 2px; + } + + &--empty { + min-width: 240px; + padding: 32px; + text-align: center; + } + + &-setup { + margin-top: 12px; + min-width: 220px; + } + + &-toolbar { + align-items: center; + background-color: var(--b3-theme-background); + border-bottom: 1px solid var(--b3-border-color); + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px 0 8px; + position: sticky; + top: 0; + z-index: 8; + } + + // --------------------------------------------------------------------------- + // Sidebar + main. + // + // The mini month must never cost the main view its width. The pane, not the + // window, is what is narrow here (a calendar lives inside a dock or a split), + // so this is a CONTAINER query, not a media query - and it sits on + // &-body rather than on .av__calendar, because container-type also makes the + // element a containing block for absolutely positioned descendants and + // .av__calendar is the offsetParent the toolbar's quick-create popover is + // measured against. + // + // Below the threshold the sidebar is simply not there, which matches the rest + // of this file's responsive idiom (min-width floors + the horizontal scroll of + // .av__scroll) and leaves the narrow pane exactly as usable as it was before + // the navigator existed. Schedule view is the intended narrow-pane view. + // --------------------------------------------------------------------------- + &-body { + align-items: flex-start; + container-type: inline-size; + display: flex; + gap: 12px; + } + + &-main { + flex: 1 1 auto; + // Without this the flex item refuses to go below its content's min-content + // width and the sidebar would be squeezed out instead of the grid scrolling. + min-width: 0; + } + + &-sidebar { + display: block; + flex: 0 0 var(--calendar-sidebar-width, 196px); + width: var(--calendar-sidebar-width, 196px); + } + + &[data-view-mode="4"] { + .av__calendar-sidebar { + display: none; + } + + .av__calendar-body { + display: block; + overflow-x: auto; + } + } + + &-title-control { + align-items: center; + display: flex; + flex: 1 1 180px; + gap: 8px; + justify-content: center; + min-width: 160px; + position: relative; + } + + &-title { + align-items: center; + color: var(--b3-theme-on-background); + display: inline-flex; + font-size: 15px; + font-weight: 600; + gap: 6px; + text-align: center; + + svg { + height: 13px; + opacity: .64; + width: 13px; + } + } + + &-week-number { + color: var(--b3-theme-on-surface); + flex: 0 0 auto; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + } + + &-search-control { + flex: 0 1 192px; + max-width: 192px; + min-width: 120px; + position: relative; + } + + &-search { + min-width: 120px; + padding-right: 30px; + width: 100%; + } + + &-search-toggle { + height: 28px; + margin-top: -14px; + position: absolute; + right: 2px; + top: 50%; + width: 28px; + + svg { + height: 12px; + transition: transform 120ms ease; + width: 12px; + } + + &[aria-expanded="true"] svg { + transform: rotate(180deg); + } + } + + &-search-dropdown { + background-color: var(--b3-menu-background); + border: 1px solid var(--b3-theme-surface-lighter); + border-radius: var(--b3-border-radius-b); + box-shadow: var(--b3-dialog-shadow); + left: 0; + min-width: 100%; + padding: 4px; + position: absolute; + top: calc(100% + 4px); + z-index: 4; + + .b3-menu__item { + width: 100%; + } + } + + &-search-count { + color: var(--b3-theme-on-surface); + flex: 0 0 auto; + font-size: 12px; + margin-left: 4px; + min-width: 44px; + text-align: right; + } + + &-jump { + height: 1px; + left: 50%; + opacity: 0; + pointer-events: none; + position: absolute; + top: 100%; + width: 1px; + } + + &-view-trigger { + align-items: center; + background-color: var(--b3-list-hover); + color: var(--b3-theme-on-background); + display: flex; + gap: 4px; + white-space: nowrap; + + svg { + height: 12px; + width: 12px; + } + } + + &-year { + display: grid; + gap: 40px 72px; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + min-width: 1000px; + padding: 12px 20px 32px; + } + + &-year-month { + min-width: 0; + + h3 { + font-size: 18px; + font-weight: 600; + margin: 0 0 18px; + text-align: left; + } + } + + &-year-weekdays, + &-year-week { + display: grid; + gap: 2px; + grid-template-columns: 28px repeat(7, minmax(26px, 1fr)); + } + + &-year-weekdays { + color: var(--b3-theme-on-surface); + font-size: 13px; + line-height: 28px; + margin-bottom: 4px; + text-align: center; + } + + &-year-weeks { + display: grid; + gap: 2px; + } + + &-year-week-number { + align-items: center; + background-color: var(--b3-theme-surface); + color: var(--b3-theme-on-surface); + display: flex; + font-size: 12px; + justify-content: center; + min-height: 30px; + } + + &-year-week:first-child &-year-week-number { + border-radius: 4px 4px 0 0; + } + + &-year-week:last-child &-year-week-number { + border-radius: 0 0 4px 4px; + } + + &-year-day { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: inherit; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 13px; + height: 30px; + justify-content: center; + min-width: 30px; + padding: 0; + position: relative; + + &:hover:not(:disabled), + &:focus-visible { + background-color: var(--b3-list-hover); + outline: 2px solid var(--b3-theme-primary-light); + outline-offset: -2px; + } + + &:disabled { + cursor: default; + } + + &--outside { + color: var(--b3-theme-on-surface); + opacity: .48; + } + + &--today { + background-color: var(--b3-theme-primary); + color: var(--b3-theme-on-primary); + } + + &--selected:not(.av__calendar-year-day--today) { + box-shadow: inset 0 0 0 1px var(--b3-theme-primary); + } + + i { + background-color: currentColor; + border-radius: 50%; + bottom: 2px; + height: 3px; + position: absolute; + width: 3px; + } + } + + @media (max-width: 1240px) { + .av__calendar-year { + gap-inline: 40px; + grid-template-columns: repeat(3, minmax(220px, 1fr)); + min-width: 760px; + } + } + + @media (max-width: 900px) { + .av__calendar-year { + grid-template-columns: repeat(2, minmax(220px, 1fr)); + min-width: 520px; + } + } + + &-weekdays, + &-month { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + + &-weekdays { + color: var(--b3-theme-on-surface); + font-size: 12px; + padding-bottom: 4px; + text-align: center; + } + + &-day { + border-bottom: 1px solid var(--b3-border-color); + border-right: 1px solid var(--b3-border-color); + min-height: 104px; + padding: 3px; + + &:nth-child(-n + 7) { + border-top: 1px solid var(--b3-border-color); + } + + &:nth-child(7n + 1) { + border-left: 1px solid var(--b3-border-color); + } + + &--muted { + background-color: var(--b3-theme-surface); + color: var(--b3-theme-on-surface); + opacity: .62; + } + + &--today { + box-shadow: none; + + .av__calendar-daynum { + background-color: var(--b3-theme-primary); + border-radius: 50%; + color: var(--b3-theme-on-primary); + height: 24px; + min-width: 24px; + text-align: center; + } + } + + &--dragover { + background-color: var(--b3-theme-primary-light); + box-shadow: inset 0 0 0 1px var(--b3-theme-primary); + } + } + + &-daynum, + &-list-title { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font: inherit; + padding: 2px 4px; + text-align: left; + + &:disabled { + cursor: default; + opacity: 0.72; + } + } + + &-events, + &-list-events { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + &-event { + align-items: center; + background-color: var(--b3-theme-primary-light); + border: 0; + border-radius: 6px; + color: var(--b3-theme-primary); + cursor: pointer; + display: flex; + gap: 5px; + font-size: 12px; + line-height: 16px; + overflow: hidden; + padding: 2px 6px; + text-align: left; + white-space: nowrap; + + &[draggable="false"] { + cursor: default; + } + + &--readonly { + cursor: pointer; + } + + &--locate { + box-shadow: 0 0 0 2px var(--b3-theme-primary); + } + + &--selected { + box-shadow: inset 3px 0 0 var(--calendar-event-accent, var(--b3-theme-primary)); + } + + &:hover, + &--selected { + filter: brightness(1.03); + } + + &:focus-visible { + outline: 2px solid var(--b3-theme-primary); + outline-offset: -1px; + } + + &--month, + &--list { + --calendar-event-accent: var(--b3-theme-primary); + background-color: transparent; + color: var(--b3-theme-on-background); + + &::before { + background-color: var(--calendar-event-accent); + border-radius: 999px; + content: ""; + flex: 0 0 3px; + height: 12px; + } + + &:hover, + &.av__calendar-event--selected { + background-color: var(--b3-list-hover); + } + } + + &--timed { + align-items: flex-start; + background-color: var(--calendar-event-fill, var(--b3-theme-primary)); + color: var(--b3-theme-on-primary); + padding: 4px 7px; + } + + &--short { + padding-bottom: 1px; + padding-top: 1px; + + .av__calendar-event-time, + .av__calendar-event-meta { + display: none; + } + } + + &--tall .av__calendar-event-meta { + display: block; + } + + &--all-day { + background-color: var(--calendar-event-fill, var(--b3-theme-primary)); + color: var(--b3-theme-on-primary); + font-weight: 500; + + .av__calendar-event-title { + flex: 1; + min-width: 0; + } + } + + &--continues-before { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + + &--continues-after { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + } + + &-event-content { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + } + + &-event-title, + &-event-time, + &-event-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-event-title { + color: inherit; + font-weight: 600; + } + + &-event-time { + color: inherit; + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + opacity: .92; + } + + &-event-meta { + display: none; + font-size: 11px; + opacity: .72; + } + + // The source marker remains in the scheduling dialog. The redundant marker + // inside a Termin chip is no longer rendered because the whole chip opens it. + &-source { + align-items: center; + border: 1px solid currentColor; + border-radius: 50%; + display: inline-flex; + flex: 0 0 auto; + font-size: 10px; + height: 16px; + justify-content: center; + line-height: 14px; + opacity: .75; + width: 16px; + } + + &-event-source { + align-items: center; + background: transparent; + border: 0; + color: var(--b3-theme-on-surface); + cursor: pointer; + display: flex; + gap: 6px; + justify-content: flex-start; + max-width: 100%; + padding: 4px 0; + text-align: left; + width: auto; + + code { + background-color: var(--b3-theme-surface); + border-radius: var(--b3-border-radius); + font-size: 11px; + min-width: 0; + overflow: hidden; + padding: 1px 4px; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &-dialog { + position: relative; + } + + &-dialog--pending { + opacity: .78; + pointer-events: none; + } + + // Also worn by the optimistic chip a page create paints before the kernel + // answers: it is a preview, so it must never look interactive. + &-event--pending { + cursor: default; + opacity: .72; + pointer-events: none; + position: relative; + } + + &-event--pending::after { + animation: av__calendar-pending 1.2s ease-in-out infinite; + background: var(--b3-theme-primary); + border-radius: 999px; + bottom: 2px; + content: ""; + height: 3px; + left: 6px; + position: absolute; + right: 6px; + } + + // A bound entry opens its page on click; keep that promise visible on hover. + &-event--page:hover { + box-shadow: inset 0 0 0 1px var(--b3-theme-primary); + } + + // "New entries" decides whether an entry is a page at all, so it is set apart + // from the field-mapping rows above it while keeping their stacked rhythm. + &-config-row { + border-top: 1px solid var(--b3-border-color); + padding-top: 8px; + + label { + display: block; + } + } + + &-new-item-target { + margin-top: 2px; + } + + &-dialog-close { + line-height: 1; + min-width: 24px; + padding: 4px 6px; + position: absolute; + right: 8px; + top: 8px; + } + + &-dialog-schedule { + display: grid; + gap: 8px; + } + + &-dialog-endpoint { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: 72px minmax(0, 1fr); + } + + &-dialog-endpoint-label { + color: var(--b3-theme-on-surface); + font-size: 85%; + } + + &-dialog-endpoint-fields { + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) minmax(96px, .55fr); + min-width: 0; + + > .b3-text-field { + min-width: 0; + } + } + + &-dialog-schedule--all-day &-dialog-time { + display: none; + } + + &-dialog-schedule--all-day &-dialog-endpoint-fields { + grid-template-columns: minmax(0, 1fr); + } + + &-dialog-all-day { + justify-self: end; + } + + &-dialog-footer { + display: grid; + gap: 8px; + padding-inline: 0; + } + + &-dialog-footer-secondary, + &-dialog-footer-primary { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &-dialog-footer-secondary { + justify-content: flex-start; + } + + &-dialog-footer-primary { + justify-content: flex-end; + } + + &-resize { + border-left: 1px solid currentColor; + cursor: ew-resize; + flex-shrink: 0; + opacity: 0.72; + padding-left: 4px; + } + + &-recurrence { + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1fr); + padding: 14px 0 4px; + } + + &-recurrence-row { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: minmax(110px, 1fr) 72px minmax(120px, 1fr); + } + + &-recurrence-weekly { + display: grid; + gap: 10px; + } + + &-recurrence-end { + border: 0; + display: grid; + gap: 10px; + margin: 0; + padding: 0; + + legend { + font-weight: 600; + margin-bottom: 8px; + } + + label { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: 18px max-content minmax(96px, 1fr) max-content; + } + + .b3-text-field { + min-width: 0; + } + } + + &-weekday { + display: flex; + flex-wrap: wrap; + gap: 6px; + grid-column: 1 / -1; + } + + &-weekday-item { + align-items: center; + display: inline-flex; + gap: 4px; + min-height: 24px; + } + + &-list { + display: flex; + flex-direction: column; + gap: 2px; + } + + &-list-day { + align-items: start; + border-bottom: 1px solid var(--b3-border-color); + display: grid; + gap: 8px; + grid-template-columns: minmax(88px, 112px) minmax(0, 1fr); + padding: 5px 0; + + &.av__calendar-day--today { + background-color: transparent; + box-shadow: none; + } + + .av__calendar-list-title { + color: var(--b3-theme-on-surface); + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-transform: uppercase; + } + } + + &-list-events { + gap: 1px; + + .av__calendar-event--list { + display: grid; + grid-template-columns: 3px minmax(72px, 96px) minmax(0, 1fr) auto; + min-height: 26px; + padding: 4px 6px; + } + } + + &-week { + display: flex; + flex-direction: column; + gap: 8px; + } + + // --------------------------------------------------------------------------- + // Week / Day time grid. + // + // ONE scroll container. The day-header row, the all-day lane and the body all + // use the very same --calendar-grid-columns track, which is the whole point: + // the previous markup gave the headers their own grid (repeat(7,...) plus an + // 8px gap) while the body was 56px + repeat(7,...), so a header never sat + // above its column. Geometry (hour height, day height, ruling spacing, + // business-hour percentages) arrives as custom properties written by + // time-grid.ts from time-geometry.ts; nothing here re-derives it. + // --------------------------------------------------------------------------- + &-time-grid { + --calendar-grid-columns: var(--calendar-gutter-width, 72px) repeat(var(--calendar-day-count, 7), minmax(var(--calendar-day-min-width, 104px), 1fr)); + --calendar-off-hours-color: rgba(127, 127, 127, .07); + --calendar-weekend-tint: rgba(127, 127, 127, .05); + --calendar-now-color: var(--b3-theme-error, #d23f31); + border: 1px solid var(--b3-border-color); + border-radius: var(--b3-border-radius); + height: clamp(420px, calc(100vh - 230px), 760px); + overflow: auto; + overscroll-behavior: contain; + position: relative; + scrollbar-gutter: stable; + } + + &-grid-header { + background-color: var(--b3-theme-background); + border-bottom: 1px solid var(--b3-border-color); + display: grid; + grid-template-columns: var(--calendar-grid-columns); + height: var(--calendar-header-height, 34px); + min-width: min-content; + position: sticky; + top: 0; + z-index: 5; + } + + &-grid-corner { + background-color: var(--b3-theme-background); + left: 0; + position: sticky; + z-index: 1; + } + + &-day-header { + align-items: center; + background-color: var(--b3-theme-background); + border-left: 1px solid var(--b3-border-color); + display: flex; + justify-content: center; + min-width: 0; + + &.av__calendar-time-day--weekend { + background-color: var(--b3-theme-surface); + } + + // The generic today ring would draw a box around the header cell; the day + // number badge below is the clearer signal. + &.av__calendar-day--today { + box-shadow: none; + } + } + + &-day-header-button { + align-items: baseline; + background: transparent; + border: 0; + border-radius: var(--b3-border-radius); + color: inherit; + cursor: pointer; + display: flex; + font: inherit; + gap: 4px; + min-width: 0; + padding: 2px 8px; + + &:disabled { + cursor: default; + opacity: .72; + } + + &:hover:not(:disabled), + &:focus-visible { + background-color: var(--b3-list-hover); + } + } + + &-day-header-weekday { + color: var(--b3-theme-on-surface); + font-size: 11px; + text-transform: uppercase; + } + + &-day-header-number { + font-size: 15px; + font-weight: 600; + line-height: 22px; + min-width: 22px; + text-align: center; + } + + &-day-header.av__calendar-day--today &-day-header-number { + background-color: var(--b3-theme-primary); + border-radius: 50%; + color: var(--b3-theme-on-primary); + } + + // Sticky all-day lane: a Tue-Thu event is ONE bar spanning three columns. + &-allday-row { + background-color: var(--b3-theme-background); + border-bottom: 1px solid var(--b3-border-color); + display: grid; + grid-template-columns: var(--calendar-grid-columns); + min-width: min-content; + position: sticky; + top: var(--calendar-header-height, 34px); + z-index: 4; + } + + &-allday-gutter { + background-color: var(--b3-theme-background); + color: var(--b3-theme-on-surface); + font-size: 11px; + left: 0; + line-height: 22px; + overflow: visible; + padding: 0 8px; + position: sticky; + text-align: right; + white-space: nowrap; + z-index: 1; + } + + &-allday-lanes { + display: grid; + grid-auto-rows: 22px; + grid-column: 2 / -1; + grid-template-columns: repeat(var(--calendar-day-count, 7), minmax(var(--calendar-day-min-width, 104px), 1fr)); + min-height: 24px; + padding-bottom: 2px; + } + + &-allday-cell { + border-left: 1px solid var(--b3-border-color); + cursor: cell; + min-width: 0; + + &.av__calendar-time-day--weekend { + background-color: var(--calendar-weekend-tint); + } + + &.av__calendar-day--today { + background-color: transparent; + box-shadow: none; + } + + &:focus-visible { + outline: 2px solid var(--b3-theme-primary); + outline-offset: -2px; + z-index: 3; + } + } + + &-allday-bar { + display: flex; + min-width: 0; + padding: 0 3px; + z-index: 1; + + .av__calendar-event { + flex: 1; + min-width: 0; + } + } + + &-allday-more { + background: transparent; + border: 0; + color: var(--b3-theme-primary); + cursor: pointer; + font-size: 11px; + overflow: hidden; + padding: 1px 5px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 2; + } + + &-grid-body { + display: grid; + grid-template-columns: var(--calendar-grid-columns); + // Without this the sticky rows would be only as wide as the scrollport and + // would clip when the week is scrolled horizontally; with it all three rows + // are exactly as wide as the column track they share. + min-width: min-content; + } + + &-time-gutter { + background-color: var(--b3-theme-background); + border-right: 1px solid var(--b3-border-color); + left: 0; + position: sticky; + z-index: 3; + } + + &-time-label { + color: var(--b3-theme-on-surface); + font-size: 11px; + line-height: 1; + padding-right: 6px; + position: absolute; + right: 0; + transform: translateY(-50%); + white-space: nowrap; + + &[data-minute="0"] { + transform: none; + } + } + + &-time-columns { + display: grid; + grid-column: 2 / -1; + grid-template-columns: repeat(var(--calendar-day-count, 7), minmax(var(--calendar-day-min-width, 104px), 1fr)); + // Gesture rects are measured from this layer. Keep it as the containing + // block too, otherwise a columns-local left value is applied from the grid + // body's origin and every ghost shifts left by the sticky time gutter. + position: relative; + } + + &-quick-create { + background: var(--b3-theme-background); + border: 1px solid var(--b3-border-color); + border-radius: var(--b3-border-radius); + box-shadow: var(--b3-dialog-shadow); + display: flex; + flex-direction: column; + gap: 8px; + left: 6px; + min-width: 220px; + padding: 10px; + position: absolute; + right: 6px; + top: var(--calendar-quick-create-top, 4px); + z-index: 2; + } + + &-quick-create-check { + align-items: center; + display: flex; + gap: 6px; + } + + &-quick-create-title { + width: 100%; + } + + &-quick-create-summary { + color: var(--b3-theme-on-surface); + font-size: 12px; + } + + &-quick-create-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + } + + // The 30-minute ruling survives only as background lines; the events on top of + // it are absolutely positioned from real minutes (G3/G6). + &-time-day { + background-image: + linear-gradient(to right, + var(--b3-theme-surface-lighter) 0 1px, + transparent 1px), + repeating-linear-gradient(to bottom, + var(--b3-border-color) 0 1px, + transparent 1px var(--calendar-hour-height, 48px)), + repeating-linear-gradient(to bottom, + var(--b3-border-color-trans, rgba(127, 127, 127, .16)) 0 1px, + transparent 1px var(--calendar-ruling-height, 24px)), + linear-gradient(to bottom, + var(--calendar-off-hours-color) 0 var(--calendar-business-start, 37.5%), + transparent var(--calendar-business-start, 37.5%) var(--calendar-business-end, 75%), + var(--calendar-off-hours-color) var(--calendar-business-end, 75%) 100%); + min-width: 0; + position: relative; + + &--weekend { + background-color: var(--calendar-weekend-tint); + } + + &.av__calendar-day--today { + background-color: transparent; + box-shadow: none; + } + } + + // Empty-space create surface: one per day column instead of 48 slot buttons. + // It is only rendered for editable calendars, so a read-only / query-embed + // calendar has no create gesture at all. + &-time-create { + cursor: cell; + inset: 0 0 auto 0; + position: absolute; + z-index: 0; + + &:hover { + background-color: var(--b3-theme-primary-lightest, rgba(66, 133, 244, .08)); + } + + &:focus-visible { + outline: 2px solid var(--b3-theme-primary); + outline-offset: -2px; + } + } + + &-timed-events { + inset: 0; + pointer-events: none; + position: absolute; + z-index: 1; + } + + // top/height/left/width all arrive inline, in exact pixels and percentages. + &-timed-event { + box-sizing: border-box; + display: flex; + min-width: 0; + padding: 0 3px 2px; + pointer-events: auto; + position: absolute; + + .av__calendar-event { + align-items: flex-start; + flex: 1; + height: 100%; + min-width: 0; + overflow: hidden; + } + + &--continues-before .av__calendar-event { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &--continues-after .av__calendar-event { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + &-week--single &-time-grid { + margin-inline: auto; + max-width: 820px; + width: 100%; + } + + // Edge handles replace the old inline -15m/+15m text buttons. They are inert + // markup here: the pointer gesture module binds them (see render.ts). + &-resize-handle { + cursor: ns-resize; + height: 7px; + left: 0; + position: absolute; + right: 2px; + z-index: 2; + + &--start { + top: -3px; + } + + &--end { + bottom: -2px; + } + + &::after { + background-color: currentColor; + border-radius: 999px; + content: ""; + display: block; + height: 3px; + margin: 2px auto; + opacity: 0; + transition: opacity .12s ease; + width: 28px; + } + } + + // Coarse pointers cannot reveal hover-only handles and need enough surface to + // hold an event edge without covering the event content with visible chrome. + @media (pointer: coarse) { + &-event, + &-resize-handle { + touch-action: none; + } + + &-resize-handle { + height: 21px; + + &--start { + top: -10px; + } + + &--end { + bottom: -10px; + } + + &::after { + margin: 9px auto; + opacity: .6; + } + } + + &--gesturing { + user-select: none; + } + } + + &-timed-event:hover &-resize-handle::after, + &-timed-event:focus-within &-resize-handle::after { + opacity: .6; + } + + // Empty-space sweep preview (event-chip.ts buildCalendarGhost). Existing timed + // event move/resize previews are applied directly to their rendered wrapper. + &-ghost { + background-color: var(--b3-theme-primary-lighter); + border: 1px dashed var(--b3-theme-primary); + border-radius: var(--b3-border-radius); + box-sizing: border-box; + color: var(--b3-theme-on-background); + font-size: 12px; + line-height: 15px; + overflow: hidden; + padding: 1px 5px; + pointer-events: none; + position: absolute; + z-index: 4; + } + + &-ghost-label { + display: block; + font-weight: 600; + } + + &-ghost-title { + display: block; + opacity: .8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-now-indicator { + align-items: center; + display: flex; + left: 0; + pointer-events: none; + position: absolute; + right: 0; + transform: translateY(-50%); + z-index: 3; + } + + &-now-dot { + background-color: var(--calendar-now-color, #d23f31); + border-radius: 50%; + flex: 0 0 auto; + height: 9px; + margin-left: -4px; + width: 9px; + } + + &-now-line { + background-color: var(--calendar-now-color, #d23f31); + flex: 1; + height: 2px; + } + + &-all-day, + &-timed { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 28px; + min-width: 0; + padding-top: 6px; + } + + &-all-day { + border-bottom: 1px solid var(--b3-border-color); + padding-bottom: 6px; + } + + &-list-day { + border-bottom: 1px solid var(--b3-border-color); + display: grid; + gap: 8px; + grid-template-columns: 120px 1fr; + min-height: 32px; + padding: 8px 0; + } + + &-no-results { + border: 1px dashed var(--b3-border-color); + border-radius: var(--b3-border-radius); + margin-bottom: 8px; + padding: 12px; + text-align: center; + } + + &-day--selected { + box-shadow: inset 0 0 0 2px var(--b3-theme-primary-lighter); + } + + + + &-more { + background: transparent; + border: 0; + border-radius: var(--b3-border-radius); + color: var(--b3-theme-on-surface); + cursor: pointer; + display: block; + font-size: 85%; + padding: 0 4px; + text-align: left; + width: 100%; + + &:hover, + &:focus-visible { + background-color: var(--b3-list-hover); + } + } + + &-dialog { + padding: 16px; + } + + &-scope { + padding: 16px; + } + + &-scope-option { + align-items: flex-start; + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 8px; + text-align: left; + width: 100%; + + &:disabled { + cursor: not-allowed; + opacity: .65; + } + } + + &-scope-title { + font-weight: 600; + } + + &-scope-desc { + color: var(--b3-theme-on-surface); + font-size: 12px; + white-space: normal; + } + + &-check { + gap: 6px; + margin-left: 12px; + } + + &-time-sep { + line-height: 28px; + } + + // --------------------------------------------------------------------------- + // Mini month navigator (mini-month.ts). + // + // Deliberately dense: it is a map of the month, not a second calendar. Keep the + // selected / in-range / today states, but do not duplicate event indicators + // from the main calendar. + // --------------------------------------------------------------------------- + &-mini { + font-size: 12px; + user-select: none; + } + + &-mini-header { + align-items: center; + display: flex; + gap: 2px; + justify-content: space-between; + padding-bottom: 4px; + } + + &-mini-title { + flex: 1; + font-weight: 600; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-mini-weekdays, + &-mini-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + } + + &-mini-weekdays { + color: var(--b3-theme-on-surface); + font-size: 10px; + text-align: center; + + span { + line-height: 18px; + } + } + + &-mini-grid { + gap: 1px; + } + + &-mini-day { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: inherit; + cursor: pointer; + display: flex; + flex-direction: column; + font: inherit; + gap: 1px; + justify-content: center; + line-height: 1; + min-height: 24px; + padding: 2px 0; + position: relative; + + &:hover { + background-color: var(--b3-list-hover); + } + + &:focus-visible { + outline: 2px solid var(--b3-theme-primary); + outline-offset: -2px; + } + + &--outside { + color: var(--b3-theme-on-surface); + opacity: .6; + } + + // The span the MAIN view is currently showing. A tint, not a ring: the ring + // belongs to today and the fill belongs to the selected day, and all three + // states have to be readable at the same time. + &--in-range { + background-color: var(--b3-theme-primary-lightest, rgba(66, 133, 244, .08)); + border-radius: 0; + } + + &--today { + box-shadow: inset 0 0 0 1px var(--b3-theme-primary); + color: var(--b3-theme-primary); + font-weight: 600; + } + + &--selected { + background-color: var(--b3-theme-primary); + border-radius: 50%; + color: var(--b3-theme-on-primary); + + &:hover { + background-color: var(--b3-theme-primary); + } + } + } + + &-mini-day-number { + line-height: 16px; + } + + + // --------------------------------------------------------------------------- + // The "?" keyboard shortcut sheet (keymap.ts). + // --------------------------------------------------------------------------- + &-shortcuts { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 60vh; + overflow: auto; + padding: 16px; + } + + &-shortcuts-section { + display: flex; + flex-direction: column; + gap: 4px; + } + + &-shortcuts-title { + color: var(--b3-theme-on-surface); + font-size: 12px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + } + + &-shortcuts-row { + align-items: baseline; + display: flex; + gap: 12px; + line-height: 22px; + } + + &-shortcuts-keys { + display: flex; + flex: 0 0 108px; + flex-wrap: wrap; + gap: 4px; + + kbd { + background-color: var(--b3-theme-surface); + border: 1px solid var(--b3-border-color); + border-bottom-width: 2px; + border-radius: var(--b3-border-radius); + color: var(--b3-theme-on-background); + font-family: var(--b3-font-family-code); + font-size: 11px; + line-height: 16px; + min-width: 18px; + padding: 0 5px; + text-align: center; + } + } + + &-shortcuts-label { + flex: 1; + min-width: 0; + } + + // --------------------------------------------------------------------------- + // Chip context menu (context-menu.ts). + // + // Parented to and positioned inline from the pointer, so only its own + // look lives here. It is never built at all on a read-only / query-embed + // calendar. + // --------------------------------------------------------------------------- + &-menu { + background-color: var(--b3-menu-background); + border: 1px solid var(--b3-border-color); + border-radius: var(--b3-border-radius-b, var(--b3-border-radius)); + box-shadow: var(--b3-dialog-shadow); + display: flex; + flex-direction: column; + max-height: 80vh; + min-width: 184px; + overflow: auto; + padding: 4px; + } + + &-menu-item { + background: transparent; + border: 0; + border-radius: var(--b3-border-radius); + color: var(--b3-theme-on-background); + cursor: pointer; + font: inherit; + line-height: 24px; + padding: 2px 10px; + text-align: left; + white-space: nowrap; + + &:hover, + &:focus-visible { + background-color: var(--b3-list-hover); + outline: none; + } + + &--separated { + border-top: 1px solid var(--b3-border-color); + margin-top: 4px; + padding-top: 4px; + } + + &--danger { + color: var(--b3-theme-error); + } + } + // Mobile uses the same Calendar renderer and transaction path as desktop. + // Keep these overrides last so the desktop base rules above cannot win by + // source order at SiYuan's existing narrow-screen breakpoint. + @media (max-width: 750px) { + &-toolbar { + align-items: stretch; + + & > .block__icon, + & > .b3-button { + min-height: 42px; + } + } + + &-title-control { + flex: 1 1 calc(100% - 144px); + min-height: 42px; + min-width: 0; + } + + &-search-control { + flex: 1 1 100%; + max-width: none; + } + + &-sidebar { + display: none; + } + + &-body { + display: block; + } + + &-main { + overflow-x: auto; + } + } +} + +// The in-flight bar under an optimistic / pending calendar chip. +@keyframes av__calendar-pending { + 0%, + 100% { + opacity: .35; + } + + 50% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .av__calendar-event--pending::after { + animation: none; + } +} diff --git a/app/src/constants.ts b/app/src/constants.ts index 8fa345ba14d..e69f7ed9467 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -178,6 +178,7 @@ export abstract class Constants { public static readonly LOCAL_SESSION_FIRSTLOAD = "local-session-firstload"; public static readonly LOCAL_OUTLINE = "local-outline"; public static readonly LOCAL_PLUGIN_DOCKS = "local-plugin-docks"; + public static readonly LOCAL_CALENDAR_DOCK = "local-calendar-dock"; public static readonly LOCAL_IMAGES = "local-images"; public static readonly LOCAL_EMOJIS = "local-emojis"; public static readonly LOCAL_MOVE_PATH = "local-move-path"; @@ -748,6 +749,11 @@ export abstract class Constants { show: false, icon: "iconTag", hotkeyLangId: "tag", + }, { + type: "calendar", + size: {width: 320, height: 0}, + show: false, + icon: "iconCalendar", }] ] }, diff --git a/app/src/protyle/render/av/action.ts b/app/src/protyle/render/av/action.ts index afc9341f732..b43d66ae6c5 100644 --- a/app/src/protyle/render/av/action.ts +++ b/app/src/protyle/render/av/action.ts @@ -323,6 +323,19 @@ export const avClick = (protyle: IProtyle, event: MouseEvent & { target: HTMLEle event.stopPropagation(); return true; } else if (type === "av-add-more" && !protyle.disabled) { + if (viewType === "calendar") { + // insertRows/模板新建出来的条目在日历日期字段上没有值,条目虽已创建但在日历里不可见, + // 因此改走日历自己的新建流程(默认落在当前锚定日期,锚点无效时为今天) + const calendarNewElement = blockElement.querySelector('.av__calendar-toolbar [data-type="calendar-new"]'); + if (calendarNewElement) { + calendarNewElement.click(); + } else { + showMessage(window.siyuan.languages.calendarNeedDateField || window.siyuan.languages.dateField || "Calendar requires a date field"); + } + event.preventDefault(); + event.stopPropagation(); + return true; + } const templateID = blockElement.querySelector(".av__header")?.dataset.defaultTemplateId; if (templateID) { createAttributeViewItem({blockElement, protyle, templateID}); @@ -338,7 +351,7 @@ export const avClick = (protyle: IProtyle, event: MouseEvent & { target: HTMLEle event.preventDefault(); event.stopPropagation(); return true; - } else if (type === "av-add-template" && !protyle.disabled) { + } else if (type === "av-add-template" && !protyle.disabled && viewType !== "calendar") { openNewItemTemplateMenu({protyle, blockElement, target}); event.preventDefault(); event.stopPropagation(); diff --git a/app/src/protyle/render/av/calendar/context-menu.ts b/app/src/protyle/render/av/calendar/context-menu.ts new file mode 100644 index 00000000000..ffd4ea4fe3d --- /dev/null +++ b/app/src/protyle/render/av/calendar/context-menu.ts @@ -0,0 +1,373 @@ +import {escapeHtml} from "../../../../util/escape"; +import {getEventDocumentID, ICalendarNormalizedEvent} from "./model"; +import {getOpenPageLabel, getOpenScheduleLabel} from "./event-chip"; +import {abortActiveCalendarGesture} from "./interactions"; + +/** + * The chip's right-click (and long-press) menu. + * + * This is where the actions that used to sit as permanent inline text buttons + * inside every chip went. The chips are quiet now; the menu is where you go for + * the things a drag cannot express. + * + * The menu items deliberately reuse the data-type values of the old inline + * buttons - calendar-open-source, calendar-open-dialog, + * calendar-duplicate-next-day, calendar-resize (+ data-delta / data-days) - so + * render.ts's existing handlers and the assertions that drive them keep working + * against the same contract, only from a different place in the DOM. + * + * Like every other direct-manipulation surface it writes nothing itself: it + * reports a command and lets render.ts route it through applyScopedEventDraft / + * withCalendarOperationFeedback, so recurring events still ask for scope and a + * rejected write still rolls back. + * + * Nothing here is built on a read-only or query-embed calendar. + */ + +export type CalendarMenuCommandType = + | "calendar-open-source" + | "calendar-open-dialog" + | "calendar-duplicate-next-day" + | "calendar-resize" + | "calendar-shift" + | "calendar-delete"; + +export interface ICalendarMenuCommand { + type: CalendarMenuCommandType; + /** calendar-resize on a timed event: minutes added to the END. */ + delta?: number; + /** calendar-resize on an all-day event / calendar-shift: whole days. */ + days?: number; + /** calendar-shift on a timed event: minutes the whole event moves by. */ + minutes?: number; + event: ICalendarNormalizedEvent; + eventElement: HTMLElement; +} + +/** + * How render.ts is expected to route each command. Written down here because + * getting `calendar-delete` wrong is the one way this menu could lose data: + * + * calendar-open-source -> openCalendarEventSource + * calendar-open-dialog -> openCalendarEventDialog (getEditableEvent first) + * calendar-duplicate-next-day -> duplicateEventToNextDay (buildDuplicateNextDayDraft) + * calendar-resize -> applyScopedEventDraft(..., "resize") with + * buildTimedDurationTransform / buildAllDayDurationTransform + * calendar-shift -> applyScopedEventDraft(..., "move") with buildShiftTransform + * calendar-delete -> the SAME scope-aware delete the dialog uses: + * openRecurrenceScopeDialog(action "delete") for a + * recurring item, then deleteCalendarEvent / + * deleteCalendarOccurrence. Deleting a recurring + * series without asking for scope would be data + * loss, and a plain delete must still remove the + * row only - never the page behind it. + */ + +export interface ICalendarEventMenuOptions { + event: ICalendarNormalizedEvent; + eventElement: HTMLElement; + /** Viewport coordinates the menu opens at. */ + clientX: number; + clientY: number; + onCommand(command: ICalendarMenuCommand): void; +} + +const MENU_CLASS = "av__calendar-menu"; +const MENU_MARGIN = 6; + +interface IMenuItem { + label: string; + attributes: string; + separated?: boolean; + danger?: boolean; +} + +export const closeCalendarEventMenu = () => { + document.querySelectorAll(`.${MENU_CLASS}`).forEach(item => item.remove()); +}; + +const shiftLabel = (amount: string, forward: boolean) => { + const move = window.siyuan.languages.move || "Move"; + return `${move} ${forward ? "+" : "−"}${amount}`; +}; + +/** + * The duration items move the END of the event, which is exactly what the old + * inline -15m/+15m and -1d/+1d buttons did. "Until" is the same word the event + * dialog uses for the end of a recurrence, so the menu does not need a new + * language key to say "this changes where the event stops". + */ +const durationLabel = (amount: string, longer: boolean) => { + const until = window.siyuan.languages.calendarUntil || "Until"; + return `${until} ${longer ? "+" : "−"}${amount}`; +}; + +const buildMenuItems = (event: ICalendarNormalizedEvent): IMenuItem[] => { + const items: IMenuItem[] = []; + if (getEventDocumentID(event)) { + items.push({label: getOpenPageLabel(), attributes: 'data-type="calendar-open-source"'}); + } + items.push({label: getOpenScheduleLabel(), attributes: 'data-type="calendar-open-dialog"'}); + items.push({ + label: window.siyuan.languages.copy || "Copy", + attributes: 'data-type="calendar-duplicate-next-day"', + separated: true, + }); + if (event.isAllDay) { + items.push({label: durationLabel("1d", false), attributes: 'data-type="calendar-resize" data-days="-1"', separated: true}); + items.push({label: durationLabel("1d", true), attributes: 'data-type="calendar-resize" data-days="1"'}); + items.push({label: shiftLabel("1d", false), attributes: 'data-type="calendar-shift" data-days="-1"', separated: true}); + items.push({label: shiftLabel("1d", true), attributes: 'data-type="calendar-shift" data-days="1"'}); + } else { + items.push({label: durationLabel("15m", false), attributes: 'data-type="calendar-resize" data-delta="-15"', separated: true}); + items.push({label: durationLabel("15m", true), attributes: 'data-type="calendar-resize" data-delta="15"'}); + items.push({label: shiftLabel("15m", false), attributes: 'data-type="calendar-shift" data-minutes="-15"', separated: true}); + items.push({label: shiftLabel("15m", true), attributes: 'data-type="calendar-shift" data-minutes="15"'}); + items.push({label: shiftLabel("1d", false), attributes: 'data-type="calendar-shift" data-days="-1"'}); + items.push({label: shiftLabel("1d", true), attributes: 'data-type="calendar-shift" data-days="1"'}); + } + items.push({ + label: window.siyuan.languages.delete || "Delete", + attributes: 'data-type="calendar-delete"', + separated: true, + danger: true, + }); + return items; +}; + +const readCommand = (item: HTMLElement, options: ICalendarEventMenuOptions): ICalendarMenuCommand | null => { + const type = item.dataset.type as CalendarMenuCommandType; + if (!type) { + return null; + } + const command: ICalendarMenuCommand = {type, event: options.event, eventElement: options.eventElement}; + if (item.dataset.delta) { + command.delta = parseInt(item.dataset.delta, 10); + } + if (item.dataset.days) { + command.days = parseInt(item.dataset.days, 10); + } + if (item.dataset.minutes) { + command.minutes = parseInt(item.dataset.minutes, 10); + } + return command; +}; + +/** + * Open the menu. Self-contained popover in the style of quick-create.ts: it + * owns its own listeners, closes on Escape / outside press / scroll / resize, + * and returns focus to the chip it came from. + */ +export const openCalendarEventMenu = (options: ICalendarEventMenuOptions): HTMLElement => { + closeCalendarEventMenu(); + // A menu and a drag must never be live at the same time. + abortActiveCalendarGesture(); + const items = buildMenuItems(options.event); + const menu = document.createElement("div"); + menu.className = MENU_CLASS; + menu.setAttribute("role", "menu"); + menu.setAttribute("aria-label", options.event.title || window.siyuan.languages.calendar || "Calendar"); + menu.innerHTML = items.map((item, index) => ``).join(""); + // Positioned inline rather than by stylesheet: the menu is anchored to a + // pointer, and it must land in the right place even before _av.scss grows a + // rule for it. + menu.style.position = "fixed"; + menu.style.zIndex = "310"; + document.body.appendChild(menu); + + const rect = menu.getBoundingClientRect(); + const left = Math.max(MENU_MARGIN, Math.min(options.clientX, window.innerWidth - rect.width - MENU_MARGIN)); + const top = Math.max(MENU_MARGIN, Math.min(options.clientY, window.innerHeight - rect.height - MENU_MARGIN)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + const menuItems = Array.from(menu.querySelectorAll(`.${MENU_CLASS}-item`)) as HTMLElement[]; + let disposed = false; + const dispose = (restoreFocus: boolean) => { + if (disposed) { + return; + } + disposed = true; + document.removeEventListener("pointerdown", onOutsidePointerDown, true); + document.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("resize", onViewportChange, true); + window.removeEventListener("scroll", onViewportChange, true); + menu.remove(); + if (restoreFocus && options.eventElement?.isConnected) { + options.eventElement.focus({preventScroll: true}); + } + }; + const focusItem = (index: number) => { + if (menuItems.length === 0) { + return; + } + const next = (index + menuItems.length) % menuItems.length; + menuItems.forEach((item, itemIndex) => item.setAttribute("tabindex", itemIndex === next ? "0" : "-1")); + menuItems[next].focus(); + }; + function onOutsidePointerDown(event: PointerEvent) { + if (!menu.contains(event.target as Node)) { + dispose(false); + } + } + function onViewportChange() { + dispose(false); + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + dispose(true); + return; + } + const currentIndex = menuItems.indexOf(document.activeElement as HTMLElement); + if (event.key === "ArrowDown") { + event.preventDefault(); + focusItem(currentIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusItem(currentIndex - 1); + } else if (event.key === "Home") { + event.preventDefault(); + focusItem(0); + } else if (event.key === "End") { + event.preventDefault(); + focusItem(menuItems.length - 1); + } else if (event.key === "Tab") { + // A popover menu does not leak focus into the page behind it. + event.preventDefault(); + focusItem(currentIndex + (event.shiftKey ? -1 : 1)); + } + } + menu.addEventListener("click", (event: MouseEvent) => { + const item = (event.target as HTMLElement).closest(`.${MENU_CLASS}-item`) as HTMLElement; + if (!item) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const command = readCommand(item, options); + dispose(false); + if (command) { + options.onCommand(command); + } + }); + document.addEventListener("pointerdown", onOutsidePointerDown, true); + document.addEventListener("keydown", onKeyDown, true); + window.addEventListener("resize", onViewportChange, true); + window.addEventListener("scroll", onViewportChange, true); + focusItem(0); + return menu; +}; + +export interface ICalendarContextMenuOptions { + calendarElement: HTMLElement; + /** false on read-only / query-embed calendars: no menu is bound at all. */ + editable: boolean; + resolveEvent(element: HTMLElement): ICalendarNormalizedEvent | undefined; + onCommand(command: ICalendarMenuCommand): void; +} + +/** How long a touch has to rest on a chip before it counts as a right-click. */ +const LONG_PRESS_MS = 500; + +/** How far that touch may drift before it is a drag instead. */ +const LONG_PRESS_MOVE_PX = 8; + +/** + * Bind right-click and long-press on chips. Returns a disposer. + */ +export const bindCalendarEventContextMenu = (options: ICalendarContextMenuOptions): (() => void) => { + const {calendarElement} = options; + if (!calendarElement || !options.editable) { + return () => undefined; + } + let longPressTimer = 0; + let longPressPointer = -1; + let longPressX = 0; + let longPressY = 0; + const cancelLongPress = () => { + if (longPressTimer) { + window.clearTimeout(longPressTimer); + longPressTimer = 0; + } + longPressPointer = -1; + }; + const openFor = (chip: HTMLElement, clientX: number, clientY: number) => { + const event = options.resolveEvent(chip); + if (!event) { + return; + } + openCalendarEventMenu({ + event, + eventElement: chip, + clientX, + clientY, + onCommand: options.onCommand, + }); + }; + const onContextMenu = (event: MouseEvent) => { + const chip = (event.target as HTMLElement)?.closest(".av__calendar-event") as HTMLElement; + if (!chip || chip.classList.contains("av__calendar-event--pending")) { + return; + } + event.preventDefault(); + event.stopPropagation(); + cancelLongPress(); + openFor(chip, event.clientX, event.clientY); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) { + return; + } + const chip = (event.target as HTMLElement)?.closest(".av__calendar-event") as HTMLElement; + if (!chip || chip.classList.contains("av__calendar-event--pending")) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const rect = chip.getBoundingClientRect(); + openFor(chip, rect.left + Math.min(rect.width / 2, 24), rect.top + Math.min(rect.height, 24)); + }; + const onPointerDown = (event: PointerEvent) => { + if (event.pointerType === "mouse") { + return; + } + const chip = (event.target as HTMLElement)?.closest(".av__calendar-event") as HTMLElement; + if (!chip || chip.classList.contains("av__calendar-event--pending")) { + return; + } + cancelLongPress(); + longPressPointer = event.pointerId; + longPressX = event.clientX; + longPressY = event.clientY; + longPressTimer = window.setTimeout(() => { + longPressTimer = 0; + openFor(chip, longPressX, longPressY); + }, LONG_PRESS_MS); + }; + const onPointerMove = (event: PointerEvent) => { + if (!longPressTimer || event.pointerId !== longPressPointer) { + return; + } + if (Math.hypot(event.clientX - longPressX, event.clientY - longPressY) > LONG_PRESS_MOVE_PX) { + cancelLongPress(); + } + }; + calendarElement.addEventListener("contextmenu", onContextMenu); + calendarElement.addEventListener("keydown", onKeyDown); + calendarElement.addEventListener("pointerdown", onPointerDown); + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", cancelLongPress); + document.addEventListener("pointercancel", cancelLongPress); + return () => { + cancelLongPress(); + calendarElement.removeEventListener("contextmenu", onContextMenu); + calendarElement.removeEventListener("keydown", onKeyDown); + calendarElement.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", cancelLongPress); + document.removeEventListener("pointercancel", cancelLongPress); + closeCalendarEventMenu(); + }; +}; diff --git a/app/src/protyle/render/av/calendar/drafts.ts b/app/src/protyle/render/av/calendar/drafts.ts new file mode 100644 index 00000000000..5ed23b3e114 --- /dev/null +++ b/app/src/protyle/render/av/calendar/drafts.ts @@ -0,0 +1,518 @@ +import * as dayjs from "dayjs"; +import {ICalendarEventDraft, ICalendarNormalizedEvent} from "./model"; +import { + CALENDAR_MINUTES_PER_DAY, + formatClockLabel, + getCalendarTimeGeometry, + ICalendarTimeGeometry, + snapMinutes, +} from "./time-geometry"; + +/** + * Pure ICalendarEventDraft builders for every direct-manipulation gesture. + * + * Nothing in this file touches the DOM, reads window.siyuan or issues a + * transaction: a builder turns (source event, gesture target, geometry) into a + * draft *transform*, and the caller routes that transform through + * applyScopedEventDraft in render.ts so the recurrence-scope prompt, the pending + * state and the rollback-on-failure keep working exactly as before. + * + * Why a transform instead of a draft: applyScopedEventDraft may apply the same + * gesture to a DIFFERENT event than the one the user grabbed. Grabbing a + * generated occurrence and choosing "whole series" re-runs the builder against + * the base event. So every gesture is expressed as "measure a delta on the + * source, then apply that delta to whatever event the scope dialog picked" - + * which is precisely what the inline handlers in render.ts did with their + * `(target) => ({...})` closures. + * + * A builder returns null when the gesture is a no-op or would invert the event + * (end before start). The caller MUST skip the write in that case, exactly like + * the old `if (!nextEnd.isAfter(sourceEvent.start)) { return; }` early exits. + */ + +/** + * The snap step, the minimum duration and the day bounds all come from + * time-geometry.ts, which is the single source of truth for the grid's + * minute<->pixel math. These builders only ever read the minute-side fields of + * that record, so a caller may hand in the real geometry, a partial override, or + * nothing at all. + */ + +/** A point on the timed grid: which day column, and which minute of that day. */ +export interface ICalendarGridPoint { + date: string; + minute: number; +} + +export interface ICalendarGridRange { + start: ICalendarGridPoint; + end: ICalendarGridPoint; +} + +/** + * Applies a measured gesture to whichever event the recurrence-scope dialog + * selected. This is the exact shape applyScopedEventDraft already expects. + */ +export type ICalendarDraftTransform = (target: ICalendarNormalizedEvent) => ICalendarEventDraft; + +/** Fill in a full geometry record from whatever (if anything) the caller has. */ +export const resolveCalendarGeometry = (geometry?: Partial): ICalendarTimeGeometry => + getCalendarTimeGeometry(geometry?.dayCount, geometry || {}); + +/** Snap a raw minute-of-day onto the grid step and clamp it into the day. */ +export const snapCalendarMinute = (minute: number, geometry?: Partial) => { + const resolved = resolveCalendarGeometry(geometry); + if (typeof minute !== "number" || !isFinite(minute)) { + return resolved.dayStartMinute; + } + return snapMinutes(minute, resolved); +}; + +const toDayStart = (date: string) => { + const parsed = dayjs(date); + return parsed.isValid() ? parsed.startOf("day") : null; +}; + +/** The absolute instant a grid point stands for, or null for an unusable date. */ +export const resolveGridPoint = (point: ICalendarGridPoint, geometry?: Partial) => { + const dayStart = toDayStart(point?.date); + if (!dayStart) { + return null; + } + return dayStart.add(snapCalendarMinute(point.minute, geometry), "minute"); +}; + +/** + * The end used for timed duration math. Mirrors render.ts: an event without an + * end is treated as one hour long. + */ +const getTimedEnd = (event: ICalendarNormalizedEvent) => event.end || event.start.add(1, "hour"); + +const carryEventMetadata = (event: ICalendarNormalizedEvent) => ({ + recurrenceRaw: event.recurrenceRaw, + location: event.location, + description: event.description, + colorContent: event.colorContent, +}); + +const buildTimedDraft = (event: ICalendarNormalizedEvent, start: dayjs.Dayjs, end: dayjs.Dayjs): ICalendarEventDraft => ({ + title: event.title, + date: start.format("YYYY-MM-DD"), + endDate: end.format("YYYY-MM-DD"), + isAllDay: false, + startTime: start.format("HH:mm"), + endTime: end.format("HH:mm"), + ...carryEventMetadata(event), +}); + +const buildAllDayDraft = (event: ICalendarNormalizedEvent, startDay: dayjs.Dayjs, endDay: dayjs.Dayjs): ICalendarEventDraft => ({ + title: event.title, + date: startDay.format("YYYY-MM-DD"), + endDate: endDay.format("YYYY-MM-DD"), + isAllDay: true, + startTime: event.start.format("HH:mm"), + endTime: event.end ? event.end.format("HH:mm") : "23:59", + ...carryEventMetadata(event), +}); + +/** + * Move an event to another DATE, keeping its clock times and its length in days. + * + * Lifted verbatim from render.ts so the day-only drop keeps behaving identically + * (multi-day all-day items keep their span, an event without an end is treated + * as one hour long). + */ +export const buildDraftForDate = (sourceEvent: ICalendarNormalizedEvent, targetDate: string): ICalendarEventDraft => { + const durationDays = Math.max((sourceEvent.end || sourceEvent.start).startOf("day").diff(sourceEvent.start.startOf("day"), "day"), 0); + return { + title: sourceEvent.title, + date: targetDate, + endDate: dayjs(targetDate).add(durationDays, "day").format("YYYY-MM-DD"), + isAllDay: sourceEvent.isAllDay, + startTime: sourceEvent.start.format("HH:mm"), + endTime: sourceEvent.end ? sourceEvent.end.format("HH:mm") : sourceEvent.start.add(1, "hour").format("HH:mm"), + recurrenceRaw: sourceEvent.recurrenceRaw, + location: sourceEvent.location, + description: sourceEvent.description, + colorContent: sourceEvent.colorContent, + }; +}; + +/** + * How many days into a multi-day event the user grabbed it. + * + * A Tue-Thu bar dropped on Friday from its Wednesday cell must start on + * Thursday, not on Friday. `displayDate` is the chip's data-date. + */ +export const getDragOffsetDays = (sourceEvent: ICalendarNormalizedEvent, displayDate?: string) => { + if (!displayDate) { + return 0; + } + const grabbed = toDayStart(displayDate); + if (!grabbed) { + return 0; + } + const dragOffsetDays = Math.max(grabbed.diff(sourceEvent.start.startOf("day"), "day"), 0); + return dragOffsetDays; +}; + +/** + * Gesture: move to a date (month cell, all-day rail, day column header). + * + * Same math the HTML5 drop handler used, including the multi-day all-day end + * time fix-up, so nothing about day-only moves changes. + */ +export const buildMoveToDateTransform = ( + sourceEvent: ICalendarNormalizedEvent, + targetDate: string, + options?: {displayDate?: string}, +): ICalendarDraftTransform | null => { + const targetDay = toDayStart(targetDate); + if (!targetDay) { + return null; + } + const dragOffsetDays = getDragOffsetDays(sourceEvent, options?.displayDate); + const draftDate = targetDay.subtract(dragOffsetDays, "day").format("YYYY-MM-DD"); + if (draftDate === sourceEvent.start.format("YYYY-MM-DD")) { + return null; + } + return (target) => { + const dayDelta = dayjs(draftDate).diff(sourceEvent.start.startOf("day"), "day"); + const targetDraftDate = target === sourceEvent ? + draftDate : + target.start.startOf("day").add(dayDelta, "day").format("YYYY-MM-DD"); + const draft = buildDraftForDate(target, targetDraftDate); + if (target.isAllDay && target.end && !target.start.isSame(target.end, "day")) { + draft.endTime = target.end?.format("HH:mm") || "23:59"; + } + return draft; + }; +}; + +/** + * Gesture: move across days AND times at once (drag on the timed grid). + * + * The source lands exactly on the snapped point the pointer is over; any other + * event the scope dialog picks is shifted by the same signed minute delta, so a + * series keeps its internal spacing instead of being collapsed onto one time. + * Duration is preserved per event (never below the geometry minimum). + * + * An all-day event is NOT silently converted to a timed one unless the caller + * asks for it: without `convertAllDay` the gesture degrades to a day-only move, + * which is the conservative reading of dropping a bar onto a column. + */ +export const buildMoveToDateTimeTransform = ( + sourceEvent: ICalendarNormalizedEvent, + point: ICalendarGridPoint, + geometry?: Partial, + options?: {convertAllDay?: boolean, defaultDurationMinutes?: number}, +): ICalendarDraftTransform | null => { + const resolved = resolveCalendarGeometry(geometry); + const desiredStart = resolveGridPoint(point, resolved); + if (!desiredStart) { + return null; + } + if (sourceEvent.isAllDay && !options?.convertAllDay) { + return buildMoveToDateTransform(sourceEvent, point.date); + } + const minuteDelta = desiredStart.diff(sourceEvent.start, "minute"); + if (minuteDelta === 0 && !sourceEvent.isAllDay) { + return null; + } + const fallbackDuration = Math.max(options?.defaultDurationMinutes || 60, resolved.minimumEventMinutes); + return (target) => { + const nextStart = target === sourceEvent ? desiredStart : target.start.add(minuteDelta, "minute"); + const duration = target.isAllDay ? + fallbackDuration : + Math.max(getTimedEnd(target).diff(target.start, "minute"), resolved.minimumEventMinutes); + return buildTimedDraft(target, nextStart, nextStart.add(duration, "minute")); + }; +}; + +/** + * Gesture: drag the BOTTOM edge of a timed chip. + * + * Keeps the start fixed and measures a new duration, then applies that duration + * to whichever event the scope dialog picked - identical in shape to the legacy + * "+15m" button, which is why a series resize still behaves the same. + */ +export const buildResizeEndTransform = ( + sourceEvent: ICalendarNormalizedEvent, + point: ICalendarGridPoint, + geometry?: Partial, +): ICalendarDraftTransform | null => { + if (sourceEvent.isAllDay) { + return null; + } + const resolved = resolveCalendarGeometry(geometry); + const desiredEnd = resolveGridPoint(point, resolved); + if (!desiredEnd) { + return null; + } + const nextDuration = Math.max(desiredEnd.diff(sourceEvent.start, "minute"), resolved.minimumEventMinutes); + if (nextDuration === getTimedEnd(sourceEvent).diff(sourceEvent.start, "minute")) { + return null; + } + return (target) => buildTimedDraft(target, target.start, target.start.add(nextDuration, "minute")); +}; + +/** + * Gesture: drag the TOP edge of a timed chip. + * + * Keeps the end fixed and moves the start, clamped so the event can never + * become shorter than the geometry minimum or end before it starts. + */ +export const buildResizeStartTransform = ( + sourceEvent: ICalendarNormalizedEvent, + point: ICalendarGridPoint, + geometry?: Partial, +): ICalendarDraftTransform | null => { + if (sourceEvent.isAllDay) { + return null; + } + const resolved = resolveCalendarGeometry(geometry); + const requestedStart = resolveGridPoint(point, resolved); + if (!requestedStart) { + return null; + } + const sourceEnd = getTimedEnd(sourceEvent); + const latestStart = sourceEnd.subtract(resolved.minimumEventMinutes, "minute"); + const desiredStart = requestedStart.isAfter(latestStart) ? latestStart : requestedStart; + const startDelta = desiredStart.diff(sourceEvent.start, "minute"); + if (startDelta === 0) { + return null; + } + return (target) => { + const targetEnd = getTimedEnd(target); + const targetLatestStart = targetEnd.subtract(resolved.minimumEventMinutes, "minute"); + const requested = target === sourceEvent ? desiredStart : target.start.add(startDelta, "minute"); + const nextStart = requested.isAfter(targetLatestStart) ? targetLatestStart : requested; + return buildTimedDraft(target, nextStart, targetEnd); + }; +}; + +/** + * Legacy "-15m / +15m": grow or shrink the END of a timed event by a delta. + * + * Kept because the context menu still offers it (and the smokes still drive it), + * lifted verbatim from render.ts including the "never invert" early exit. + */ +export const buildTimedDurationTransform = ( + sourceEvent: ICalendarNormalizedEvent, + deltaMinutes: number, +): ICalendarDraftTransform | null => { + if (!deltaMinutes || sourceEvent.isAllDay) { + return null; + } + const currentEnd = sourceEvent.end || sourceEvent.start.add(1, "hour"); + const nextEnd = currentEnd.add(deltaMinutes, "minute"); + if (!nextEnd.isAfter(sourceEvent.start)) { + return null; + } + const nextDuration = nextEnd.diff(sourceEvent.start, "minute"); + return (target) => { + const targetEnd = target.start.add(nextDuration, "minute"); + return buildTimedDraft(target, target.start, targetEnd); + }; +}; + +/** + * Legacy "-1d / +1d": grow or shrink an all-day item by whole days. + * + * Lifted verbatim from render.ts, including the `data-days` inversion guard. + */ +export const buildAllDayDurationTransform = ( + sourceEvent: ICalendarNormalizedEvent, + deltaDays: number, +): ICalendarDraftTransform | null => { + if (!deltaDays || !sourceEvent.isAllDay) { + return null; + } + const sourceEnd = (sourceEvent.end || sourceEvent.start.endOf("day")).add(deltaDays, "day"); + if (sourceEnd.isBefore(sourceEvent.start, "day")) { + return null; + } + const nextDurationDays = Math.max(sourceEnd.startOf("day").diff(sourceEvent.start.startOf("day"), "day"), 0); + return (target) => buildAllDayDraft(target, target.start.startOf("day"), target.start.startOf("day").add(nextDurationDays, "day")); +}; + +/** Gesture: drag the RIGHT edge of an all-day bar onto an absolute date. */ +export const buildAllDayResizeEndTransform = ( + sourceEvent: ICalendarNormalizedEvent, + targetDate: string, +): ICalendarDraftTransform | null => { + if (!sourceEvent.isAllDay) { + return null; + } + const requestedEnd = toDayStart(targetDate); + if (!requestedEnd) { + return null; + } + const sourceStartDay = sourceEvent.start.startOf("day"); + const desiredEnd = requestedEnd.isBefore(sourceStartDay, "day") ? sourceStartDay : requestedEnd; + const nextDurationDays = Math.max(desiredEnd.diff(sourceStartDay, "day"), 0); + const currentDurationDays = Math.max((sourceEvent.end || sourceEvent.start).startOf("day").diff(sourceStartDay, "day"), 0); + if (nextDurationDays === currentDurationDays) { + return null; + } + return (target) => buildAllDayDraft(target, target.start.startOf("day"), target.start.startOf("day").add(nextDurationDays, "day")); +}; + +/** Gesture: drag the LEFT edge of an all-day bar onto an absolute date. */ +export const buildAllDayResizeStartTransform = ( + sourceEvent: ICalendarNormalizedEvent, + targetDate: string, +): ICalendarDraftTransform | null => { + if (!sourceEvent.isAllDay) { + return null; + } + const requestedStart = toDayStart(targetDate); + if (!requestedStart) { + return null; + } + const sourceEndDay = (sourceEvent.end || sourceEvent.start).startOf("day"); + const desiredStart = requestedStart.isAfter(sourceEndDay, "day") ? sourceEndDay : requestedStart; + const startDeltaDays = desiredStart.diff(sourceEvent.start.startOf("day"), "day"); + if (startDeltaDays === 0) { + return null; + } + return (target) => { + const targetEndDay = (target.end || target.start).startOf("day"); + const requested = target === sourceEvent ? desiredStart : target.start.startOf("day").add(startDeltaDays, "day"); + const nextStart = requested.isAfter(targetEndDay, "day") ? targetEndDay : requested; + return buildAllDayDraft(target, nextStart, targetEndDay); + }; +}; + +/** + * Context-menu "shift": move the whole event without changing its length. + * + * `days` shifts an event of either kind; `minutes` only applies to timed events + * (an all-day item has no clock time to shift). + */ +export const buildShiftTransform = ( + sourceEvent: ICalendarNormalizedEvent, + delta: {minutes?: number, days?: number}, +): ICalendarDraftTransform | null => { + const days = delta.days || 0; + const minutes = delta.minutes || 0; + if (!days && !minutes) { + return null; + } + if (minutes && sourceEvent.isAllDay) { + return null; + } + if (days && !minutes) { + return (target) => { + const draft = buildDraftForDate(target, target.start.startOf("day").add(days, "day").format("YYYY-MM-DD")); + if (target.isAllDay && target.end && !target.start.isSame(target.end, "day")) { + draft.endTime = target.end?.format("HH:mm") || "23:59"; + } + return draft; + }; + } + const totalMinutes = minutes + days * CALENDAR_MINUTES_PER_DAY; + return (target) => { + const nextStart = target.start.add(totalMinutes, "minute"); + const duration = getTimedEnd(target).diff(target.start, "minute"); + return buildTimedDraft(target, nextStart, nextStart.add(duration, "minute")); + }; +}; + +/** + * Context-menu "Duplicate to next day". + * + * A copy is a NEW one-off entry, so it must never inherit the recurrence rule or + * the exception list of the event it was copied from. + */ +export const buildDuplicateNextDayDraft = (sourceEvent: ICalendarNormalizedEvent): ICalendarEventDraft => { + const draft = buildDraftForDate(sourceEvent, sourceEvent.start.add(1, "day").format("YYYY-MM-DD")); + draft.recurrenceRaw = ""; + draft.recurrenceExceptionRaw = ""; + return draft; +}; + +/** + * Gesture: sweep an empty range on the timed grid. + * + * Both ends are snapped; the range is normalised so sweeping upwards works, and + * a degenerate sweep is widened to the minimum duration so the quick-create + * popover is always prefilled with a range the kernel will accept. + */ +export const buildSweepCreateDraft = ( + anchor: ICalendarGridPoint, + current: ICalendarGridPoint, + geometry?: Partial, +): ICalendarEventDraft | null => { + const resolved = resolveCalendarGeometry(geometry); + const anchorTime = resolveGridPoint(anchor, resolved); + const currentTime = resolveGridPoint(current, resolved); + if (!anchorTime || !currentTime) { + return null; + } + const start = currentTime.isBefore(anchorTime) ? currentTime : anchorTime; + let end = currentTime.isBefore(anchorTime) ? anchorTime : currentTime; + if (end.diff(start, "minute") < resolved.minimumEventMinutes) { + end = start.add(resolved.minimumEventMinutes, "minute"); + } + return { + title: "", + date: start.format("YYYY-MM-DD"), + endDate: end.format("YYYY-MM-DD"), + isAllDay: false, + startTime: start.format("HH:mm"), + endTime: end.format("HH:mm"), + }; +}; + +/** Sweep across the all-day rail: one all-day draft spanning both dates. */ +export const buildAllDaySweepCreateDraft = (anchorDate: string, currentDate: string): ICalendarEventDraft | null => { + const anchorDay = toDayStart(anchorDate); + const currentDay = toDayStart(currentDate); + if (!anchorDay || !currentDay) { + return null; + } + const start = currentDay.isBefore(anchorDay) ? currentDay : anchorDay; + const end = currentDay.isBefore(anchorDay) ? anchorDay : currentDay; + return { + title: "", + date: start.format("YYYY-MM-DD"), + endDate: end.format("YYYY-MM-DD"), + isAllDay: true, + startTime: "09:00", + endTime: "09:30", + }; +}; + +/** + * The live readout shown while a gesture is in flight ("09:15 – 10:30"). + * Pure string math so it can be unit-tested with the builders. + */ +export const describeCalendarRange = ( + range: ICalendarGridRange, + geometry?: Partial, +): string => { + const resolved = resolveCalendarGeometry(geometry); + const start = resolveGridPoint(range.start, resolved); + const end = resolveGridPoint(range.end, resolved); + if (!start || !end) { + return ""; + } + const ordered = end.isBefore(start) ? {start: end, end: start} : {start, end}; + const startLabel = ordered.start.format("HH:mm"); + const endMinutes = ordered.end.diff(ordered.end.startOf("day"), "minute"); + const endLabel = ordered.end.isSame(ordered.start, "day") ? + formatClockLabel(endMinutes) : + `${ordered.end.format("MM-DD")} ${formatClockLabel(endMinutes)}`; + return `${startLabel} – ${endLabel}`; +}; + +/** The grid range an event currently occupies, for ghost placement. */ +export const getEventGridRange = (event: ICalendarNormalizedEvent): ICalendarGridRange => { + const end = getTimedEnd(event); + const startDay = event.start.startOf("day"); + return { + start: {date: startDay.format("YYYY-MM-DD"), minute: event.start.diff(startDay, "minute")}, + end: {date: end.startOf("day").format("YYYY-MM-DD"), minute: end.diff(end.startOf("day"), "minute")}, + }; +}; diff --git a/app/src/protyle/render/av/calendar/event-chip.ts b/app/src/protyle/render/av/calendar/event-chip.ts new file mode 100644 index 00000000000..bf1ce346fd6 --- /dev/null +++ b/app/src/protyle/render/av/calendar/event-chip.ts @@ -0,0 +1,181 @@ +import * as dayjs from "dayjs"; +import {escapeAttr, escapeHtml} from "../../../../util/escape"; +import {getEventDocumentID, ICalendarEventDraft, ICalendarNormalizedEvent} from "./model"; + +/** + * Every piece of calendar chip HTML lives here. + * + * The chip used to carry permanent inline text buttons (-15m/+15m, -1d/+1d, + * Copy). They are gone: those actions moved into the right-click menu + * (context-menu.ts), and duration is now changed by dragging the chip's edges + * (interactions.ts). What must NOT change is everything the rest of the app and + * the smoke suite reads off a chip - the class list, the data attributes, the + * tooltip/aria-label text, the pending class and the draggable/read-only + * attributes. Those are reproduced here exactly as render.ts emitted them. + */ + +export type CalendarChipVariant = "month" | "list" | "timed" | "all-day"; + +export const CALENDAR_CHIP_VARIANTS: CalendarChipVariant[] = ["month", "list", "timed", "all-day"]; + +/** + * Same accessor as render.ts / event-dialog.ts / layout.ts: the calendar formats + * dates with the UI language, which is already a BCP 47 tag. + */ +const getCalendarLocale = () => window.siyuan.config.lang; + +export const formatCalendarDate = (date: dayjs.Dayjs, options: Intl.DateTimeFormatOptions) => { + return new Intl.DateTimeFormat(getCalendarLocale(), options).format(date.toDate()); +}; + +export const getOpenPageLabel = () => { + return window.siyuan.languages.calendarOpenSource || "Open page"; +}; + +export const getOpenScheduleLabel = () => { + return window.siyuan.languages.calendarEditEvent || "Edit event"; +}; + +export const getEventDateLabel = (event: ICalendarNormalizedEvent) => { + if (event.isAllDay) { + return event.end && !event.start.isSame(event.end, "day") ? + `${formatCalendarDate(event.start, {year: "numeric", month: "short", day: "numeric"})} - ${formatCalendarDate(event.end, {year: "numeric", month: "short", day: "numeric"})}` : + formatCalendarDate(event.start, {year: "numeric", month: "short", day: "numeric"}); + } + const dateLabel = event.end && !event.start.isSame(event.end, "day") ? + `${formatCalendarDate(event.start, {year: "numeric", month: "short", day: "numeric"})} ${event.start.format("HH:mm")} - ${formatCalendarDate(event.end, {year: "numeric", month: "short", day: "numeric"})} ${event.end.format("HH:mm")}` : + `${formatCalendarDate(event.start, {year: "numeric", month: "short", day: "numeric"})} ${event.start.format("HH:mm")} - ${(event.end || event.start.add(1, "hour")).format("HH:mm")}`; + return dateLabel; +}; + +export const getEventTimeLabel = (event: ICalendarNormalizedEvent) => { + if (event.isAllDay) { + return window.siyuan.languages.allDay || "All day"; + } + const end = event.end || event.start.add(30, "minute"); + if (!event.start.isSame(end, "day")) { + const dateOptions: Intl.DateTimeFormatOptions = {month: "short", day: "numeric"}; + if (!event.start.isSame(end, "year")) { + dateOptions.year = "numeric"; + } + return `${formatCalendarDate(event.start, dateOptions)} ${event.start.format("HH:mm")} – ${formatCalendarDate(end, dateOptions)} ${end.format("HH:mm")}`; + } + return `${event.start.format("HH:mm")}–${end.format("HH:mm")}`; +}; + +export const getEventTooltip = (event: ICalendarNormalizedEvent) => { + return [ + event.title, + getEventDateLabel(event), + // Bound entries open their page on a plain click, so say so before the click. + getEventDocumentID(event) ? getOpenPageLabel() : "", + event.location ? `${window.siyuan.languages.calendarLocation || "Location"}: ${event.location}` : "", + event.description ? `${window.siyuan.languages.calendarDescription || "Description"}: ${event.description}` : "", + event.recurrenceRaw ? `${window.siyuan.languages.calendarRecurrence || "Recurrence"}: ${event.recurrenceRaw}` : "", + event.isOccurrence ? window.siyuan.languages.calendarOccurrence || "Recurring occurrence" : "", + ].filter(Boolean).join("\n"); +}; + +export interface ICalendarChipOptions { + event: ICalendarNormalizedEvent; + /** Which surface the chip is drawn on. Decides shape, not behaviour. */ + variant: CalendarChipVariant; + /** The day cell the chip is painted in; becomes data-date. */ + displayDate?: dayjs.Dayjs; + /** false for read-only / query-embed calendars: no drag, no edge handles. */ + editable?: boolean; + /** Extra classes for the caller's layout (never behavioural). */ + className?: string; + /** Inline style the caller needs for absolute placement in the timed layer. */ + style?: string; + /** All-day bars only: the run continues off the left/right edge of the view. */ + continuesBefore?: boolean; + continuesAfter?: boolean; +} + +/** + * The colour a chip paints itself with, as the inline style render.ts produced. + */ +const getChipColorStyle = (event: ICalendarNormalizedEvent, variant: CalendarChipVariant) => { + if (!event.color) { + return ""; + } + const background = `var(--b3-font-background${escapeAttr(event.color)})`; + const foreground = `var(--b3-font-color${escapeAttr(event.color)})`; + return `--calendar-event-accent:${foreground};--calendar-event-fill:${background};`; +}; + +/** + * One chip, in one of four shapes. + * + * Month, list and timed surfaces use the same semantic parts but arrange them + * differently: month is compact time + title, agenda gets separate time/title + * columns, and a timed block puts title before time. The accessible name remains + * the complete tooltip, independently of how much a short block can display. + */ +export const renderCalendarEventChip = (options: ICalendarChipOptions) => { + const {event, variant} = options; + const editable = options.editable !== false; + const inlineStyle = `${getChipColorStyle(event, variant)}${options.style || ""}`; + const colorStyle = inlineStyle ? ` style="${inlineStyle}"` : ""; + const eventTooltip = getEventTooltip(event); + const documentID = getEventDocumentID(event); + + const variantClass = ` av__calendar-event--${variant === "all-day" ? "all-day" : variant}`; + const continuationClass = `${options.continuesBefore ? " av__calendar-event--continues-before" : ""}${options.continuesAfter ? " av__calendar-event--continues-after" : ""}`; + const durationMinutes = event.isAllDay ? 24 * 60 : Math.max((event.end || event.start.add(30, "minute")).diff(event.start, "minute"), 0); + const densityClass = durationMinutes < 45 ? " av__calendar-event--short" : durationMinutes >= 90 ? " av__calendar-event--tall" : ""; + const timeRange = getEventTimeLabel(event); + const secondary = event.location ? `${escapeHtml(event.location)}` : ""; + const content = variant === "month" ? + `${event.isAllDay ? "" : `${escapeHtml(timeRange)}`}${escapeHtml(event.title)}` : + variant === "list" ? + `${escapeHtml(timeRange)}${escapeHtml(event.title)}${secondary}` : + variant === "all-day" ? + `${escapeHtml(event.title)}${event.isAllDay ? "" : `${escapeHtml(timeRange)}`}` : + `${escapeHtml(event.title)}${event.isAllDay ? "" : `${escapeHtml(timeRange)}`}${secondary}`; + return ``; +}; + +/** + * The chip painted for an entry the user just saved, before the kernel answers. + * + * Not a + ${!readOnly && editsSeries ? `
${window.siyuan.languages.calendarEditSeriesNotice || "This will edit the recurring series. Map an exception field to edit a single occurrence."}
` : ""} +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+ ${visibleTextFields.map(field => `
+ + +
`).join("")} +
+ + ${renderRecurrenceFields(event, readOnly, startDate)} +
+ ${renderColorField(colorField, event, readOnly)} + ${event?.blockID ? `` : ""} + +`; + const guardedClose = {unbind: undefined as (() => void) | undefined}; + const dialog = new Dialog({ + title: isEditing ? (window.siyuan.languages.calendarEditEvent || "Edit event") : (window.siyuan.languages.newEvent || "New Event"), + content, + width: "560px", + disableClose: true, + destroyCallback: () => guardedClose.unbind?.(), + }); + guardedClose.unbind = bindGuardedEventDialogClose(dialog); + bindFormEvents(dialog, options); + return dialog; +}; + +const bindGuardedEventDialogClose = (dialog: Dialog) => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.isComposing) { + return; + } + if (window.siyuan.dialogs[window.siyuan.dialogs.length - 1] !== dialog) { + return; + } + event.preventDefault(); + event.stopPropagation(); + dialog.destroy(); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); +}; + +const bindFormEvents = (dialog: Dialog, options: IEventDialogOptions) => { + const allDayCheckbox = dialog.element.querySelector("#av-event-allday") as HTMLInputElement; + const schedule = dialog.element.querySelector("#av-event-schedule") as HTMLElement; + const startTimeInput = dialog.element.querySelector("#av-event-start") as HTMLInputElement; + const endTimeInput = dialog.element.querySelector("#av-event-end") as HTMLInputElement; + let rememberedTimedStart = allDayCheckbox.checked ? "09:00" : (startTimeInput.value || "09:00"); + let rememberedTimedEnd = allDayCheckbox.checked ? "10:00" : (endTimeInput.value || "10:00"); + allDayCheckbox?.addEventListener("change", () => { + if (allDayCheckbox.checked) { + rememberedTimedStart = startTimeInput.value || rememberedTimedStart; + rememberedTimedEnd = endTimeInput.value || rememberedTimedEnd; + } else { + startTimeInput.value = rememberedTimedStart; + endTimeInput.value = rememberedTimedEnd; + } + schedule?.classList.toggle("av__calendar-dialog-schedule--all-day", allDayCheckbox.checked); + }); + const dateInput = dialog.element.querySelector("#av-event-date") as HTMLInputElement; + const endDateInput = dialog.element.querySelector("#av-event-end-date") as HTMLInputElement; + const recurrenceFreq = dialog.element.querySelector("#av-event-recurrence-freq") as HTMLSelectElement; + const weekdayRow = dialog.element.querySelector('[data-type="calendar-weekday-row"]') as HTMLElement; + const presetSelect = dialog.element.querySelector("#av-event-recurrence-preset") as HTMLSelectElement; + const customRow = dialog.element.querySelector("#av-event-recurrence-custom") as HTMLElement; + const summaryElement = dialog.element.querySelector("#av-event-recurrence-summary") as HTMLElement; + const updateRecurrenceSummary = () => { + if (summaryElement) { + summaryElement.textContent = describeRecurrence(getRecurrenceFromDialog(dialog), dateInput?.value || options.date); + } + }; + dateInput?.addEventListener("change", () => { + if (!endDateInput.value || endDateInput.value < dateInput.value) { + endDateInput.value = dateInput.value; + } + const recurrenceUntilInput = dialog.element.querySelector("#av-event-recurrence-until") as HTMLInputElement; + if (recurrenceUntilInput?.value && recurrenceUntilInput.value < dateInput.value) { + recurrenceUntilInput.value = dateInput.value; + } + // "Weekly on Tue" and "Annually on 5 August" are read off the start + // date, so moving the event has to relabel the presets too. + if (presetSelect) { + presetSelect.innerHTML = renderRecurrencePresetOptions(presetSelect.value as CalendarRecurrencePreset, dateInput.value || options.date); + } + updateRecurrenceSummary(); + }); + const updateWeekdayVisibility = () => { + if (weekdayRow) { + weekdayRow.style.display = recurrenceFreq?.value === "WEEKLY" ? "flex" : "none"; + } + }; + recurrenceFreq?.addEventListener("change", updateWeekdayVisibility); + updateWeekdayVisibility(); + const updateRecurrenceEnd = () => { + const endMode = (dialog.element.querySelector('input[name="calendar-recurrence-end"]:checked') as HTMLInputElement)?.value || "never"; + const untilInput = dialog.element.querySelector("#av-event-recurrence-until") as HTMLInputElement; + const countInput = dialog.element.querySelector("#av-event-recurrence-count") as HTMLInputElement; + if (untilInput) untilInput.disabled = !!options.readOnly || endMode !== "until"; + if (countInput) countInput.disabled = !!options.readOnly || endMode !== "count"; + updateRecurrenceSummary(); + }; + dialog.element.querySelectorAll('input[name="calendar-recurrence-end"]').forEach(item => item.addEventListener("change", updateRecurrenceEnd)); + updateRecurrenceEnd(); + let previousPreset = presetSelect?.value as CalendarRecurrencePreset; + presetSelect?.addEventListener("change", () => { + const preset = presetSelect.value as CalendarRecurrencePreset; + if (customRow) { + customRow.style.display = preset === "custom" ? "" : "none"; + } + if (shouldResetCustomWeekdays(previousPreset, preset)) { + dialog.element.querySelectorAll(CALENDAR_RECURRENCE_WEEKDAY_SELECTOR).forEach(item => { + const checkbox = item as HTMLInputElement; + checkbox.checked = false; + }); + } + if (preset !== "custom") { + writeRecurrenceRuleToControls(dialog, getRecurrencePresetRule(preset)); + } + previousPreset = preset; + updateWeekdayVisibility(); + updateRecurrenceSummary(); + }); + // Editing the escape-hatch controls only refreshes the prose; it must never + // rewrite them, or the smoke that sets FREQ/INTERVAL/COUNT directly would + // have its values clobbered. + [recurrenceFreq, + dialog.element.querySelector("#av-event-recurrence-interval"), + dialog.element.querySelector("#av-event-recurrence-count"), + dialog.element.querySelector("#av-event-recurrence-until"), + ...Array.from(dialog.element.querySelectorAll(CALENDAR_RECURRENCE_WEEKDAY_SELECTOR)), + ].forEach(item => item?.addEventListener("change", updateRecurrenceSummary)); + dialog.element.querySelector('[data-type="event-cancel"]')?.addEventListener("click", () => dialog.destroy()); + dialog.element.querySelector('[data-type="event-close"]')?.addEventListener("click", () => dialog.destroy()); + if (options.readOnly) { + dialog.element.querySelector('[data-type="event-open-block"]')?.addEventListener("click", () => openEventBlock(dialog, options)); + return; + } + dialog.element.querySelector('[data-type="event-save"]')?.addEventListener("click", () => runRecurringEventAction(dialog, options, "edit", (scope) => withPendingSave(dialog, "event-save", () => saveEventWithScope(dialog, options, scope)))); + dialog.element.querySelector('[data-type="event-delete"]')?.addEventListener("click", () => runRecurringEventAction(dialog, options, "delete", (scope) => withCalendarDialogOperationFeedback(dialog, "event-delete", window.siyuan.languages.calendarDeleteFailed || "Delete failed.", () => deleteEventWithScope(dialog, options, scope)))); + dialog.element.querySelector('[data-type="event-delete-page"]')?.addEventListener("click", () => withCalendarDialogOperationFeedback(dialog, "event-delete-page", window.siyuan.languages.calendarDeleteFailed || "Delete failed.", () => deleteEventWithPage(dialog, options))); + dialog.element.querySelector('[data-type="event-duplicate"]')?.addEventListener("click", () => withCalendarDialogOperationFeedback(dialog, "event-duplicate", window.siyuan.languages.calendarDuplicateFailed || "Duplicate failed.", () => duplicateEvent(dialog, options))); + dialog.element.querySelector('[data-type="event-open-block"]')?.addEventListener("click", () => openEventBlock(dialog, options)); + dialog.element.querySelector("#av-event-title")?.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault(); + runRecurringEventAction(dialog, options, "edit", (scope) => withPendingSave(dialog, "event-save", () => saveEventWithScope(dialog, options, scope))); + } + }); +}; + +const openEventBlock = (dialog: Dialog, options: IEventDialogOptions) => { + const blockID = options.event?.blockID; + if (!blockID) { + return; + } + /// #if !MOBILE + openFileById({ + app: options.protyle.app, + id: blockID, + action: [Constants.CB_GET_FOCUS], + }); + /// #else + openMobileFileById(options.protyle.app, blockID, [Constants.CB_GET_FOCUS]); + /// #endif + dialog.destroy(); +}; + +const getDraftFromDialog = (dialog: Dialog) => { + const date = (dialog.element.querySelector("#av-event-date") as HTMLInputElement).value; + const endDateInput = (dialog.element.querySelector("#av-event-end-date") as HTMLInputElement).value; + const endDate = isRealDateInputValue(endDateInput) && endDateInput >= date ? endDateInput : date; + const fieldValues: { [fieldID: string]: string } = {}; + dialog.element.querySelectorAll('[data-type="calendar-field-value"]').forEach(item => { + const input = item as HTMLInputElement; + if (input.dataset.fieldId) { + fieldValues[input.dataset.fieldId] = input.value; + } + }); + return { + title: (dialog.element.querySelector("#av-event-title") as HTMLInputElement).value.trim(), + date, + endDate, + isAllDay: (dialog.element.querySelector("#av-event-allday") as HTMLInputElement).checked, + startTime: (dialog.element.querySelector("#av-event-start") as HTMLInputElement).value || "09:00", + endTime: (dialog.element.querySelector("#av-event-end") as HTMLInputElement).value || "10:00", + recurrenceRaw: getRecurrenceFromDialog(dialog), + colorContent: (dialog.element.querySelector("#av-event-color") as HTMLSelectElement)?.value || "", + fieldValues, + }; +}; + +const getEventScheduleDraft = (event: ICalendarNormalizedEvent) => ({ + date: event.start.format("YYYY-MM-DD"), + endDate: (event.end || event.start).format("YYYY-MM-DD"), + isAllDay: event.isAllDay, + startTime: event.start.format("HH:mm"), + endTime: (event.end || event.start.add(1, "hour")).format("HH:mm"), +}); + +export const getWholeSeriesDraft = ( + draft: ICalendarEventDraft, + selectedEvent?: ICalendarNormalizedEvent, + seriesEvent?: ICalendarNormalizedEvent, +): ICalendarEventDraft => { + if (!selectedEvent?.isOccurrence || !seriesEvent) { + return draft; + } + return {...draft, ...getEventScheduleDraft(seriesEvent)}; +}; + +const withPendingSave = (dialog: Dialog, saveType: string, callback: () => Promise) => withCalendarDialogOperationFeedback(dialog, saveType, window.siyuan.languages.calendarSaveFailed || "Save failed.", callback); + +const withCalendarDialogOperationFeedback = async (dialog: Dialog, actionType: string, failureMessage: string, callback: () => Promise) => { + const actionButton = dialog.element.querySelector(`[data-type="${actionType}"]`) as HTMLButtonElement; + if (actionButton?.disabled || dialog.element.dataset.calendarOperation === "pending") { + return; + } + dialog.element.dataset.calendarOperation = "pending"; + dialog.element.setAttribute("aria-busy", "true"); + dialog.element.classList.add("av__calendar-dialog--pending"); + if (actionButton) { + actionButton.disabled = true; + } + try { + const saved = await callback(); + if (!saved && actionButton) { + actionButton.disabled = false; + showMessage(failureMessage); + } + } catch (error) { + if (actionButton) { + actionButton.disabled = false; + } + showMessage(failureMessage); + } finally { + delete dialog.element.dataset.calendarOperation; + dialog.element.removeAttribute("aria-busy"); + dialog.element.classList.remove("av__calendar-dialog--pending"); + } +}; + +const showInvalidDraftMessage = (draft: ReturnType, mapping: ReturnType, allowEmptyTitle = false) => { + if (!draft.title && !allowEmptyTitle) { + showMessage(`${window.siyuan.languages.title || "Title"} ${window.siyuan.languages.invalid || "Invalid"}`); + return; + } + if (!isRealDateInputValue(draft.date)) { + showMessage(`${window.siyuan.languages.date || "Date"} ${window.siyuan.languages.invalid || "Invalid"}`); + return; + } + if (!mapping.dateFieldID) { + showMessage(window.siyuan.languages.calendarNeedDateField || window.siyuan.languages.dateField || "Calendar requires a date field"); + return; + } + showMessage(window.siyuan.languages._kernel[258]); +}; + +export const isRecurringSourceEvent = (event?: ICalendarNormalizedEvent) => { + if (!event || event.isOccurrence) { + return false; + } + // recurrenceRaw "None" explicitly means non-recurring; only treat other + // non-empty raw rules (including advanced ones we keep verbatim) as recurring. + const raw = (event.recurrenceRaw || "").trim(); + return !!event.recurrence || (!!raw && raw.toUpperCase() !== "NONE"); +}; + +export const getDisabledRecurrenceScopes = (mapping: ReturnType, action: "edit" | "delete", event?: ICalendarNormalizedEvent) => { + const isSourceEvent = isRecurringSourceEvent(event); + return { + occurrence: isSourceEvent ? + (window.siyuan.languages.calendarRecurrenceScopeRootOccurrenceDisabled || "This source event stores the recurring series. Single-occurrence changes are only available from generated occurrences.") : + (mapping.exceptionFieldID ? "" : (window.siyuan.languages.calendarRecurrenceScopeOccurrenceDisabled || "Map an exception field to change only this occurrence.")), + future: isSourceEvent ? + (window.siyuan.languages.calendarRecurrenceScopeRootFutureDisabled || "This option is only available from a later occurrence.") : + (mapping.recurrenceFieldID ? "" : + (window.siyuan.languages.calendarRecurrenceScopeFutureDisabled || "Map a recurrence field to change this and following items.")), + }; +}; + +export const openRecurrenceScopeDialog = (options: { + action: "edit" | "delete" | "move" | "resize"; + disabledScopes: Partial>; + onSelect: (scope: CalendarRecurrenceScope) => void; +}) => { + const isGerman = /^de(?:-|$)/i.test(window.siyuan.config.lang || ""); + const seriesTitle = options.action === "delete" ? + (isGerman ? "Alle löschen" : (window.siyuan.languages.calendarDeleteSeries || "Delete all")) : + (isGerman ? "Alle bearbeiten" : (window.siyuan.languages.calendarRecurrenceScopeSeries || window.siyuan.languages.all || "All")); + const title = options.action === "delete" ? + (window.siyuan.languages.calendarRecurrenceScopeDeleteTitle || "Delete recurring item") : + (window.siyuan.languages.calendarRecurrenceScopeEditTitle || "Edit recurring item"); + const labels: Array<{scope: CalendarRecurrenceScope, title: string, description: string}> = [ + {scope: "occurrence", title: window.siyuan.languages.calendarRecurrenceScopeOccurrence || "This occurrence", description: window.siyuan.languages.calendarRecurrenceScopeOccurrenceDesc || "Only the selected occurrence."}, + {scope: "future", title: window.siyuan.languages.calendarThisAndFuture || "This and future", description: window.siyuan.languages.calendarRecurrenceScopeFutureDesc || "This occurrence and following items in the series."}, + {scope: "series", title: seriesTitle, description: window.siyuan.languages.calendarRecurrenceScopeSeriesDesc || "Every item in the recurring series."}, + ]; + const availableLabels = labels.filter(item => !options.disabledScopes[item.scope]); + if (availableLabels.length === 1 && options.action !== "delete") { + options.onSelect(availableLabels[0].scope); + return; + } + const isSingleDelete = availableLabels.length === 1 && options.action === "delete"; + const dialog = new Dialog({ + title, + width: "420px", + content: `
+ ${isSingleDelete ? "" : `
${window.siyuan.languages.calendarRecurrenceScopePrompt || "Choose how far this change should apply."}
+ ${availableLabels.map(item => ``).join("")}`} +
${isSingleDelete ? `` : ""}
+
`, + }); + availableLabels.forEach(item => { + dialog.element.querySelector(`[data-type="calendar-scope-${item.scope}"]`)?.addEventListener("click", () => { + dialog.destroy(); + options.onSelect(item.scope); + }); + }); + dialog.element.querySelector('[data-type="calendar-scope-cancel"]')?.addEventListener("click", () => dialog.destroy()); + (dialog.element.querySelector(".av__calendar-scope-option:not([disabled])") as HTMLButtonElement)?.focus(); + return dialog; +}; + +const runRecurringEventAction = (dialog: Dialog, options: IEventDialogOptions, action: "edit" | "delete", run: (scope: CalendarRecurrenceScope) => void) => { + if (!options.event || (!options.event.isOccurrence && !isRecurringSourceEvent(options.event))) { + run("series"); + return; + } + const mapping = getCalendarFieldMapping(options.data.view as IAVCalendar); + openRecurrenceScopeDialog({ + action, + disabledScopes: getDisabledRecurrenceScopes(mapping, action, options.event), + onSelect: run, + }); +}; + +const ensureRecurrenceStorage = ( + options: IEventDialogOptions, + calendarData: IAVCalendar, + mapping: ReturnType, + recurrenceRaw = "", +) => { + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + const viewID = getViewID(options); + if (!avID || !blockID || !viewID) { + return Promise.resolve(mapping); + } + return ensureCalendarRecurrenceStorage({ + protyle: options.protyle, + calendarData, + mapping, + avID, + blockID, + viewID, + storageRequired: Boolean(recurrenceRaw), + }); +}; + +const saveEventWithScope = async (dialog: Dialog, options: IEventDialogOptions, scope: CalendarRecurrenceScope) => { + if (scope === "future") { + return saveFutureEvent(dialog, options); + } + return saveEvent(dialog, options, scope); +}; + +const deleteEventWithScope = async (dialog: Dialog, options: IEventDialogOptions, scope: CalendarRecurrenceScope) => deleteEvent(dialog, options, scope); + +/** + * One new entry. When the view creates entries as documents this goes through the + * kernel (only it can create the .sy file and bind it in one transaction); + * otherwise it stays the detached row-only path. + */ +const createEntryFromDraft = async (options: IEventDialogOptions, args: { + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ReturnType; + draft: ICalendarEventDraft; +}) => { + const createOptions = { + protyle: options.protyle, + avID: args.avID, + blockID: args.blockID, + viewID: getViewID(options), + dateFieldID: args.dateFieldID, + fields: args.fields, + mapping: args.mapping, + draft: args.draft, + templateID: options.templateID, + previousUpdated: options.blockElement.getAttribute("updated") || "", + }; + if (options.createAsDocument) { + return !!await createCalendarEventAsDocument(createOptions); + } + return createCalendarEvent(createOptions); +}; + +const saveEvent = async (dialog: Dialog, options: IEventDialogOptions, scope: CalendarRecurrenceScope = "series") => { + const calendarData = options.data.view as IAVCalendar; + const draft = getDraftFromDialog(dialog); + const initialMapping = getCalendarFieldMapping(calendarData); + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if ((!draft.title && !options.event?.isTitleFallback) || !isRealDateInputValue(draft.date) || !avID || !blockID || !initialMapping.dateFieldID) { + showInvalidDraftMessage(draft, initialMapping, Boolean(options.event?.isTitleFallback)); + return false; + } + const mapping = await ensureRecurrenceStorage(options, calendarData, initialMapping, draft.recurrenceRaw); + if (options.event) { + if (scope === "occurrence" && options.event.isOccurrence && mapping.exceptionFieldID) { + if (!await createCalendarEventReplacingOccurrence({ + protyle: options.protyle, + avID, + blockID, + dateFieldID: mapping.dateFieldID, + fields: calendarData.fields, + mapping, + event: options.event, + draft, + occurrenceDate: options.event.start.format("YYYY-MM-DD"), + previousUpdated: options.blockElement.getAttribute("updated") || "", + viewID: getViewID(options), + createAsDocument: options.createAsDocument, + templateID: options.templateID, + })) { + return false; + } + dialog.destroy(); + options.onSave?.(); + return true; + } + const eventToUpdate = scope === "series" ? (options.seriesEvent || options.event) : options.event; + const draftToUpdate = scope === "series" ? getWholeSeriesDraft(draft, options.event, options.seriesEvent) : draft; + if (!await updateCalendarEvent({ + protyle: options.protyle, + avID, + blockID, + dateFieldID: mapping.dateFieldID, + fields: calendarData.fields, + mapping, + event: eventToUpdate, + draft: draftToUpdate, + viewID: getViewID(options), + previousUpdated: options.blockElement.getAttribute("updated") || "", + })) { + return false; + } + } else if (!await createEntryFromDraft(options, { + avID, + blockID, + dateFieldID: mapping.dateFieldID, + fields: calendarData.fields, + mapping, + draft, + })) { + return false; + } + dialog.destroy(); + options.onSave?.(); + return true; +}; + +const saveFutureEvent = async (dialog: Dialog, options: IEventDialogOptions) => { + const calendarData = options.data.view as IAVCalendar; + const draft = getDraftFromDialog(dialog); + const initialMapping = getCalendarFieldMapping(calendarData); + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if (!options.event || !options.event.isOccurrence || (!draft.title && !options.event.isTitleFallback) || !isRealDateInputValue(draft.date) || !avID || !blockID || !initialMapping.dateFieldID) { + showInvalidDraftMessage(draft, initialMapping, Boolean(options.event?.isTitleFallback)); + return false; + } + const mapping = await ensureRecurrenceStorage(options, calendarData, initialMapping, draft.recurrenceRaw); + if (!mapping.recurrenceFieldID) { + showInvalidDraftMessage(draft, mapping, Boolean(options.event?.isTitleFallback)); + return false; + } + if (!await updateCalendarEventThisAndFuture({ + protyle: options.protyle, + avID, + blockID, + dateFieldID: mapping.dateFieldID, + fields: calendarData.fields, + mapping, + event: options.event, + draft, + occurrenceDate: options.event.start.format("YYYY-MM-DD"), + previousUpdated: options.blockElement.getAttribute("updated") || "", + viewID: getViewID(options), + createAsDocument: options.createAsDocument, + templateID: options.templateID, + })) { + return false; + } + dialog.destroy(); + options.onSave?.(); + return true; +}; + +const duplicateEvent = async (dialog: Dialog, options: IEventDialogOptions) => { + const calendarData = options.data.view as IAVCalendar; + const mapping = getCalendarFieldMapping(calendarData); + const currentDraft = getDraftFromDialog(dialog); + const draft = { + ...currentDraft, + recurrenceRaw: "", + recurrenceExceptionRaw: "", + }; + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if ((!draft.title && !options.event?.isTitleFallback) || !isRealDateInputValue(draft.date) || !avID || !blockID || !mapping.dateFieldID) { + showInvalidDraftMessage(draft, mapping, Boolean(options.event?.isTitleFallback)); + return false; + } + if (!await createEntryFromDraft(options, { + avID, + blockID, + dateFieldID: mapping.dateFieldID, + fields: calendarData.fields, + mapping, + draft, + })) { + return false; + } + dialog.destroy(); + options.onSave?.(); + return true; +}; + +const deleteEventWithPage = async (dialog: Dialog, options: IEventDialogOptions) => { + const documentID = getEventDocumentID(options.event); + if (!documentID) { + return false; + } + // The row removal runs FIRST: a page must never be destroyed for a row + // removal that did not land. + if (!await deleteEvent(dialog, options, "series")) { + return false; + } + // The row is already gone, so a failing page removal is not a failed delete - + // deleteCalendarEventDocument has shown why the page survived. + await deleteCalendarEventDocument(documentID); + return true; +}; + +const deleteEvent = async (dialog: Dialog, options: IEventDialogOptions, scope: CalendarRecurrenceScope = "series") => { + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if (!options.event || !avID || !blockID) { + return false; + } + const calendarData = options.data.view as IAVCalendar; + const mapping = getCalendarFieldMapping(calendarData); + if (scope === "occurrence" && options.event.isOccurrence && mapping.exceptionFieldID) { + if (!await deleteCalendarOccurrence({ + protyle: options.protyle, + avID, + blockID, + fields: calendarData.fields, + mapping, + event: options.event, + occurrenceDate: options.event.start.format("YYYY-MM-DD"), + previousUpdated: options.blockElement.getAttribute("updated") || "", + })) { + return false; + } + dialog.destroy(); + options.onDelete?.(); + return true; + } + if (scope === "future" && mapping.recurrenceFieldID) { + if (!await deleteCalendarEventThisAndFuture({ + protyle: options.protyle, + avID, + blockID, + fields: calendarData.fields, + mapping, + event: options.event, + occurrenceDate: options.event.start.format("YYYY-MM-DD"), + previousUpdated: options.blockElement.getAttribute("updated") || "", + })) { + return false; + } + dialog.destroy(); + options.onDelete?.(); + return true; + } + if (!await deleteCalendarEvent({ + protyle: options.protyle, + avID, + blockID, + event: options.event, + previousUpdated: options.blockElement.getAttribute("updated") || "", + })) { + return false; + } + dialog.destroy(); + options.onDelete?.(); + return true; +}; diff --git a/app/src/protyle/render/av/calendar/ics.ts b/app/src/protyle/render/av/calendar/ics.ts new file mode 100644 index 00000000000..1e766308b2a --- /dev/null +++ b/app/src/protyle/render/av/calendar/ics.ts @@ -0,0 +1,310 @@ +import {ICalendarEventDraft} from "./model"; + +interface IICSProperty { + name: string; + params: { [key: string]: string }; + value: string; +} + +interface IICSDateValue { + date: Date; + isAllDay: boolean; +} + +export interface ICalendarICSImportEvent { + uid: string; + draft: ICalendarEventDraft; +} + +const pad = (value: number) => value.toString().padStart(2, "0"); +const formatDate = (value: Date) => `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`; +const formatTime = (value: Date) => `${pad(value.getHours())}:${pad(value.getMinutes())}`; + +const unescapeICSText = (value = "") => value + .replace(/\\[nN]/g, "\n") + .replace(/\\,/g, ",") + .replace(/\\;/g, ";") + .replace(/\\\\/g, "\\"); + +const unfoldICSLines = (source: string) => { + const lines = source.replace(/\r\n?/g, "\n").split("\n"); + const unfolded: string[] = []; + lines.forEach(line => { + if (/^[ \t]/.test(line) && unfolded.length > 0) { + unfolded[unfolded.length - 1] += line.slice(1); + } else { + unfolded.push(line); + } + }); + return unfolded; +}; + +const findContentSeparator = (line: string) => { + let quoted = false; + for (let index = 0; index < line.length; index++) { + if (line[index] === '"') { + quoted = !quoted; + } else if (line[index] === ":" && !quoted) { + return index; + } + } + return -1; +}; + +const splitPropertyParts = (value: string) => { + const parts: string[] = []; + let current = ""; + let quoted = false; + for (const character of value) { + if (character === '"') { + quoted = !quoted; + current += character; + } else if (character === ";" && !quoted) { + parts.push(current); + current = ""; + } else { + current += character; + } + } + parts.push(current); + return parts; +}; + +const parseProperty = (line: string): IICSProperty | undefined => { + const separator = findContentSeparator(line); + if (separator < 1) { + return undefined; + } + const header = splitPropertyParts(line.slice(0, separator)); + const name = (header.shift() || "").trim().toUpperCase(); + if (!name) { + return undefined; + } + const params: { [key: string]: string } = {}; + header.forEach(part => { + const equals = part.indexOf("="); + if (equals < 1) { + return; + } + const key = part.slice(0, equals).trim().toUpperCase(); + const raw = part.slice(equals + 1).trim(); + params[key] = raw.replace(/^"|"$/g, ""); + }); + return {name, params, value: line.slice(separator + 1)}; +}; + +const parseDateParts = (value: string) => { + const match = value.trim().match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$/i); + if (!match) { + return undefined; + } + const parts = { + year: parseInt(match[1], 10), + month: parseInt(match[2], 10), + day: parseInt(match[3], 10), + hour: parseInt(match[4] || "0", 10), + minute: parseInt(match[5] || "0", 10), + second: parseInt(match[6] || "0", 10), + utc: Boolean(match[7]), + hasTime: Boolean(match[4]), + }; + if (parts.month < 1 || parts.month > 12 || parts.day < 1 || parts.day > 31 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { + return undefined; + } + const calendarDate = new Date(parts.year, parts.month - 1, parts.day); + if (calendarDate.getFullYear() !== parts.year || calendarDate.getMonth() !== parts.month - 1 || calendarDate.getDate() !== parts.day) { + return undefined; + } + return parts; +}; + +const dateInTimeZone = (parts: ReturnType & object, timeZone: string) => { + const resolvedTimeZone = timezoneAliases[timeZone] || timeZone; + let timestamp = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); + try { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: resolvedTimeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); + // Pass one applies the nominal zone offset, pass two corrects a DST + // boundary, and pass three verifies that the wall-clock delta is zero. + for (let attempt = 0; attempt < 3; attempt++) { + const rendered = Object.fromEntries(formatter.formatToParts(new Date(timestamp)) + .filter(part => part.type !== "literal") + .map(part => [part.type, parseInt(part.value, 10)])); + const renderedAsUTC = Date.UTC(rendered.year, rendered.month - 1, rendered.day, rendered.hour, rendered.minute, rendered.second); + const requestedAsUTC = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); + const delta = requestedAsUTC - renderedAsUTC; + timestamp += delta; + if (delta === 0) { + break; + } + } + return new Date(timestamp); + } catch (_) { + return new Date(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); + } +}; + +const parseICSDate = (property?: IICSProperty): IICSDateValue | undefined => { + if (!property) { + return undefined; + } + const parts = parseDateParts(property.value); + if (!parts) { + return undefined; + } + const isAllDay = property.params.VALUE?.toUpperCase() === "DATE" || !parts.hasTime; + if (isAllDay) { + return {date: new Date(parts.year, parts.month - 1, parts.day), isAllDay: true}; + } + if (parts.utc) { + return {date: new Date(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second)), isAllDay: false}; + } + if (property.params.TZID) { + return {date: dateInTimeZone(parts, property.params.TZID), isAllDay: false}; + } + return {date: new Date(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second), isAllDay: false}; +}; + +const parseDurationMilliseconds = (value = "") => { + const match = value.trim().toUpperCase().match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); + if (!match) { + return 0; + } + const weeks = parseInt(match[1] || "0", 10); + const days = parseInt(match[2] || "0", 10); + const hours = parseInt(match[3] || "0", 10); + const minutes = parseInt(match[4] || "0", 10); + const seconds = parseInt(match[5] || "0", 10); + return (((weeks * 7 + days) * 24 + hours) * 60 * 60 + minutes * 60 + seconds) * 1000; +}; + +const normalizeRecurrenceRule = (value = "") => { + const supported = new Set(["FREQ", "INTERVAL", "COUNT", "UNTIL", "BYDAY"]); + const parts = value.trim().toUpperCase().split(";").filter(Boolean).filter(part => { + const separator = part.indexOf("="); + return separator > 0 && supported.has(part.slice(0, separator)); + }); + const frequency = parts.find(part => part.startsWith("FREQ="))?.slice(5); + return ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].includes(frequency || "") ? parts.join(";") : ""; +}; + +const firstProperty = (properties: IICSProperty[], name: string) => properties.find(property => property.name === name); +const allProperties = (properties: IICSProperty[], name: string) => properties.filter(property => property.name === name); + +// Exchange/Outlook commonly emits Windows timezone IDs. Thunderbird resolves +// these to the same rules as IANA zones before it constructs an item. +const timezoneAliases: {[key: string]: string} = { + "AUS Central Standard Time": "Australia/Darwin", + "Central Europe Standard Time": "Europe/Budapest", + "Cuba Standard Time": "America/Havana", + "Egypt Standard Time": "Africa/Cairo", + "Pacific SA Standard Time": "America/Santiago", + "Romance Standard Time": "Europe/Paris", + "Sri Lanka Standard Time": "Asia/Colombo", + "Taipei Standard Time": "Asia/Taipei", + "Tonga Standard Time": "Pacific/Tongatapu", + "W. Europe Standard Time": "Europe/Berlin", +}; + +/** + * Browser File.text() assumes UTF-8. Calendar exports in the wild still use + * legacy single-byte encodings, so decode the bytes explicitly before parsing. + * Thunderbird's importer accepts these files, including the Latin-1 fixture in + * calendar/test/unit/data/importLatin1.ics. + */ +export const decodeICSBytes = (bytes: ArrayBuffer | Uint8Array) => { + const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + for (const encoding of ["utf-8", "windows-1252", "iso-8859-1"]) { + try { + return new TextDecoder(encoding, {fatal: true}).decode(input); + } catch (_) { + // Try the next compatible encoding. + } + } + return new TextDecoder("utf-8").decode(input); +}; + +const buildImportEvent = (properties: IICSProperty[]): ICalendarICSImportEvent | undefined => { + if (firstProperty(properties, "STATUS")?.value.trim().toUpperCase() === "CANCELLED") { + return undefined; + } + const start = parseICSDate(firstProperty(properties, "DTSTART")); + if (!start) { + return undefined; + } + const endProperty = parseICSDate(firstProperty(properties, "DTEND")); + let end = endProperty?.date; + if (!end) { + const duration = parseDurationMilliseconds(firstProperty(properties, "DURATION")?.value); + end = duration ? new Date(start.date.getTime() + duration) : + (start.isAllDay ? new Date(start.date.getFullYear(), start.date.getMonth(), start.date.getDate() + 1) : new Date(start.date.getTime() + 60 * 60 * 1000)); + } + if (start.isAllDay) { + const exclusiveEnd = end.getTime() > start.date.getTime() ? end : new Date(start.date.getFullYear(), start.date.getMonth(), start.date.getDate() + 1); + end = new Date(exclusiveEnd.getFullYear(), exclusiveEnd.getMonth(), exclusiveEnd.getDate() - 1); + } else if (end.getTime() <= start.date.getTime()) { + end = new Date(start.date.getTime() + 60 * 60 * 1000); + } + const exceptionDates = allProperties(properties, "EXDATE").flatMap(property => property.value.split(",").map(value => + parseICSDate({...property, value})?.date + )).filter((value): value is Date => Boolean(value)).map(formatDate); + const summary = unescapeICSText(firstProperty(properties, "SUMMARY")?.value || "").trim(); + const uid = unescapeICSText(firstProperty(properties, "UID")?.value || "").trim(); + return { + uid, + draft: { + title: summary, + date: formatDate(start.date), + endDate: formatDate(end), + isAllDay: start.isAllDay, + startTime: formatTime(start.date), + endTime: formatTime(end), + recurrenceRaw: normalizeRecurrenceRule(firstProperty(properties, "RRULE")?.value), + recurrenceExceptionRaw: Array.from(new Set(exceptionDates)).join(","), + location: unescapeICSText(firstProperty(properties, "LOCATION")?.value || ""), + description: unescapeICSText(firstProperty(properties, "DESCRIPTION")?.value || ""), + }, + }; +}; + +export const parseICSCalendar = (source: string): ICalendarICSImportEvent[] => { + const events: ICalendarICSImportEvent[] = []; + let current: IICSProperty[] | undefined; + unfoldICSLines(source).forEach(line => { + const upper = line.trim().toUpperCase(); + if (upper === "BEGIN:VEVENT") { + current = []; + return; + } + if (upper === "END:VEVENT") { + if (current) { + const event = buildImportEvent(current); + if (event) { + events.push(event); + } + } + current = undefined; + return; + } + if (current) { + const property = parseProperty(line); + if (property) { + current.push(property); + } + } + }); + const collator = new Intl.Collator(undefined, {numeric: true}); + return events.sort((a, b) => { + const aStart = `${a.draft.date}T${a.draft.startTime || "00:00"}`; + const bStart = `${b.draft.date}T${b.draft.startTime || "00:00"}`; + return aStart.localeCompare(bStart) || collator.compare(a.draft.title, b.draft.title); + }); +}; diff --git a/app/src/protyle/render/av/calendar/interactions.ts b/app/src/protyle/render/av/calendar/interactions.ts new file mode 100644 index 00000000000..0a59939fc21 --- /dev/null +++ b/app/src/protyle/render/av/calendar/interactions.ts @@ -0,0 +1,780 @@ +import {ICalendarEventDraft, ICalendarNormalizedEvent} from "./model"; +import {buildCalendarGhost, updateCalendarGhost} from "./event-chip"; +import { + buildAllDayResizeEndTransform, + buildAllDayResizeStartTransform, + buildAllDaySweepCreateDraft, + buildMoveToDateTimeTransform, + buildMoveToDateTransform, + buildResizeEndTransform, + buildResizeStartTransform, + buildSweepCreateDraft, + describeCalendarRange, + getEventGridRange, + ICalendarDraftTransform, + ICalendarGridPoint, + ICalendarGridRange, + resolveCalendarGeometry, +} from "./drafts"; +import { + clampToDayMinutes, + getCalendarTimeGeometry, + ICalendarTimeGeometry, + minuteToOffsetPx, + offsetPxToMinute, + parseClockMinutes, +} from "./time-geometry"; +import {CALENDAR_RESIZE_HANDLE_TYPE, CALENDAR_TIME_DAY_CLASS, CALENDAR_TIME_GRID_CLASS} from "./time-grid"; + +/** + * The pointer state machine for direct manipulation on the time grid. + * + * Three gestures, all on Pointer Events with pointer capture so a fast drag that + * leaves the grid (or the window) does not lose the pointer: + * (a) sweep on empty grid -> ghost block growing in snap steps -> quick create + * (b) drag a chip -> previews on that chip, across days and times + * (c) grab a chip edge -> previews that end on the existing chip + * + * Three things this module deliberately does NOT do: + * - It never writes. It emits a result and render.ts routes it through + * applyScopedEventDraft, so the recurrence-scope prompt, the pending state + * and the rollback on failure all keep working untouched. + * - It never handles clicks. A press that stays inside the movement threshold + * is left completely alone: no capture is taken, no default is prevented, so + * the existing click handlers (open page / open dialog / open quick create + * on an empty slot) fire exactly as they do today. + * - It does nothing at all on a read-only or query-embed calendar. + */ + +/** Pointer travel, in CSS pixels, before a press stops being a click. */ +const DRAG_THRESHOLD_PX = 4; + +/** + * Touch input needs the same drift allowance as the chip long-press menu. A + * smaller threshold turns a normal finger tap into an accidental move before + * the click handler can open the scheduling dialog. + */ +export const TOUCH_DRAG_THRESHOLD_PX = 8; + +/** Where a chip edge stops being an edge, when the chip has no handle element. */ +const EDGE_FALLBACK_PX = 6; + +export interface ICalendarGridAdapter { + /** + * Snap/limit constants. The Grid agent's time-geometry record satisfies this + * structurally; anything missing falls back to the shared defaults. + */ + geometry?: Partial; + /** + * Which day column and minute-of-day sits under a viewport point, or null + * when the point is not over the timed grid. All pixel math lives here, in + * the grid's own module - this state machine only speaks (date, minute). + */ + resolvePoint(clientX: number, clientY: number): ICalendarGridPoint | null; + /** Positioned element the ghost is appended to, per lane. */ + getGhostLayer(lane: CalendarGhostLane): HTMLElement | null; + /** Ghost placement for a range, in getGhostLayer("timed")-relative pixels. */ + getGhostRect(range: ICalendarGridRange): ICalendarGhostRect | null; + /** Optional: which date sits under a point on the all-day rail. */ + resolveAllDayDate?(clientX: number, clientY: number): string | null; + /** Optional: ghost placement for an all-day run, relative to its own lane. */ + getAllDayGhostRect?(startDate: string, endDate: string): ICalendarGhostRect | null; +} + +export type CalendarGhostLane = "timed" | "all-day"; + +export interface ICalendarGhostRect { + top: number; + left: number; + width: number; + height: number; +} + +export type CalendarGestureKind = "sweep" | "move" | "resize-start" | "resize-end"; + +export type ICalendarInteractionResult = + | { + type: "create"; + /** Prefilled with the swept range; hand straight to openQuickCreate. */ + draft: ICalendarEventDraft; + /** + * The day column the sweep started in - pass it as openQuickCreate's + * `target`, exactly like the old time-slot button was passed. + */ + anchorElement: HTMLElement; + /** + * Vertical offset of the swept block inside the timed ghost layer, for + * openQuickCreate's `top` (--calendar-quick-create-top). + */ + top: number; + range: ICalendarGridRange | null; + } + | { + type: "move" | "resize"; + /** Pass as the `action` argument of applyScopedEventDraft. */ + event: ICalendarNormalizedEvent; + /** The chip that was grabbed; use it as the pending/rollback element. */ + eventElement: HTMLElement; + /** Feed straight into applyScopedEventDraft's buildDraft parameter. */ + buildDraft: ICalendarDraftTransform; + edge?: "start" | "end"; + }; + +export interface ICalendarInteractionOptions { + calendarElement: HTMLElement; + /** false on read-only / query-embed calendars: nothing is bound at all. */ + editable: boolean; + adapter: ICalendarGridAdapter; + /** Chip element -> the normalized event render.ts has in memory. */ + resolveEvent(element: HTMLElement): ICalendarNormalizedEvent | undefined; + /** Called once per completed gesture. Never called for a plain click. */ + onResult(result: ICalendarInteractionResult): void; +} + +interface IActiveGesture { + kind: CalendarGestureKind; + pointerId: number; + captureElement: HTMLElement; + startClientX: number; + startClientY: number; + started: boolean; + aborted: boolean; + /** Sweep only. */ + anchorPoint: ICalendarGridPoint | null; + anchorAllDayDate: string | null; + /** Move/resize only. */ + event: ICalendarNormalizedEvent | null; + eventElement: HTMLElement | null; + /** Where inside the chip the pointer grabbed it, in minutes. */ + grabOffsetMinutes: number; + lastPoint: ICalendarGridPoint | null; + lastAllDayDate: string | null; + ghost: HTMLElement | null; + liveRegion: HTMLElement | null; + restoreDraggable: HTMLElement | null; + previewElement: HTMLElement | null; + previewStyle: string | null; + options: ICalendarInteractionOptions; + cleanup: () => void; +} + +let activeGesture: IActiveGesture | null = null; + +/** + * Abort whatever gesture is in flight without writing anything. + * + * Exported because the context menu has to win over a drag: a long press, or a + * right button pressed mid-drag, must open the menu on a calendar that is not + * silently mid-move. + */ +export const abortActiveCalendarGesture = () => { + if (!activeGesture) { + return; + } + activeGesture.aborted = true; + finishGesture(activeGesture, false); +}; + +/** True while a gesture has passed the movement threshold. */ +export const isCalendarGestureActive = () => !!activeGesture && activeGesture.started; + +const removeGhost = (gesture: IActiveGesture) => { + gesture.ghost?.remove(); + gesture.ghost = null; + gesture.liveRegion?.remove(); + gesture.liveRegion = null; +}; + +const restoreEventPreview = (gesture: IActiveGesture) => { + if (!gesture.previewElement) { + return; + } + if (gesture.previewStyle === null) { + gesture.previewElement.removeAttribute("style"); + } else { + gesture.previewElement.setAttribute("style", gesture.previewStyle); + } + gesture.previewElement = null; + gesture.previewStyle = null; +}; + +const placeTimedEventPreview = (gesture: IActiveGesture, rect: ICalendarGhostRect | null) => { + const event = gesture.event; + const eventElement = gesture.eventElement; + if (!event || !eventElement || !rect) { + return; + } + const previewElement = eventElement.closest(".av__calendar-timed-event") as HTMLElement; + const sourceRect = gesture.options.adapter.getGhostRect(getEventGridRange(event)); + if (!previewElement || !sourceRect) { + return; + } + if (!gesture.previewElement) { + gesture.previewElement = previewElement; + gesture.previewStyle = previewElement.getAttribute("style"); + } + if (gesture.kind === "move") { + previewElement.style.transform = `translate(${rect.left - sourceRect.left}px, ${rect.top - sourceRect.top}px)`; + return; + } + const originalTop = parseFloat(/(?:^|;)\s*top:\s*(-?\d+(?:\.\d+)?)px/.exec(gesture.previewStyle || "")?.[1] || "0"); + previewElement.style.top = `${originalTop + rect.top - sourceRect.top}px`; + previewElement.style.height = `${rect.height}px`; +}; + +const ensureLiveRegion = (gesture: IActiveGesture) => { + if (gesture.liveRegion) { + return gesture.liveRegion; + } + const region = document.createElement("div"); + region.className = "av__calendar-gesture-live"; + region.setAttribute("aria-live", "polite"); + gesture.options.calendarElement.appendChild(region); + gesture.liveRegion = region; + return region; +}; + +const setReadout = (gesture: IActiveGesture, text: string) => { + ensureLiveRegion(gesture).textContent = text; +}; + +const placeGhost = (gesture: IActiveGesture, lane: CalendarGhostLane, rect: ICalendarGhostRect | null, label: string, title?: string) => { + const layer = gesture.options.adapter.getGhostLayer(lane); + if (!layer) { + return; + } + if (!gesture.ghost) { + gesture.ghost = buildCalendarGhost(label, title); + gesture.ghost.dataset.gesture = gesture.kind; + gesture.ghost.dataset.lane = lane; + // Positioned inline so the ghost works even before the grid's stylesheet + // grows a rule for it; the layer itself must be position:relative. + gesture.ghost.style.position = "absolute"; + layer.appendChild(gesture.ghost); + } else { + updateCalendarGhost(gesture.ghost, label, title); + } + if (rect) { + gesture.ghost.style.top = `${rect.top}px`; + gesture.ghost.style.left = `${rect.left}px`; + gesture.ghost.style.width = `${rect.width}px`; + gesture.ghost.style.height = `${rect.height}px`; + } +}; + +/** + * Suppress the one click the browser fires after a real drag, so releasing a + * moved chip does not also open its page. + */ +const swallowNextClick = () => { + const swallow = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + window.addEventListener("click", swallow, true); + window.setTimeout(() => window.removeEventListener("click", swallow, true), 0); +}; + +const finishGesture = (gesture: IActiveGesture, commit: boolean) => { + gesture.cleanup(); + removeGhost(gesture); + restoreEventPreview(gesture); + gesture.eventElement?.classList.remove("av__calendar-event--dragging"); + gesture.options.calendarElement.classList.remove("av__calendar--gesturing"); + if (gesture.restoreDraggable) { + gesture.restoreDraggable.setAttribute("draggable", "true"); + gesture.restoreDraggable = null; + } + try { + if (gesture.captureElement.hasPointerCapture?.(gesture.pointerId)) { + gesture.captureElement.releasePointerCapture(gesture.pointerId); + } + } catch { + // Releasing a capture the browser already dropped is not an error here. + } + if (activeGesture === gesture) { + activeGesture = null; + } + if (gesture.started) { + swallowNextClick(); + } + if (!commit || gesture.aborted || !gesture.started) { + return; + } + emitResult(gesture); +}; + +const getAnchorElement = (element: HTMLElement, calendarElement: HTMLElement): HTMLElement => { + const column = element.closest(".av__calendar-time-day, .av__calendar-week-day, .av__calendar-day-view, .av__calendar-day, .av__calendar-list-day") as HTMLElement; + return column || element || calendarElement; +}; + +const emitResult = (gesture: IActiveGesture) => { + const {options} = gesture; + const geometry = resolveCalendarGeometry(options.adapter.geometry); + if (gesture.kind === "sweep") { + const draft = gesture.anchorAllDayDate && gesture.lastAllDayDate ? + buildAllDaySweepCreateDraft(gesture.anchorAllDayDate, gesture.lastAllDayDate) : + (gesture.anchorPoint && gesture.lastPoint ? buildSweepCreateDraft(gesture.anchorPoint, gesture.lastPoint, geometry) : null); + if (!draft) { + return; + } + const anchorElement = getAnchorElement(gesture.captureElement, options.calendarElement); + const range = gesture.anchorPoint && gesture.lastPoint ? normalizeRange(gesture.anchorPoint, gesture.lastPoint) : null; + const rect = range ? options.adapter.getGhostRect(range) : null; + options.onResult({ + type: "create", + draft, + anchorElement, + top: rect ? rect.top : 0, + range, + }); + return; + } + const sourceEvent = gesture.event; + const eventElement = gesture.eventElement; + if (!sourceEvent || !eventElement) { + return; + } + const buildDraft = buildTransformForGesture(gesture, geometry); + if (!buildDraft) { + return; + } + options.onResult({ + type: gesture.kind === "move" ? "move" : "resize", + event: sourceEvent, + eventElement, + buildDraft, + edge: gesture.kind === "resize-start" ? "start" : gesture.kind === "resize-end" ? "end" : undefined, + }); +}; + +const buildTransformForGesture = (gesture: IActiveGesture, geometry: ICalendarTimeGeometry): ICalendarDraftTransform | null => { + const sourceEvent = gesture.event; + if (!sourceEvent) { + return null; + } + // All-day gestures live on the rail and only ever speak in whole dates. + if (gesture.lastAllDayDate) { + if (gesture.kind === "move") { + return buildMoveToDateTransform(sourceEvent, gesture.lastAllDayDate, { + displayDate: gesture.eventElement?.dataset.date, + }); + } + if (gesture.kind === "resize-start") { + return buildAllDayResizeStartTransform(sourceEvent, gesture.lastAllDayDate); + } + return buildAllDayResizeEndTransform(sourceEvent, gesture.lastAllDayDate); + } + const point = gesture.lastPoint; + if (!point) { + return null; + } + if (gesture.kind === "move") { + // The pointer holds the chip where it was grabbed, not by its top edge. + const grabbed: ICalendarGridPoint = {date: point.date, minute: point.minute - gesture.grabOffsetMinutes}; + return buildMoveToDateTimeTransform(sourceEvent, grabbed, geometry); + } + if (gesture.kind === "resize-start") { + return buildResizeStartTransform(sourceEvent, point, geometry); + } + return buildResizeEndTransform(sourceEvent, point, geometry); +}; + +const normalizeRange = (anchor: ICalendarGridPoint, current: ICalendarGridPoint): ICalendarGridRange => { + const anchorKey = `${anchor.date}T${String(anchor.minute).padStart(4, "0")}`; + const currentKey = `${current.date}T${String(current.minute).padStart(4, "0")}`; + return currentKey < anchorKey ? {start: current, end: anchor} : {start: anchor, end: current}; +}; + +const getGestureRange = (gesture: IActiveGesture, geometry: ICalendarTimeGeometry): ICalendarGridRange | null => { + if (gesture.kind === "sweep") { + return gesture.anchorPoint && gesture.lastPoint ? normalizeRange(gesture.anchorPoint, gesture.lastPoint) : null; + } + const sourceEvent = gesture.event; + const point = gesture.lastPoint; + if (!sourceEvent || !point) { + return null; + } + const transform = buildTransformForGesture(gesture, geometry); + if (!transform) { + return getEventGridRange(sourceEvent); + } + const draft = transform(sourceEvent); + return { + start: {date: draft.date, minute: parseClockMinutes(draft.startTime)}, + end: {date: draft.endDate || draft.date, minute: parseClockMinutes(draft.endTime)}, + }; +}; + +const updateGesture = (gesture: IActiveGesture, clientX: number, clientY: number) => { + const {adapter} = gesture.options; + const geometry = resolveCalendarGeometry(adapter.geometry); + const anchorAllDayDate = gesture.anchorAllDayDate; + const allDayDate = anchorAllDayDate ? (adapter.resolveAllDayDate?.(clientX, clientY) || gesture.lastAllDayDate) : null; + if (anchorAllDayDate && allDayDate) { + gesture.lastAllDayDate = allDayDate; + const sweepsForward = anchorAllDayDate < allDayDate; + const startDate = gesture.kind === "sweep" ? (sweepsForward ? anchorAllDayDate : allDayDate) : allDayDate; + const endDate = gesture.kind === "sweep" ? (sweepsForward ? allDayDate : anchorAllDayDate) : allDayDate; + const rect = adapter.getAllDayGhostRect?.(startDate, endDate) || null; + const label = startDate === endDate ? startDate : `${startDate} – ${endDate}`; + placeGhost(gesture, "all-day", rect, label, gesture.event?.title); + setReadout(gesture, label); + return; + } + const point = adapter.resolvePoint(clientX, clientY); + if (point) { + gesture.lastPoint = point; + } + const range = getGestureRange(gesture, geometry); + if (!range) { + return; + } + const label = describeCalendarRange(range, geometry); + const rect = adapter.getGhostRect(range); + if (gesture.kind === "sweep") { + placeGhost(gesture, "timed", rect, label); + } else { + placeTimedEventPreview(gesture, rect); + } + setReadout(gesture, label); +}; + +const startGesture = (gesture: IActiveGesture) => { + gesture.started = true; + gesture.options.calendarElement.classList.add("av__calendar--gesturing"); + try { + gesture.captureElement.setPointerCapture(gesture.pointerId); + } catch { + // Safari can refuse a capture for a pointer that already ended; the + // document-level move/up listeners still drive the gesture. + } + if (gesture.eventElement) { + gesture.eventElement.classList.add("av__calendar-event--dragging"); + // The chip is still draggable="true" for the legacy HTML5 drop path; + // turn that off for the duration so the browser does not start a native + // drag on top of the pointer gesture. Restored in finishGesture. + if (gesture.eventElement.getAttribute("draggable") === "true") { + gesture.eventElement.setAttribute("draggable", "false"); + gesture.restoreDraggable = gesture.eventElement; + } + } +}; + +const isInertTarget = (element: HTMLElement) => + !!element.closest('[data-type="calendar-open-source"], [data-type="calendar-open-dialog"], .av__calendar-quick-create, .av__calendar-menu, .av__calendar-toolbar, .av__calendar-event--pending, [data-calendar-operation="pending"]'); + +/** + * Bind the three gestures. Returns a disposer; render.ts replaces its own HTML + * on every rerender, so the listeners it puts on the (discarded) calendar + * element go away with it, but the document-level ones must not leak. + */ +export const bindCalendarPointerInteractions = (options: ICalendarInteractionOptions): (() => void) => { + const {calendarElement, adapter} = options; + if (!calendarElement || !options.editable) { + // Read-only and query-embed calendars get no gesture surface at all. + return () => undefined; + } + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || activeGesture) { + return; + } + const target = event.target as HTMLElement; + if (!target || isInertTarget(target)) { + return; + } + const handle = target.closest(`[data-type="${CALENDAR_RESIZE_HANDLE_TYPE}"]`) as HTMLElement; + // The grid renders the edge handles as SIBLINGS of the chip inside the + // positioned wrapper, so a press on a handle has to walk out to the + // wrapper to find the chip it belongs to. + const chip = (handle ? + handle.closest(".av__calendar-timed-event, .av__calendar-allday-bar")?.querySelector(".av__calendar-event") : + target.closest(".av__calendar-event")) as HTMLElement; + // The all-day rail is its own lane above the columns; anything grabbed + // there speaks in whole dates, never in minutes. + const allDayRail = target.closest(".av__calendar-allday-row, .av__calendar-allday-lanes, .av__calendar-all-day") as HTMLElement; + const anchorAllDayDate = allDayRail && adapter.resolveAllDayDate ? + adapter.resolveAllDayDate(event.clientX, event.clientY) : null; + const anchorPoint = anchorAllDayDate ? null : adapter.resolvePoint(event.clientX, event.clientY); + if (!anchorPoint && !anchorAllDayDate) { + return; + } + if (handle && !chip) { + // An orphaned handle must never fall through to "sweep a new event". + return; + } + let kind: CalendarGestureKind = "sweep"; + let sourceEvent: ICalendarNormalizedEvent | undefined; + let grabOffsetMinutes = 0; + if (chip) { + sourceEvent = options.resolveEvent(chip); + if (!sourceEvent) { + return; + } + const edge = handle?.dataset.edge || detectFallbackEdge(chip, event); + kind = edge === "start" ? "resize-start" : edge === "end" ? "resize-end" : "move"; + if (kind === "move" && anchorPoint) { + const eventRange = getEventGridRange(sourceEvent); + grabOffsetMinutes = anchorPoint.date === eventRange.start.date ? + anchorPoint.minute - eventRange.start.minute : 0; + } + } + + const documentListeners: Array<[string, EventListener]> = []; + const addDocumentListener = (type: string, listener: EventListener, capture = false) => { + document.addEventListener(type, listener, capture); + documentListeners.push([type, listener]); + }; + + const gesture: IActiveGesture = { + kind, + pointerId: event.pointerId, + captureElement: (chip || target) as HTMLElement, + startClientX: event.clientX, + startClientY: event.clientY, + started: false, + aborted: false, + anchorPoint, + anchorAllDayDate, + event: sourceEvent || null, + eventElement: chip || null, + grabOffsetMinutes, + lastPoint: anchorPoint, + lastAllDayDate: anchorAllDayDate, + ghost: null, + liveRegion: null, + restoreDraggable: null, + previewElement: null, + previewStyle: null, + options, + cleanup: () => { + documentListeners.forEach(([type, listener]) => { + document.removeEventListener(type, listener, true); + document.removeEventListener(type, listener, false); + }); + window.removeEventListener("blur", onWindowBlur); + }, + }; + + const onMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== gesture.pointerId) { + return; + } + if (!gesture.started) { + const travelled = Math.hypot(moveEvent.clientX - gesture.startClientX, moveEvent.clientY - gesture.startClientY); + const threshold = moveEvent.pointerType === "touch" ? TOUCH_DRAG_THRESHOLD_PX : DRAG_THRESHOLD_PX; + if (travelled < threshold) { + return; + } + startGesture(gesture); + } + // Past the threshold the grid owns the pointer: no text selection, + // no scroll chaining, no native drag image. + moveEvent.preventDefault(); + updateGesture(gesture, moveEvent.clientX, moveEvent.clientY); + }; + const onUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== gesture.pointerId) { + return; + } + finishGesture(gesture, true); + }; + const onCancel = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== gesture.pointerId) { + return; + } + gesture.aborted = true; + finishGesture(gesture, false); + }; + const onKeyDown = (keyEvent: KeyboardEvent) => { + if (keyEvent.key !== "Escape") { + return; + } + // Escape mid-gesture throws the gesture away without a write, and + // must not also reach the calendar's own Escape (clear search). + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + gesture.aborted = true; + finishGesture(gesture, false); + }; + const onDragStart = (dragEvent: Event) => { + if (gesture.started) { + dragEvent.preventDefault(); + } + }; + const onContextMenu = () => { + gesture.aborted = true; + finishGesture(gesture, false); + }; + function onWindowBlur() { + gesture.aborted = true; + finishGesture(gesture, false); + } + + addDocumentListener("pointermove", onMove as EventListener); + addDocumentListener("pointerup", onUp as EventListener); + addDocumentListener("pointercancel", onCancel as EventListener); + addDocumentListener("keydown", onKeyDown as EventListener, true); + addDocumentListener("dragstart", onDragStart, true); + addDocumentListener("contextmenu", onContextMenu as EventListener, true); + window.addEventListener("blur", onWindowBlur); + activeGesture = gesture; + }; + + calendarElement.addEventListener("pointerdown", onPointerDown); + return () => { + calendarElement.removeEventListener("pointerdown", onPointerDown); + if (activeGesture && activeGesture.options.calendarElement === calendarElement) { + abortActiveCalendarGesture(); + } + }; +}; + +/** + * Which end of a chip a press landed on when the chip has no handle element + * (month and list chips do not get handles, so this returns "" for them). + */ +const detectFallbackEdge = (chip: HTMLElement, event: PointerEvent): "" | "start" | "end" => { + const variant = chip.dataset.variant; + if (variant !== "timed" && variant !== "all-day") { + return ""; + } + const rect = chip.getBoundingClientRect(); + if (variant === "all-day") { + if (event.clientX - rect.left <= EDGE_FALLBACK_PX) { + return "start"; + } + return rect.right - event.clientX <= EDGE_FALLBACK_PX ? "end" : ""; + } + if (event.clientY - rect.top <= EDGE_FALLBACK_PX) { + return "start"; + } + return rect.bottom - event.clientY <= EDGE_FALLBACK_PX ? "end" : ""; +}; + +/** + * A hit-testing adapter for the grid time-grid.ts actually renders. + * + * It reads only the DOM contract that module exports as public + * (.av__calendar-time-columns / .av__calendar-time-day[data-date] / + * .av__calendar-allday-lanes / .av__calendar-allday-cell[data-date]), so the + * pixel math stays on the grid side of the fence and the state machine above + * keeps speaking nothing but (date, minute). Replace it wholesale if the grid + * ever grows its own hit-tester - `ICalendarGridAdapter` is the only contract + * the state machine cares about. + * + * resolvePoint is deliberately strict: it answers null for anything outside the + * columns, so a press on the toolbar or the header can never start a sweep. A + * drag that leaves the grid keeps the last point that WAS inside, which is what + * makes dragging past the bottom of the last column feel like it holds. + */ +export const createCalendarGridAdapter = ( + timeGridElement: HTMLElement | null, + geometry?: Partial, +): ICalendarGridAdapter => { + const grid = timeGridElement?.classList.contains(CALENDAR_TIME_GRID_CLASS) ? + timeGridElement : + (timeGridElement?.querySelector(`.${CALENDAR_TIME_GRID_CLASS}`) as HTMLElement) || timeGridElement; + const resolved = getCalendarTimeGeometry(geometry?.dayCount, geometry || {}); + const columnsLayer = () => grid?.querySelector(".av__calendar-time-columns") as HTMLElement; + const allDayLayer = () => grid?.querySelector(".av__calendar-allday-lanes") as HTMLElement; + const columnFor = (date: string) => grid?.querySelector(`.${CALENDAR_TIME_DAY_CLASS}[data-date="${date}"]`) as HTMLElement; + const allDayCellFor = (date: string) => grid?.querySelector(`.av__calendar-allday-cell[data-date="${date}"]`) as HTMLElement; + + const relativeRect = (element: HTMLElement, layer: HTMLElement) => { + const elementRect = element.getBoundingClientRect(); + const layerRect = layer.getBoundingClientRect(); + return { + left: elementRect.left - layerRect.left, + top: elementRect.top - layerRect.top, + width: elementRect.width, + height: elementRect.height, + }; + }; + + return { + geometry: resolved, + resolvePoint(clientX: number, clientY: number) { + const layer = columnsLayer(); + if (!layer) { + return null; + } + const layerRect = layer.getBoundingClientRect(); + if (clientY < layerRect.top || clientY > layerRect.bottom || clientX < layerRect.left || clientX > layerRect.right) { + return null; + } + const columns = Array.from(layer.querySelectorAll(`.${CALENDAR_TIME_DAY_CLASS}`)) as HTMLElement[]; + if (columns.length === 0) { + return null; + } + const column = columns.find(item => { + const rect = item.getBoundingClientRect(); + return clientX >= rect.left && clientX <= rect.right; + }) || columns[columns.length - 1]; + const date = column.dataset.date; + if (!date) { + return null; + } + const columnRect = column.getBoundingClientRect(); + const minute = offsetPxToMinute(clientY - columnRect.top, resolved); + return {date, minute: clampToDayMinutes(minute, resolved)}; + }, + resolveAllDayDate(clientX: number, clientY: number) { + const layer = allDayLayer(); + if (!layer) { + return null; + } + const cells = Array.from(layer.querySelectorAll(".av__calendar-allday-cell")) as HTMLElement[]; + if (cells.length === 0) { + return null; + } + const layerRect = layer.getBoundingClientRect(); + if (clientX < layerRect.left || clientX > layerRect.right) { + return null; + } + const cell = cells.find(item => { + const rect = item.getBoundingClientRect(); + return clientX >= rect.left && clientX <= rect.right; + }) || cells[cells.length - 1]; + // The vertical test is intentionally loose: the rail is only a couple + // of lanes tall, and a bar being dragged sideways must not fall out of + // it because the pointer drifted a few pixels. + return cell.dataset.date || null; + }, + getGhostLayer(lane: CalendarGhostLane) { + return lane === "all-day" ? allDayLayer() : columnsLayer(); + }, + getGhostRect(range: ICalendarGridRange) { + const layer = columnsLayer(); + const column = columnFor(range.start.date); + if (!layer || !column) { + return null; + } + const box = relativeRect(column, layer); + const startMinute = clampToDayMinutes(range.start.minute, resolved); + const sameDay = range.end.date === range.start.date; + const endMinute = clampToDayMinutes(sameDay ? range.end.minute : resolved.dayEndMinute, resolved); + const top = minuteToOffsetPx(startMinute, resolved); + const height = Math.max(minuteToOffsetPx(endMinute, resolved) - top, minuteToOffsetPx(resolved.minimumEventMinutes, resolved)); + return {top: box.top + top, left: box.left, width: box.width, height}; + }, + getAllDayGhostRect(startDate: string, endDate: string) { + const layer = allDayLayer(); + const startCell = allDayCellFor(startDate); + const endCell = allDayCellFor(endDate) || startCell; + if (!layer || !startCell) { + return null; + } + const startBox = relativeRect(startCell, layer); + const endBox = relativeRect(endCell, layer); + return { + top: startBox.top, + left: Math.min(startBox.left, endBox.left), + width: Math.abs(endBox.left - startBox.left) + endBox.width, + height: startBox.height, + }; + }, + }; +}; diff --git a/app/src/protyle/render/av/calendar/keymap.ts b/app/src/protyle/render/av/calendar/keymap.ts new file mode 100644 index 00000000000..acdad51c9d6 --- /dev/null +++ b/app/src/protyle/render/av/calendar/keymap.ts @@ -0,0 +1,261 @@ + + +/** + * The calendar key map. + * + * Two halves that must not be confused: + * - `resolveCalendarCommand` is PURE. It takes a plain `{key, ctrlKey, ...}` + * record (a KeyboardEvent satisfies it structurally) and returns a command + * name. No DOM, no window, no side effects - so it is unit-testable from a + * bare node script. + * - `bindCalendarKeymap` is the only part that touches the DOM. It owns the + * focus scope, `preventDefault`, and the dispatch into the renderer's + * handlers. + * + * Focus scope: the old renderer bailed whenever the keydown target was + * INPUT|SELECT|TEXTAREA|BUTTON. Every interactive element in the calendar is a + * button, so the shortcuts died the moment anything was clicked. The real rule + * is `shouldIgnoreCalendarKey`: bail only while the user is typing into a text + * field (input / textarea / contenteditable), while a listbox has focus (a + * + +
+
+ + + +
`; + target.appendChild(panel); + const summaryElement = panel.querySelector('[data-type="calendar-quick-create-summary"]') as HTMLElement; + const titleInput = panel.querySelector('[data-type="calendar-quick-create-title"]') as HTMLInputElement; + const allDayInput = panel.querySelector('[data-type="calendar-quick-create-all-day"]') as HTMLInputElement; + const errorElement = panel.querySelector('[data-type="calendar-quick-create-error"]') as HTMLElement; + const saveButton = panel.querySelector('[data-type="calendar-quick-create-save"]') as HTMLButtonElement; + const cancelButton = panel.querySelector('[data-type="calendar-quick-create-cancel"]') as HTMLButtonElement; + const moreButton = panel.querySelector('[data-type="calendar-quick-create-more"]') as HTMLButtonElement; + let pending = false; + const setPending = (value: boolean) => { + pending = value; + saveButton.disabled = value; + cancelButton.disabled = value; + moreButton.disabled = value; + panel.classList.toggle("is--saving", value); + if (value) { + panel.setAttribute("aria-busy", "true"); + saveButton.setAttribute("aria-busy", "true"); + } else { + panel.removeAttribute("aria-busy"); + saveButton.removeAttribute("aria-busy"); + } + }; + const close = () => { + if (pending) { + return; + } + panel.remove(); + options.onCancel(); + }; + const getDraft = () => ({...draft, title: titleInput.value.trim(), isAllDay: allDayInput.checked}); + const save = async () => { + if (pending || saveButton.disabled) { + return; + } + const nextDraft = getDraft(); + if (!nextDraft.title) { + errorElement.textContent = `${window.siyuan.languages.title || "Title"} ${window.siyuan.languages.invalid || "Invalid"}`; + titleInput.focus(); + return; + } + setPending(true); + try { + await options.onSave(nextDraft); + panel.remove(); + } catch (error) { + setPending(false); + errorElement.textContent = error instanceof Error ? error.message : (window.siyuan.languages._kernel?.[29] || "Save failed"); + showMessage(errorElement.textContent); + } + }; + panel.querySelector('[data-type="calendar-quick-create-cancel"]')?.addEventListener("click", close); + moreButton.addEventListener("click", () => { + if (pending || moreButton.disabled) { + return; + } + panel.remove(); + options.onMoreOptions(getDraft()); + }); + allDayInput.addEventListener("change", () => { + summaryElement.textContent = getDateTimeSummary(getDraft()); + }); + saveButton.addEventListener("click", () => save()); + titleInput.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + save(); + } else if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }); + titleInput.focus(); + titleInput.select(); + // Global click/focus handlers (and bare X servers) can asynchronously drop + // focus back to right after the popover opens; reclaim it briefly so + // typing always lands in the title input, without fighting a deliberate + // focus move to another control. + const focusGuardDeadline = Date.now() + 250; + const reclaimFocus = () => { + if (!panel.isConnected || Date.now() > focusGuardDeadline) { + return; + } + if (document.activeElement === document.body || document.activeElement === null) { + titleInput.focus(); + if (titleInput.value === (draft.title || "")) { + titleInput.select(); + } + } + window.requestAnimationFrame(reclaimFocus); + }; + window.requestAnimationFrame(reclaimFocus); + return panel; +}; diff --git a/app/src/protyle/render/av/calendar/recurrence-storage.ts b/app/src/protyle/render/av/calendar/recurrence-storage.ts new file mode 100644 index 00000000000..eb4eb776406 --- /dev/null +++ b/app/src/protyle/render/av/calendar/recurrence-storage.ts @@ -0,0 +1,75 @@ +import {Constants} from "../../../../constants"; +import {fetchSyncPost} from "../../../../util/fetch"; +import {ICalendarFieldMapping} from "./model"; + +export const ensureCalendarRecurrenceStorage = async (options: { + protyle: IProtyle; + calendarData: IAVCalendar; + mapping: ICalendarFieldMapping; + avID: string; + blockID: string; + viewID: string; + storageRequired?: boolean; +}) => { + if (!options.storageRequired || (options.mapping.recurrenceFieldID && options.mapping.exceptionFieldID)) { + return options.mapping; + } + const recurrenceFieldID = options.mapping.recurrenceFieldID || Lute.NewNodeID(); + const exceptionFieldID = options.mapping.exceptionFieldID || Lute.NewNodeID(); + const previousID = options.calendarData.fields.at(-1)?.id || ""; + const doOperations: IOperation[] = []; + if (!options.mapping.recurrenceFieldID) { + doOperations.push({ + action: "addAttrViewCol", avID: options.avID, id: recurrenceFieldID, previousID, + name: "__calendar_recurrence", type: "text", + }, { + action: "setAttrViewColHidden", avID: options.avID, blockID: options.blockID, viewID: options.viewID, id: recurrenceFieldID, data: true, + }); + } + if (!options.mapping.exceptionFieldID) { + doOperations.push({ + action: "addAttrViewCol", avID: options.avID, id: exceptionFieldID, previousID: recurrenceFieldID, + name: "__calendar_recurrence_exceptions", type: "text", + }, { + action: "setAttrViewColHidden", avID: options.avID, blockID: options.blockID, viewID: options.viewID, id: exceptionFieldID, data: true, + }); + } + doOperations.push({ + action: "setAttrViewCalendarFieldMapping", avID: options.avID, blockID: options.blockID, viewID: options.viewID, + data: {recurrenceFieldID, exceptionFieldID}, + }); + const response = await fetchSyncPost("/api/transactions", { + app: Constants.SIYUAN_APPID, + session: options.protyle?.id || Constants.SIYUAN_APPID, + reqId: Date.now(), + transactions: [{doOperations, undoOperations: []}], + }); + if (response?.code !== 0) { + return options.mapping; + } + const rendered = await fetchSyncPost("/api/av/renderAttributeView", { + id: options.avID, + viewID: options.viewID, + pageSize: -1, + }); + const persistedCalendar = rendered?.data?.view as IAVCalendar | undefined; + const recurrencePersisted = persistedCalendar?.fields?.some(field => field.id === recurrenceFieldID && field.type === "text"); + const exceptionPersisted = persistedCalendar?.fields?.some(field => field.id === exceptionFieldID && field.type === "text"); + if (rendered?.code !== 0 || !recurrencePersisted || !exceptionPersisted || + persistedCalendar?.fieldMapping?.recurrenceFieldID !== recurrenceFieldID || + persistedCalendar?.fieldMapping?.exceptionFieldID !== exceptionFieldID) { + return options.mapping; + } + const hiddenField = (id: string, name: string): IAVColumn => ({ + id, name, type: "text", hidden: true, icon: "", wrap: false, desc: "", + calc: undefined, numberFormat: "", template: "", pin: false, width: "", align: "", + }); + if (!options.mapping.recurrenceFieldID) { + options.calendarData.fields.push(hiddenField(recurrenceFieldID, "__calendar_recurrence")); + } + if (!options.mapping.exceptionFieldID) { + options.calendarData.fields.push(hiddenField(exceptionFieldID, "__calendar_recurrence_exceptions")); + } + options.calendarData.fieldMapping = {...options.calendarData.fieldMapping, recurrenceFieldID, exceptionFieldID}; + return {...options.mapping, recurrenceFieldID, exceptionFieldID}; +}; diff --git a/app/src/protyle/render/av/calendar/recurrence-summary.ts b/app/src/protyle/render/av/calendar/recurrence-summary.ts new file mode 100644 index 00000000000..bc01c6ba6ac --- /dev/null +++ b/app/src/protyle/render/av/calendar/recurrence-summary.ts @@ -0,0 +1,243 @@ +import * as dayjs from "dayjs"; +import {escapeAttr, escapeHtml} from "../../../../util/escape"; +import {ICalendarRecurrence} from "./model"; +import {parseRecurrence} from "./recurrence"; + +/** + * Human prose for a recurrence rule, plus the preset menu that replaces the raw + * FREQ/INTERVAL/COUNT/UNTIL/BYDAY control row. + * + * HONESTY RULE: this module never describes a rule `recurrence.ts` cannot + * expand, and never OFFERS one either. + * - The parser only understands FREQ, INTERVAL, COUNT, UNTIL and BYDAY, and + * only accepts BYDAY together with FREQ=WEEKLY. Anything else parses to + * `undefined`, which means the event does not repeat at all - so + * `describeRecurrence` returns "" rather than inventing a sentence. + * - There is no "monthly on the 2nd Tuesday" preset: BYSETPOS/BYMONTHDAY are + * unsupported by both recurrence.ts and transactions.ts, so offering it + * would silently drop the rule on save. + * - MONTHLY/YEARLY expansion walks with dayjs `.add()` from the previous + * occurrence, which clamps and then drifts (Jan 31 -> Feb 28 -> Mar 28). + * For a start date that cannot survive that (day > 28, or Feb 29) the + * summary deliberately drops the "on day N" clause instead of promising a + * date the expander will not produce. + */ + +export type CalendarRecurrencePreset = "none" | "daily" | "weekly" | "weekday" | "yearly" | "custom"; + +export const CALENDAR_RECURRENCE_PRESETS: CalendarRecurrencePreset[] = ["none", "daily", "weekly", "weekday", "yearly", "custom"]; + +const WEEKDAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; +const WEEKDAY_INDEX: { [key: string]: number } = {SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6}; +const EVERY_WEEKDAY_RULE = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"; + +const getCalendarLocale = () => window.siyuan.config.lang; + +const lang = (key: string, fallback: string) => window.siyuan?.languages?.[key] || fallback; + +/** + * Intl throws a RangeError on a malformed tag, and this runs while the event + * dialog is being built - a bad `config.lang` must degrade to the runtime + * default, never take the whole dialog down. + */ +const formatWithLocale = (locale: string, options: Intl.DateTimeFormatOptions, date: Date) => { + try { + return new Intl.DateTimeFormat(locale, options).format(date); + } catch (error) { + return new Intl.DateTimeFormat(undefined, options).format(date); + } +}; + +const fill = (template: string, x: string | number, y?: string | number) => + template.replace("${x}", String(x)).replace("${y}", String(y ?? "")); + +const toDay = (start: dayjs.Dayjs | string) => (typeof start === "string" ? dayjs(start) : start); + +const getWeekdayName = (weekdayIndex: number, locale: string, style: "short" | "long" = "short") => + // 2020-06-07 is a Sunday, so index 0 is Sunday. + formatWithLocale(locale, {weekday: style}, new Date(2020, 5, 7 + weekdayIndex)); + +const getMonthDayLabel = (date: dayjs.Dayjs, locale: string) => + formatWithLocale(locale, {month: "long", day: "numeric"}, date.toDate()); + +const getUntilLabel = (until: dayjs.Dayjs, start: dayjs.Dayjs, locale: string) => + formatWithLocale(locale, until.year() === start.year() ? + {month: "short", day: "numeric"} : + {year: "numeric", month: "short", day: "numeric"}, until.toDate()); + +/** The weekdays a WEEKLY rule actually fires on, in week order. */ +const getWeeklyDayCodes = (recurrence: ICalendarRecurrence, start: dayjs.Dayjs): string[] => { + if (recurrence.byDay?.length) { + return [...recurrence.byDay].sort((a, b) => WEEKDAY_INDEX[a] - WEEKDAY_INDEX[b]); + } + return [WEEKDAY_CODES[start.day()]]; +}; + +const isEveryWeekday = (codes: string[]) => + codes.length === 5 && ["MO", "TU", "WE", "TH", "FR"].every(code => codes.includes(code)); + +/** dayjs month arithmetic drifts off a day-31 (or Feb-29) start; see the header. */ +const isStableMonthDay = (start: dayjs.Dayjs) => start.date() <= 28; +const isStableYearDay = (start: dayjs.Dayjs) => !(start.month() === 1 && start.date() === 29); + +const describeBase = (recurrence: ICalendarRecurrence, start: dayjs.Dayjs, locale: string): string => { + const interval = recurrence.interval && recurrence.interval > 0 ? recurrence.interval : 1; + if (recurrence.freq === "DAILY") { + return interval === 1 ? + lang("calendarDaily", "Daily") : + fill(lang("calendarRepeatEveryNDays", "Every ${x} days"), interval); + } + if (recurrence.freq === "WEEKLY") { + const codes = getWeeklyDayCodes(recurrence, start); + if (interval === 1 && isEveryWeekday(codes)) { + return lang("calendarRepeatEveryWeekday", "Every weekday (Monday to Friday)"); + } + const dayList = codes.map(code => getWeekdayName(WEEKDAY_INDEX[code], locale)).join(", "); + return interval === 1 ? + fill(lang("calendarRepeatWeeklyOn", "Weekly on ${x}"), dayList) : + fill(lang("calendarRepeatEveryNWeeksOn", "Every ${x} weeks on ${y}"), interval, dayList); + } + if (recurrence.freq === "MONTHLY") { + if (!isStableMonthDay(start)) { + return interval === 1 ? + lang("calendarMonthly", "Monthly") : + fill(lang("calendarRepeatEveryNMonths", "Every ${x} months"), interval); + } + return interval === 1 ? + fill(lang("calendarRepeatMonthlyOnDay", "Monthly on day ${x}"), start.date()) : + fill(lang("calendarRepeatEveryNMonthsOnDay", "Every ${x} months on day ${y}"), interval, start.date()); + } + if (!isStableYearDay(start)) { + return interval === 1 ? + lang("calendarYearly", "Yearly") : + fill(lang("calendarRepeatEveryNYears", "Every ${x} years"), interval); + } + return interval === 1 ? + fill(lang("calendarRepeatAnnuallyOn", "Annually on ${x}"), getMonthDayLabel(start, locale)) : + fill(lang("calendarRepeatEveryNYearsOn", "Every ${x} years on ${y}"), interval, getMonthDayLabel(start, locale)); +}; + +/** + * Human prose for `raw`, or "" when the event does not repeat OR when the rule + * is one the expander cannot honour (an "advanced" rule kept verbatim). + */ +export const describeRecurrence = (raw: unknown, start: dayjs.Dayjs | string, locale?: string): string => { + const recurrence = parseRecurrence(raw); + if (!recurrence) { + return ""; + } + const startDate = toDay(start); + if (!startDate?.isValid()) { + return ""; + } + const resolvedLocale = locale || getCalendarLocale(); + let summary = describeBase(recurrence, startDate, resolvedLocale); + if (recurrence.until) { + summary += fill(lang("calendarRepeatUntilSuffix", ", until ${x}"), getUntilLabel(recurrence.until, startDate, resolvedLocale)); + } + if (recurrence.count) { + summary += fill(lang("calendarRepeatCountSuffix", ", ${x} times"), recurrence.count); + } + return summary; +}; + +/** + * True when `raw` is non-empty but the parser refuses it, i.e. the rule is kept + * verbatim and will NOT expand. The dialog shows its read-only escape hatch for + * exactly this case. + */ +export const isAdvancedRecurrence = (raw: unknown): boolean => { + const value = typeof raw === "string" ? raw.trim() : ""; + if (!value || value.toUpperCase() === "NONE") { + return false; + } + return !parseRecurrence(value); +}; + +/** + * The rule string a preset stores. Every one of these round-trips through + * `parseRecurrence` unchanged - that is asserted by the preset round-trip check. + */ +export const getRecurrencePresetRule = (preset: CalendarRecurrencePreset): string => { + if (preset === "daily") { + return "FREQ=DAILY"; + } + if (preset === "weekly") { + // No BYDAY needed: without it the expander steps a whole week from the + // start date, which is the start weekday by construction. + return "FREQ=WEEKLY"; + } + if (preset === "weekday") { + return EVERY_WEEKDAY_RULE; + } + if (preset === "yearly") { + return "FREQ=YEARLY"; + } + // "none" stores nothing; "custom" is driven by the detailed controls. + return ""; +}; + +/** Which preset an existing rule corresponds to. Anything richer is "custom". */ +export const detectRecurrencePreset = (raw: unknown, start: dayjs.Dayjs | string): CalendarRecurrencePreset => { + const value = typeof raw === "string" ? raw.trim() : ""; + if (!value || value.toUpperCase() === "NONE") { + return "none"; + } + const recurrence = parseRecurrence(value); + if (!recurrence) { + return "custom"; + } + if (recurrence.count || recurrence.until || (recurrence.interval && recurrence.interval > 1)) { + return "custom"; + } + if (recurrence.freq === "DAILY") { + return "daily"; + } + if (recurrence.freq === "YEARLY") { + return "yearly"; + } + if (recurrence.freq === "WEEKLY") { + const codes = getWeeklyDayCodes(recurrence, toDay(start)); + if (isEveryWeekday(codes)) { + return "weekday"; + } + const startDate = toDay(start); + if (codes.length === 1 && startDate?.isValid() && codes[0] === WEEKDAY_CODES[startDate.day()]) { + return "weekly"; + } + return "custom"; + } + // MONTHLY has no honest preset (see the header note on BYMONTHDAY). + return "custom"; +}; + +export const getRecurrencePresetLabel = (preset: CalendarRecurrencePreset, start: dayjs.Dayjs | string, locale?: string): string => { + const resolvedLocale = locale || getCalendarLocale(); + const startDate = toDay(start); + if (preset === "none") { + return lang("calendarDoesNotRepeat", "Does not repeat"); + } + if (preset === "daily") { + return lang("calendarDaily", "Daily"); + } + if (preset === "weekly") { + return startDate?.isValid() ? + fill(lang("calendarRepeatWeeklyOn", "Weekly on ${x}"), getWeekdayName(startDate.day(), resolvedLocale, "long")) : + lang("calendarWeekly", "Weekly"); + } + if (preset === "weekday") { + return lang("calendarRepeatEveryWeekday", "Every weekday (Monday to Friday)"); + } + if (preset === "yearly") { + return startDate?.isValid() && isStableYearDay(startDate) ? + fill(lang("calendarRepeatAnnuallyOn", "Annually on ${x}"), getMonthDayLabel(startDate, resolvedLocale)) : + lang("calendarYearly", "Yearly"); + } + return lang("calendarRepeatCustom", "Custom…"); +}; + +/** ` + ).join(""); diff --git a/app/src/protyle/render/av/calendar/recurrence.ts b/app/src/protyle/render/av/calendar/recurrence.ts new file mode 100644 index 00000000000..b5a77ae0cf2 --- /dev/null +++ b/app/src/protyle/render/av/calendar/recurrence.ts @@ -0,0 +1,225 @@ +import * as dayjs from "dayjs"; +import {ICalendarNormalizedEvent, ICalendarRange, ICalendarRecurrence} from "./model"; + +const isValidFreq = (value: string) => ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].includes(value); +const weekdayMap: { [key: string]: number } = {SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6}; +const supportedKeys = ["FREQ", "INTERVAL", "COUNT", "UNTIL", "BYDAY"]; + +export const shouldResetCustomWeekdays = (previousPreset?: string, nextPreset?: string) => + nextPreset === "custom" && previousPreset !== "custom"; + +const parseDateStrict = (value: string, format: string) => { + const date = dayjs(value); + return date.isValid() && date.format(format) === value ? date : undefined; +}; + +const parseUntil = (value: string) => { + if (/^\d{8}$/.test(value)) { + return parseDateStrict(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`, "YYYY-MM-DD"); + } + const dateTimeMatch = value.match(/^(\d{4})(\d{2})(\d{2})T\d{6}Z?$/); + if (dateTimeMatch) { + return parseDateStrict(`${dateTimeMatch[1]}-${dateTimeMatch[2]}-${dateTimeMatch[3]}`, "YYYY-MM-DD"); + } + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return parseDateStrict(value, "YYYY-MM-DD"); + } + return undefined; +}; + +export const parseRecurrence = (value: unknown): ICalendarRecurrence | undefined => { + if (typeof value !== "string") { + return undefined; + } + const raw = value.trim(); + if (!raw) { + return undefined; + } + const str = raw.toUpperCase(); + if (str === "NONE") { + return undefined; + } + if (isValidFreq(str)) { + return {freq: str as ICalendarRecurrence["freq"]}; + } + const result: Partial = {raw}; + let isMalformed = false; + const seenKeys = new Set(); + str.split(";").filter(Boolean).forEach(part => { + const separatorIndex = part.indexOf("="); + const key = separatorIndex > -1 ? part.slice(0, separatorIndex) : ""; + const val = separatorIndex > -1 ? part.slice(separatorIndex + 1) : ""; + if (!key || !val || !supportedKeys.includes(key) || seenKeys.has(key)) { + isMalformed = true; + return; + } + seenKeys.add(key); + if (key === "FREQ" && isValidFreq(val)) { + result.freq = val as ICalendarRecurrence["freq"]; + } else if (key === "INTERVAL") { + const interval = /^\d+$/.test(val) ? parseInt(val, 10) : 0; + if (interval > 0) { + result.interval = interval; + } else { + isMalformed = true; + } + } else if (key === "COUNT") { + const count = /^\d+$/.test(val) ? parseInt(val, 10) : 0; + if (count > 0) { + result.count = count; + } else { + isMalformed = true; + } + } else if (key === "UNTIL") { + const until = parseUntil(val); + if (until) { + result.until = until.endOf("day"); + } else { + isMalformed = true; + } + } else if (key === "BYDAY") { + const values = val.split(","); + const byDay = values.filter(day => weekdayMap[day] !== undefined).sort((a, b) => weekdayMap[a] - weekdayMap[b]); + if (byDay.length > 0) { + result.byDay = byDay; + } + if (byDay.length !== values.length || new Set(byDay).size !== byDay.length) { + isMalformed = true; + } + } else { + isMalformed = true; + } + }); + if (result.byDay?.length && result.freq !== "WEEKLY") { + isMalformed = true; + } + return result.freq && !isMalformed ? result as ICalendarRecurrence : undefined; +}; + +const getRecurringStartAtIndex = (start: dayjs.Dayjs, recurrence: ICalendarRecurrence, index: number) => { + const interval = recurrence.interval || 1; + if (recurrence.freq === "DAILY") { + return start.add(index * interval, "day"); + } + if (recurrence.freq === "WEEKLY") { + return start.add(index * interval, "week"); + } + if (recurrence.freq === "MONTHLY") { + return start.add(index * interval, "month"); + } + return start.add(index * interval, "year"); +}; + +const getExpansionStart = (range: ICalendarRange, duration: number) => range.start.subtract(Math.max(duration, 0), "millisecond"); + +const getAlignedRecurringStart = (event: ICalendarNormalizedEvent, expansionStart: dayjs.Dayjs) => { + if (!event.recurrence || event.recurrence.count || !event.start.isBefore(expansionStart)) { + return {occurrenceStart: event.start, index: 0}; + } + const interval = event.recurrence.interval || 1; + const unit = event.recurrence.freq === "DAILY" ? "day" : (event.recurrence.freq === "WEEKLY" ? "week" : (event.recurrence.freq === "MONTHLY" ? "month" : "year")); + const diff = Math.max(expansionStart.diff(event.start, unit), 0); + let index = Math.max(Math.floor(diff / interval), 0); + let occurrenceStart = getRecurringStartAtIndex(event.start, event.recurrence, index); + while (occurrenceStart.isBefore(expansionStart)) { + index++; + occurrenceStart = getRecurringStartAtIndex(event.start, event.recurrence, index); + } + return {occurrenceStart, index}; +}; + +const getAlignedRecurringWeekStart = (event: ICalendarNormalizedEvent, expansionStart: dayjs.Dayjs) => { + let weekCursor = event.start.startOf("week"); + if (event.recurrence?.count || !weekCursor.isBefore(expansionStart, "week")) { + return weekCursor; + } + const interval = event.recurrence?.interval || 1; + const diff = Math.max(expansionStart.startOf("week").diff(event.start.startOf("week"), "week"), 0); + const skipped = Math.max(Math.floor(diff / interval), 0); + weekCursor = weekCursor.add(skipped * interval, "week"); + while (weekCursor.isBefore(expansionStart, "week")) { + weekCursor = weekCursor.add(interval, "week"); + } + return weekCursor; +}; + +export const expandRecurrences = (events: ICalendarNormalizedEvent[], range: ICalendarRange): ICalendarNormalizedEvent[] => { + const expanded: ICalendarNormalizedEvent[] = []; + events.forEach(event => { + const isException = (date: dayjs.Dayjs) => event.recurrenceExceptions?.includes(date.format("YYYY-MM-DD")); + if (!event.recurrence) { + if (!event.end?.isBefore(range.start, "day") && !event.start.isAfter(range.end, "day")) { + expanded.push(event); + } + return; + } + const duration = event.end ? event.end.diff(event.start) : 0; + const expansionStart = getExpansionStart(range, duration); + if (event.recurrence.freq === "WEEKLY" && event.recurrence.byDay?.length > 0) { + let weekCursor = getAlignedRecurringWeekStart(event, expansionStart); + let index = 0; + while (!weekCursor.isAfter(range.end, "day")) { + const weeksFromStart = weekCursor.diff(event.start.startOf("week"), "week"); + if (weeksFromStart >= 0 && weeksFromStart % (event.recurrence.interval || 1) === 0) { + for (const byDay of event.recurrence.byDay) { + const occurrenceStart = weekCursor.day(weekdayMap[byDay]) + .hour(event.start.hour()) + .minute(event.start.minute()) + .second(event.start.second()) + .millisecond(event.start.millisecond()); + if (occurrenceStart.isBefore(event.start)) { + continue; + } + if (event.recurrence.count && index >= event.recurrence.count) { + break; + } + if (event.recurrence.until && occurrenceStart.isAfter(event.recurrence.until)) { + break; + } + if (!isException(occurrenceStart) && + !occurrenceStart.isAfter(range.end, "day") && + !(event.end ? occurrenceStart.add(duration, "millisecond").isBefore(range.start, "day") : occurrenceStart.isBefore(range.start, "day"))) { + expanded.push({ + ...event, + start: occurrenceStart, + end: event.end ? occurrenceStart.add(duration, "millisecond") : undefined, + isOccurrence: index > 0 || !occurrenceStart.isSame(event.start), + occurrenceID: `${event.id}:${occurrenceStart.format("YYYYMMDD")}`, + baseEventID: event.id, + }); + } + index++; + } + } + if (event.recurrence.count && index >= event.recurrence.count) { + break; + } + weekCursor = weekCursor.add(1, "week"); + } + return; + } + let {occurrenceStart, index} = getAlignedRecurringStart(event, expansionStart); + while (!occurrenceStart.isAfter(range.end, "day")) { + if (event.recurrence.count && index >= event.recurrence.count) { + break; + } + if (event.recurrence.until && occurrenceStart.isAfter(event.recurrence.until)) { + break; + } + if (!isException(occurrenceStart) && + !(event.end ? occurrenceStart.add(duration, "millisecond").isBefore(range.start, "day") : occurrenceStart.isBefore(range.start, "day"))) { + expanded.push({ + ...event, + start: occurrenceStart, + end: event.end ? occurrenceStart.add(duration, "millisecond") : undefined, + isOccurrence: index > 0, + occurrenceID: `${event.id}:${occurrenceStart.format("YYYYMMDD")}`, + baseEventID: event.id, + }); + } + index++; + occurrenceStart = getRecurringStartAtIndex(event.start, event.recurrence, index); + } + }); + return expanded.sort((a, b) => a.start.valueOf() - b.start.valueOf()); +}; diff --git a/app/src/protyle/render/av/calendar/render.ts b/app/src/protyle/render/av/calendar/render.ts new file mode 100644 index 00000000000..66962650bbd --- /dev/null +++ b/app/src/protyle/render/av/calendar/render.ts @@ -0,0 +1,1985 @@ +import * as dayjs from "dayjs"; +import {Constants} from "../../../../constants"; +import {Menu} from "../../../../plugin/Menu"; +import {showMessage} from "../../../../dialog/message"; +import {escapeAttr, escapeHtml} from "../../../../util/escape"; +import {fetchSyncPost} from "../../../../util/fetch"; +import {hasClosestByAttribute, hasClosestByClassName} from "../../../util/hasClosest"; +import {focusBlock} from "../../../util/selection"; +import {transaction} from "../../../wysiwyg/transaction"; +import {avRender, genTabHeaderHTML, updateSearch} from "../render"; +import {renderGallery} from "../gallery/render"; +import {renderKanban} from "../kanban/render"; +import {bindAvSearch} from "../search"; +import {beginAVRender, finishAVLocate, getAVLocateParams, isCurrentAVRender, prepareAVLocate} from "../locate"; +import {openDatabaseRowByData} from "../openDatabaseRow"; +import {getCalendarFieldMapping} from "./mapped-fields"; +import {getBlockCell, getCellByFieldID, getEventDocumentID, ICalendarEventDraft, ICalendarNormalizedEvent, ICalendarRange} from "./model"; +import {eventOverlapsDay, normalizeCalendarEvents, sortCalendarEvents} from "./normalize"; +import {CalendarRecurrenceScope, getDisabledRecurrenceScopes, isRecurringSourceEvent, openEventDialog, openRecurrenceScopeDialog} from "./event-dialog"; +import {createCalendarEvent, createCalendarEventAsDocument, createCalendarEventReplacingOccurrence, deleteCalendarEvent, deleteCalendarEventThisAndFuture, deleteCalendarOccurrence, ICalendarCreateOptions, updateCalendarEvent, updateCalendarEventThisAndFuture} from "./transactions"; +import { + CALENDAR_DEFAULT_EVENT_MINUTES, + formatClockLabel, + getCalendarTimeGeometry, + getEventMinuteRange, + ICalendarTimeGeometry, + minuteToOffsetPx, + offsetPxToMinute, + parseClockMinutes, + snapMinutes, +} from "./time-geometry"; +import {CALENDAR_TIME_CREATE_TYPE, renderCalendarTimeGrid} from "./time-grid"; +import {mountCalendarNowIndicator, unmountCalendarNowIndicator} from "./now-indicator"; +import {abortActiveCalendarGesture, bindCalendarPointerInteractions, createCalendarGridAdapter, isCalendarGestureActive} from "./interactions"; +// The chip markup, the right-click menu, the key map and the mini month are all +// owned by their own modules; this renderer only decides WHEN they appear and +// routes everything they report back through the same write paths a click uses. +import {buildOptimisticChip, renderCalendarEventChip} from "./event-chip"; +import {bindCalendarEventContextMenu, closeCalendarEventMenu, ICalendarMenuCommand} from "./context-menu"; +import {bindCalendarKeymap, CALENDAR_ARIA_KEYSHORTCUTS} from "./keymap"; +import {bindCalendarMiniMonth, getCalendarMiniMonthEventDays, getMiniMonthDays} from "./mini-month"; + +interface IRenderCalendarOptions { + protyle: IProtyle; + blockElement: HTMLElement; + cb?: (data: IAV) => void; + renderAll: boolean; + data?: IAV; +} + +const startOfCalendarWeek = (date: dayjs.Dayjs, weekStart = 0) => { + const offset = (date.day() - weekStart + 7) % 7; + return date.subtract(offset, "day").startOf("day"); +}; + +const endOfCalendarWeek = (date: dayjs.Dayjs, weekStart = 0) => startOfCalendarWeek(date, weekStart).add(6, "day").endOf("day"); + +export const getISOCalendarWeekNumber = (date: dayjs.Dayjs) => { + const target = new Date(Date.UTC(date.year(), date.month(), date.date())); + const weekday = target.getUTCDay() || 7; + target.setUTCDate(target.getUTCDate() + 4 - weekday); + const yearStart = Date.UTC(target.getUTCFullYear(), 0, 1); + return Math.ceil((((target.getTime() - yearStart) / 86400000) + 1) / 7); +}; + +const CALENDAR_VIEW_MODES = [0, 1, 2, 3, 4, 5]; + +const getSafeViewMode = (viewMode?: number) => CALENDAR_VIEW_MODES.includes(viewMode || 0) ? viewMode || 0 : 0; + +const getCalendarViewMode = (calendar: IAVCalendar, blockElement: HTMLElement) => { + const localViewMode = blockElement.dataset.calendarViewMode; + if (localViewMode && /^[0-5]$/.test(localViewMode)) { + return parseInt(localViewMode, 10); + } + return getSafeViewMode(calendar.viewMode); +}; + +const getSafeWeekStart = (weekStart?: number) => weekStart === 1 ? 1 : 0; + +/** "each entry is a page": new entries become real SiYuan documents. */ +export const CALENDAR_NEW_ITEM_TARGET_DOCUMENT = "document"; +export const CALENDAR_NEW_ITEM_TARGET_ROW = "row"; + +/** The persisted target, read through a cast (see getCalendarNewItemTarget). */ +const getPersistedNewItemTarget = (calendar: IAVCalendar) => + calendar.newItemTarget || ""; + +/** + * The view's new-entry target, as sent by the kernel alongside dateFieldID / + * viewMode / weekStart (kernel/av/layout_calendar.go Calendar.NewItemTarget). + * + * The zero value "" is a view that existed before page-per-entry shipped, and it + * must keep creating detached rows: only views created (or explicitly switched) + * after the upgrade create documents, so nobody's existing data habits change + * underneath them. + * + * The block-element override is what the config panel writes when the user flips + * the setting: "setAttrViewCalendarNewItemTarget" is not in the refresh list of + * app/src/protyle/wysiwyg/transaction.ts (not our file), so without it the very + * next create would still use the value this render was fetched with. Same + * mechanism as data-calendar-view-mode; renderCalendar drops it as soon as the + * kernel confirms the value. + * + * Read through a cast because IAVCalendar in app/src/types/index.d.ts does not + * declare the field yet - that file belongs to another agent in this change. + */ +export const getCalendarNewItemTarget = (calendar: IAVCalendar, blockElement?: HTMLElement) => { + const localTarget = blockElement?.dataset.calendarNewItemTarget; + if (localTarget === CALENDAR_NEW_ITEM_TARGET_DOCUMENT || localTarget === CALENDAR_NEW_ITEM_TARGET_ROW) { + return localTarget; + } + return getPersistedNewItemTarget(calendar); +}; + +export const calendarCreatesDocuments = (calendar: IAVCalendar, blockElement?: HTMLElement) => + getCalendarNewItemTarget(calendar, blockElement) === CALENDAR_NEW_ITEM_TARGET_DOCUMENT; + +const getVisibleRange = (anchor: dayjs.Dayjs, viewMode: number, weekStart = 0): ICalendarRange => { + if (viewMode === 1) { + return {start: startOfCalendarWeek(anchor, weekStart), end: endOfCalendarWeek(anchor, weekStart)}; + } + if (viewMode === 2) { + return {start: anchor.startOf("day"), end: anchor.endOf("day")}; + } + if (viewMode === 3) { + return {start: anchor.startOf("day"), end: anchor.add(29, "day").endOf("day")}; + } + if (viewMode === 4) { + return {start: anchor.startOf("year"), end: anchor.endOf("year")}; + } + if (viewMode === 5) { + return {start: anchor.startOf("day"), end: anchor.add(4, "day").endOf("day")}; + } + return {start: startOfCalendarWeek(anchor.startOf("month"), weekStart), end: endOfCalendarWeek(anchor.endOf("month"), weekStart)}; +}; + +const getAgendaRange = (anchor: dayjs.Dayjs, blockElement: HTMLElement): ICalendarRange => { + const days = Math.max(parseInt(blockElement.dataset.calendarAgendaDays || "30", 10) || 30, 30); + return {start: anchor.startOf("day"), end: anchor.add(days - 1, "day").endOf("day")}; +}; + +const getViewModeLabel = (viewMode: number) => { + const labels: Record = { + 0: window.siyuan.languages.month || "Month", + 1: window.siyuan.languages.week || "Week", + 2: window.siyuan.languages.calendarDay || "Day", + 3: window.siyuan.languages.calendarSchedule || "Schedule", + 4: window.siyuan.languages.year || "Year", + 5: window.siyuan.languages.calendarFiveDayView || "5 Days", + }; + return labels[viewMode] || labels[0]; +}; + +const getCalendarLocale = () => window.siyuan.config.lang; + +const formatCalendarDate = (date: dayjs.Dayjs, options: Intl.DateTimeFormatOptions) => { + return new Intl.DateTimeFormat(getCalendarLocale(), options).format(date.toDate()); +}; + +const getCalendarTitle = (anchor: dayjs.Dayjs, range: ICalendarRange, viewMode: number) => { + if (viewMode === 1 || viewMode === 3 || viewMode === 5) { + return `${formatCalendarDate(range.start, {year: "numeric", month: "short", day: "numeric"})} - ${formatCalendarDate(range.end, {year: "numeric", month: "short", day: "numeric"})}`; + } + if (viewMode === 2) { + return formatCalendarDate(anchor, {year: "numeric", month: "short", day: "numeric"}); + } + if (viewMode === 4) { + return formatCalendarDate(anchor, {year: "numeric"}); + } + return formatCalendarDate(anchor, {year: "numeric", month: "long"}); +}; + +const getWeekdayLabels = (weekStart = 0) => { + const formatter = new Intl.DateTimeFormat(getCalendarLocale(), {weekday: "short"}); + return [0, 1, 2, 3, 4, 5, 6].map(index => formatter.format(new Date(2020, 5, 7 + ((weekStart + index) % 7)))); +}; + + +/** + * There is no slot constant here any more, on purpose. + * + * The grid used to be 48 CSS rows of 30 minutes and every time value in this + * file was rounded onto that ruling, so a 12:45-13:20 event drew at 12:30-13:30. + * All minute<->pixel arithmetic now lives in ./time-geometry.ts and the only + * granularity the user ever meets is CALENDAR_SNAP_MINUTES (15), applied to the + * value being written rather than to the drawing. + * calendar-time-grid-smoke.mjs asserts that the old constant cannot come back. + */ +const getCalendarSearch = (blockElement: HTMLElement) => (blockElement.dataset.calendarSearch || "").trim(); + +const getCalendarFilter = (blockElement: HTMLElement) => { + const filter = blockElement.dataset.calendarFilter || "all"; + return ["all", "timed", "all-day"].includes(filter) ? filter : "all"; +}; + +const eventMatchesSearch = (event: ICalendarNormalizedEvent, query: string) => { + if (!query) { + return true; + } + const haystack = [ + event.title, + event.start.format("YYYY-MM-DD"), + event.start.format("HH:mm"), + event.end?.format("YYYY-MM-DD"), + event.end?.format("HH:mm"), + event.location, + event.description, + event.colorContent, + event.recurrenceRaw, + event.recurrence?.freq, + ].filter(Boolean).join("\n").toLowerCase(); + return query.toLowerCase().split(/\s+/).every(term => haystack.includes(term)); +}; + +const eventMatchesCalendarFilter = (event: ICalendarNormalizedEvent, filter: string) => { + if (filter === "timed") { + return !event.isAllDay; + } + if (filter === "all-day") { + return event.isAllDay; + } + return true; +}; + +const getCalendarSearchResultRange = ( + calendar: IAVCalendar, + mapping: ReturnType, + fallback: ICalendarRange, +): ICalendarRange => { + let first: dayjs.Dayjs | undefined; + let last: dayjs.Dayjs | undefined; + calendar.cards?.forEach(card => { + const date = getCellByFieldID(card, mapping.dateFieldID)?.value?.date; + if (!date?.isNotEmpty || !date.content) { + return; + } + const start = dayjs(date.content); + if (!start.isValid()) { + return; + } + const rawEnd = date.hasEndDate && date.content2 ? dayjs(date.content2) : start; + const end = rawEnd.isValid() && !rawEnd.isBefore(start) ? rawEnd : start; + first = !first || start.isBefore(first) ? start : first; + last = !last || end.isAfter(last) ? end : last; + }); + return first && last ? {start: first.startOf("day"), end: last.endOf("day")} : fallback; +}; + +const getNavDate = (anchor: dayjs.Dayjs, viewMode: number, direction: -1 | 1) => { + if (viewMode === 0) { + return anchor.add(direction, "month"); + } + if (viewMode === 1) { + return anchor.add(direction, "week"); + } + if (viewMode === 3) { + return anchor.add(direction * 30, "day"); + } + if (viewMode === 4) { + return anchor.add(direction, "year"); + } + if (viewMode === 5) { + return anchor.add(direction * 5, "day"); + } + return anchor.add(direction, "day"); +}; + +const getEventSeekRange = (anchor: dayjs.Dayjs): ICalendarRange => ({ + start: anchor.subtract(1, "year").startOf("day"), + end: anchor.add(1, "year").endOf("day"), +}); + +/** + * The chip anatomy (tooltip, recurrence marker, source/schedule affordances, + * colour dot, read-only and draggable attributes) lives in ./event-chip.ts. + * The inline -15m/+15m, -1d/+1d and Copy buttons it used to carry are gone: they + * are in the right-click menu (./context-menu.ts) and on the drag edges, so the + * chip is quiet enough to read at a glance. + */ + +/** + * Open the page behind a bound entry. + * + * Goes through upstream's openDatabaseRowByData (../openDatabaseRow.ts:83-151) + * rather than a bare openFileById so the calendar behaves like every other + * database surface: an already open tab for that document is reused instead of + * spawning duplicates, and the database attribute panel is expanded so Date / + * Location stay editable on the page itself. + */ +const openCalendarEventSource = (protyle: IProtyle, blockElement: HTMLElement, event: ICalendarNormalizedEvent) => { + const documentID = getEventDocumentID(event); + if (!documentID) { + showMessage(window.siyuan.languages.calendarSourceMissing || "Calendar item has no source block"); + return; + } + openDatabaseRowByData(protyle, { + avID: blockElement.getAttribute("data-av-id") || "", + databaseBlockID: blockElement.getAttribute("data-node-id") || "", + notebookID: protyle.notebookId, + // The item id of the row, never the occurrence id: a generated occurrence + // has no row of its own and shares the base item's document. + itemID: event.baseEventID || event.id, + valueID: getBlockCell(event.sourceCard)?.id || "", + title: event.title, + boundBlockID: documentID, + isDetached: false, + }); +}; + +const isDateKey = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value || ""); + +/** + * The geometry the currently rendered grid was built from. Reading it back out + * of the DOM (rather than recomputing a default) keeps every listener and the + * optimistic preview on exactly the same ruler as the markup. + */ +const getGridGeometry = (root: HTMLElement | null): ICalendarTimeGeometry => { + const gridElement = root?.querySelector(".av__calendar-time-grid") as HTMLElement; + return getCalendarTimeGeometry(parseInt(gridElement?.dataset.dayCount || "7", 10) || 7); +}; + +/** + * Paint the entry the user just saved before the kernel has answered. + * + * Creating a page is much heavier than inserting a detached row (the kernel takes + * createDocLock and flushes the transaction queue three times), so the quick + * create popover closes at once and the chip appears immediately. The caller MUST + * remove the returned node in both the success and the failure path, otherwise a + * failed create leaves a phantom event on the grid. + * + * Returns null when the target day is not on screen (creating from the toolbar + * while looking at another month, for example); the reconciling rerender is then + * the only visible feedback, which is correct because there is nothing to paint. + */ +const paintOptimisticEvent = (calendarElement: HTMLElement, draft: ICalendarEventDraft): HTMLElement | null => { + if (!calendarElement || !isDateKey(draft.date) || !draft.title) { + return null; + } + // Read the geometry off the grid the chip is about to land on, so the + // preview cannot be positioned with a different hour height than the render. + const geometry = getGridGeometry(calendarElement); + const chip = buildOptimisticChip(draft); + if (!draft.isAllDay) { + const timedLayer = calendarElement.querySelector(`.av__calendar-time-day[data-date="${draft.date}"] .av__calendar-timed-events`); + if (timedLayer) { + // Same geometry as a real chip, so the preview does not jump when the + // kernel answers and the real render replaces it. + const range = getEventMinuteRange(parseClockMinutes(draft.startTime), parseClockMinutes(draft.endTime), geometry); + const wrapper = document.createElement("div"); + wrapper.className = "av__calendar-timed-event"; + wrapper.style.top = `${range.topPx}px`; + wrapper.style.height = `${range.heightPx}px`; + wrapper.style.left = "0%"; + wrapper.style.width = "100%"; + wrapper.appendChild(chip); + timedLayer.appendChild(wrapper); + return wrapper; + } + } + const allDayCell = calendarElement.querySelector(`.av__calendar-allday-lanes .av__calendar-allday-cell[data-date="${draft.date}"]`); + if (allDayCell?.parentElement) { + const wrapper = document.createElement("div"); + wrapper.className = "av__calendar-allday-bar"; + wrapper.style.gridColumn = `${parseInt(allDayCell.getAttribute("data-day-index") || "0", 10) + 1} / span 1`; + wrapper.appendChild(chip); + allDayCell.parentElement.appendChild(wrapper); + return wrapper; + } + const dayElement = calendarElement.querySelector(`[data-type="calendar-drop-day"][data-date="${draft.date}"]`); + const container = dayElement?.querySelector(".av__calendar-all-day, .av__calendar-events, .av__calendar-list-events"); + if (!container) { + return null; + } + container.appendChild(chip); + return chip; +}; + +const CALENDAR_VIEW_MENU_ITEMS = [ + {mode: 2, accelerator: "D"}, + {mode: 1, accelerator: "W"}, + {mode: 0, accelerator: "M"}, + {mode: 4, accelerator: "Y"}, + {mode: 3, accelerator: "A"}, + {mode: 5, accelerator: "X"}, +]; + +const renderModeSwitcher = (viewMode: number) => { + return ``; +}; + +const renderCalendarFilter = (filter: string, panelID: string) => { + const options = [ + {value: "all", label: window.siyuan.languages.all || "All"}, + {value: "timed", label: window.siyuan.languages.calendarTimed || "Timed"}, + {value: "all-day", label: window.siyuan.languages.allDay || "All day"}, + ]; + return ``; +}; + +const renderDateFieldSetup = (calendar: IAVCalendar, editable = true) => { + const dateFields = calendar.fields.filter(field => field.type === "date"); + if (dateFields.length === 0) { + return `
+
${window.siyuan.languages.calendarNeedDateField || window.siyuan.languages.dateField || "Calendar requires a date field"}
+ ${editable ? `` : ""} +
`; + } + return `
+ + + ${editable ? `` : ""} +
`; +}; + +const MONTH_DAY_EVENT_LIMIT = 3; + +const renderMonth = (anchor: dayjs.Dayjs, range: ICalendarRange, events: ICalendarNormalizedEvent[], weekStart = 0, editable = true) => { + let html = `
${getWeekdayLabels(weekStart).map(day => `
${escapeHtml(day)}
`).join("")}
`; + let cursor = range.start; + while (!cursor.isAfter(range.end, "day")) { + const dayEvents = sortCalendarEvents(events.filter(event => eventOverlapsDay(event, cursor))); + const visibleEvents = dayEvents.slice(0, MONTH_DAY_EVENT_LIMIT); + const hiddenCount = dayEvents.length - visibleEvents.length; + const moreHTML = hiddenCount > 0 ? + `` : ""; + html += `
+ +
${visibleEvents.map(event => renderCalendarEventChip({event, variant: "month", displayDate: cursor, editable})).join("")}${moreHTML}
+
`; + cursor = cursor.add(1, "day"); + } + return `${html}
`; +}; + + +/** + * Week and Day are the same grid with a different day list (see ./time-grid.ts). + * All the geometry, the overlap packing and the sticky chrome live there; this + * file only supplies the chip markup and the locale-aware labels. + */ +const renderTimeGridView = (days: dayjs.Dayjs[], events: ICalendarNormalizedEvent[], viewKind: "week" | "day" | "five-day", editable = true, expandAllDay = false) => + renderCalendarTimeGrid({ + days, + events, + editable, + expandAllDay, + geometry: getCalendarTimeGeometry(days.length), + viewKind, + labels: { + allDay: window.siyuan.languages.allDay || "All day", + createEvent: window.siyuan.languages.newEvent || window.siyuan.languages.newRow || "New", + }, + formatWeekday: (day) => formatCalendarDate(day, {weekday: "short"}), + formatFullDate: (day) => formatCalendarDate(day, {weekday: "long", year: "numeric", month: "short", day: "numeric"}), + // The grid decides where a chip sits; the chip decides what it looks + // like. An all-day entry is a filled bar in the sticky lane, a timed one + // is a dot + time + title block in the column. + renderEventChip: (event, day, chipEditable, surface) => renderCalendarEventChip({ + event, + variant: surface, + displayDate: day, + editable: chipEditable, + }), + }); + +const renderWeek = (range: ICalendarRange, events: ICalendarNormalizedEvent[], editable = true, expandAllDay = false) => { + const days: dayjs.Dayjs[] = []; + let cursor = range.start.startOf("day"); + while (!cursor.isAfter(range.end, "day")) { + days.push(cursor); + cursor = cursor.add(1, "day"); + } + return `
${renderTimeGridView(days, events, "week", editable, expandAllDay)}
`; +}; + +const renderDay = (anchor: dayjs.Dayjs, events: ICalendarNormalizedEvent[], editable = true, expandAllDay = false) => + `
${renderTimeGridView([anchor.startOf("day")], events, "day", editable, expandAllDay)}
`; + +const renderFiveDay = (range: ICalendarRange, events: ICalendarNormalizedEvent[], editable = true, expandAllDay = false) => { + const days = Array.from({length: 5}, (unused, index) => range.start.add(index, "day")); + return `
${renderTimeGridView(days, events, "five-day", editable, expandAllDay)}
`; +}; + +const getISOWeekNumber = (date: dayjs.Dayjs) => { + const value = new Date(Date.UTC(date.year(), date.month(), date.date())); + const weekday = value.getUTCDay() || 7; + value.setUTCDate(value.getUTCDate() + 4 - weekday); + const yearStart = new Date(Date.UTC(value.getUTCFullYear(), 0, 1)); + return Math.ceil((((value.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); +}; + +const getYearWeekdayLabels = (weekStart: number) => { + let formatter: Intl.DateTimeFormat; + try { + formatter = new Intl.DateTimeFormat(getCalendarLocale(), {weekday: "narrow"}); + } catch (error) { + formatter = new Intl.DateTimeFormat(undefined, {weekday: "narrow"}); + } + return [0, 1, 2, 3, 4, 5, 6].map(index => formatter.format(new Date(2020, 5, 7 + ((weekStart + index) % 7)))); +}; + +const renderYearMonth = (month: dayjs.Dayjs, anchor: dayjs.Dayjs, eventDays: Set, weekStart: number, editable: boolean) => { + const days = getMiniMonthDays(month, weekStart); + const weeks = Array.from({length: 6}, (unused, index) => days.slice(index * 7, index * 7 + 7)); + return `
+

${escapeHtml(formatCalendarDate(month, {month: "long"}))}

+ +
${weeks.map(week => `
+ ${getISOWeekNumber(week[0])} + ${week.map(day => { + const date = day.format("YYYY-MM-DD"); + const classes = ["av__calendar-year-day"]; + if (!day.isSame(month, "month")) { + classes.push("av__calendar-year-day--outside"); + } + const isOwnMonth = day.isSame(month, "month"); + const isToday = isOwnMonth && day.isSame(dayjs(), "day"); + if (isToday) { + classes.push("av__calendar-year-day--today"); + } + if (isOwnMonth && day.isSame(anchor, "day") && !isToday) { + classes.push("av__calendar-year-day--selected"); + } + if (eventDays.has(date)) { + classes.push("av__calendar-year-day--has-events"); + } + return ``; + }).join("")} +
`).join("")}
+
`; +}; + +const renderYear = (anchor: dayjs.Dayjs, events: ICalendarNormalizedEvent[], weekStart = 0, editable = true) => { + const eventDays = getCalendarMiniMonthEventDays(events); + return `
${Array.from({length: 12}, (unused, index) => + renderYearMonth(anchor.startOf("year").add(index, "month"), anchor, eventDays, weekStart, editable)).join("")}
`; +}; + +const renderList = (range: ICalendarRange, events: ICalendarNormalizedEvent[], hideEmpty = false, editable = true, progressive = false) => { + let cursor = range.start.startOf("day"); + let html = '
'; + let renderedDays = 0; + const renderedMultiDay = new Set(); + while (!cursor.isAfter(range.end, "day")) { + const dayEvents = sortCalendarEvents(events.filter(event => { + if (!eventOverlapsDay(event, cursor)) { + return false; + } + const isMultiDay = !!event.end && !event.start.isSame(event.end, "day"); + const key = event.occurrenceID || event.id; + if (isMultiDay && renderedMultiDay.has(key)) { + return false; + } + if (isMultiDay) { + renderedMultiDay.add(key); + } + return true; + })); + if (!hideEmpty || dayEvents.length > 0) { + renderedDays++; + html += `
+ +
${dayEvents.length > 0 ? dayEvents.map(event => renderCalendarEventChip({event, variant: "list", displayDate: cursor, editable})).join("") : `${window.siyuan.languages.emptyContent}`}
+
`; + } + cursor = cursor.add(1, "day"); + } + if (renderedDays === 0) { + html += `
${window.siyuan.languages.calendarNoMatchingEvent || window.siyuan.languages.emptyContent}
`; + } + if (progressive) { + html += ``; + } + return `${html}
`; +}; + +// databaseQuery 是工具栏放大镜(av-search)里的关键字,已由内核过滤过条目; +// 日历自己的搜索框只在返回结果上再做一次本地细化,两者共用同一个清除入口与提示状态。 +const getCalendarHTML = (data: IAV, blockElement: HTMLElement, editable = true, databaseQuery = "") => { + const calendar = data.view as IAVCalendar; + const viewMode = getCalendarViewMode(calendar, blockElement); + const weekStart = getSafeWeekStart(calendar.weekStart); + const mapping = getCalendarFieldMapping(calendar); + if (!mapping.hasDateField) { + return renderDateFieldSetup(calendar, editable); + } + const anchor = dayjs(blockElement.dataset.calendarDate || undefined); + const safeAnchor = anchor.isValid() ? anchor : dayjs(); + const visibleRange = viewMode === 3 ? getAgendaRange(safeAnchor, blockElement) : getVisibleRange(safeAnchor, viewMode, weekStart); + const search = getCalendarSearch(blockElement); + const hasSearchQuery = !!search || !!databaseQuery.trim(); + const range = hasSearchQuery ? getCalendarSearchResultRange(calendar, mapping, visibleRange) : visibleRange; + const normalized = normalizeCalendarEvents(calendar, mapping, range); + const filter = getCalendarFilter(blockElement); + const filteredEvents = normalized.events.filter(event => eventMatchesCalendarFilter(event, filter)); + const totalEventCount = normalized.events.length; + const events = filteredEvents.filter(event => eventMatchesSearch(event, search)); + const hasLocalQuery = !!search || filter !== "all"; + const hasActiveQuery = !!search || filter !== "all" || !!databaseQuery; + const expandAllDay = blockElement.dataset.calendarAllDayExpanded === "true"; + const searchFilterID = `calendar-search-filter-${blockElement.getAttribute("data-node-id") || blockElement.getAttribute("data-av-id") || "view"}`; + const title = getCalendarTitle(safeAnchor, range, viewMode); + const weekNumber = getISOCalendarWeekNumber(safeAnchor); + const weekNumberLabel = (window.siyuan.languages.calendarWeekNumber || "Week ${x}").replace("${x}", String(weekNumber)); + let body = hasSearchQuery ? renderList(range, events, true, editable) : renderMonth(safeAnchor, range, events, weekStart, editable); + if (!hasSearchQuery && viewMode === 1) { + body = renderWeek(range, events, editable, expandAllDay); + } else if (!hasSearchQuery && viewMode === 2) { + body = renderDay(safeAnchor, events, editable, expandAllDay); + } else if (!hasSearchQuery && viewMode === 3) { + body = renderList(range, events, true, editable, true); + } else if (!hasSearchQuery && viewMode === 4) { + body = renderYear(safeAnchor, events, weekStart, editable); + } else if (!hasSearchQuery && viewMode === 5) { + body = renderFiveDay(range, events, editable, expandAllDay); + } + + blockElement.dataset.baseEvents = JSON.stringify(Array.from(normalized.baseEventsByID.keys())); + return `
+
+ + + +
+ ${escapeHtml(title)} + ${escapeHtml(weekNumberLabel)} +
+
+ + + ${renderCalendarFilter(filter, searchFilterID)} +
+ ${hasLocalQuery ? `${events.length}/${totalEventCount}` : ""}${hasActiveQuery ? `` : ""} + ${renderModeSwitcher(viewMode)} + ${editable ? `` : ""} + +
+
+ + +
${body}
+
+
`; +}; + +/** + * Listeners that do NOT die with the markup they were bound to. + * + * A re-render replaces the calendar's innerHTML, so a listener on a chip or on + * the calendar root goes away with it. The key map, the context menu and the + * mini month also reach OUTSIDE that subtree (document-level pointer/keydown + * listeners, a popover parented to ), so each of them hands back an + * unbind that has to be called by the render that replaces them - otherwise + * every re-render stacks another live listener on top of the last. + */ +const calendarTeardowns = new WeakMap void>>(); + +const addCalendarTeardown = (blockElement: HTMLElement, teardown: () => void) => { + const teardowns = calendarTeardowns.get(blockElement) || []; + teardowns.push(teardown); + calendarTeardowns.set(blockElement, teardowns); +}; + +const runCalendarTeardowns = (blockElement: HTMLElement) => { + const teardowns = calendarTeardowns.get(blockElement); + calendarTeardowns.delete(blockElement); + teardowns?.forEach(teardown => { + try { + teardown(); + } catch (error) { + console.error("calendar teardown failed", error); + } + }); +}; + +const bindCalendarEvents = (options: IRenderCalendarOptions, data: IAV) => { + // Before anything is bound: everything the PREVIOUS render left alive. + runCalendarTeardowns(options.blockElement); + const calendarElement = options.blockElement.querySelector(".av__calendar") as HTMLElement; + const calendar = data.view as IAVCalendar; + const viewMode = getCalendarViewMode(calendar, options.blockElement); + const weekStart = getSafeWeekStart(calendar.weekStart); + const editable = !options.protyle.disabled && !hasClosestByAttribute(options.blockElement, "data-type", "NodeBlockQueryEmbed"); + // "Each entry is a page" is a per-view setting; every creation site in this + // renderer (time slot, day cell, toolbar button, dialog) branches on this one + // value so they can never diverge. + const createsDocuments = calendarCreatesDocuments(calendar, options.blockElement); + // The grid the listeners below measure against. It must be the same record + // the markup was built from, otherwise a click would resolve to a different + // minute than the one the user pointed at. + const gridElement = calendarElement?.querySelector(".av__calendar-time-grid") as HTMLElement; + const gridGeometry = getGridGeometry(calendarElement); + const rerender = (focusSearch = false, useCurrentData = false) => { + options.blockElement.removeAttribute("data-render"); + renderCalendar({...options, data: useCurrentData ? data : undefined}).then(() => { + if (!focusSearch) { + return; + } + const searchInput = options.blockElement.querySelector('[data-type="calendar-search"]') as HTMLInputElement; + searchInput?.focus(); + searchInput?.setSelectionRange(searchInput.value.length, searchInput.value.length); + }).catch((error) => { + // 重绘抛错时 data-render 已被摘掉:不恢复标记的话日历会永久停在陈旧画面, + // 而且用户看不到任何提示(这正是渲染类缺陷难以察觉的原因)。 + options.blockElement.setAttribute("data-render", "true"); + showMessage(window.siyuan.languages._kernel[258]); + console.error("calendar rerender failed", error); + }); + }; + const setCalendarAnchor = (date: dayjs.Dayjs) => { + options.blockElement.dataset.calendarDate = date.format("YYYY-MM-DD"); + rerender(); + }; + const getCurrentAnchor = () => { + const anchor = dayjs(options.blockElement.dataset.calendarDate || undefined); + return anchor.isValid() ? anchor : dayjs(); + }; + const seekEvent = (direction: -1 | 1) => { + const mapping = getCalendarFieldMapping(calendar); + if (!mapping.hasDateField) { + return; + } + const anchor = getCurrentAnchor(); + const search = getCalendarSearch(options.blockElement); + const filter = getCalendarFilter(options.blockElement); + const events = normalizeCalendarEvents(calendar, mapping, getEventSeekRange(anchor)).events + .filter(event => eventMatchesCalendarFilter(event, filter)) + .filter(event => eventMatchesSearch(event, search)) + .filter(event => direction > 0 ? event.start.isAfter(anchor, "day") : event.start.isBefore(anchor, "day")); + const target = direction > 0 ? sortCalendarEvents(events)[0] : sortCalendarEvents(events).reverse()[0]; + if (target) { + setCalendarAnchor(target.start); + } else { + showMessage(window.siyuan.languages.calendarNoMatchingEvent || window.siyuan.languages.emptyContent || "No matching event"); + } + }; + const withCalendarOperationFeedback = async (operationElement: HTMLElement | null, operationLabel: string, failureMessage: string, callback: () => Promise) => { + if (operationElement?.dataset.calendarOperation === "pending") { + return false; + } + if (operationElement) { + operationElement.dataset.calendarOperation = "pending"; + operationElement.setAttribute("aria-busy", "true"); + operationElement.classList.add("av__calendar-event--pending"); + } + try { + const saved = await callback(); + if (!saved) { + showMessage(`${failureMessage || window.siyuan.languages._kernel[258]} ${window.siyuan.languages.calendarEventRestored || "Event restored."}`); + rerender(); + return false; + } + return true; + } catch (error) { + showMessage(`${failureMessage} ${window.siyuan.languages.calendarEventRestored || "Event restored."}`); + rerender(); + return false; + } finally { + if (operationElement) { + delete operationElement.dataset.calendarOperation; + operationElement.removeAttribute("aria-busy"); + operationElement.classList.remove("av__calendar-event--pending"); + } + } + }; + // Every dialog entry point in this renderer goes through here, so the + // "new entries are pages" decision cannot be forgotten at one of them. + const openCalendarEventDialog = (dialogOptions: { + date: string; + event?: ICalendarNormalizedEvent; + seriesEvent?: ICalendarNormalizedEvent; + draft?: Partial; + readOnly?: boolean; + onSave?: () => void; + onDelete?: () => void; + }) => { + openEventDialog({ + protyle: options.protyle, + blockElement: options.blockElement, + data, + createAsDocument: createsDocuments, + templateID: data.defaultTemplateID || "", + ...dialogOptions, + }); + }; + const openFullCalendarCreate = (draft: ICalendarEventDraft) => { + openCalendarEventDialog({ + date: draft.date, + draft, + onSave: rerender, + }); + }; + const setCalendarViewMode = (mode: number) => { + const persistedMode = getSafeViewMode(calendar.viewMode); + const hasLocalOverride = !!options.blockElement.dataset.calendarViewMode; + if (!CALENDAR_VIEW_MODES.includes(mode) || (mode === persistedMode && !hasLocalOverride && mode === viewMode)) { + return; + } + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if (!editable || !avID || !blockID) { + options.blockElement.dataset.calendarViewMode = String(mode); + rerender(false, true); + return; + } + if (mode === persistedMode) { + // Leaving a local peek for the persisted mode must not touch av.json. + delete options.blockElement.dataset.calendarViewMode; + calendar.viewMode = mode; + rerender(); + return; + } + transaction(options.protyle, [{ + action: "setAttrViewCalendarViewMode", + avID, + blockID, + data: mode, + viewID: data.viewID, + }], [{ + action: "setAttrViewCalendarViewMode", + avID, + blockID, + data: persistedMode, + viewID: data.viewID, + }]); + delete options.blockElement.dataset.calendarViewMode; + calendar.viewMode = mode; + rerender(); + }; + + calendarElement?.querySelector('[data-type="calendar-prev"]')?.addEventListener("click", () => { + setCalendarAnchor(getNavDate(getCurrentAnchor(), viewMode, -1)); + }); + calendarElement?.querySelector('[data-type="calendar-next"]')?.addEventListener("click", () => { + setCalendarAnchor(getNavDate(getCurrentAnchor(), viewMode, 1)); + }); + calendarElement?.querySelector('[data-type="calendar-prev-event"]')?.addEventListener("click", () => seekEvent(-1)); + calendarElement?.querySelector('[data-type="calendar-next-event"]')?.addEventListener("click", () => seekEvent(1)); + calendarElement?.querySelector('[data-type="calendar-today"]')?.addEventListener("click", () => { + setCalendarAnchor(dayjs()); + }); + + const startAllDayCreate = (surface: HTMLElement) => { + if (!editable) { + return; + } + const date = surface.dataset.date || dayjs().format("YYYY-MM-DD"); + openFullCalendarCreate({ + title: "", + date, + endDate: date, + isAllDay: true, + startTime: "09:00", + endTime: "09:30", + }); + }; + + calendarElement?.querySelectorAll('[data-type="calendar-drop-day"]').forEach(item => { + item.addEventListener("click", (event: MouseEvent) => { + if ((event.target as HTMLElement).closest(`.av__calendar-event, .av__calendar-quick-create, [data-type="${CALENDAR_TIME_CREATE_TYPE}"], button, input, select`)) { + return; + } + const date = (item as HTMLElement).dataset.date; + if (!date) { + return; + } + if ((item as HTMLElement).classList.contains("av__calendar-allday-cell")) { + event.preventDefault(); + event.stopPropagation(); + startAllDayCreate(item as HTMLElement); + return; + } + if (viewMode === 0 && (item as HTMLElement).classList.contains("av__calendar-day")) { + (item as HTMLElement).querySelector('[data-type="calendar-new"]')?.click(); + return; + } + if (options.blockElement.dataset.calendarDate === date) { + return; + } + if ((item as HTMLElement).classList.contains("av__calendar-day--muted")) { + // Adjacent-month cells navigate, so the visible month always + // matches the stored anchor. + setCalendarAnchor(dayjs(date)); + return; + } + // Select without re-rendering so the current view stays put; the + // anchor is picked up by view switches and prev/next navigation. + options.blockElement.dataset.calendarDate = date; + + calendarElement.querySelectorAll(".av__calendar-day--selected").forEach(selectedElement => selectedElement.classList.remove("av__calendar-day--selected")); + (item as HTMLElement).classList.add("av__calendar-day--selected"); + }); + if ((item as HTMLElement).classList.contains("av__calendar-allday-cell")) { + item.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + startAllDayCreate(item as HTMLElement); + }); + } + }); + calendarElement?.querySelectorAll('[data-type="calendar-more"]').forEach(item => { + item.addEventListener("click", (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const date = (item as HTMLElement).dataset.date; + if (!date) { + return; + } + if ((item as HTMLElement).classList.contains("av__calendar-allday-more")) { + options.blockElement.dataset.calendarAllDayExpanded = "true"; + rerender(false, true); + return; + } + // Peek at the day locally without persisting the saved view mode. + options.blockElement.dataset.calendarDate = date; + options.blockElement.dataset.calendarViewMode = "2"; + rerender(false, true); + }); + }); + calendarElement?.querySelector('[data-type="calendar-list-more"]')?.addEventListener("click", () => { + const currentDays = Math.max(parseInt(options.blockElement.dataset.calendarAgendaDays || "30", 10) || 30, 30); + options.blockElement.dataset.calendarAgendaDays = String(currentDays + 30); + rerender(false, true); + }); + // Creating in the timed grid: one create surface per day column instead of + // 48 slot buttons. The minute comes from where the pointer actually is, + // snapped to CALENDAR_SNAP_MINUTES, so a click at 12:47 creates 12:45 - not + // "the half hour this click landed in". + const startTimedQuickCreate = (surface: HTMLElement, startMinute: number) => { + const date = surface.dataset.date || dayjs().format("YYYY-MM-DD"); + const start = snapMinutes(startMinute, gridGeometry); + const end = Math.min(start + CALENDAR_DEFAULT_EVENT_MINUTES, gridGeometry.dayEndMinute); + const draft: ICalendarEventDraft = { + title: "", + date, + endDate: date, + isAllDay: false, + startTime: formatClockLabel(start), + endTime: formatClockLabel(end), + }; + openFullCalendarCreate(draft); + }; + calendarElement?.querySelectorAll(`[data-type="${CALENDAR_TIME_CREATE_TYPE}"]`).forEach(item => { + const surface = item as HTMLElement; + surface.addEventListener("click", (event: MouseEvent) => { + if (!editable) { + return; + } + const rect = surface.getBoundingClientRect(); + // A keyboard "click" has no coordinates; land on the working morning. + const pointerMinute = rect.height > 0 && (event.clientY || 0) > 0 ? + offsetPxToMinute(event.clientY - rect.top, gridGeometry) : + 9 * 60; + startTimedQuickCreate(surface, pointerMinute); + }); + surface.addEventListener("keydown", (event: KeyboardEvent) => { + if (!editable || (event.key !== "Enter" && event.key !== " ")) { + return; + } + event.preventDefault(); + startTimedQuickCreate(surface, 9 * 60); + }); + }); + // Call site reserved for the pointer gesture module (sweep-to-create, + // drag-to-move, edge resize). It binds to the contract this renderer emits: + // [data-type="calendar-time-create"] for empty space, .av__calendar-timed-event + // (data-start-minute / data-end-minute) for chips and + // [data-type="calendar-resize-handle"][data-edge] for the edges. Every write + // it performs must go through applyScopedEventDraft below, never straight to + // a transaction, and it must no-op when `editable` is false. + calendarElement?.querySelectorAll('[data-type="calendar-new"]').forEach(item => { + item.addEventListener("click", () => { + if (!editable) { + return; + } + const newElement = item as HTMLElement; + const date = newElement.dataset.date || dayjs().format("YYYY-MM-DD"); + const draft: ICalendarEventDraft = { + title: "", + date, + endDate: date, + isAllDay: true, + startTime: "09:00", + endTime: "09:30", + }; + openFullCalendarCreate(draft); + }); + }); + calendarElement?.querySelectorAll('[data-type="calendar-drop-day"]').forEach(item => { + item.addEventListener("dblclick", (event: MouseEvent) => { + if (!editable || (event.target as HTMLElement).closest(".av__calendar-event, [data-type='calendar-new'], [data-type='calendar-time-create']")) { + return; + } + openCalendarEventDialog({date: (item as HTMLElement).dataset.date || dayjs().format("YYYY-MM-DD"), onSave: rerender}); + }); + }); + const searchInput = calendarElement?.querySelector('[data-type="calendar-search"]') as HTMLInputElement; + const searchControl = calendarElement?.querySelector('[data-type="calendar-search-control"]') as HTMLElement; + const searchDropdown = searchControl?.querySelector('[data-type="calendar-search-dropdown"]') as HTMLElement; + const filterToggle = searchControl?.querySelector('[data-type="calendar-filter-toggle"]') as HTMLElement; + const setSearchDropdownOpen = (open: boolean) => { + if (!searchDropdown) { + return; + } + searchDropdown.classList.toggle("fn__none", !open); + searchInput?.setAttribute("aria-expanded", String(open)); + filterToggle?.setAttribute("aria-expanded", String(open)); + if (open) { + options.blockElement.dataset.calendarSearchDropdown = "true"; + } else { + delete options.blockElement.dataset.calendarSearchDropdown; + } + }; + const openSearchDropdown = () => setSearchDropdownOpen(true); + if (options.blockElement.dataset.calendarSearchDropdown === "true") { + setSearchDropdownOpen(true); + } + searchInput?.addEventListener("click", openSearchDropdown); + searchInput?.addEventListener("focus", openSearchDropdown); + searchInput?.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || options.blockElement.dataset.calendarSearchDropdown !== "true") { + return; + } + event.preventDefault(); + event.stopPropagation(); + setSearchDropdownOpen(false); + }); + filterToggle?.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + setSearchDropdownOpen(searchDropdown?.classList.contains("fn__none") !== false); + }); + searchControl?.querySelectorAll('[data-type="calendar-filter-option"]').forEach(item => { + item.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const filter = (item as HTMLElement).dataset.filter || "all"; + if (filter === "all") { + delete options.blockElement.dataset.calendarFilter; + } else { + options.blockElement.dataset.calendarFilter = filter; + } + setSearchDropdownOpen(false); + // A programmatic or assistive click can leave the search input focused. + // Blur it before rerendering so focus restoration cannot reopen the menu. + searchInput?.blur(); + rerender(false, true); + }); + }); + const closeSearchDropdownFromOutside = (event: PointerEvent) => { + if (!searchControl?.contains(event.target as Node)) { + setSearchDropdownOpen(false); + } + }; + document.addEventListener("pointerdown", closeSearchDropdownFromOutside); + addCalendarTeardown(options.blockElement, () => document.removeEventListener("pointerdown", closeSearchDropdownFromOutside)); + let calendarSearchTimeout: number; + searchInput?.addEventListener("input", () => { + options.blockElement.dataset.calendarSearch = searchInput.value.trim(); + window.clearTimeout(calendarSearchTimeout); + calendarSearchTimeout = window.setTimeout(() => rerender(true), Constants.TIMEOUT_INPUT); + }); + addCalendarTeardown(options.blockElement, () => window.clearTimeout(calendarSearchTimeout)); + + // 工具栏放大镜的关键字由内核过滤,清除时必须一并清掉并重新取数, + // 否则用户点了“清除”仍看不到被内核过滤掉的条目。 + const clearDatabaseQuery = () => { + const headerSearchElement = options.blockElement.querySelector('[data-type="av-search"]') as HTMLElement; + if (!headerSearchElement?.textContent) { + return false; + } + headerSearchElement.textContent = ""; + options.blockElement.querySelector(".av__views")?.classList.remove("av__views--show"); + return true; + }; + + calendarElement?.querySelector('[data-type="calendar-clear-search"]')?.addEventListener("click", () => { + clearDatabaseQuery(); + delete options.blockElement.dataset.calendarSearch; + delete options.blockElement.dataset.calendarFilter; + // The current render may contain only the cards returned by the kernel + // query. Fetch again so clearing restores the complete calendar. + rerender(true); + }); + /** + * Back out, in the order a user expects: an in-flight drag/sweep first (it + * is the most recent thing they started), then the search/filter. Returns + * false when there was nothing to back out, so Escape keeps reaching the + * app's own handling instead of being silently swallowed. + */ + const backOutOfCalendar = () => { + closeCalendarEventMenu(); + if (isCalendarGestureActive()) { + abortActiveCalendarGesture(); + return true; + } + if (options.blockElement.dataset.calendarSearchDropdown === "true") { + setSearchDropdownOpen(false); + searchInput?.focus(); + return true; + } + const hasQuery = !!getCalendarSearch(options.blockElement) || + getCalendarFilter(options.blockElement) !== "all" || + !!(options.blockElement.querySelector('[data-type="av-search"]') as HTMLElement)?.textContent; + if (!hasQuery) { + return false; + } + clearDatabaseQuery(); + delete options.blockElement.dataset.calendarSearch; + delete options.blockElement.dataset.calendarFilter; + rerender(); + return true; + }; + // The whole key map - the legacy 1-4 / arrows / [ ] / T / N / "/" / Escape + // set plus Google Calendar's d w m x a j k p c ? - lives in keymap.ts. The + // focus-scope bug this replaces: the old block bailed on BUTTON, and every + // interactive element in the calendar is a button, so the shortcuts died the + // moment anything was clicked. The unbind is registered so a re-render can + // never stack a second listener on the same calendar. + addCalendarTeardown(options.blockElement, bindCalendarKeymap(calendarElement, { + setViewMode: (mode: number) => setCalendarViewMode(mode), + goToRange: (direction) => setCalendarAnchor(getNavDate(getCurrentAnchor(), viewMode, direction)), + goToToday: () => setCalendarAnchor(dayjs()), + // The one shared create entry point: the same toolbar button a click + // uses, so the keyboard can never diverge from the pointer (and it is + // simply absent on a read-only calendar). + createEvent: () => { + if (!(editable && mapping.hasDateField)) { + return; + } + (calendarElement.querySelector('[data-type="calendar-new"]:not(.av__calendar-daynum)') as HTMLElement)?.click(); + }, + focusSearch: () => (calendarElement.querySelector('[data-type="calendar-search"]') as HTMLInputElement)?.focus(), + seekEvent: (direction) => seekEvent(direction), + escape: backOutOfCalendar, + })); + + const emptyDateFieldElement = calendarElement?.querySelector('[data-type="calendar-empty-date-field"]') as HTMLSelectElement; + emptyDateFieldElement?.addEventListener("change", () => { + if (!editable) { + emptyDateFieldElement.value = ""; + return; + } + const current = emptyDateFieldElement.value; + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if (!current || !avID || !blockID) { + return; + } + transaction(options.protyle, [{ + action: "setAttrViewCalendarDateField", + avID, + blockID, + data: current, + viewID: data.viewID, + }], [{ + action: "setAttrViewCalendarDateField", + avID, + blockID, + data: calendar.dateFieldID || "", + viewID: data.viewID, + }]); + calendar.dateFieldID = current; + rerender(); + }); + calendarElement?.querySelector('[data-type="calendar-create-date-field"]')?.addEventListener("click", () => { + if (!editable) { + return; + } + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if (!avID || !blockID) { + return; + } + const keyID = Lute.NewNodeID(); + const keyName = window.siyuan.languages.date || window.siyuan.languages.dateField || "Date"; + transaction(options.protyle, [{ + action: "addAttrViewCol", + avID, + id: keyID, + name: keyName, + type: "date", + }, { + action: "setAttrViewCalendarDateField", + avID, + blockID, + keyID, + data: keyID, + viewID: data.viewID, + }], [{ + action: "setAttrViewCalendarDateField", + avID, + blockID, + keyID: calendar.dateFieldID || "", + data: calendar.dateFieldID || "", + viewID: data.viewID, + }, { + action: "removeAttrViewCol", + avID, + id: keyID, + }]); + rerender(); + }); + const viewMenuTrigger = calendarElement?.querySelector('[data-type="calendar-view-menu"]') as HTMLElement; + viewMenuTrigger?.addEventListener("click", (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + window.siyuan.menus.menu.remove(); + const menu = new Menu("calendar-view-menu", () => viewMenuTrigger.setAttribute("aria-expanded", "false")); + CALENDAR_VIEW_MENU_ITEMS.forEach(item => { + menu.addItem({ + id: `calendar-view-${item.mode}`, + icon: "", + label: getViewModeLabel(item.mode), + accelerator: item.accelerator, + current: item.mode === viewMode, + click: () => setCalendarViewMode(item.mode), + }); + }); + const rect = viewMenuTrigger.getBoundingClientRect(); + viewMenuTrigger.setAttribute("aria-expanded", "true"); + menu.open({x: rect.left, y: rect.bottom}); + }); + const anchor = dayjs(options.blockElement.dataset.calendarDate || undefined); + const range = getVisibleRange(anchor.isValid() ? anchor : dayjs(), viewMode, weekStart); + const mapping = getCalendarFieldMapping(calendar); + const normalizedForEvents = normalizeCalendarEvents(calendar, mapping, range); + const baseEvents = normalizedForEvents.baseEventsByID; + const renderedEvents = new Map(); + normalizedForEvents.events.forEach(event => { + renderedEvents.set(event.occurrenceID || event.id, event); + }); + /** + * The mini month beside the main view. + * + * Its dots are computed over the whole anchor MONTH, not over the visible + * range: in Week or Day view the visible range is a handful of days, and a + * navigator that only dots the week you are already looking at is useless for + * the thing it exists for - seeing where the busy days are before you go + * there. Paging the navigator repaints only itself; the main view moves only + * when a day is clicked. + */ + const miniMonthAnchor = getCurrentAnchor(); + const miniMonthRange = getVisibleRange(miniMonthAnchor, 0, weekStart); + const miniMonthEvents = viewMode === 0 ? + normalizedForEvents.events : + normalizeCalendarEvents(calendar, mapping, miniMonthRange).events; + addCalendarTeardown(options.blockElement, bindCalendarMiniMonth( + calendarElement?.querySelector('[data-type="calendar-mini-month-wrapper"]') as HTMLElement, + { + anchor: miniMonthAnchor, + range, + weekStart, + locale: getCalendarLocale(), + }, + {onSelectDate: (date) => setCalendarAnchor(date)}, + )); + const getEditableEvent = (sourceEvent: ICalendarNormalizedEvent) => { + if (!sourceEvent.isOccurrence || mapping.exceptionFieldID) { + return sourceEvent; + } + return baseEvents.get(sourceEvent.baseEventID || sourceEvent.id) || sourceEvent; + }; + const updateEventWithDraft = (sourceEvent: ICalendarNormalizedEvent, draft: ICalendarEventDraft, operationElement: HTMLElement | null, operationLabel: string, failureMessage: string, scope: CalendarRecurrenceScope = "series") => { + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if (!avID || !blockID || !mapping.dateFieldID) { + showMessage(`${failureMessage} ${window.siyuan.languages.calendarEventRestored || "Event restored."}`); + rerender(); + return; + } + const transactionOptions = { + protyle: options.protyle, + avID, + blockID, + dateFieldID: mapping.dateFieldID, + fields: calendar.fields, + mapping, + event: sourceEvent, + draft, + viewID: data.viewID, + previousUpdated: options.blockElement.getAttribute("updated") || "", + }; + withCalendarOperationFeedback(operationElement, operationLabel, failureMessage, async () => { + const pageOptions = createsDocuments ? { + createAsDocument: true, + templateID: data.defaultTemplateID || "", + } : {}; + const saved = await (scope === "occurrence" ? createCalendarEventReplacingOccurrence({ + ...transactionOptions, + ...pageOptions, + occurrenceDate: sourceEvent.start.format("YYYY-MM-DD"), + }) : scope === "future" ? updateCalendarEventThisAndFuture({ + ...transactionOptions, + ...pageOptions, + occurrenceDate: sourceEvent.start.format("YYYY-MM-DD"), + }) : updateCalendarEvent(transactionOptions)); + if (saved) { + rerender(); + } + return saved; + }); + }; + // Direct manipulation (drag move, resize, schedule drop) of a recurring item + // must ask the user for scope, exactly like dialog edits do (P0.6). + const applyScopedEventDraft = (sourceEvent: ICalendarNormalizedEvent, buildDraft: (target: ICalendarNormalizedEvent) => ICalendarEventDraft, operationElement: HTMLElement | null, operationLabel: string, failureMessage: string, action: "move" | "resize") => { + if (!sourceEvent.isOccurrence && !isRecurringSourceEvent(sourceEvent)) { + updateEventWithDraft(sourceEvent, buildDraft(sourceEvent), operationElement, operationLabel, failureMessage); + return; + } + openRecurrenceScopeDialog({ + action, + disabledScopes: getDisabledRecurrenceScopes(mapping, "edit", sourceEvent), + onSelect: (scope) => { + if (scope === "series") { + const baseEvent = baseEvents.get(sourceEvent.baseEventID || sourceEvent.id) || sourceEvent; + updateEventWithDraft(baseEvent, buildDraft(baseEvent), operationElement, operationLabel, failureMessage, "series"); + return; + } + updateEventWithDraft(sourceEvent, buildDraft(sourceEvent), operationElement, operationLabel, failureMessage, scope); + }, + }); + }; + const buildDraftForDate = (sourceEvent: ICalendarNormalizedEvent, targetDate: string): ICalendarEventDraft => { + const durationDays = Math.max((sourceEvent.end || sourceEvent.start).startOf("day").diff(sourceEvent.start.startOf("day"), "day"), 0); + return { + title: sourceEvent.title, + date: targetDate, + endDate: dayjs(targetDate).add(durationDays, "day").format("YYYY-MM-DD"), + isAllDay: sourceEvent.isAllDay, + startTime: sourceEvent.start.format("HH:mm"), + endTime: sourceEvent.end ? sourceEvent.end.format("HH:mm") : sourceEvent.start.add(1, "hour").format("HH:mm"), + recurrenceRaw: sourceEvent.recurrenceRaw, + location: sourceEvent.location, + description: sourceEvent.description, + colorContent: sourceEvent.colorContent, + }; + }; + const duplicateEventToNextDay = (sourceEvent: ICalendarNormalizedEvent, operationElement: HTMLElement | null) => { + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + if (!avID || !blockID || !mapping.dateFieldID) { + showMessage(window.siyuan.languages.calendarCreateFailed || "Create failed."); + return; + } + const draft = buildDraftForDate(sourceEvent, sourceEvent.start.add(1, "day").format("YYYY-MM-DD")); + draft.recurrenceRaw = ""; + draft.recurrenceExceptionRaw = ""; + withCalendarOperationFeedback(operationElement, window.siyuan.languages.saved || "Saved", window.siyuan.languages.calendarCreateFailed || "Create failed.", async () => { + const createOptions: ICalendarCreateOptions = { + protyle: options.protyle, + avID, + blockID, + viewID: data.viewID, + dateFieldID: mapping.dateFieldID, + fields: calendar.fields, + mapping, + draft, + previousUpdated: options.blockElement.getAttribute("updated") || "", + }; + const saved = createsDocuments ? + Boolean(await createCalendarEventAsDocument({...createOptions, templateID: data.defaultTemplateID || ""})) : + await createCalendarEvent(createOptions); + if (saved) { + rerender(); + } + return saved; + }); + }; + /** + * Change how LONG an entry lasts by moving its end: whole days for an all-day + * entry, minutes for a timed one. This is exactly what the chip's inline + * -1d/+1d and -15m/+15m buttons did. Those buttons are gone - the same change + * is now a menu item (context-menu.ts emits data-type="calendar-resize" with + * data-days / data-delta, the same contract) or a drag on the chip's edge - + * but the write path is unchanged: applyScopedEventDraft, so a recurring + * entry is still asked for scope and a rejected write still rolls back. + */ + const applyCalendarDurationChange = (sourceEvent: ICalendarNormalizedEvent, change: {days?: number, delta?: number}, operationElement: HTMLElement | null) => { + if (!editable) { + return; + } + if (sourceEvent.isAllDay) { + const deltaDays = change.days || 0; + if (!deltaDays) { + return; + } + const sourceEnd = (sourceEvent.end || sourceEvent.start.endOf("day")).add(deltaDays, "day"); + if (sourceEnd.isBefore(sourceEvent.start, "day")) { + return; + } + const nextDurationDays = Math.max(sourceEnd.startOf("day").diff(sourceEvent.start.startOf("day"), "day"), 0); + applyScopedEventDraft(sourceEvent, (target) => ({ + title: target.title, + date: target.start.format("YYYY-MM-DD"), + endDate: target.start.startOf("day").add(nextDurationDays, "day").format("YYYY-MM-DD"), + isAllDay: true, + startTime: target.start.format("HH:mm"), + endTime: target.end ? target.end.format("HH:mm") : "23:59", + recurrenceRaw: target.recurrenceRaw, + location: target.location, + description: target.description, + colorContent: target.colorContent, + }), operationElement, window.siyuan.languages.saved || "Saved", window.siyuan.languages.calendarResizeFailed || "Resize failed.", "resize"); + return; + } + const delta = change.delta || 0; + const currentEnd = sourceEvent.end || sourceEvent.start.add(1, "hour"); + const nextEnd = currentEnd.add(delta, "minute"); + if (!delta || !nextEnd.isAfter(sourceEvent.start)) { + return; + } + const nextDuration = nextEnd.diff(sourceEvent.start, "minute"); + applyScopedEventDraft(sourceEvent, (target) => { + const targetEnd = target.start.add(nextDuration, "minute"); + return { + title: target.title, + date: target.start.format("YYYY-MM-DD"), + endDate: targetEnd.format("YYYY-MM-DD"), + isAllDay: false, + startTime: target.start.format("HH:mm"), + endTime: targetEnd.format("HH:mm"), + recurrenceRaw: target.recurrenceRaw, + location: target.location, + description: target.description, + colorContent: target.colorContent, + }; + }, operationElement, window.siyuan.languages.saved || "Saved", window.siyuan.languages.calendarResizeFailed || "Resize failed.", "resize"); + }; + /** + * Move the WHOLE entry, keeping its duration. The menu offers this in the + * same steps a drag would give you (±15m, ±1d) for the times a drag is not + * practical - a chip in a crowded month cell, or no pointer at all. + */ + const shiftCalendarEvent = (sourceEvent: ICalendarNormalizedEvent, shift: {days?: number, minutes?: number}, operationElement: HTMLElement | null) => { + if (!editable) { + return; + } + const days = shift.days || 0; + const minutes = shift.minutes || 0; + if (!days && !minutes) { + return; + } + applyScopedEventDraft(sourceEvent, (target) => { + const targetStart = target.start.add(days, "day").add(minutes, "minute"); + const targetEnd = (target.end || target.start.add(CALENDAR_DEFAULT_EVENT_MINUTES, "minute")).add(days, "day").add(minutes, "minute"); + return { + title: target.title, + date: targetStart.format("YYYY-MM-DD"), + endDate: targetEnd.format("YYYY-MM-DD"), + isAllDay: target.isAllDay, + startTime: targetStart.format("HH:mm"), + endTime: targetEnd.format("HH:mm"), + recurrenceRaw: target.recurrenceRaw, + location: target.location, + description: target.description, + colorContent: target.colorContent, + }; + }, operationElement, window.siyuan.languages.saved || "Saved", window.siyuan.languages.calendarMoveFailed || "Move failed.", "move"); + }; + /** + * Remove an entry from the calendar. This is the row/occurrence removal the + * dialog's Delete performs - never the page behind it, which stays a separate + * confirm-gated action in the dialog because undo can restore a row but not a + * document. + */ + const deleteCalendarEventWithScope = (sourceEvent: ICalendarNormalizedEvent, operationElement: HTMLElement | null, scope: CalendarRecurrenceScope) => { + const avID = options.blockElement.getAttribute("data-av-id"); + const blockID = options.blockElement.getAttribute("data-node-id"); + const failureMessage = window.siyuan.languages.calendarDeleteFailed || "Delete failed."; + if (!avID || !blockID) { + showMessage(`${failureMessage} ${window.siyuan.languages.calendarEventRestored || "Event restored."}`); + return; + } + withCalendarOperationFeedback(operationElement, window.siyuan.languages.saved || "Saved", failureMessage, async () => { + const removed = await (scope === "occurrence" && sourceEvent.isOccurrence && mapping.exceptionFieldID ? + deleteCalendarOccurrence({ + protyle: options.protyle, + avID, + blockID, + fields: calendar.fields, + mapping, + event: sourceEvent, + occurrenceDate: sourceEvent.start.format("YYYY-MM-DD"), + previousUpdated: options.blockElement.getAttribute("updated") || "", + }) : scope === "future" && mapping.recurrenceFieldID ? + deleteCalendarEventThisAndFuture({ + protyle: options.protyle, + avID, + blockID, + fields: calendar.fields, + mapping, + event: sourceEvent, + occurrenceDate: sourceEvent.start.format("YYYY-MM-DD"), + previousUpdated: options.blockElement.getAttribute("updated") || "", + }) : + deleteCalendarEvent({ + protyle: options.protyle, + avID, + blockID, + event: sourceEvent, + previousUpdated: options.blockElement.getAttribute("updated") || "", + })); + if (removed) { + rerender(); + } + return removed; + }); + }; + // Deleting a recurring series without asking would be data loss, so the menu + // uses the very same scope prompt the dialog's Delete uses. + const requestCalendarEventDelete = (sourceEvent: ICalendarNormalizedEvent, operationElement: HTMLElement | null) => { + if (!editable) { + return; + } + if (!sourceEvent.isOccurrence && !isRecurringSourceEvent(sourceEvent)) { + deleteCalendarEventWithScope(sourceEvent, operationElement, "series"); + return; + } + openRecurrenceScopeDialog({ + action: "delete", + disabledScopes: getDisabledRecurrenceScopes(mapping, "delete", sourceEvent), + onSelect: (scope) => { + if (scope === "series") { + const baseEvent = baseEvents.get(sourceEvent.baseEventID || sourceEvent.id) || sourceEvent; + deleteCalendarEventWithScope(baseEvent, operationElement, "series"); + return; + } + deleteCalendarEventWithScope(sourceEvent, operationElement, scope); + }, + }); + }; + const resolveCalendarEvent = (element: HTMLElement | null) => + renderedEvents.get(element?.dataset.occurrence || "") || baseEvents.get(element?.dataset.id || ""); + const openEventSchedulingFor = (calendarEvent?: ICalendarNormalizedEvent) => { + if (!calendarEvent) { + return; + } + if (!editable) { + openCalendarEventDialog({event: calendarEvent, date: calendarEvent.start.format("YYYY-MM-DD"), readOnly: true}); + return; + } + const eventForDialog = getEditableEvent(calendarEvent); + const seriesEvent = baseEvents.get(calendarEvent.baseEventID || calendarEvent.id) || calendarEvent; + openCalendarEventDialog({event: eventForDialog, seriesEvent, date: eventForDialog.start.format("YYYY-MM-DD"), onSave: rerender, onDelete: rerender}); + }; + /** + * Right-click / long-press on a chip. The menu itself writes nothing: it + * reports a command with the same data-type values the old inline buttons + * carried, and everything is routed here into the same helpers a click, a + * drag or the dialog would use. It is never bound at all on a read-only or + * query-embed calendar. + */ + const runCalendarMenuCommand = (command: ICalendarMenuCommand) => { + if (!editable) { + return; + } + const sourceEvent = resolveCalendarEvent(command.eventElement) || command.event; + if (!sourceEvent) { + return; + } + if (command.type === "calendar-open-source") { + if (getEventDocumentID(sourceEvent)) { + openCalendarEventSource(options.protyle, options.blockElement, sourceEvent); + } + return; + } + if (command.type === "calendar-open-dialog") { + openEventSchedulingFor(sourceEvent); + return; + } + if (command.type === "calendar-duplicate-next-day") { + duplicateEventToNextDay(sourceEvent, command.eventElement); + return; + } + if (command.type === "calendar-resize") { + applyCalendarDurationChange(sourceEvent, {days: command.days, delta: command.delta}, command.eventElement); + return; + } + if (command.type === "calendar-shift") { + shiftCalendarEvent(sourceEvent, {days: command.days, minutes: command.minutes}, command.eventElement); + return; + } + if (command.type === "calendar-delete") { + requestCalendarEventDelete(sourceEvent, command.eventElement); + } + }; + addCalendarTeardown(options.blockElement, bindCalendarEventContextMenu({ + calendarElement, + editable, + resolveEvent: (element) => resolveCalendarEvent(element), + onCommand: runCalendarMenuCommand, + })); + const eventOpenTimers = new Set(); + addCalendarTeardown(options.blockElement, () => { + eventOpenTimers.forEach(timer => window.clearTimeout(timer)); + eventOpenTimers.clear(); + }); + calendarElement?.querySelectorAll(".av__calendar-event").forEach(item => { + const eventElement = item as HTMLElement; + eventElement.addEventListener("click", (event: MouseEvent) => { + event.preventDefault(); + const calendarEvent = resolveCalendarEvent(eventElement); + calendarElement.querySelectorAll(".av__calendar-event--selected").forEach(selected => selected.classList.remove("av__calendar-event--selected")); + eventElement.classList.add("av__calendar-event--selected"); + if (event.detail > 1) { + return; + } + const timer = window.setTimeout(() => { + eventOpenTimers.delete(timer); + openEventSchedulingFor(calendarEvent); + }, 220); + eventOpenTimers.add(timer); + }); + eventElement.addEventListener("dblclick", (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + eventOpenTimers.forEach(timer => window.clearTimeout(timer)); + eventOpenTimers.clear(); + const calendarEvent = resolveCalendarEvent(eventElement); + if (calendarEvent && getEventDocumentID(calendarEvent)) { + openCalendarEventSource(options.protyle, options.blockElement, calendarEvent); + return; + } + openEventSchedulingFor(calendarEvent); + }); + }); + calendarElement?.querySelectorAll(".av__calendar-event").forEach(item => { + item.addEventListener("dragstart", (event: DragEvent) => { + if (!editable) { + event.preventDefault(); + return; + } + const eventElement = item as HTMLElement; + event.dataTransfer?.setData("text/plain", JSON.stringify({ + id: eventElement.dataset.occurrence || eventElement.dataset.id || "", + displayDate: eventElement.dataset.date || "", + })); + event.dataTransfer.effectAllowed = "move"; + }); + }); + calendarElement?.querySelectorAll('[data-type="calendar-drop-day"]').forEach(item => { + item.addEventListener("dragover", (event: DragEvent) => { + if (!editable) { + return; + } + event.preventDefault(); + (item as HTMLElement).classList.add("av__calendar-day--dragover"); + }); + item.addEventListener("dragleave", () => { + (item as HTMLElement).classList.remove("av__calendar-day--dragover"); + }); + item.addEventListener("drop", (event: DragEvent) => { + event.preventDefault(); + (item as HTMLElement).classList.remove("av__calendar-day--dragover"); + if (!editable) { + return; + } + const rawDragData = event.dataTransfer?.getData("text/plain") || ""; + const targetDate = (item as HTMLElement).dataset.date; + let eventID = rawDragData; + let displayDate = ""; + try { + const parsed = JSON.parse(rawDragData); + eventID = parsed.id || ""; + displayDate = parsed.displayDate || ""; + } catch { + // Older drag payloads were plain event IDs. + } + const sourceEvent = renderedEvents.get(eventID) || baseEvents.get(eventID); + if (!sourceEvent || !targetDate) { + return; + } + const dragOffsetDays = displayDate ? Math.max(dayjs(displayDate).startOf("day").diff(sourceEvent.start.startOf("day"), "day"), 0) : 0; + const draftDate = dayjs(targetDate).subtract(dragOffsetDays, "day").format("YYYY-MM-DD"); + const draggedEventElement = calendarElement?.querySelector(`.av__calendar-event[data-occurrence="${eventID}"], .av__calendar-event[data-id="${eventID}"]`) as HTMLElement; + // Dropping into a day COLUMN also moves the event in time: the old + // grid could only change the day, because the drop target was the + // whole column and nothing read the pointer's minute. + const timeColumn = (item as HTMLElement).classList.contains("av__calendar-time-day") ? item as HTMLElement : null; + const dropRect = timeColumn?.getBoundingClientRect(); + const dropStartMinute = timeColumn && !sourceEvent.isAllDay && dropRect && dropRect.height > 0 && event.clientY > 0 ? + snapMinutes(offsetPxToMinute(event.clientY - dropRect.top, gridGeometry), gridGeometry) : + null; + applyScopedEventDraft(sourceEvent, (target) => { + const dayDelta = dayjs(draftDate).diff(sourceEvent.start.startOf("day"), "day"); + const targetDraftDate = target === sourceEvent ? + draftDate : + target.start.startOf("day").add(dayDelta, "day").format("YYYY-MM-DD"); + const draft = buildDraftForDate(target, targetDraftDate); + if (target.isAllDay && target.end && !target.start.isSame(target.end, "day")) { + draft.endTime = target.end?.format("HH:mm") || "23:59"; + } + if (dropStartMinute !== null && !target.isAllDay) { + // Preserve the duration; only the start moves. + const durationMinutes = Math.max((target.end || target.start.add(CALENDAR_DEFAULT_EVENT_MINUTES, "minute")).diff(target.start, "minute"), CALENDAR_DEFAULT_EVENT_MINUTES); + const nextStart = dayjs(targetDraftDate).startOf("day").add(dropStartMinute, "minute"); + const nextEnd = nextStart.add(durationMinutes, "minute"); + draft.startTime = nextStart.format("HH:mm"); + draft.endTime = nextEnd.format("HH:mm"); + draft.endDate = nextEnd.format("YYYY-MM-DD"); + } + return draft; + }, draggedEventElement, window.siyuan.languages.saved || "Saved", window.siyuan.languages.calendarMoveFailed || "Move failed.", "move"); + }); + }); + // Pointer gestures on the grid: sweep-to-create, drag-to-move (day AND time + // together) and edge resize. interactions.ts owns the state machine and + // never writes; every result it emits is routed through the same + // applyScopedEventDraft / openQuickCreate paths a click uses, so the + // recurrence-scope prompt, the pending state and the rollback on failure all + // still apply. It binds nothing at all when `editable` is false. + abortActiveCalendarGesture(); + bindCalendarPointerInteractions({ + calendarElement, + editable, + adapter: createCalendarGridAdapter(gridElement, gridGeometry), + resolveEvent: (element) => + renderedEvents.get(element.dataset.occurrence || "") || baseEvents.get(element.dataset.id || ""), + onResult: (result) => { + if (!editable) { + return; + } + if (result.type === "create") { + openFullCalendarCreate(result.draft); + return; + } + applyScopedEventDraft( + result.event, + result.buildDraft, + result.eventElement, + window.siyuan.languages.saved || "Saved", + result.type === "move" ? + (window.siyuan.languages.calendarMoveFailed || "Move failed.") : + (window.siyuan.languages.calendarResizeFailed || "Resize failed."), + result.type, + ); + }, + }); +}; + +// 重渲染会整块替换 HTML,焦点元素随之消失。用这些属性拼一个可复原的选择器, +// 使新建/移动/缩放事件后焦点仍停在原来的事件或时间格上。 +const CALENDAR_FOCUS_ATTRIBUTES = ["data-id", "data-occurrence", "data-date", "data-day-index", "data-mode"]; + +const isSelectorSafe = (value: string) => !!value && !/["\\]/.test(value); + +const getFocusAttributeSelector = (element: HTMLElement) => { + let selector = ""; + CALENDAR_FOCUS_ATTRIBUTES.forEach(attribute => { + const value = element.getAttribute(attribute); + if (isSelectorSafe(value)) { + selector += `[${attribute}="${value}"]`; + } + }); + return selector; +}; + +const getCalendarFocusSelector = (blockElement: HTMLElement): {selector: string, fallback: string} => { + const activeElement = document.activeElement as HTMLElement; + if (!activeElement || !blockElement.contains(activeElement) || !hasClosestByClassName(activeElement, "av__calendar")) { + return {selector: "", fallback: ""}; + } + if (activeElement.classList.contains("av__calendar-event")) { + const id = activeElement.getAttribute("data-id"); + return { + selector: `.av__calendar-event${getFocusAttributeSelector(activeElement)}`, + // 移动/缩放后事件可能落到别的日期格,此时按条目 ID 兜底 + fallback: isSelectorSafe(id) ? `.av__calendar-event[data-id="${id}"]` : "", + }; + } + const type = activeElement.getAttribute("data-type"); + if (!isSelectorSafe(type)) { + return {selector: "", fallback: ""}; + } + // 时间格等控件只有连同日期/时刻才能唯一定位,不做只按 data-type 的兜底 + return {selector: `[data-type="${type}"]${getFocusAttributeSelector(activeElement)}`, fallback: ""}; +}; + +const restoreCalendarFocus = (blockElement: HTMLElement, focus: {selector: string, fallback: string}) => { + if (!focus.selector) { + return; + } + const targetElement = (blockElement.querySelector(focus.selector) || + (focus.fallback ? blockElement.querySelector(focus.fallback) : null)) as HTMLElement; + if (!targetElement) { + return; + } + // preventScroll:滚动位置已单独恢复,聚焦不能再把网格拉走 + targetElement.focus({preventScroll: true}); + if (targetElement instanceof HTMLInputElement && ["text", "search"].includes(targetElement.type)) { + targetElement.setSelectionRange(targetElement.value.length, targetElement.value.length); + } +}; + +// 定位请求(siyuan://blocks/?avViewID=&avItemID=)指向的条目可能不在当前可见日期范围内, +// 先把锚定日期挪到该条目的开始日期,事件才会被渲染出来供 finishAVLocate 高亮。 +const anchorCalendarOnLocateTarget = (data: IAV, blockElement: HTMLElement) => { + const itemID = data.target?.itemID; + if (!itemID) { + return; + } + const calendar = data.view as IAVCalendar; + const mapping = getCalendarFieldMapping(calendar); + if (!mapping.hasDateField) { + return; + } + const card = calendar.cards?.find(item => item.id === itemID); + if (!card) { + return; + } + const content = getCellByFieldID(card, mapping.dateFieldID)?.value?.date?.content; + if (!content) { + return; + } + const start = dayjs(content); + if (start.isValid()) { + blockElement.dataset.calendarDate = start.format("YYYY-MM-DD"); + } +}; + +export const renderCalendar = async (options: IRenderCalendarOptions) => { + const e = options.blockElement; + const renderToken = beginAVRender(e); + const searchInputElement = e.querySelector('[data-type="av-search"]'); + const timeGridElement = e.querySelector(".av__calendar-time-grid") as HTMLElement; + const resetData = { + isSearching: !!searchInputElement && document.activeElement === searchInputElement, + // Keep the header AV search and the Calendar-local search visually + // independent. Either may drive the same kernel query, but typing in the + // Calendar field must not make the header field appear populated. + query: searchInputElement?.textContent || "", + kernelQuery: (searchInputElement?.textContent || getCalendarSearch(e)).trim(), + oldOffset: options.protyle.contentElement?.scrollTop, + scrollLeft: (e.querySelector(".av__scroll") as HTMLElement)?.scrollLeft || 0, + hadTimeGrid: !!timeGridElement, + gridScrollTop: timeGridElement?.scrollTop || 0, + gridScrollLeft: timeGridElement?.scrollLeft || 0, + focusTarget: getCalendarFocusSelector(e), + virtualData: {} as { [key: string]: IAVVirtualData }, + }; + let data = options.data; + if (!data) { + const created = options.protyle.options.history?.created; + const snapshot = options.protyle.options.history?.snapshot; + const locateParams = getAVLocateParams(e, !created && !snapshot); + const response = await fetchSyncPost(created ? "/api/av/renderHistoryAttributeView" : (snapshot ? "/api/av/renderSnapshotAttributeView" : "/api/av/renderAttributeView"), { + id: e.getAttribute("data-av-id"), + created, + snapshot, + // 日历目前不分页:内核的日历渲染路径只回填 CardCount/PageSize 而不切片, + // 所以整库条目都会被传回并归一化(payload 无上界),配置面板里的“条目数” + // 设置对日历没有任何作用(应在 av/layout.ts 里对日历隐藏该行)。 + // 未来的正解是按可见日期范围在服务端分页,而不是在前端补虚拟滚动。 + pageSize: -1, + viewID: locateParams?.viewID || e.getAttribute(Constants.CUSTOM_SY_AV_VIEW) || "", + query: resetData.kernelQuery, + blockID: e.getAttribute("data-node-id"), + // 浏览历史/快照时不能创建数据 + createIfNotExist: !created && !snapshot && !options.protyle.block.action?.includes(Constants.CB_GET_AV_NO_CREATE), + targetItemID: locateParams?.targetItemID || "", + targetGroupID: locateParams?.targetGroupID || "", + }); + data = response.data; + } + // 取数期间可能已有更新的渲染开始(或视图被切走),陈旧结果不能覆盖新结果 + if (!isCurrentAVRender(e, renderToken)) { + return; + } + if (!data) { + return; + } + prepareAVLocate(e, data, resetData); + // data-av-type 可能是陈旧值(视图布局在别处被改过),此时必须转交给对应的渲染器 + if (data.viewType === "table") { + e.setAttribute("data-av-type", data.viewType); + await avRender(e, options.protyle, options.cb, options.renderAll, data); + return; + } + if (data.viewType === "gallery") { + e.setAttribute("data-av-type", data.viewType); + await renderGallery({ + blockElement: e, + protyle: options.protyle, + cb: options.cb, + renderAll: options.renderAll, + data + }); + return; + } + if (data.viewType === "kanban") { + e.setAttribute("data-av-type", data.viewType); + await renderKanban({ + blockElement: e, + protyle: options.protyle, + cb: options.cb, + renderAll: options.renderAll, + data + }); + return; + } + e.setAttribute("data-av-type", "calendar"); + // The local new-entry target has served its purpose once the kernel reports + // the same value; keeping it would silently outrank a change made elsewhere + // (another view of the same database, another window). + if (e.dataset.calendarNewItemTarget && + e.dataset.calendarNewItemTarget === (getPersistedNewItemTarget(data.view as IAVCalendar) || CALENDAR_NEW_ITEM_TARGET_ROW)) { + delete e.dataset.calendarNewItemTarget; + } + anchorCalendarOnLocateTarget(data, e); + const editable = !options.protyle.disabled && !hasClosestByAttribute(e, "data-type", "NodeBlockQueryEmbed"); + const body = `
${getCalendarHTML(data, e, editable, resetData.query)}
`; + // 上一次渲染可能是别的布局(没有 av__scroll),此时必须整体重建容器 + const scrollElement = options.renderAll ? null : e.firstElementChild?.querySelector(".av__scroll"); + if (scrollElement) { + scrollElement.innerHTML = body; + } else { + e.firstElementChild.outerHTML = `
+ ${genTabHeaderHTML(data, resetData.isSearching || !!resetData.query, editable)} +
${body}
+
`; + } + // HTML 已在 DOM 中后才置 data-render,避免中途失败留下“已渲染”的空壳 + e.setAttribute("data-render", "true"); + // 模板新建的条目在日历日期字段上没有值(不可见),日历用工具栏里的新建入口替代 + e.querySelector('[data-type="av-add-template"]')?.remove(); + bindCalendarEvents(options, data); + if (!scrollElement) { + bindAvSearch({ + blockElement: e, + query: resetData.query, + isSearching: resetData.isSearching, + onChange: () => updateSearch(e, options.protyle), + }); + } + if (typeof resetData.oldOffset === "number" && options.protyle.contentElement) { + options.protyle.contentElement.scrollTop = resetData.oldOffset; + } + if (e.getAttribute("data-need-focus") === "true") { + focusBlock(e); + e.removeAttribute("data-need-focus"); + } + const newScrollElement = e.querySelector(".av__scroll") as HTMLElement; + if (newScrollElement && resetData.scrollLeft) { + newScrollElement.scrollLeft = resetData.scrollLeft; + } + const newTimeGridElement = e.querySelector(".av__calendar-time-grid") as HTMLElement; + if (newTimeGridElement) { + newTimeGridElement.scrollTop = resetData.gridScrollTop; + newTimeGridElement.scrollLeft = resetData.gridScrollLeft; + } + // The now line is mounted, never rendered: it owns an interval, so it has to + // be torn down by the render that replaces it. mountCalendarNowIndicator + // unmounts the previous one for this block element first, and it only + // auto-scrolls when there was no scroll position to restore - never fight + // the user's own scroll. + if (newTimeGridElement) { + mountCalendarNowIndicator({ + blockElement: e, + gridElement: newTimeGridElement, + geometry: getGridGeometry(e), + hasRestoredScroll: resetData.hadTimeGrid, + }); + } else { + unmountCalendarNowIndicator(e); + } + restoreCalendarFocus(e, resetData.focusTarget); + options.cb?.(data); + finishAVLocate(e, options.protyle, data); +}; diff --git a/app/src/protyle/render/av/calendar/time-geometry.ts b/app/src/protyle/render/av/calendar/time-geometry.ts new file mode 100644 index 00000000000..36552cc15ba --- /dev/null +++ b/app/src/protyle/render/av/calendar/time-geometry.ts @@ -0,0 +1,244 @@ +/** + * The single source of truth for the calendar time grid's minute <-> pixel math. + * + * Pure by contract: no DOM globals, no date library. Everything that positions + * something inside the timed grid - the grid renderer, the now indicator, the + * optimistic chip, drag/resize gestures - MUST go through the helpers here. The + * old renderer did this arithmetic inline with a 30 minute CSS grid row, which + * is exactly why a 12:45-13:20 event drew at 12:30-13:30: the grid could only + * express row boundaries, so the geometry silently lied about the data. + * + * Minutes are always "minutes since midnight of the day the column represents", + * so an event that starts the previous evening enters as a negative number and + * one that ends after midnight enters as a number past 1440; clampToDayMinutes + * is what turns those into something drawable. + */ + +export const CALENDAR_MINUTES_PER_HOUR = 60; +export const CALENDAR_MINUTES_PER_DAY = 24 * CALENDAR_MINUTES_PER_HOUR; + +/** + * Everything the user can express with a gesture snaps to this. 15 is Google + * Calendar's granularity and, unlike the old 30 minute row, it is a snap of the + * *value* rather than of the drawing: a 12:45 event is stored as 12:45 and drawn + * at 12:45. + */ +export const CALENDAR_SNAP_MINUTES = 15; +/** Shortest event a gesture may produce; also the shortest chip we will draw. */ +export const CALENDAR_MINIMUM_EVENT_MINUTES = 15; +/** + * Duration given to an event that has no end, and to a plain click on empty + * grid. This is a *default length*, never a rounding step - the old renderer + * used one 30 minute constant for both, which is how snapping crept in. + */ +export const CALENDAR_DEFAULT_EVENT_MINUTES = 30; +/** One hour of the grid, in CSS pixels. 48px = 24px per half hour ruling. */ +export const CALENDAR_HOUR_HEIGHT_PX = 48; +/** The background ruling stays at half-hour spacing; only the maths changed. */ +export const CALENDAR_RULING_MINUTES = 30; +export const CALENDAR_DAY_START_MINUTE = 0; +export const CALENDAR_DAY_END_MINUTE = CALENDAR_MINUTES_PER_DAY; +/** Unshaded working window; a pure default, never persisted (see G6). */ +export const CALENDAR_BUSINESS_START_MINUTE = 9 * CALENDAR_MINUTES_PER_HOUR; +export const CALENDAR_BUSINESS_END_MINUTE = 18 * CALENDAR_MINUTES_PER_HOUR; +/** Hour gutter width; the sticky day headers share this track. */ +export const CALENDAR_GUTTER_WIDTH_PX = 72; +export const CALENDAR_DAY_MIN_WIDTH_PX = 104; +export const CALENDAR_ALL_DAY_LANE_HEIGHT_PX = 22; +export const CALENDAR_HEADER_HEIGHT_PX = 34; +/** Where the grid scrolls to when "now" is not on screen at all. */ +export const CALENDAR_FALLBACK_SCROLL_MINUTE = 6 * CALENDAR_MINUTES_PER_HOUR; + +export interface ICalendarTimeGeometry { + /** CSS pixels per hour. */ + hourHeight: number; + /** How many day columns the grid draws (7 for week, 1 for day). */ + dayCount: number; + /** Gesture granularity in minutes. */ + snapMinutes: number; + /** Shortest drawable / creatable event, in minutes. */ + minimumEventMinutes: number; + /** First minute of the visible day (0 = midnight). */ + dayStartMinute: number; + /** Last minute of the visible day (1440 = midnight). */ + dayEndMinute: number; + /** Spacing of the background ruling lines, in minutes. */ + rulingMinutes: number; + businessStartMinute: number; + businessEndMinute: number; + gutterWidth: number; + dayMinWidth: number; +} + +const DEFAULT_GEOMETRY: ICalendarTimeGeometry = { + hourHeight: CALENDAR_HOUR_HEIGHT_PX, + dayCount: 7, + snapMinutes: CALENDAR_SNAP_MINUTES, + minimumEventMinutes: CALENDAR_MINIMUM_EVENT_MINUTES, + dayStartMinute: CALENDAR_DAY_START_MINUTE, + dayEndMinute: CALENDAR_DAY_END_MINUTE, + rulingMinutes: CALENDAR_RULING_MINUTES, + businessStartMinute: CALENDAR_BUSINESS_START_MINUTE, + businessEndMinute: CALENDAR_BUSINESS_END_MINUTE, + gutterWidth: CALENDAR_GUTTER_WIDTH_PX, + dayMinWidth: CALENDAR_DAY_MIN_WIDTH_PX, +}; + +/** + * The geometry record every caller should start from. Overrides exist so a test + * can pin a different hour height, not so the product grows a setting. + */ +export const getCalendarTimeGeometry = (dayCount = 7, overrides: Partial = {}): ICalendarTimeGeometry => { + const geometry = { + ...DEFAULT_GEOMETRY, + dayCount: Math.max(Math.round(dayCount) || 1, 1), + ...overrides, + }; + if (geometry.dayEndMinute <= geometry.dayStartMinute) { + geometry.dayStartMinute = CALENDAR_DAY_START_MINUTE; + geometry.dayEndMinute = CALENDAR_DAY_END_MINUTE; + } + return geometry; +}; + +/** How many minutes of a day the grid actually shows. */ +export const getVisibleDayMinutes = (geometry: ICalendarTimeGeometry) => geometry.dayEndMinute - geometry.dayStartMinute; + +export const minutesToPx = (minutes: number, geometry: ICalendarTimeGeometry) => + (minutes / CALENDAR_MINUTES_PER_HOUR) * geometry.hourHeight; + +export const pxToMinutes = (px: number, geometry: ICalendarTimeGeometry) => + (px / geometry.hourHeight) * CALENDAR_MINUTES_PER_HOUR; + +/** Full pixel height of one day column. */ +export const getDayHeightPx = (geometry: ICalendarTimeGeometry) => minutesToPx(getVisibleDayMinutes(geometry), geometry); + +/** Pixel offset of an absolute day-minute from the top of the column. */ +export const minuteToOffsetPx = (minute: number, geometry: ICalendarTimeGeometry) => + minutesToPx(minute - geometry.dayStartMinute, geometry); + +/** Inverse of minuteToOffsetPx: what did the user point at? */ +export const offsetPxToMinute = (offsetPx: number, geometry: ICalendarTimeGeometry) => + geometry.dayStartMinute + pxToMinutes(offsetPx, geometry); + +export const clampToDayMinutes = (minute: number, geometry: ICalendarTimeGeometry) => + Math.min(Math.max(minute, geometry.dayStartMinute), geometry.dayEndMinute); + +/** Round a minute value onto the snap grid (15 minutes), clamped to the day. */ +export const snapMinutes = (minute: number, geometry: ICalendarTimeGeometry) => { + const snap = Math.max(geometry.snapMinutes, 1); + return clampToDayMinutes(Math.round(minute / snap) * snap, geometry); +}; + +/** Snap that never moves past the pointer, for a range's leading edge. */ +export const snapMinutesDown = (minute: number, geometry: ICalendarTimeGeometry) => { + const snap = Math.max(geometry.snapMinutes, 1); + return clampToDayMinutes(Math.floor(minute / snap) * snap, geometry); +}; + +/** Snap that never falls short of the pointer, for a range's trailing edge. */ +export const snapMinutesUp = (minute: number, geometry: ICalendarTimeGeometry) => { + const snap = Math.max(geometry.snapMinutes, 1); + return clampToDayMinutes(Math.ceil(minute / snap) * snap, geometry); +}; + +export interface ICalendarMinuteRange { + startMinute: number; + endMinute: number; + topPx: number; + heightPx: number; + /** True when the event actually begins before this column's first minute. */ + continuesBefore: boolean; + /** True when the event actually ends after this column's last minute. */ + continuesAfter: boolean; +} + +/** + * Turn a raw start/end minute pair into something drawable on one day column. + * + * This is where "a 12:45-13:20 event draws at exactly 12:45 with its true + * height" is enforced: nothing is rounded to a row, only clamped to the visible + * day and floored at minimumEventMinutes so a 5 minute event stays clickable. + */ +export const getEventMinuteRange = (rawStartMinute: number, rawEndMinute: number, geometry: ICalendarTimeGeometry): ICalendarMinuteRange => { + const continuesBefore = rawStartMinute < geometry.dayStartMinute; + const continuesAfter = rawEndMinute > geometry.dayEndMinute; + const startMinute = clampToDayMinutes(rawStartMinute, geometry); + const clampedEnd = clampToDayMinutes(rawEndMinute, geometry); + const endMinute = Math.min( + Math.max(clampedEnd, startMinute + geometry.minimumEventMinutes), + geometry.dayEndMinute, + ); + const topPx = minuteToOffsetPx(startMinute, geometry); + return { + startMinute, + endMinute, + topPx, + heightPx: Math.max(minutesToPx(endMinute - startMinute, geometry), minutesToPx(geometry.minimumEventMinutes, geometry)), + continuesBefore, + continuesAfter, + }; +}; + +/** Minutes since midnight for a wall clock instant. */ +export const getMinutesOfDay = (now: Date) => now.getHours() * CALENDAR_MINUTES_PER_HOUR + now.getMinutes() + now.getSeconds() / 60; + +/** + * Pixel offset of the red "now" line inside a day column, or null when the + * current time falls outside the drawn part of the day. + */ +export const getNowOffsetPx = (now: Date, geometry: ICalendarTimeGeometry): number | null => { + const minute = getMinutesOfDay(now); + if (minute < geometry.dayStartMinute || minute > geometry.dayEndMinute) { + return null; + } + return minuteToOffsetPx(minute, geometry); +}; + +/** "HH:mm" for a minute-of-day value. */ +export const formatClockLabel = (minutes: number) => { + const total = Math.max(Math.round(minutes), 0) % (CALENDAR_MINUTES_PER_DAY + 1); + const hour = Math.floor(total / CALENDAR_MINUTES_PER_HOUR); + const minute = total % CALENDAR_MINUTES_PER_HOUR; + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +}; + +/** Parse "HH:mm" into minutes since midnight; anything unparsable is 0. */ +export const parseClockMinutes = (value: string) => { + const match = /^(\d{1,2}):(\d{2})$/.exec(value || ""); + if (!match) { + return 0; + } + return Math.min(Math.max(parseInt(match[1], 10) * CALENDAR_MINUTES_PER_HOUR + parseInt(match[2], 10), 0), CALENDAR_MINUTES_PER_DAY); +}; + +/** The hour marks the sticky gutter labels. */ +export const getHourMarks = (geometry: ICalendarTimeGeometry) => { + const marks: { minute: number, label: string, offsetPx: number }[] = []; + for (let minute = geometry.dayStartMinute; minute < geometry.dayEndMinute; minute += CALENDAR_MINUTES_PER_HOUR) { + marks.push({minute, label: formatClockLabel(minute), offsetPx: minuteToOffsetPx(minute, geometry)}); + } + return marks; +}; + +/** + * Business hours expressed as percentages of the column, so the shading can be + * a single CSS gradient instead of extra DOM (G6). + */ +export const getBusinessHoursPercent = (geometry: ICalendarTimeGeometry) => { + const visible = getVisibleDayMinutes(geometry) || 1; + const toPercent = (minute: number) => Math.min(Math.max(((minute - geometry.dayStartMinute) / visible) * 100, 0), 100); + return { + startPercent: toPercent(geometry.businessStartMinute), + endPercent: toPercent(geometry.businessEndMinute), + }; +}; + +/** + * Where the grid should be scrolled so `minute` sits roughly in the middle of a + * viewport `viewportHeightPx` tall. Never negative, never past the bottom. + */ +export const getCenteredScrollTopPx = (minute: number, viewportHeightPx: number, geometry: ICalendarTimeGeometry) => { + const target = minuteToOffsetPx(clampToDayMinutes(minute, geometry), geometry) - viewportHeightPx / 2; + return Math.min(Math.max(target, 0), Math.max(getDayHeightPx(geometry) - viewportHeightPx, 0)); +}; diff --git a/app/src/protyle/render/av/calendar/time-grid.ts b/app/src/protyle/render/av/calendar/time-grid.ts new file mode 100644 index 00000000000..8a8be6ad4c9 --- /dev/null +++ b/app/src/protyle/render/av/calendar/time-grid.ts @@ -0,0 +1,248 @@ +import * as dayjs from "dayjs"; +import {escapeAttr, escapeHtml} from "../../../../util/escape"; +import {ICalendarNormalizedEvent} from "./model"; +import {eventOverlapsDay, sortCalendarEvents} from "./normalize"; +import { + CALENDAR_ALL_DAY_LANE_HEIGHT_PX, + CALENDAR_DEFAULT_EVENT_MINUTES, + CALENDAR_HEADER_HEIGHT_PX, + getBusinessHoursPercent, + getDayHeightPx, + getEventMinuteRange, + getHourMarks, + ICalendarTimeGeometry, + minutesToPx, +} from "./time-geometry"; +import {IAllDayLaneItem, ITimedLayoutItem, packAllDayLanes, packTimedEventColumns} from "./layout-overlap"; + +/** + * The Week/Day time grid. + * + * One scroll container, three sticky rows that all share the same column track: + * + * .av__calendar-time-grid scroll container, owns every geometry var + * .av__calendar-grid-header sticky top - day headers + * .av__calendar-allday-row sticky below - one lane per stacked bar + * .av__calendar-grid-body hour gutter (sticky left) + day columns + * + * Before this module the day headers were their own grid with their own gap, so + * a header never sat above its column, and the timed layer was 48 CSS rows of + * 30 minutes, so every event was rounded to the nearest half hour. Here the + * headers, the all-day lane and the columns are all built from the same + * `repeat(dayCount, minmax(dayMinWidth, 1fr))` track, and every timed chip is + * absolutely positioned from time-geometry.ts - a 12:45-13:20 event draws at + * 12:45 with its true height. + * + * Week and Day are the same renderer; Day just passes a one-element day list. + */ + +export interface ICalendarTimeGridLabels { + allDay: string; + /** aria-label prefix for the empty-space create surface. */ + createEvent: string; +} + +export interface ICalendarTimeGridOptions { + days: dayjs.Dayjs[]; + events: ICalendarNormalizedEvent[]; + editable: boolean; + geometry: ICalendarTimeGeometry; + viewKind: "week" | "day" | "five-day"; + labels: ICalendarTimeGridLabels; + /** Reveal every stacked all-day lane after the user activates +x more. */ + expandAllDay?: boolean; + /** Locale-aware short header, e.g. "Tue". */ + formatWeekday: (day: dayjs.Dayjs) => string; + /** Locale-aware full date, used for aria-labels. */ + formatFullDate: (day: dayjs.Dayjs) => string; + /** render.ts owns the chip markup; the grid only owns where it sits. */ + renderEventChip: (event: ICalendarNormalizedEvent, day: dayjs.Dayjs, editable: boolean, surface: "timed" | "all-day") => string; +} + +/** Grid classes/attributes other modules and the smokes may rely on. */ +export const CALENDAR_TIME_GRID_CLASS = "av__calendar-time-grid"; +export const CALENDAR_TIME_DAY_CLASS = "av__calendar-time-day"; +export const CALENDAR_TIMED_EVENT_CLASS = "av__calendar-timed-event"; +export const CALENDAR_TIME_CREATE_TYPE = "calendar-time-create"; +export const CALENDAR_RESIZE_HANDLE_TYPE = "calendar-resize-handle"; +export const CALENDAR_ALL_DAY_VISIBLE_LANES = 3; + +const isWeekend = (day: dayjs.Dayjs) => day.day() === 0 || day.day() === 6; + +const dayKey = (day: dayjs.Dayjs) => day.format("YYYY-MM-DD"); + +/** + * A timed event spanning date boundaries is one continuous event. Drawing it in + * every timed column makes it look like a daily recurrence, so it belongs in the + * spanning lane alongside all-day events while retaining its exact timestamps. + */ +export const isMultiDayTimedEvent = (event: ICalendarNormalizedEvent) => + !event.isAllDay && !!event.end && !event.start.isSame(event.end, "day"); + +const belongsInAllDayLane = (event: ICalendarNormalizedEvent) => + event.isAllDay || isMultiDayTimedEvent(event); + +/** Raw minute range of a timed event relative to one day column's midnight. */ +const getRawMinuteRange = (event: ICalendarNormalizedEvent, day: dayjs.Dayjs) => { + const dayStart = day.startOf("day"); + const start = event.start.diff(dayStart, "minute"); + const end = (event.end || event.start.add(CALENDAR_DEFAULT_EVENT_MINUTES, "minute")).diff(dayStart, "minute"); + return {start, end}; +}; + +const renderTimedEvents = (options: ICalendarTimeGridOptions, day: dayjs.Dayjs) => { + const geometry = options.geometry; + const timedEvents = sortCalendarEvents(options.events.filter(event => !belongsInAllDayLane(event) && eventOverlapsDay(event, day))); + const ranges = new Map>(); + const layoutItems: ITimedLayoutItem[] = []; + timedEvents.forEach(event => { + const key = event.occurrenceID || event.id; + const raw = getRawMinuteRange(event, day); + const range = getEventMinuteRange(raw.start, raw.end, geometry); + ranges.set(key, range); + layoutItems.push({key, startMinute: range.startMinute, endMinute: range.endMinute}); + }); + const boxes = new Map(packTimedEventColumns(layoutItems).map(box => [box.key, box])); + const handles = options.editable ? + { + start: ``, + end: ``, + } : + {start: "", end: ""}; + return timedEvents.map(event => { + const key = event.occurrenceID || event.id; + const range = ranges.get(key); + const box = boxes.get(key); + if (!range || !box) { + return ""; + } + const continuation = `${range.continuesBefore ? " av__calendar-timed-event--continues-before" : ""}${range.continuesAfter ? " av__calendar-timed-event--continues-after" : ""}`; + // Pixel-exact: top/height come straight from the minute range, width/left + // from the overlap packer. Nothing is snapped to a row. + const style = `top:${range.topPx}px;height:${range.heightPx}px;left:${box.leftPercent}%;width:${box.widthPercent}%`; + return `
${handles.start}${options.renderEventChip(event, day, options.editable, "timed")}${handles.end}
`; + }).join(""); +}; + +const renderDayColumn = (options: ICalendarTimeGridOptions, day: dayjs.Dayjs, index: number) => { + const heightPx = getDayHeightPx(options.geometry); + const isToday = day.isSame(dayjs(), "day"); + const classes = [ + CALENDAR_TIME_DAY_CLASS, + isToday ? "av__calendar-day--today" : "", + isWeekend(day) ? "av__calendar-time-day--weekend" : "", + ].filter(Boolean).join(" "); + // Read-only / query-embed calendars simply never get the create surface, so + // no gesture bound to it can reach them. + const createSurface = options.editable ? + `
` : + ""; + return `
+ ${createSurface} +
${renderTimedEvents(options, day)}
+
`; +}; + +const renderAllDayRow = (options: ICalendarTimeGridOptions) => { + const days = options.days; + const allDayEvents = sortCalendarEvents(options.events.filter(event => belongsInAllDayLane(event) && + days.some(day => eventOverlapsDay(event, day)))); + const laneItems: IAllDayLaneItem[] = []; + const eventByKey = new Map(); + allDayEvents.forEach(event => { + const key = event.occurrenceID || event.id; + // A Tue-Thu event is ONE bar: find the first and last visible column it + // touches instead of emitting a chip per day. + const covered = days + .map((day, index) => eventOverlapsDay(event, day) ? index : -1) + .filter(index => index >= 0); + if (covered.length === 0) { + return; + } + eventByKey.set(key, event); + laneItems.push({key, startIndex: covered[0], endIndex: covered[covered.length - 1]}); + }); + const {bars, laneCount} = packAllDayLanes(laneItems); + const visibleLaneLimit = options.expandAllDay ? Math.max(laneCount, 1) : CALENDAR_ALL_DAY_VISIBLE_LANES; + const visibleLaneCount = Math.max(Math.min(laneCount, visibleLaneLimit), 1); + const hiddenByDay = days.map((unused, index) => bars.filter(bar => bar.lane >= CALENDAR_ALL_DAY_VISIBLE_LANES && bar.startIndex <= index && bar.startIndex + bar.spanCount > index).length); + const hasHiddenLanes = !options.expandAllDay && hiddenByDay.some(count => count > 0); + const renderedLaneCount = visibleLaneCount + (hasHiddenLanes ? 1 : 0); + const cells = days.map((day, index) => + `
`).join(""); + const barHTML = bars.filter(bar => bar.lane < visibleLaneLimit).map(bar => { + const event = eventByKey.get(bar.key); + if (!event) { + return ""; + } + const barDay = days[bar.startIndex]; + return `
${options.renderEventChip(event, barDay, options.editable, "all-day")}
`; + }).join(""); + const moreHTML = hasHiddenLanes ? hiddenByDay.map((count, index) => count > 0 ? + `` : "").join("") : ""; + return { + laneCount: renderedLaneCount, + html: `
+
${escapeHtml(options.labels.allDay)}
+
${cells}${barHTML}${moreHTML}
+
`, + }; +}; + +const renderHeaderRow = (options: ICalendarTimeGridOptions) => { + const headers = options.days.map((day, index) => { + const isToday = day.isSame(dayjs(), "day"); + const classes = [ + "av__calendar-day-header", + isToday ? "av__calendar-day--today" : "", + isWeekend(day) ? "av__calendar-time-day--weekend" : "", + ].filter(Boolean).join(" "); + return `
+ +
`; + }).join(""); + return `
+
+ ${headers} +
`; +}; + +const renderGutter = (options: ICalendarTimeGridOptions) => { + const marks = getHourMarks(options.geometry); + return `
${marks.map(mark => + `
${escapeHtml(mark.label)}
`).join("")}
`; +}; + +/** + * Render the whole Week/Day grid as one HTML string. + * + * The now indicator is deliberately NOT part of this markup: it is mounted (and + * torn down) by now-indicator.ts so its ticker cannot outlive a re-render. + */ +export const renderCalendarTimeGrid = (options: ICalendarTimeGridOptions) => { + const geometry = options.geometry; + const days = options.days; + const business = getBusinessHoursPercent(geometry); + const allDay = renderAllDayRow(options); + const gridStyle = [ + `--calendar-day-count:${days.length}`, + `--calendar-hour-height:${geometry.hourHeight}px`, + `--calendar-day-height:${getDayHeightPx(geometry)}px`, + `--calendar-ruling-height:${minutesToPx(geometry.rulingMinutes, geometry)}px`, + `--calendar-gutter-width:${geometry.gutterWidth}px`, + `--calendar-day-min-width:${geometry.dayMinWidth}px`, + `--calendar-header-height:${CALENDAR_HEADER_HEIGHT_PX}px`, + `--calendar-allday-lane-count:${allDay.laneCount}`, + `--calendar-allday-height:${allDay.laneCount * CALENDAR_ALL_DAY_LANE_HEIGHT_PX}px`, + `--calendar-business-start:${business.startPercent}%`, + `--calendar-business-end:${business.endPercent}%`, + ].join(";"); + return `
+ ${renderHeaderRow(options)} + ${allDay.html} +
+ ${renderGutter(options)} +
${days.map((day, index) => renderDayColumn(options, day, index)).join("")}
+
+
`; +}; diff --git a/app/src/protyle/render/av/calendar/transactions.ts b/app/src/protyle/render/av/calendar/transactions.ts new file mode 100644 index 00000000000..c6d54f5380f --- /dev/null +++ b/app/src/protyle/render/av/calendar/transactions.ts @@ -0,0 +1,1266 @@ +import * as dayjs from "dayjs"; +import {Constants} from "../../../../constants"; +import {showMessage} from "../../../../dialog/message"; +import {fetchSyncPost} from "../../../../util/fetch"; +import {cloneCellValue, getBlockCell, getCellByFieldID, getEventDocumentID, getFieldByID, ICalendarEventDraft, ICalendarFieldMapping, ICalendarNormalizedEvent} from "./model"; + +export interface ICalendarOperationSet { + doOperations: IOperation[]; + undoOperations: IOperation[]; +} + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)); + +const normalizeRecurrenceValue = (value?: string) => { + const trimmed = (value || "").trim(); + return trimmed.toLowerCase() === "none" ? "" : trimmed; +}; + +const recurrenceWithUntil = (value: string | undefined, untilDate: string) => { + const normalized = normalizeRecurrenceValue(value); + if (!normalized) { + return ""; + } + const upper = normalized.toUpperCase(); + const parts = upper.includes("=") ? upper.split(";").filter(Boolean) : [`FREQ=${upper}`]; + let hasUntil = false; + const nextParts = parts.map((part) => { + if (part.startsWith("UNTIL=")) { + hasUntil = true; + return `UNTIL=${untilDate}`; + } + return part; + }); + if (!hasUntil) { + nextParts.push(`UNTIL=${untilDate}`); + } + return nextParts.join(";"); +}; + +const getEventRecurrenceRaw = (event: ICalendarNormalizedEvent) => event.recurrenceRaw || event.recurrence?.raw || event.recurrence?.freq || ""; + +const weekdayMap: { [key: string]: number } = {SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6}; + +const getRecurringStartAtIndex = (event: ICalendarNormalizedEvent, index: number) => { + const interval = event.recurrence?.interval || 1; + if (event.recurrence?.freq === "DAILY") { + return event.start.add(index * interval, "day"); + } + if (event.recurrence?.freq === "WEEKLY") { + return event.start.add(index * interval, "week"); + } + if (event.recurrence?.freq === "MONTHLY") { + return event.start.add(index * interval, "month"); + } + return event.start.add(index * interval, "year"); +}; + +const countOccurrencesBefore = (event: ICalendarNormalizedEvent, occurrenceDate: string) => { + if (!event.recurrence) { + return 0; + } + const splitStart = dayjs(occurrenceDate).startOf("day"); + let count = 0; + let generated = 0; + const limit = event.recurrence.count || 10000; + if (event.recurrence.freq === "WEEKLY" && event.recurrence.byDay?.length > 0) { + let weekCursor = event.start.startOf("week"); + while (generated < limit && !weekCursor.isAfter(splitStart, "day")) { + const weeksFromStart = weekCursor.diff(event.start.startOf("week"), "week"); + if (weeksFromStart >= 0 && weeksFromStart % (event.recurrence.interval || 1) === 0) { + for (const byDay of event.recurrence.byDay) { + const occurrenceStart = weekCursor.day(weekdayMap[byDay]) + .hour(event.start.hour()) + .minute(event.start.minute()) + .second(event.start.second()) + .millisecond(event.start.millisecond()); + if (occurrenceStart.isBefore(event.start)) { + continue; + } + if (event.recurrence.until && occurrenceStart.isAfter(event.recurrence.until)) { + return count; + } + if (!occurrenceStart.isBefore(splitStart, "day")) { + return count; + } + count++; + generated++; + if (generated >= limit) { + return count; + } + } + } + weekCursor = weekCursor.add(1, "week"); + } + return count; + } + let cursor = event.start; + while (generated < limit && cursor.isBefore(splitStart, "day")) { + if (event.recurrence.until && cursor.isAfter(event.recurrence.until)) { + break; + } + count++; + generated++; + cursor = getRecurringStartAtIndex(event, generated); + } + return count; +}; + +const recurrenceCount = (value: string) => { + const countPart = value.toUpperCase().split(";").find(part => part.startsWith("COUNT=")); + if (!countPart) { + return undefined; + } + const countValue = countPart.slice("COUNT=".length); + if (!/^\d+$/.test(countValue)) { + return undefined; + } + const count = parseInt(countValue, 10); + return count > 0 ? count : undefined; +}; + +const recurrenceWithCount = (value: string, count: number) => { + const upper = value.toUpperCase(); + if (!upper.includes("COUNT=")) { + return value; + } + return upper.split(";").filter(Boolean).map(part => part.startsWith("COUNT=") ? `COUNT=${count}` : part).join(";"); +}; + +const canonicalRecurrenceValue = (value: string) => { + const normalized = normalizeRecurrenceValue(value).toUpperCase(); + if (!normalized) { + return ""; + } + const parts = normalized.includes("=") ? normalized.split(";").filter(Boolean) : [`FREQ=${normalized}`]; + return parts.sort().join(";"); +}; + +const recurrenceForSplitFuture = (value: string, event: ICalendarNormalizedEvent, occurrenceDate: string, originalValue: string) => { + const count = recurrenceCount(value); + if (!count || canonicalRecurrenceValue(value) !== canonicalRecurrenceValue(originalValue)) { + return value; + } + return recurrenceWithCount(value, Math.max(count - countOccurrencesBefore(event, occurrenceDate), 1)); +}; + +const isRealDateInputValue = (value?: string) => { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + const parsed = dayjs(value); + return parsed.isValid() && parsed.format("YYYY-MM-DD") === value; +}; + +const getTimeInputValue = (value: string | undefined, fallback: string) => { + if (!value || !/^\d{2}:\d{2}$/.test(value)) { + return fallback; + } + const [hour, minute] = value.split(":").map(item => parseInt(item, 10)); + return hour >= 0 && hour < 24 && minute >= 0 && minute < 60 ? value : fallback; +}; + +const buildDateValue = (draft: ICalendarEventDraft): IAVCellValue | undefined => { + if (!isRealDateInputValue(draft.date)) { + return undefined; + } + const startTime = getTimeInputValue(draft.startTime, "09:00"); + const endTime = getTimeInputValue(draft.endTime, "10:00"); + const start = draft.isAllDay ? dayjs(draft.date).startOf("day") : dayjs(`${draft.date}T${startTime}`); + const endDate = draft.endDate && isRealDateInputValue(draft.endDate) && dayjs(draft.endDate).isAfter(dayjs(draft.date), "day") ? draft.endDate : draft.date; + let end = draft.isAllDay ? dayjs(endDate).endOf("day") : dayjs(`${endDate}T${endTime}`); + if (!draft.isAllDay && !end.isAfter(start)) { + end = start.add(1, "hour"); + } + return { + type: "date", + date: { + content: start.valueOf(), + isNotEmpty: true, + content2: end.valueOf(), + isNotEmpty2: true, + hasEndDate: true, + isNotTime: draft.isAllDay, + }, + }; +}; + +// Template cells are computed by the kernel (fillAttributeViewBaseValue replaces the +// stored value with the field's expression on every render), so calendar metadata is +// only ever written into text fields. +const buildTextLikeValue = (field: IAVColumn, value: string, oldValue?: IAVCellValue): IAVCellValue => { + const base = oldValue ? clone(oldValue) : {type: "text", keyID: field.id} as IAVCellValue; + base.type = "text"; + base.keyID = field.id; + base.text = {content: value}; + delete base.template; + return base; +}; + +const buildEmptyTextLikeValue = (field: IAVColumn) => buildTextLikeValue(field, ""); + +const buildSelectValue = (field: IAVColumn, value?: string, oldValue?: IAVCellValue): IAVCellValue | undefined => { + const content = (value || "").trim(); + if (!content) { + return oldValue ? { + ...clone(oldValue), + type: field.type, + keyID: field.id, + mSelect: [], + } : undefined; + } + const option = field.options?.find(item => item.name === content); + if (!option) { + return undefined; + } + const selectValue = { + content, + color: option.color || "1", + }; + const base = oldValue ? clone(oldValue) : {type: field.type, keyID: field.id} as IAVCellValue; + base.type = field.type; + base.keyID = field.id; + base.mSelect = field.type === "mSelect" ? [selectValue] : [selectValue]; + return base; +}; + +const buildEmptySelectValue = (field: IAVColumn): IAVCellValue => ({ + type: field.type, + keyID: field.id, + mSelect: [], +} as IAVCellValue); + +const buildBlockValue = (event: ICalendarNormalizedEvent, title: string): IAVCellValue | undefined => { + const blockCell = getBlockCell(event.sourceCard); + if (!blockCell?.value) { + return undefined; + } + const value = clone(blockCell.value); + value.type = "block"; + value.keyID = blockCell.value.keyID; + value.block = { + ...(value.block || {}), + content: title, + }; + return value; +}; + +const pushUpdate = (ops: ICalendarOperationSet, options: { + avID: string; + rowID: string; + keyID?: string; + oldValue?: IAVCellValue; + newValue?: IAVCellValue; +}) => { + if (!options.keyID || !options.newValue) { + return; + } + if (options.oldValue && JSON.stringify(options.oldValue) === JSON.stringify(options.newValue)) { + return; + } + ops.doOperations.push({ + action: "updateAttrViewCell", + avID: options.avID, + keyID: options.keyID, + rowID: options.rowID, + data: options.newValue, + }); + if (options.oldValue) { + ops.undoOperations.unshift({ + action: "updateAttrViewCell", + avID: options.avID, + keyID: options.keyID, + rowID: options.rowID, + data: options.oldValue, + }); + } +}; + +const pushUpdated = (ops: ICalendarOperationSet, blockID: string, previousUpdated = "") => { + const newUpdated = dayjs().format("YYYYMMDDHHmmss"); + ops.doOperations.push({action: "doUpdateUpdated", id: blockID, data: newUpdated}); + ops.undoOperations.push({action: "doUpdateUpdated", id: blockID, data: previousUpdated}); +}; + +export interface ICalendarWriteTarget { + avID: string; + blockID: string; + viewID?: string; +} + +/** + * D3 read-back verification. + * + * /api/transactions always answers {code: 0}: kernel/model/transaction.go + * flushTx() only logs `handle attribute view failed: ...` and pushes a generic + * message when an operation inside the transaction is rejected, and + * updateAttributeViewValue() even accepts cells addressed at rows that do not + * exist. So the HTTP code proves nothing about the write. + * + * Option (a) of the fix was chosen: after the write we re-read the attribute + * view once (the same endpoint renderCalendar uses) and assert the primary + * effect of the operation set - inserted rows exist, removed rows are gone and + * the date/text cells we wrote read back with the value we sent. Nothing else + * is asserted, so it stays one cheap extra read per mutation. + * + * When the read itself cannot be trusted (request failed, the payload carries + * no card list, or the view has filters/groups that may legitimately hide a + * row) we do not invent a failure: a false "save failed" would revert the UI + * for a write that actually landed. + */ +type ICalendarWriteCheck = (cards: IAVGalleryItem[], mayHideItems: boolean) => boolean; + +const findCardByID = (cards: IAVGalleryItem[], rowID: string) => cards.find(card => card.id === rowID); + +const getCardCellValue = (card: IAVGalleryItem, keyID: string) => card.values?.find(cell => cell.value?.keyID === keyID)?.value; + +const getCardTextContent = (card: IAVGalleryItem, keyID: string) => { + const value = getCardCellValue(card, keyID); + return value?.text?.content ?? value?.template?.content ?? ""; +}; + +const buildWriteChecks = (doOperations: IOperation[]): ICalendarWriteCheck[] => { + const checks: ICalendarWriteCheck[] = []; + const removedIDs = new Set(); + doOperations.forEach(operation => { + if (operation.action === "removeAttrViewBlock") { + (operation.srcIDs || []).forEach(srcID => removedIDs.add(srcID)); + } + }); + doOperations.forEach(operation => { + if (operation.action === "insertAttrViewBlock") { + (operation.srcs || []).forEach(src => { + const itemID = src.itemID; + if (!itemID || removedIDs.has(itemID)) { + return; + } + checks.push((cards, mayHideItems) => !!findCardByID(cards, itemID) || mayHideItems); + }); + return; + } + if (operation.action === "removeAttrViewBlock") { + (operation.srcIDs || []).forEach(srcID => { + checks.push((cards) => !findCardByID(cards, srcID)); + }); + return; + } + if (operation.action !== "updateAttrViewCell" || !operation.rowID || !operation.keyID || removedIDs.has(operation.rowID)) { + return; + } + const data = operation.data as IAVCellValue; + const rowID = operation.rowID; + const keyID = operation.keyID; + if (data?.type === "date" && data.date) { + const expected = data.date; + checks.push((cards, mayHideItems) => { + const card = findCardByID(cards, rowID); + if (!card) { + return mayHideItems; + } + const date = getCardCellValue(card, keyID)?.date; + return !!date && date.isNotEmpty === expected.isNotEmpty && date.content === expected.content; + }); + return; + } + if (data?.type === "text" && data.text) { + const expected = data.text.content || ""; + checks.push((cards, mayHideItems) => { + const card = findCardByID(cards, rowID); + if (!card) { + return mayHideItems; + } + return getCardTextContent(card, keyID) === expected; + }); + } + }); + return checks; +}; + +const readCalendarCards = async (target: ICalendarWriteTarget) => { + const response = await fetchSyncPost("/api/av/renderAttributeView", { + id: target.avID, + blockID: target.blockID, + viewID: target.viewID || "", + pageSize: -1, + createIfNotExist: false, + }); + if (response?.code !== 0) { + return undefined; + } + const view = response.data?.view as IAVCalendar; + if (!view || !Array.isArray(view.cards)) { + return undefined; + } + const cards = [...view.cards]; + (view.groups || []).forEach(group => { + ((group as unknown as IAVCalendar).cards || []).forEach(card => cards.push(card)); + }); + return { + cards, + mayHideItems: (view.filters || []).length > 0 || (view.groups || []).length > 0, + }; +}; + +const verifyCalendarWrite = async (target: ICalendarWriteTarget, doOperations: IOperation[]) => { + const checks = buildWriteChecks(doOperations); + if (checks.length === 0) { + return true; + } + const readBack = await readCalendarCards(target); + if (!readBack) { + return true; + } + return checks.every(check => check(readBack.cards, readBack.mayHideItems)); +}; + +const verifyBoundCalendarItemUpdate = async (target: ICalendarWriteTarget, itemID: string, boundBlockID: string, primaryKey: string, fieldValues: { [keyID: string]: IAVCellValue }) => { + const readBack = await readCalendarCards(target); + if (!readBack) { + return true; + } + const card = findCardByID(readBack.cards, itemID); + if (!card) { + return readBack.mayHideItems; + } + const block = card.values?.find(cell => cell.valueType === "block")?.value?.block; + if (block?.id !== boundBlockID || block.content !== primaryKey) { + return false; + } + const operations = buildCellOperations(target.avID, itemID, fieldValues); + return buildWriteChecks(operations).every(check => check(readBack.cards, readBack.mayHideItems)); +}; + +const executeCalendarOperations = async (protyle: IProtyle, ops: ICalendarOperationSet, target?: ICalendarWriteTarget) => { + if (ops.doOperations.length === 0) { + return false; + } + const response = await fetchSyncPost("/api/transactions", { + session: protyle?.id || Constants.SIYUAN_APPID, + app: Constants.SIYUAN_APPID, + reqId: Date.now(), + transactions: [{ + doOperations: ops.doOperations, + undoOperations: ops.undoOperations, + }], + }); + if (response?.code !== 0) { + return false; + } + if (target && !await verifyCalendarWrite(target, ops.doOperations)) { + // The kernel dropped at least part of the transaction; never report a + // success the persisted data does not back, and do not register an undo + // step for a write that did not happen. + return false; + } + if (protyle && ops.undoOperations.length > 0) { + if (window.siyuan.config.fileTree.openFilesUseCurrentTab && protyle.model) { + protyle.model.headElement.classList.remove("item--unupdate"); + } + protyle.updated = true; + protyle.undo?.add(ops.doOperations, ops.undoOperations, protyle); + } + return true; +}; + +const addMetadataUpdate = (ops: ICalendarOperationSet, options: { + avID: string; + rowID: string; + fields: IAVColumn[]; + fieldID?: string; + value?: string; + oldCell?: IAVCell; + undoEmptyWhenMissing?: boolean; +}) => { + if (!options.fieldID || options.value === undefined) { + return; + } + const field = getFieldByID(options.fields, options.fieldID); + if (!field || field.type !== "text") { + return; + } + const oldValue = cloneCellValue(options.oldCell?.value) || (options.undoEmptyWhenMissing ? buildEmptyTextLikeValue(field) : undefined); + const newValue = buildTextLikeValue(field, options.value, oldValue); + pushUpdate(ops, { + avID: options.avID, + rowID: options.rowID, + keyID: options.fieldID, + oldValue, + newValue, + }); +}; + +const addDraftFieldUpdates = (ops: ICalendarOperationSet, options: { + avID: string; + rowID: string; + fields: IAVColumn[]; + values?: { [fieldID: string]: string }; + sourceCard?: IAVGalleryItem; +}) => { + Object.entries(options.values || {}).forEach(([fieldID, value]) => { + addMetadataUpdate(ops, { + avID: options.avID, + rowID: options.rowID, + fields: options.fields, + fieldID, + value, + oldCell: options.sourceCard ? getCellByFieldID(options.sourceCard, fieldID) : undefined, + undoEmptyWhenMissing: !!options.sourceCard, + }); + }); +}; + +export const buildOccurrenceExceptionOperations = (options: { + avID: string; + blockID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + occurrenceDate: string; + previousUpdated?: string; +}): ICalendarOperationSet => { + const ops: ICalendarOperationSet = {doOperations: [], undoOperations: []}; + const oldCell = getCellByFieldID(options.event.sourceCard, options.mapping.exceptionFieldID); + const existing = (options.event.recurrenceExceptions || []).filter(item => item !== options.occurrenceDate); + existing.push(options.occurrenceDate); + existing.sort(); + addMetadataUpdate(ops, { + avID: options.avID, + rowID: options.event.id, + fields: options.fields, + fieldID: options.mapping.exceptionFieldID, + value: existing.join(","), + oldCell, + undoEmptyWhenMissing: true, + }); + if (ops.doOperations.length > 0) { + pushUpdated(ops, options.blockID, options.previousUpdated); + } + return ops; +}; + +interface ICalendarTruncateOptions { + avID: string; + blockID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + occurrenceDate: string; + previousUpdated?: string; +} + +interface ICalendarSplitOptions extends ICalendarTruncateOptions { + dateFieldID: string; + draft: ICalendarEventDraft; +} + +/** Truncate the original series so it stops before the edited occurrence. */ +const buildSplitTruncateOperations = (options: ICalendarTruncateOptions): ICalendarOperationSet => { + const untilDate = dayjs(options.occurrenceDate).subtract(1, "day").format("YYYY-MM-DD"); + const truncatedRecurrence = recurrenceWithUntil(getEventRecurrenceRaw(options.event), untilDate); + const truncateOps: ICalendarOperationSet = {doOperations: [], undoOperations: []}; + addMetadataUpdate(truncateOps, { + avID: options.avID, + rowID: options.event.id, + fields: options.fields, + fieldID: options.mapping.recurrenceFieldID, + value: truncatedRecurrence, + oldCell: getCellByFieldID(options.event.sourceCard, options.mapping.recurrenceFieldID), + undoEmptyWhenMissing: true, + }); + if (truncateOps.doOperations.length > 0) { + pushUpdated(truncateOps, options.blockID, options.previousUpdated); + } + return truncateOps; +}; + +/** The draft of the follow-up series that starts at the edited occurrence. */ +const buildSplitFutureDraft = (options: ICalendarSplitOptions): ICalendarEventDraft => { + const recurrenceRaw = getEventRecurrenceRaw(options.event); + return { + ...options.draft, + recurrenceRaw: recurrenceForSplitFuture(normalizeRecurrenceValue(options.draft.recurrenceRaw) || recurrenceRaw, options.event, options.occurrenceDate, recurrenceRaw), + recurrenceExceptionRaw: "", + }; +}; + +export const buildSplitSeriesOperations = (options: ICalendarSplitOptions): ICalendarOperationSet => { + if (!isRealDateInputValue(options.draft.date)) { + return {doOperations: [], undoOperations: []}; + } + const truncateOps = buildSplitTruncateOperations(options); + const createOps = buildCreateEventOperations({ + avID: options.avID, + blockID: options.blockID, + dateFieldID: options.dateFieldID, + fields: options.fields, + mapping: options.mapping, + draft: buildSplitFutureDraft(options), + previousUpdated: options.previousUpdated, + }); + return { + doOperations: [...truncateOps.doOperations, ...createOps.doOperations], + undoOperations: [...createOps.undoOperations, ...truncateOps.undoOperations], + }; +}; + +const addColorUpdate = (ops: ICalendarOperationSet, options: { + avID: string; + rowID: string; + fields: IAVColumn[]; + fieldID?: string; + value?: string; + oldCell?: IAVCell; + undoEmptyWhenMissing?: boolean; +}) => { + if (!options.fieldID || options.value === undefined) { + return; + } + const field = getFieldByID(options.fields, options.fieldID); + if (!field || !["select", "mSelect"].includes(field.type)) { + return; + } + const oldValue = cloneCellValue(options.oldCell?.value) || (options.undoEmptyWhenMissing ? buildEmptySelectValue(field) : undefined); + const newValue = buildSelectValue(field, options.value, oldValue); + pushUpdate(ops, { + avID: options.avID, + rowID: options.rowID, + keyID: options.fieldID, + oldValue, + newValue, + }); +}; + +export const buildCreateEventOperations = (options: { + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + draft: ICalendarEventDraft; + previousUpdated?: string; +}): ICalendarOperationSet => { + const dateValue = buildDateValue(options.draft); + if (!dateValue) { + return {doOperations: [], undoOperations: []}; + } + // ONE id only: AddAttributeViewBlock() in kernel/model/attribute_view.go + // creates the item under srcs[].itemID and only reads srcs[].id as the bound + // block id (ignored for detached rows). Minting a second id here would make + // every updateAttrViewCell below address a row that does not exist, and the + // kernel would silently store those values as orphans. + const rowID = Lute.NewNodeID(); + const ops: ICalendarOperationSet = {doOperations: [], undoOperations: []}; + ops.doOperations.push({ + action: "insertAttrViewBlock", + avID: options.avID, + previousID: "", + srcs: [{itemID: rowID, id: rowID, isDetached: true, content: options.draft.title}], + blockID: options.blockID, + context: {ignoreTip: "true"}, + }); + pushUpdate(ops, { + avID: options.avID, + rowID, + keyID: options.dateFieldID, + newValue: dateValue, + }); + addMetadataUpdate(ops, { + avID: options.avID, + rowID, + fields: options.fields, + fieldID: options.mapping.recurrenceFieldID, + value: normalizeRecurrenceValue(options.draft.recurrenceRaw), + }); + addMetadataUpdate(ops, { + avID: options.avID, + rowID, + fields: options.fields, + fieldID: options.mapping.exceptionFieldID, + value: options.draft.recurrenceExceptionRaw, + }); + addMetadataUpdate(ops, { + avID: options.avID, + rowID, + fields: options.fields, + fieldID: options.mapping.locationFieldID, + value: options.draft.location, + }); + addMetadataUpdate(ops, { + avID: options.avID, + rowID, + fields: options.fields, + fieldID: options.mapping.descriptionFieldID, + value: options.draft.description, + }); + addColorUpdate(ops, { + avID: options.avID, + rowID, + fields: options.fields, + fieldID: options.mapping.colorFieldID, + value: options.draft.colorContent, + }); + addDraftFieldUpdates(ops, { + avID: options.avID, + rowID, + fields: options.fields, + values: options.draft.fieldValues, + }); + ops.undoOperations.push({action: "removeAttrViewBlock", srcIDs: [rowID], avID: options.avID}); + pushUpdated(ops, options.blockID, options.previousUpdated); + return ops; +}; + +export const buildUpdateEventOperations = (options: { + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + draft: ICalendarEventDraft; + previousUpdated?: string; +}): ICalendarOperationSet => { + const dateValue = buildDateValue(options.draft); + if (!dateValue) { + return {doOperations: [], undoOperations: []}; + } + const ops: ICalendarOperationSet = {doOperations: [], undoOperations: []}; + const blockCell = getBlockCell(options.event.sourceCard); + // Only a DETACHED row stores its title in the block cell. Writing the block + // cell of a BOUND row makes the kernel persist a per-AV static anchor override + // (custom-sy-av-s-text-, kernel/model/attribute_view.go ~:6590-6601) that + // shadows the real document title forever. For bound rows the document title + // is authoritative and updateCalendarEvent renames the page instead. + if (!getEventDocumentID(options.event)) { + pushUpdate(ops, { + avID: options.avID, + rowID: options.event.id, + keyID: blockCell?.value?.keyID, + oldValue: cloneCellValue(blockCell?.value), + newValue: buildBlockValue(options.event, options.draft.title), + }); + } + pushUpdate(ops, { + avID: options.avID, + rowID: options.event.id, + keyID: options.dateFieldID, + oldValue: cloneCellValue(options.event.dateCell?.value), + newValue: dateValue, + }); + addMetadataUpdate(ops, { + avID: options.avID, + rowID: options.event.id, + fields: options.fields, + fieldID: options.mapping.recurrenceFieldID, + value: normalizeRecurrenceValue(options.draft.recurrenceRaw), + oldCell: getCellByFieldID(options.event.sourceCard, options.mapping.recurrenceFieldID), + undoEmptyWhenMissing: true, + }); + addMetadataUpdate(ops, { + avID: options.avID, + rowID: options.event.id, + fields: options.fields, + fieldID: options.mapping.locationFieldID, + value: options.draft.location, + oldCell: getCellByFieldID(options.event.sourceCard, options.mapping.locationFieldID), + undoEmptyWhenMissing: true, + }); + addMetadataUpdate(ops, { + avID: options.avID, + rowID: options.event.id, + fields: options.fields, + fieldID: options.mapping.descriptionFieldID, + value: options.draft.description, + oldCell: getCellByFieldID(options.event.sourceCard, options.mapping.descriptionFieldID), + undoEmptyWhenMissing: true, + }); + addColorUpdate(ops, { + avID: options.avID, + rowID: options.event.id, + fields: options.fields, + fieldID: options.mapping.colorFieldID, + value: options.draft.colorContent, + oldCell: getCellByFieldID(options.event.sourceCard, options.mapping.colorFieldID), + undoEmptyWhenMissing: true, + }); + addDraftFieldUpdates(ops, { + avID: options.avID, + rowID: options.event.id, + fields: options.fields, + values: options.draft.fieldValues, + sourceCard: options.event.sourceCard, + }); + if (ops.doOperations.length > 0) { + pushUpdated(ops, options.blockID, options.previousUpdated); + } + return ops; +}; + +export const buildDeleteEventOperations = (options: { + avID: string; + blockID: string; + event: ICalendarNormalizedEvent; + previousUpdated?: string; +}): ICalendarOperationSet => { + const ops: ICalendarOperationSet = {doOperations: [], undoOperations: []}; + const blockCell = getBlockCell(options.event.sourceCard); + const blockValue = blockCell?.value; + const cellSnapshots = options.event.sourceCard.values + .map(cell => ({keyID: cell.value?.keyID, value: cloneCellValue(cell.value)})) + .filter(item => item.keyID && item.value); + // Boundness comes from the bound block id, NEVER from blockValue.isDetached: + // kernel/av/value.go:40 marks IsDetached `omitempty`, so a bound row (false) + // omits the field and `blockValue?.isDetached ?? true` used to answer + // "detached" for every bound row - which restored bound rows as detached + // duplicates on undo. See getBoundBlockID in ./model. + const boundBlockID = getEventDocumentID(options.event); + const isDetached = !boundBlockID; + ops.doOperations.push({action: "removeAttrViewBlock", avID: options.avID, srcIDs: [options.event.id]}); + ops.undoOperations.push({ + action: "insertAttrViewBlock", + avID: options.avID, + blockID: options.blockID, + previousID: "", + srcs: [{ + // itemID is the ITEM id the kernel restores the row under, so it must + // be the deleted event's own id - a freshly minted one would leave a + // phantom duplicate row behind after every Ctrl+Z. srcs[].id is the + // BOUND BLOCK id: keep the real block for bound rows, and fall back to + // the item id for detached rows where the kernel ignores it anyway. + itemID: options.event.id, + id: boundBlockID || options.event.id, + isDetached, + content: blockValue?.block?.content || options.event.title || "", + }], + }); + cellSnapshots.forEach(item => { + ops.undoOperations.push({ + action: "updateAttrViewCell", + avID: options.avID, + keyID: item.keyID, + rowID: options.event.id, + data: item.value, + }); + }); + pushUpdated(ops, options.blockID, options.previousUpdated); + return ops; +}; + +export const createCalendarEvent = async (options: { + protyle: IProtyle; + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + draft: ICalendarEventDraft; + previousUpdated?: string; + viewID?: string; +}) => { + return executeCalendarOperations(options.protyle, buildCreateEventOperations(options), options); +}; + +export interface ICalendarCreatedItem { + itemID: string; + /** The created document root id, or "" when the entry is a detached row. */ + blockID: string; +} + +export interface ICalendarCreateOptions { + protyle: IProtyle; + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + draft: ICalendarEventDraft; + viewID?: string; + /** New-item template that resolves the notebook/path of the created page. */ + templateID?: string; + previousID?: string; + groupID?: string; + previousUpdated?: string; +} + +/** + * Cell values for a NEW calendar entry, keyed by field id. + * + * Built with the SAME value builders the row-only path uses, so the payloads the + * kernel receives through /api/av/createAttributeViewItem are shape-identical to + * the ones /api/transactions already accepts. + * + * The title is not in here: for a bound row the kernel discards the caller's + * block content and derives the primary key from the document + * (getNodeAvBlockText, kernel/model/attribute_view.go ~:4673-4677), so the title + * travels as the request's primaryKey and becomes the document name. + */ +const buildCalendarFieldValues = (options: { + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + draft: ICalendarEventDraft; +}): { [keyID: string]: IAVCellValue } | undefined => { + const dateValue = buildDateValue(options.draft); + if (!dateValue) { + return undefined; + } + const fieldValues: { [keyID: string]: IAVCellValue } = {}; + fieldValues[options.dateFieldID] = {...dateValue, keyID: options.dateFieldID}; + const addTextValue = (fieldID?: string, value?: string) => { + if (!fieldID || value === undefined) { + return; + } + const field = getFieldByID(options.fields, fieldID); + if (!field || field.type !== "text") { + return; + } + fieldValues[fieldID] = buildTextLikeValue(field, value); + }; + addTextValue(options.mapping.recurrenceFieldID, normalizeRecurrenceValue(options.draft.recurrenceRaw)); + addTextValue(options.mapping.exceptionFieldID, options.draft.recurrenceExceptionRaw); + addTextValue(options.mapping.locationFieldID, options.draft.location); + addTextValue(options.mapping.descriptionFieldID, options.draft.description); + Object.entries(options.draft.fieldValues || {}).forEach(([fieldID, value]) => addTextValue(fieldID, value)); + const colorField = getFieldByID(options.fields, options.mapping.colorFieldID); + if (colorField && ["select", "mSelect"].includes(colorField.type)) { + const colorValue = buildSelectValue(colorField, options.draft.colorContent); + if (colorValue) { + fieldValues[colorField.id] = colorValue; + } + } + return fieldValues; +}; + +const buildCellOperations = (avID: string, rowID: string, fieldValues: { [keyID: string]: IAVCellValue }): IOperation[] => + Object.keys(fieldValues).map(keyID => ({ + action: "updateAttrViewCell", + avID, + keyID, + rowID, + data: fieldValues[keyID], + } as IOperation)); + +/** + * The kernel writes fieldValues inside its own transaction, so this normally + * verifies and does nothing. It exists because a calendar entry whose date cell + * did not land is INVISIBLE in the calendar: if the read-back positively proves + * the cells are missing we write them once, rather than leaving a page the user + * can never find again. verifyCalendarWrite stays silent when the read cannot be + * trusted, so this never fires on a write that actually landed. + */ +const reconcileCreatedItemFieldValues = async (protyle: IProtyle, target: ICalendarWriteTarget, itemID: string, fieldValues: { [keyID: string]: IAVCellValue }) => { + const operations = buildCellOperations(target.avID, itemID, fieldValues); + if (operations.length === 0 || await verifyCalendarWrite(target, operations)) { + return; + } + await executeCalendarOperations(protyle, {doOperations: operations, undoOperations: []}); +}; + +const createDetachedCalendarEventItem = async (options: ICalendarCreateOptions): Promise => { + const ops = buildCreateEventOperations(options); + const itemID = (ops.doOperations[0]?.srcs || [])[0]?.itemID || ""; + if (!itemID) { + return null; + } + return await executeCalendarOperations(options.protyle, ops, options) ? {itemID, blockID: ""} : null; +}; + +/** + * Create a calendar entry as a real SiYuan document bound to a new AV item. + * + * This cannot be expressed as an operation set: only the kernel can create the + * .sy file and bind it in one transaction, because Operation.Tree is `json:"-"` + * (kernel/model/transaction.go:2041) and restoreCreatedDoc is therefore + * kernel-only. So it is one POST to /api/av/createAttributeViewItem, which + * inserts the row with srcs[{itemID, id: , isDetached: false}] and + * writes the field values in the same kernel transaction. + * + * Returns null on failure, after showing the reason. + */ +export const createCalendarEventAsDocument = async (options: ICalendarCreateOptions): Promise => { + const fieldValues = buildCalendarFieldValues(options); + if (!fieldValues) { + showMessage(`${window.siyuan.languages.date || "Date"} ${window.siyuan.languages.invalid || "Invalid"}`); + return null; + } + const response = await fetchSyncPost("/api/av/createAttributeViewItem", { + avID: options.avID, + blockID: options.blockID, + viewID: options.viewID || "", + templateID: options.templateID || "", + previousID: options.previousID || "", + groupID: options.groupID || "", + primaryKey: options.draft.title, + fieldValues, + app: options.protyle?.app?.appId || Constants.SIYUAN_APPID, + session: options.protyle?.id || "", + }); + if (response?.code !== 0) { + if (response?.data?.unavailableNotebook) { + // The configured notebook is closed or gone. Never block the user on + // it: keep the entry as a row so the save still lands, and say why the + // page is missing. + showMessage(window.siyuan.languages.newItemTemplateUnavailableNotebookTip || + "The notebook for new entries is unavailable, the entry was created without a page.", 6000, "error"); + return createDetachedCalendarEventItem(options); + } + showMessage(response?.msg || window.siyuan.languages.calendarCreateFailed || "Create failed."); + return null; + } + const itemID = (response.data?.itemID || "") as string; + if (!itemID) { + showMessage(window.siyuan.languages.calendarCreateFailed || "Create failed."); + return null; + } + const warnings = (response.data?.warnings || []) as string[]; + if (warnings.length > 0) { + showMessage(warnings.join("
")); + } + await reconcileCreatedItemFieldValues(options.protyle, options, itemID, fieldValues); + // The kernel answers BlockID == ItemID for a detached item (no document was + // created); report that as "no page" instead of a document id that does not + // resolve to anything openable. + const documentID = (response.data?.blockID || "") as string; + return {itemID, blockID: documentID === itemID ? "" : documentID}; +}; + + +/** + * Deleting the page behind an entry. The caller exposes this as a separate, + * explicit page-removal action because the calendar's undo stack cannot restore + * a document. + * Endpoint verified against kernel/api/router.go:150 -> + * kernel/api/filetree.go:622 removeDocByID, which takes {id}. + */ +export const deleteCalendarEventDocument = async (documentID: string) => { + const response = await fetchSyncPost("/api/filetree/removeDocByID", {id: documentID}); + if (response?.code !== 0) { + showMessage(response?.msg || window.siyuan.languages.calendarDeletePageFailed || "Deleting the event page failed."); + return false; + } + return true; +}; + +/** Undo an already committed operation set without registering a new undo step. */ +const revertCalendarOperations = async (protyle: IProtyle, ops: ICalendarOperationSet) => { + if (ops.undoOperations.length === 0) { + return true; + } + return executeCalendarOperations(protyle, {doOperations: [...ops.undoOperations], undoOperations: []}); +}; + +export const createCalendarEventReplacingOccurrence = async (options: { + protyle: IProtyle; + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + draft: ICalendarEventDraft; + occurrenceDate: string; + previousUpdated?: string; + viewID?: string; + /** The view creates entries as documents, so the replacement needs a page too. */ + createAsDocument?: boolean; + templateID?: string; +}) => { + const exceptionOps = buildOccurrenceExceptionOperations({ + avID: options.avID, + blockID: options.blockID, + fields: options.fields, + mapping: options.mapping, + event: options.event, + occurrenceDate: options.occurrenceDate, + previousUpdated: options.previousUpdated, + }); + const replacementDraft: ICalendarEventDraft = { + ...options.draft, + recurrenceRaw: "", + recurrenceExceptionRaw: "", + }; + if (exceptionOps.doOperations.length === 0 || !isRealDateInputValue(options.draft.date)) { + return false; + } + if (!options.createAsDocument) { + const createOps = buildCreateEventOperations({ + avID: options.avID, + blockID: options.blockID, + dateFieldID: options.dateFieldID, + fields: options.fields, + mapping: options.mapping, + draft: replacementDraft, + previousUpdated: options.previousUpdated, + }); + if (createOps.doOperations.length === 0) { + return false; + } + return executeCalendarOperations(options.protyle, { + doOperations: [...exceptionOps.doOperations, ...createOps.doOperations], + undoOperations: [...createOps.undoOperations, ...exceptionOps.undoOperations], + }, options); + } + // A page cannot be created from inside an operation set, so the two halves are + // sequenced instead. Hide the occurrence FIRST - that is a single, fully + // reversible cell write - and only then create the replacement page. If the + // page cannot be created the exception is put back, so the series is never + // left with a hidden occurrence and no replacement, and no freshly created + // document ever has to be destroyed to clean up. + if (!await executeCalendarOperations(options.protyle, exceptionOps, options)) { + return false; + } + const created = await createCalendarEventAsDocument({...options, draft: replacementDraft}); + if (!created) { + if (!await revertCalendarOperations(options.protyle, exceptionOps)) { + showMessage(window.siyuan.languages.calendarOccurrenceRollbackFailed || + "The occurrence stayed hidden and no replacement was created."); + } + return false; + } + return true; +}; + +export const updateCalendarEvent = async (options: { + protyle: IProtyle; + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + draft: ICalendarEventDraft; + previousUpdated?: string; + viewID?: string; +}) => { + if (!isRealDateInputValue(options.draft.date)) { + return false; + } + const documentID = getEventDocumentID(options.event); + const currentTitle = options.event.isTitleFallback ? "" : (options.event.title || ""); + const nextTitle = (options.draft.title || "").trim(); + if (documentID) { + const fieldValues = buildCalendarFieldValues(options); + if (!fieldValues) { + return false; + } + const response = await fetchSyncPost("/api/av/updateAttributeViewItem", { + avID: options.avID, + blockID: options.blockID, + viewID: options.viewID || "", + itemID: options.event.id, + boundBlockID: documentID, + primaryKey: nextTitle || currentTitle, + fieldValues, + app: options.protyle?.app?.appId || Constants.SIYUAN_APPID, + session: options.protyle?.id || Constants.SIYUAN_APPID, + }); + if (response?.code !== 0) { + showMessage(response?.msg || window.siyuan.languages.calendarUpdateFailed || "Updating the calendar event failed."); + return false; + } + if (options.viewID && !await verifyBoundCalendarItemUpdate(options, options.event.id, documentID, nextTitle || currentTitle, fieldValues)) { + showMessage(window.siyuan.languages.calendarUpdateFailed || "The calendar update could not be confirmed."); + return false; + } + return true; + } + const ops = buildUpdateEventOperations(options); + if (ops.doOperations.length > 0) { + return executeCalendarOperations(options.protyle, ops, options); + } + return true; +}; + +export const updateCalendarEventThisAndFuture = async (options: { + protyle: IProtyle; + avID: string; + blockID: string; + dateFieldID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + draft: ICalendarEventDraft; + occurrenceDate: string; + previousUpdated?: string; + viewID?: string; + /** The view creates entries as documents, so the follow-up series needs a page too. */ + createAsDocument?: boolean; + templateID?: string; +}) => { + if (!isRealDateInputValue(options.draft.date)) { + return false; + } + if (!options.createAsDocument) { + const ops = buildSplitSeriesOperations(options); + if (ops.doOperations.length > 0) { + return executeCalendarOperations(options.protyle, ops, options); + } + return true; + } + // Same sequencing as the occurrence replacement: truncate the original series + // first (one reversible cell write), then create the follow-up series page, + // and put the original recurrence back if the page cannot be created. + const truncateOps = buildSplitTruncateOperations(options); + if (truncateOps.doOperations.length > 0 && !await executeCalendarOperations(options.protyle, truncateOps, options)) { + return false; + } + const created = await createCalendarEventAsDocument({...options, draft: buildSplitFutureDraft(options)}); + if (!created) { + if (!await revertCalendarOperations(options.protyle, truncateOps)) { + showMessage(window.siyuan.languages.calendarSplitRollbackFailed || + "The series was cut short and no follow-up series was created."); + } + return false; + } + return true; +}; + +export const deleteCalendarEvent = async (options: { + protyle: IProtyle; + avID: string; + blockID: string; + event: ICalendarNormalizedEvent; + previousUpdated?: string; + viewID?: string; +}) => { + return executeCalendarOperations(options.protyle, buildDeleteEventOperations(options), options); +}; + +export const deleteCalendarEventThisAndFuture = async (options: { + protyle: IProtyle; + avID: string; + blockID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + occurrenceDate: string; + previousUpdated?: string; + viewID?: string; +}) => { + if (!options.mapping.recurrenceFieldID) { + return false; + } + if (!dayjs(options.occurrenceDate).isAfter(options.event.start, "day")) { + return deleteCalendarEvent(options); + } + const ops = buildSplitTruncateOperations(options); + return ops.doOperations.length > 0 ? executeCalendarOperations(options.protyle, ops, options) : false; +}; + +export const deleteCalendarOccurrence = async (options: { + protyle: IProtyle; + avID: string; + blockID: string; + fields: IAVColumn[]; + mapping: ICalendarFieldMapping; + event: ICalendarNormalizedEvent; + occurrenceDate: string; + previousUpdated?: string; + viewID?: string; +}) => { + const ops = buildOccurrenceExceptionOperations(options); + if (ops.doOperations.length > 0) { + return executeCalendarOperations(options.protyle, ops, options); + } + return false; +}; diff --git a/app/src/protyle/render/av/col.ts b/app/src/protyle/render/av/col.ts index 128a098cc9b..0ef4ea75b8f 100644 --- a/app/src/protyle/render/av/col.ts +++ b/app/src/protyle/render/av/col.ts @@ -27,7 +27,7 @@ import {getAVData} from "./virtualScroll"; export const getColId = (element: Element, viewType: TAVView) => { if (viewType === "table" || hasClosestByClassName(element, "custom-attr")) { return element.getAttribute("data-col-id"); - } else if (["gallery", "kanban"].includes(viewType)) { + } else if (["gallery", "kanban", "calendar"].includes(viewType)) { return element.getAttribute("data-field-id"); } }; @@ -1482,6 +1482,7 @@ export const removeCol = (options: { blockElement: Element avPanelElement: Element tabRect: DOMRect, + viewType?: string, isTwoWay: boolean }) => { const colId = options.menuElement.querySelector(".b3-menu__item").getAttribute("data-col-id"); diff --git a/app/src/protyle/render/av/gallery/render.ts b/app/src/protyle/render/av/gallery/render.ts index 53cd5e032e2..1d20a457e6a 100644 --- a/app/src/protyle/render/av/gallery/render.ts +++ b/app/src/protyle/render/av/gallery/render.ts @@ -9,6 +9,7 @@ import {getPageSize} from "../groups"; import {renderKanban} from "../kanban/render"; import {getAVSelectedItemPoints, getBodyVirtualData, initVirtualScroll, setAVData} from "../virtualScroll"; import {getRowHTML, stickyRow, updateAVSelectionStatus, updateHeader} from "../row"; +import {renderCalendar} from "../calendar/render"; import {beginAVRender, finishAVLocate, getAVLocateParams, isCurrentAVRender, prepareAVLocate} from "../locate"; import {getCardStyle} from "./style"; import {setGroupFoldedStates} from "../groupFold"; @@ -291,6 +292,17 @@ export const renderGallery = async (options: { }); return; } + if (data.viewType === "calendar") { + options.blockElement.setAttribute("data-av-type", data.viewType); + renderCalendar({ + blockElement: options.blockElement, + protyle: options.protyle, + cb: options.cb, + renderAll: options.renderAll, + data + }); + return; + } const view: IAVGallery = data.view as IAVGallery; if (view.groups?.length > 0) { renderGroupGallery({ diff --git a/app/src/protyle/render/av/kanban/render.ts b/app/src/protyle/render/av/kanban/render.ts index c0cc55263b8..a5e86a0955b 100644 --- a/app/src/protyle/render/av/kanban/render.ts +++ b/app/src/protyle/render/av/kanban/render.ts @@ -5,6 +5,7 @@ import {Constants} from "../../../../constants"; import {avRender, genTabHeaderHTML} from "../render"; import {afterRenderGallery, renderGallery} from "../gallery/render"; import {escapeAttr, escapeHtml} from "../../../../util/escape"; +import {renderCalendar} from "../calendar/render"; import {getRowHTML} from "../row"; import {getAVSelectedItemPoints, getBodyVirtualData} from "../virtualScroll"; import {beginAVRender, getAVLocateParams, isCurrentAVRender, prepareAVLocate} from "../locate"; @@ -175,6 +176,17 @@ export const renderKanban = async (options: { }); return; } + if (data.viewType === "calendar") { + options.blockElement.setAttribute("data-av-type", data.viewType); + renderCalendar({ + blockElement: options.blockElement, + protyle: options.protyle, + cb: options.cb, + renderAll: options.renderAll, + data + }); + return; + } const view = data.view as IAVKanban; const groupKey = view.fields.find(item => item.id === view.group?.field); const groupOptions = groupKey?.options || []; diff --git a/app/src/protyle/render/av/layout.ts b/app/src/protyle/render/av/layout.ts index 1831ddf87cc..1dc77a5a10f 100644 --- a/app/src/protyle/render/av/layout.ts +++ b/app/src/protyle/render/av/layout.ts @@ -1,6 +1,8 @@ import {transaction} from "../../wysiwyg/transaction"; import {Constants} from "../../../constants"; +import {escapeAttr, escapeHtml} from "../../../util/escape"; import {fetchSyncPost} from "../../../util/fetch"; +import {showMessage} from "../../../dialog/message"; import {setPosition} from "../../../util/setPosition"; import {getCardAspectRatioLabel, getCardAspectRatioValue, getCardWidth} from "./gallery/style"; import {getFieldsByData} from "./view"; @@ -117,6 +119,11 @@ export const getLayoutHTML = (data: IAV) => {
${window.siyuan.languages.gallery}
+
+ +
+
${window.siyuan.languages.calendar || "Calendar"}
+
`; } + if (data.viewType === "calendar") { + const calendarView = data.view as IAVCalendar; + const textFields = getFieldsByData(data).filter(field => field.type === "text"); + const buildOptions = (selected: string, fields: IAVColumn[]) => { + let options = ``; + if (selected && !fields.some(field => field.id === selected)) { + options += ``; + } + fields.forEach(field => { + options += ``; + }); + return options; + }; + const buildDateOptions = () => { + const matched = getFieldsByData(data).filter(field => field.type === "date"); + return buildOptions(calendarView.dateFieldID || "", matched); + }; + const effectiveMapping = getCalendarFieldMapping(calendarView); + html += `
+
+ + +
+ + +
+
+ + +
+
+ +
+ ${textFields.filter(field => ![effectiveMapping.recurrenceFieldID, effectiveMapping.exceptionFieldID].includes(field.id) && !isCalendarRecurrenceStorageField(field)).map(field => ``).join("")} +
+
+ + +
+
+ +
+
+
`; + } + if (data.viewType === "calendar") { + // 日历渲染始终请求整个数据库(pageSize -1),分页项对它无效,不展示。 + return html + ""; + } return html + ` ${showHTML} ${hideHTML} - +${viewType === "calendar" ? "" : ` +`} `; }; diff --git a/app/src/protyle/render/av/render.ts b/app/src/protyle/render/av/render.ts index bbf9277d1ab..cb79c5c0f3a 100644 --- a/app/src/protyle/render/av/render.ts +++ b/app/src/protyle/render/av/render.ts @@ -18,6 +18,7 @@ import {getPageSize} from "./groups"; import {clearSelect} from "../../util/clear"; import {showMessage} from "../../../dialog/message"; import {renderKanban} from "./kanban/render"; +import {renderCalendar} from "./calendar/render"; import {bindAvSearch} from "./search"; import {getAVSelectedItemPoints, getBodyVirtualData, initVirtualScroll, setAVData} from "./virtualScroll"; import {beginAVRender, finishAVLocate, getAVLocateParams, isCurrentAVRender, prepareAVLocate, setAVLocateRequest} from "./locate"; @@ -106,6 +107,7 @@ export const genTabHeaderHTML = (data: IAV, showSearch: boolean, editable: boole const defaultTemplate = data.newItemTemplates?.find(item => item.id === data.defaultTemplateID); const defaultTemplateID = defaultTemplate && (defaultTemplate.targetType !== "detached" || defaultTemplate.primaryKeyTemplate || Object.keys(defaultTemplate.fieldValues || {}).length) ? defaultTemplate.id : ""; + const calendarHeaderSearchClass = data.viewType === "calendar" ? " fn__none" : ""; return `
@@ -136,10 +138,10 @@ export const genTabHeaderHTML = (data: IAV, showSearch: boolean, editable: boole
- -
+
@@ -488,6 +490,10 @@ export const avRender = async (element: Element, protyle: IProtyle, cb?: (data: await renderKanban({blockElement: e, protyle, cb, renderAll}); continue; } + if (e.getAttribute("data-av-type") === "calendar") { + await renderCalendar({blockElement: e, protyle, cb, renderAll}); + continue; + } let selectCellId; const selectCellElement = e.querySelector(".av__cell--select") as HTMLElement; @@ -617,6 +623,11 @@ export const avRender = async (element: Element, protyle: IProtyle, cb?: (data: await renderKanban({blockElement: e, protyle, cb, renderAll, data}); continue; } + if (data.viewType === "calendar") { + e.setAttribute("data-av-type", data.viewType); + await renderCalendar({blockElement: e, protyle, cb, renderAll, data}); + continue; + } const view = data.view as IAVTable; if (view.groups?.length > 0) { renderGroupTable({blockElement: e, protyle, cb, renderAll, data, resetData}); diff --git a/app/src/protyle/render/av/row.ts b/app/src/protyle/render/av/row.ts index 158f503b215..b2645864935 100644 --- a/app/src/protyle/render/av/row.ts +++ b/app/src/protyle/render/av/row.ts @@ -405,7 +405,8 @@ export const insertAttrViewBlockAnimation = (options: { // 有排序需要加入最后一行 const hasSort = options.blockElement.querySelector('.av__views [data-type="av-sort"]').classList.contains("block__icon--active"); if (hasSort) { - previousElement = options.blockElement.querySelector(groupQuery + ".av__row--util").previousElementSibling; + // 日历等布局不渲染 .av__row--util,缺失时保持原有插入位置。 + previousElement = options.blockElement.querySelector(groupQuery + ".av__row--util")?.previousElementSibling || previousElement; } const bodyElement = options.blockElement.querySelector(`.av__body[data-group-id="${options.groupID}"] `); if (bodyElement && ["updated", "created"].includes(bodyElement.getAttribute("data-dtype")) && diff --git a/app/src/protyle/render/av/view.ts b/app/src/protyle/render/av/view.ts index 2d0cc93f95b..8ae8920a4bc 100644 --- a/app/src/protyle/render/av/view.ts +++ b/app/src/protyle/render/av/view.ts @@ -318,12 +318,12 @@ export const getViewHTML = (data: IAV) => { ${view.sorts.length} - +`} `).join("")} +
`; + } + + private renderSourcePicker() { + const options = this.available.map(ref => ``).join(""); + const selectedNames = this.available.filter(ref => this.selected.indexOf(ref.avID) > -1).map(ref => ref.databaseName); + return `
${options || `
${escape(lang("calendarNoSources", "No calendar databases found"))}
`}
`; + } + + private renderEvent(item: ICalendarDockEvent) { + const event = item.event; + const time = event.isAllDay ? lang("calendarAllDay", "All day") : `${event.start.format("HH:mm")}${event.end ? `–${event.end.format("HH:mm")}` : ""}`; + return ``; + } + + private eventOccursOn(item: ICalendarDockEvent, date: dayjs.Dayjs) { + return item.event.start.isSame(date, "day") || + !!item.event.end && !date.isBefore(item.event.start, "day") && !date.isAfter(item.event.end, "day"); + } + + private renderEmpty() { + return `
${escape(lang("calendarNoEvents", "No events"))}
`; + } + + private renderDay(events: ICalendarDockEvent[]) { + const dayEvents = events.filter(item => this.eventOccursOn(item, this.anchor)); + return `
+ +
${dayEvents.length ? dayEvents.map(item => this.renderEvent(item)).join("") : this.renderEmpty()}
+
`; + } + + private renderMonth(events: ICalendarDockEvent[]) { + const miniMonth = renderCalendarMiniMonth({anchor: this.anchor, cursor: this.anchor, locale: window.siyuan.config.lang}); + const dayEvents = events.filter(item => this.eventOccursOn(item, this.anchor)); + return `
+
${miniMonth}
+
${escape(formatDate(this.anchor, {weekday: "long", month: "long", day: "numeric"}))}
+
${dayEvents.length ? dayEvents.map(item => this.renderEvent(item)).join("") : this.renderEmpty()}
+
`; + } + + private renderAgenda(events: ICalendarDockEvent[]) { + const monthStart = this.anchor.startOf("month"); + const monthEnd = this.anchor.endOf("month"); + const grouped = new Map(); + events.forEach(item => { + const eventEnd = item.event.end || item.event.start; + if (eventEnd.isBefore(monthStart, "day") || item.event.start.isAfter(monthEnd, "day")) return; + const key = (item.event.start.isBefore(monthStart, "day") ? monthStart : item.event.start).format("YYYY-MM-DD"); + grouped.set(key, [...(grouped.get(key) || []), item]); + }); + const scrollDate = this.agendaScrollDate && this.anchor.isSame(dayjs(this.agendaScrollDate), "month") ? this.agendaScrollDate : ""; + const dates = [...grouped.keys()]; + const hasTodayGroup = !!scrollDate && grouped.has(scrollDate); + const todayMarker = scrollDate && !hasTodayGroup ? `
+ +
` : ""; + if (!grouped.size) return `
${todayMarker}${this.renderEmpty()}
`; + const todayMarkerIndex = todayMarker ? dates.findIndex(date => date > scrollDate) : -1; + const scrollTarget = hasTodayGroup ? scrollDate : ""; + return `
+ ${[...grouped.entries()].map(([date, items], index) => `${index === todayMarkerIndex ? todayMarker : ""}
+ +
${items.map(item => this.renderEvent(item)).join("")}
+
`).join("")}${todayMarker && todayMarkerIndex === -1 ? todayMarker : ""} +
`; + } + + private markMiniMonthEventDays(events: ICalendarDockEvent[]) { + const eventDays = getCalendarMiniMonthEventDays(events.map(item => ({start: item.event.start, end: item.event.end}))); + eventDays.forEach(date => this.element.querySelector(`.av__calendar-mini-day[data-date="${date}"]`)?.classList.add("av__calendar-mini-day--has-events")); + } + + private async render() { + const content = this.element.querySelector(".av__calendar-dock-content") as HTMLElement; + if (!content) return; + const events = this.collectEvents(); + const title = formatDate(this.anchor, this.view === "day" ? {weekday: "long", month: "long", day: "numeric"} : {month: "long", year: "numeric"}); + const body = this.view === "day" ? this.renderDay(events) : this.view === "month" ? this.renderMonth(events) : this.renderAgenda(events); + // Markup is static; every user-derived value is escaped and inline colours + // pass through getSafeCalendarColor before insertion. + content.innerHTML = `${this.renderViewSwitch()}${this.renderSourcePicker()}${renderCalendarDockPeriod(this.view, title)}${body}`; + if (this.view === "month") this.markMiniMonthEventDays(events); + if (this.view === "agenda" && this.agendaScrollDate) { + this.agendaScrollDate = ""; + requestAnimationFrame(() => { + const target = content.querySelector("[data-calendar-agenda-scroll-target]") as HTMLElement; + if (target) content.scrollTop = Math.max(0, target.offsetTop - content.offsetTop); + }); + } + } + + private openEvent(key: string) { + const item = this.events.get(key); + if (!item) { + showMessage(lang("calendarNoMatchingEvent", "No matching event")); + return; + } + const documentID = getEventDocumentID(item.event); + if (!documentID) { + showMessage(lang("calendarSourceMissing", "Calendar item has no source block")); + return; + } + openFileById({app: this.app, id: documentID, action: [Constants.CB_GET_FOCUS]}); + } +} + +export const CALENDAR_DOCK_TYPE = "calendar"; diff --git a/app/src/layout/dock/calendar-period.test.ts b/app/src/layout/dock/calendar-period.test.ts new file mode 100644 index 00000000000..b1fc3a64f68 --- /dev/null +++ b/app/src/layout/dock/calendar-period.test.ts @@ -0,0 +1,62 @@ +import {after, before, describe, it} from "node:test"; +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import {renderCalendarDockPeriod} from "./calendar-period"; + +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + +before(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + siyuan: {config: {lang: "en"}, languages: {}}, + }, + }); +}); + +after(() => { + if (originalWindow) { + Object.defineProperty(globalThis, "window", originalWindow); + } else { + Reflect.deleteProperty(globalThis, "window"); + } +}); + +describe("Calendar dock period label", () => { + const title = "August 2026"; + + it("keeps the outer period label in Day view", () => { + const markup = renderCalendarDockPeriod("day", title); + assert.match(markup, /class="av__calendar-dock-period"/); + assert.ok(markup.includes(title)); + }); + + it("keeps the outer period label in Schedule view", () => { + const markup = renderCalendarDockPeriod("agenda", title); + assert.match(markup, /class="av__calendar-dock-period"/); + assert.ok(markup.includes(title)); + }); + + it("suppresses the outer period label in Month view", () => { + assert.equal(renderCalendarDockPeriod("month", title), ""); + }); + + it("Month view keeps exactly one month title, inside the mini navigator between its arrows", () => { + // The mini month navigator cannot execute under node:test (its dayjs + // `import *` binding is webpack-only), so assert the rendered template + // contract directly from source: one title, ordered prev < title < next. + const source = fs.readFileSync(path.join(__dirname, "../../protyle/render/av/calendar/mini-month.ts"), "utf8"); + const headerStart = source.indexOf("av__calendar-mini-header"); + const headerEnd = source.indexOf("av__calendar-mini-weekdays"); + assert.ok(headerStart !== -1 && headerEnd !== -1 && headerEnd > headerStart, "mini header template found"); + const header = source.slice(headerStart, headerEnd); + const titles = header.match(/av__calendar-mini-title/g) || []; + assert.equal(titles.length, 1, "mini navigator renders exactly one month title"); + const prev = header.indexOf('data-type="calendar-mini-prev"'); + const titleIndex = header.indexOf("av__calendar-mini-title"); + const next = header.indexOf('data-type="calendar-mini-next"'); + assert.ok(prev !== -1 && titleIndex !== -1 && next !== -1, "prev arrow, title and next arrow all present in mini header"); + assert.ok(prev < titleIndex && titleIndex < next, "the single month title sits between the prev/next arrows"); + }); +}); diff --git a/app/src/layout/dock/calendar-period.ts b/app/src/layout/dock/calendar-period.ts new file mode 100644 index 00000000000..540b0ac183a --- /dev/null +++ b/app/src/layout/dock/calendar-period.ts @@ -0,0 +1,11 @@ +const escape = (value: string) => value.replace(/[&<>"']/g, item => ({"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"} as Record)[item]); + +/** + * Outer period label of the Calendar dock. + * + * Month view suppresses it: the mini month navigator already renders the + * month title between its previous/next arrows, so keeping both would + * duplicate the month title. Day and Schedule (agenda) keep their labels. + */ +export const renderCalendarDockPeriod = (view: "day" | "month" | "agenda", title: string): string => + view === "month" ? "" : `
${escape(title)}
`; diff --git a/app/src/layout/dock/index.ts b/app/src/layout/dock/index.ts index 2e23c18d16d..838eefaf64a 100644 --- a/app/src/layout/dock/index.ts +++ b/app/src/layout/dock/index.ts @@ -15,6 +15,7 @@ import {Inbox} from "./Inbox"; import {Protyle} from "../../protyle"; import {Backlink} from "./Backlink"; import {AgentChat} from "./agent/AgentChat"; +import {Calendar} from "./Calendar"; import {adjustDockPadding, resetFloatDockSize} from "./util"; import {hasClosestByAttribute, hasClosestByClassName} from "../../protyle/util/hasClosest"; import type {App} from "../../index"; @@ -23,7 +24,7 @@ import {Custom} from "./Custom"; import {clearBeforeResizeTop, recordBeforeResizeTop} from "../../protyle/util/resize"; import {Constants} from "../../constants"; -const TYPES = ["file", "outline", "inbox", "bookmark", "tag", "graph", "globalGraph", "backlink", "agentChat"]; +const TYPES = ["file", "outline", "inbox", "bookmark", "tag", "graph", "globalGraph", "backlink", "agentChat", "calendar"]; export class Dock { public elements: HTMLElement[]; @@ -638,6 +639,13 @@ export class Dock { } }); break; + case "calendar": + tab = new Tab({ + callback: (tab: Tab) => { + tab.addModel(new Calendar(this.app, tab)); + } + }); + break; default: tab = new Tab({ callback: (tab: Tab) => { diff --git a/app/src/layout/util.ts b/app/src/layout/util.ts index 8e170214d14..35f009c7f42 100644 --- a/app/src/layout/util.ts +++ b/app/src/layout/util.ts @@ -305,6 +305,28 @@ const ensureAgentChatDock = (layout: Pick) => { + for (const key of DOCK_KEYS) { + if (layout[key]?.data.some(section => section?.some(item => item?.type === "calendar"))) { + return; + } + } + for (const key of DOCK_KEYS) { + const sourceSections = Constants.SIYUAN_EMPTY_LAYOUT[key]?.data; + const targetSections = layout[key]?.data; + if (!sourceSections || !targetSections) { + continue; + } + for (let sectionIndex = 0; sectionIndex < sourceSections.length; sectionIndex++) { + const source = sourceSections[sectionIndex]?.find(item => item?.type === "calendar"); + if (source && targetSections[sectionIndex]) { + targetSections[sectionIndex].push({...source, show: false}); + return; + } + } + } +}; + const initInternalDock = (dockItem: Config.IUILayoutDockTab[]) => { dockItem.forEach((existSubItem, index) => { if (window.siyuan.isPublish && (existSubItem.type === "inbox" || existSubItem.type === "agentChat")) { @@ -315,12 +337,15 @@ const initInternalDock = (dockItem: Config.IUILayoutDockTab[]) => { existSubItem.title = window.siyuan.languages[existSubItem.hotkeyLangId]; const km = window.siyuan.config.keymap.general[existSubItem.hotkeyLangId]; existSubItem.hotkey = km ? km.custom : ""; + } else if (existSubItem.type === "calendar") { + existSubItem.title = window.siyuan.languages.calendar || "Calendar"; } }); }; const JSONToDock = (json: any, app: App) => { ensureAgentChatDock(json); + ensureCalendarDock(json); json.left.data.forEach((existItem: Config.IUILayoutDockTab[]) => { initInternalDock(existItem); }); From 91dac0b07accfed74d3fc904c1e8170a6a593b2c Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 19:23:02 +0200 Subject: [PATCH 04/10] :globe_with_meridians: Localize calendar strings https://github.com/siyuan-note/siyuan/issues/13740 --- app/appearance/langs/ar.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/de.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/en.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/es.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/fr.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/he.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/hi.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/id.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/it.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/ja.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/ko.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/nl.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/pl.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/pt-BR.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/ru.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/sk.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/th.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/tr.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/uk.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/zh-CN.json | 126 +++++++++++++++++++++++++++++++- app/appearance/langs/zh-TW.json | 126 +++++++++++++++++++++++++++++++- 21 files changed, 2604 insertions(+), 42 deletions(-) diff --git a/app/appearance/langs/ar.json b/app/appearance/langs/ar.json index 68c88b3d53f..c587ddc98c4 100644 --- a/app/appearance/langs/ar.json +++ b/app/appearance/langs/ar.json @@ -1,4 +1,124 @@ { + "calendar": "التقويم", + "calendarRecurringAdvancedReadOnly": "تم الاحتفاظ بإعدادات التكرار المتقدمة (غير قابلة للتحرير هنا).", + "calendarDuplicateMetadataField": "يرجى اختيار حقول مختلفة لبيانات ${x} الوصفية.", + "calendarNeedDateField": "يتطلب التقويم حقل تاريخ", + "calendarCreateDateColumn": "لم يتم العثور على حقل تاريخ. إنشاء عمود «${x}» والتبديل إلى عرض التقويم؟", + "calendarSearch": "بحث في التقويم", + "calendarThisAndFuture": "هذا وما يليه", + "calendarCreateDateField": "إنشاء حقل تاريخ", + "calendarDeleteOccurrence": "حذف الظهور", + "calendarDeleteSeries": "حذف الكل", + "calendarRecurrenceScopeSeries": "كل الأحداث", + "calendarDaily": "يوميًا", + "calendarWeekly": "أسبوعيًا", + "calendarMonthly": "شهريًا", + "calendarYearly": "سنويًا", + "calendarInterval": "الفاصل الزمني", + "calendarCount": "عدد المرات", + "calendarUntil": "حتى", + "calendarWeekStart": "يبدأ الأسبوع في", + "calendarWeekNumber": "الأسبوع ${x}", + "calendarRecurrence": "التكرار", + "calendarOccurrence": "ظهور متكرر", + "calendarDiscardChanges": "تجاهل تغييرات التقويم غير المحفوظة؟", + "calendarSource": "الملاحظة/الكتلة المصدر", + "calendarOpenSource": "فتح المصدر", + "calendarSourceMissing": "عنصر التقويم بلا كتلة مصدر", + "calendarTimed": "محدد بوقت", + "calendarEvents": "الأحداث", + "calendarPreviousEvent": "الحدث السابق", + "calendarNextEvent": "الحدث التالي", + "calendarNoMatchingEvent": "لا يوجد حدث مطابق", + "calendarEditSeriesNotice": "سيؤدي هذا إلى تحرير السلسلة المتكررة. اربط حقل استثناء لتحرير ظهور واحد فقط.", + "calendarExceptions": "الاستثناءات", + "calendarLocation": "الموقع", + "calendarDescription": "الوصف", + "calendarSchedule": "الجدول", + "calendarEventRestored": "تمت استعادة الحدث.", + "calendarCreateFailed": "فشل الإنشاء.", + "calendarResizeFailed": "فشل تغيير الحجم.", + "calendarMoveFailed": "فشل النقل.", + "calendarDeleteFailed": "فشل الحذف.", + "calendarDuplicateFailed": "فشل التكرار.", + "calendarSaveFailed": "فشل الحفظ.", + "calendarDeleteRecurring": "حذف عنصر متكرر", + "calendarRecurrenceScopeOccurrenceDisabled": "اربط حقل استثناء لتغيير هذا الظهور فقط.", + "calendarRecurrenceScopeFutureDeleteDisabled": "حذف «هذا وما يليه» غير مدعوم بعد.", + "calendarRecurrenceScopeFutureDisabled": "اربط حقل تكرار لتغيير هذا العنصر وما يليه.", + "calendarRecurrenceScopeDeleteTitle": "حذف عنصر متكرر", + "calendarRecurrenceScopeEditTitle": "تحرير عنصر متكرر", + "calendarRecurrenceScopeOccurrence": "هذا الظهور", + "calendarRecurrenceScopeOccurrenceDesc": "الظهور المحدد فقط.", + "calendarRecurrenceScopeFutureDesc": "هذا الظهور وما يليه من عناصر السلسلة.", + "calendarRecurrenceScopeSeriesDesc": "كل عنصر في السلسلة المتكررة.", + "calendarRecurrenceScopePrompt": "اختر نطاق تطبيق هذا التغيير.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "هذا الحدث المصدر يخزّن سلسلة التكرار. تغييرات الظهور الفردي متاحة فقط من الظهورات المُنشأة.", + "calendarRecurrenceScopeRootFutureDisabled": "خيار «هذا وما يليه» متاح فقط من الظهورات المُنشأة.", + "newEvent": "حدث جديد", + "calendarEditEvent": "تعديل الحدث", + "calendarStaleMapping": "الحقل مفقود أو غير صالح", + "today": "اليوم", + "allDay": "طوال اليوم", + "calendarAllDay": "طوال اليوم", + "none": "لا شيء", + "saved": "تم الحفظ", + "dateField": "حقل التاريخ", + "calendarEmptyHint": "لا توجد عناصر تقويم بعد — انقر على يوم أو فترة زمنية لإنشاء العنصر الأول.", + "calendarRenamePageFailed": "فشل إعادة تسمية صفحة الحدث.", + "calendarDeletePageFailed": "فشل حذف صفحة الحدث.", + "calendarDeleteEvent": "حذف الحدث", + "calendarDeleteEventAndDocument": "حذف الحدث والمستند", + "calendarOccurrenceRollbackFailed": "بقي التكرار مخفيًا ولم يتم إنشاء بديل.", + "calendarSplitRollbackFailed": "تم اختصار السلسلة ولم يتم إنشاء سلسلة لاحقة.", + "calendarShortcuts": "اختصارات لوحة المفاتيح", + "calendarShortcutsViews": "العروض", + "calendarShortcutsNavigation": "التنقل", + "calendarShortcutsActions": "الإجراءات", + "calendarDayView": "عرض اليوم", + "calendarFiveDayView": "5 أيام", + "calendarWeekView": "عرض الأسبوع", + "calendarMonthView": "عرض الشهر", + "calendarScheduleView": "عرض الجدول", + "calendarNextRange": "الفترة التالية", + "calendarPreviousRange": "الفترة السابقة", + "calendarJumpToToday": "الانتقال إلى اليوم", + "calendarCreateEvent": "إنشاء حدث", + "calendarFocusSearch": "بحث", + "calendarShowShortcuts": "تعليمات اختصارات لوحة المفاتيح", + "calendarBackOut": "إلغاء الإجراء الحالي ثم مسح البحث", + "calendarMiniMonth": "متصفح الأشهر", + "calendarPreviousMonth": "الشهر السابق", + "calendarNextMonth": "الشهر التالي", + "calendarRepeat": "التكرار", + "calendarDoesNotRepeat": "لا يتكرر", + "calendarRepeatWeeklyOn": "أسبوعيًا يوم ${x}", + "calendarRepeatEveryNDays": "كل ${x} أيام", + "calendarRepeatEveryNWeeksOn": "كل ${x} أسابيع يوم ${y}", + "calendarRepeatMonthlyOnDay": "شهريًا في اليوم ${x}", + "calendarRepeatEveryNMonthsOnDay": "كل ${x} أشهر في اليوم ${y}", + "calendarRepeatEveryNMonths": "كل ${x} أشهر", + "calendarRepeatAnnuallyOn": "سنويًا في ${x}", + "calendarRepeatEveryNYearsOn": "كل ${x} سنوات في ${y}", + "calendarRepeatEveryNYears": "كل ${x} سنوات", + "calendarRepeatUntilSuffix": "، حتى ${x}", + "calendarRepeatCountSuffix": "، ${x} مرات", + "calendarRepeatEveryWeekday": "كل أيام الأسبوع (الاثنين إلى الجمعة)", + "calendarRepeatCustom": "مخصص…", + "calendarMapLocationHint": "عيّن حقلاً نصيًا في إعدادات التقويم لحفظ هذا الحقل.", + "calendarMapRecurrenceHint": "عيّن حقلاً نصيًا في إعدادات التقويم لحفظ التكرار.", + "calendarRepeatEvery": "التكرار كل", + "calendarDay": "يوم", + "calendarWeek": "أسبوع", + "calendarMonth": "شهر", + "calendarYear": "سنة", + "calendarRepeatOn": "التكرار في", + "calendarEnd": "النهاية", + "calendarStart": "البداية", + "calendarNever": "أبدًا", + "calendarOn": "في", + "calendarAfter": "بعد", + "calendarOccurrences": "مرات", "entrySeparator": "فاصل", "tableAddColumnTip": "انقر لإدراج عمود إلى اليمين
اسحب لإدراج أعمدة متعددة أو حذفها", "tableAddRowTip": "انقر لإدراج صف في الأسفل
اسحب لإدراج صفوف متعددة أو حذفها", @@ -2052,7 +2172,9 @@ "gallery": "بطاقة", "kanban": "Kanban", "key": "المفتاح الرئيسي", - "select": "تحديد" + "select": "تحديد", + "calendar": "التقويم", + "date": "تاريخ" }, "_kernel": { "0": "فشل استعلام دفتر الملاحظات", @@ -2420,4 +2542,4 @@ "362": "اسم مستخدم حساب خدمة النشر مكرر", "363": "يجب أن تتكون كلمة مرور حساب خدمة النشر من 8 أحرف على الأقل" } - } +} diff --git a/app/appearance/langs/de.json b/app/appearance/langs/de.json index fa76fca40aa..e1f0865533c 100644 --- a/app/appearance/langs/de.json +++ b/app/appearance/langs/de.json @@ -1,4 +1,124 @@ { + "calendar": "Kalender", + "calendarRecurringAdvancedReadOnly": "Erweiterte Wiederholungsmuster bleiben erhalten (hier nicht bearbeitbar).", + "calendarDuplicateMetadataField": "Bitte wählen Sie unterschiedliche Felder für ${x} aus.", + "calendarNeedDateField": "Der Kalender benötigt ein Datumsfeld", + "calendarCreateDateColumn": "Es wurde kein Datumsfeld gefunden. Möchten Sie ein Feld \"${x}\" erstellen und zur Kalenderansicht wechseln?", + "calendarSearch": "Kalender durchsuchen", + "calendarThisAndFuture": "Diesen Termin und alle folgenden", + "calendarCreateDateField": "Datumsfeld erstellen", + "calendarDeleteOccurrence": "Termin löschen", + "calendarDeleteSeries": "Alle löschen", + "calendarRecurrenceScopeSeries": "Alle bearbeiten", + "calendarDaily": "Täglich", + "calendarWeekly": "Wöchentlich", + "calendarMonthly": "Monatlich", + "calendarYearly": "Jährlich", + "calendarInterval": "Intervall", + "calendarCount": "Anzahl", + "calendarUntil": "Bis", + "calendarWeekStart": "Woche beginnt am", + "calendarWeekNumber": "KW ${x}", + "calendarRecurrence": "Wiederholung", + "calendarOccurrence": "Wiederkehrender Termin", + "calendarDiscardChanges": "Ungespeicherte Kalenderänderungen verwerfen?", + "calendarSource": "Quelle", + "calendarOpenSource": "Quelle öffnen", + "calendarSourceMissing": "Termin hat keine Quelle", + "calendarTimed": "Mit Uhrzeit", + "calendarEvents": "Termine", + "calendarPreviousEvent": "Vorheriger Termin", + "calendarNextEvent": "Nächster Termin", + "calendarNoMatchingEvent": "Kein passender Termin", + "calendarEditSeriesNotice": "Dies bearbeitet alle wiederkehrenden Termine. Ordnen Sie ein Ausnahmefeld zu, um nur einen Termin zu bearbeiten.", + "calendarExceptions": "Ausnahmen", + "calendarLocation": "Ort", + "calendarDescription": "Beschreibung", + "calendarSchedule": "Terminübersicht", + "calendarEventRestored": "Termin wiederhergestellt.", + "calendarCreateFailed": "Erstellen fehlgeschlagen.", + "calendarResizeFailed": "Größenänderung fehlgeschlagen.", + "calendarMoveFailed": "Verschieben fehlgeschlagen.", + "calendarDeleteFailed": "Löschen fehlgeschlagen.", + "calendarDuplicateFailed": "Duplizieren fehlgeschlagen.", + "calendarSaveFailed": "Speichern fehlgeschlagen.", + "calendarDeleteRecurring": "Termin löschen", + "calendarRecurrenceScopeOccurrenceDisabled": "Ordnen Sie ein Ausnahmefeld zu, um nur diesen Termin zu ändern.", + "calendarRecurrenceScopeFutureDeleteDisabled": "„Diesen und folgende“ löschen wird noch nicht unterstützt.", + "calendarRecurrenceScopeFutureDisabled": "Ordnen Sie ein Wiederholungsfeld zu, um diesen und folgende Termine zu ändern.", + "calendarRecurrenceScopeDeleteTitle": "Termine löschen", + "calendarRecurrenceScopeEditTitle": "Termine bearbeiten", + "calendarRecurrenceScopeOccurrence": "Nur diesen Termin", + "calendarRecurrenceScopeOccurrenceDesc": "Nur der ausgewählte Termin.", + "calendarRecurrenceScopeFutureDesc": "Diesen und alle folgenden Termine.", + "calendarRecurrenceScopeSeriesDesc": "Alle Termine.", + "calendarRecurrenceScopePrompt": "Wählen Sie, wie weit diese Änderung gelten soll.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Nur bei einem einzelnen Termin verfügbar.", + "calendarRecurrenceScopeRootFutureDisabled": "Nur bei einem einzelnen Termin verfügbar.", + "newEvent": "Neuer Termin", + "calendarEditEvent": "Termin bearbeiten", + "calendarStaleMapping": "Feld fehlt oder ist ungültig", + "today": "Heute", + "allDay": "Ganztägig", + "calendarAllDay": "Ganztägig", + "none": "Keine", + "saved": "Gespeichert", + "dateField": "Datumsfeld", + "calendarEmptyHint": "Noch keine Kalendereinträge — klicken Sie auf einen Tag oder ein Zeitfenster, um den ersten Eintrag zu erstellen.", + "calendarRenamePageFailed": "Umbenennen der Terminseite fehlgeschlagen.", + "calendarDeletePageFailed": "Löschen der Terminseite fehlgeschlagen.", + "calendarDeleteEvent": "Termin löschen", + "calendarDeleteEventAndDocument": "Termin und Dokument löschen", + "calendarOccurrenceRollbackFailed": "Der Termin bleibt ausgeblendet und es wurde kein Ersatz erstellt.", + "calendarSplitRollbackFailed": "Die Wiederholung wurde gekürzt und es wurden keine folgenden Termine erstellt.", + "calendarShortcuts": "Tastenkürzel", + "calendarShortcutsViews": "Ansichten", + "calendarShortcutsNavigation": "Navigation", + "calendarShortcutsActions": "Aktionen", + "calendarDayView": "Tagesansicht", + "calendarFiveDayView": "5 Tage", + "calendarWeekView": "Wochenansicht", + "calendarMonthView": "Monatsansicht", + "calendarScheduleView": "Terminübersicht", + "calendarNextRange": "Nächster Zeitraum", + "calendarPreviousRange": "Vorheriger Zeitraum", + "calendarJumpToToday": "Zu heute springen", + "calendarCreateEvent": "Termin erstellen", + "calendarFocusSearch": "Suchen", + "calendarShowShortcuts": "Hilfe zu Tastenkürzeln", + "calendarBackOut": "Aktuelle Geste abbrechen, dann die Suche zurücksetzen", + "calendarMiniMonth": "Monatsnavigator", + "calendarPreviousMonth": "Vorheriger Monat", + "calendarNextMonth": "Nächster Monat", + "calendarRepeat": "Wiederholen", + "calendarDoesNotRepeat": "Wiederholt sich nicht", + "calendarRepeatWeeklyOn": "Wöchentlich am ${x}", + "calendarRepeatEveryNDays": "Alle ${x} Tage", + "calendarRepeatEveryNWeeksOn": "Alle ${x} Wochen am ${y}", + "calendarRepeatMonthlyOnDay": "Monatlich am ${x}.", + "calendarRepeatEveryNMonthsOnDay": "Alle ${x} Monate am ${y}.", + "calendarRepeatEveryNMonths": "Alle ${x} Monate", + "calendarRepeatAnnuallyOn": "Jährlich am ${x}", + "calendarRepeatEveryNYearsOn": "Alle ${x} Jahre am ${y}", + "calendarRepeatEveryNYears": "Alle ${x} Jahre", + "calendarRepeatUntilSuffix": " bis ${x}", + "calendarRepeatCountSuffix": " ${x}-mal", + "calendarRepeatEveryWeekday": "Jeden Wochentag (Montag bis Freitag)", + "calendarRepeatCustom": "Benutzerdefiniert…", + "calendarMapLocationHint": "Ordnen Sie in den Kalendereinstellungen ein Textfeld zu, um dieses Feld zu speichern.", + "calendarMapRecurrenceHint": "Ordnen Sie in den Kalendereinstellungen ein Textfeld zu, um Wiederholungen zu speichern.", + "calendarRepeatEvery": "Intervall", + "calendarDay": "Tag", + "calendarWeek": "Woche", + "calendarMonth": "Monat", + "calendarYear": "Jahr", + "calendarRepeatOn": "Wiederholen am", + "calendarEnd": "Ende", + "calendarStart": "Beginn", + "calendarNever": "Nie", + "calendarOn": "Am", + "calendarAfter": "Nach", + "calendarOccurrences": "Terminen", "entrySeparator": "Trennlinie", "tableAddColumnTip": "Klicken, um rechts eine Spalte einzufügen
Ziehen, um mehrere Spalten einzufügen oder zu löschen", "tableAddRowTip": "Klicken, um unten eine Zeile einzufügen
Ziehen, um mehrere Zeilen einzufügen oder zu löschen", @@ -2052,7 +2172,9 @@ "gallery": "Karte", "kanban": "Kanban", "key": "Primärschlüssel", - "select": "Auswählen" + "select": "Auswählen", + "calendar": "Kalender", + "date": "Datum" }, "_kernel": { "0": "Abfrage des Notizbuchs fehlgeschlagen", @@ -2420,4 +2542,4 @@ "362": "Der Benutzername des Veröffentlichungsdienst-Kontos ist bereits vergeben", "363": "Das Passwort des Veröffentlichungsdienst-Kontos muss mindestens 8 Zeichen lang sein" } - } +} diff --git a/app/appearance/langs/en.json b/app/appearance/langs/en.json index ed2e0d9dc8b..55bd6e6eb66 100644 --- a/app/appearance/langs/en.json +++ b/app/appearance/langs/en.json @@ -1,4 +1,124 @@ { + "calendar": "Calendar", + "calendarRecurringAdvancedReadOnly": "Advanced recurrence is retained (not editable here).", + "calendarDuplicateMetadataField": "Please choose different fields for ${x} metadata.", + "calendarNeedDateField": "Calendar requires a date field", + "calendarCreateDateColumn": "No date field was found. Create a \"${x}\" column and switch to Calendar view?", + "calendarSearch": "Search calendar", + "calendarThisAndFuture": "This and future", + "calendarCreateDateField": "Create date field", + "calendarDeleteOccurrence": "Delete occurrence", + "calendarDeleteSeries": "Delete all", + "calendarRecurrenceScopeSeries": "All events", + "calendarDaily": "Daily", + "calendarWeekly": "Weekly", + "calendarMonthly": "Monthly", + "calendarYearly": "Yearly", + "calendarInterval": "Interval", + "calendarCount": "Count", + "calendarUntil": "Until", + "calendarWeekStart": "Week starts on", + "calendarWeekNumber": "Week ${x}", + "calendarRecurrence": "Recurrence", + "calendarOccurrence": "Recurring occurrence", + "calendarDiscardChanges": "Discard unsaved calendar changes?", + "calendarSource": "Source note/block", + "calendarOpenSource": "Open source", + "calendarSourceMissing": "Calendar item has no source block", + "calendarTimed": "Timed", + "calendarEvents": "Events", + "calendarPreviousEvent": "Previous event", + "calendarNextEvent": "Next event", + "calendarNoMatchingEvent": "No matching event", + "calendarEditSeriesNotice": "This will edit the recurring series. Map an exception field to edit a single occurrence.", + "calendarExceptions": "Exceptions", + "calendarLocation": "Location", + "calendarDescription": "Description", + "calendarSchedule": "Schedule", + "calendarEventRestored": "Event restored.", + "calendarCreateFailed": "Create failed.", + "calendarResizeFailed": "Resize failed.", + "calendarMoveFailed": "Move failed.", + "calendarDeleteFailed": "Delete failed.", + "calendarDuplicateFailed": "Duplicate failed.", + "calendarSaveFailed": "Save failed.", + "calendarDeleteRecurring": "Delete recurring item", + "calendarRecurrenceScopeOccurrenceDisabled": "Map an exception field to change only this occurrence.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Deleting this and following is not supported yet.", + "calendarRecurrenceScopeFutureDisabled": "Map a recurrence field to change this and following items.", + "calendarRecurrenceScopeDeleteTitle": "Delete recurring item", + "calendarRecurrenceScopeEditTitle": "Edit recurring item", + "calendarRecurrenceScopeOccurrence": "This occurrence", + "calendarRecurrenceScopeOccurrenceDesc": "Only the selected occurrence.", + "calendarRecurrenceScopeFutureDesc": "This occurrence and following items in the series.", + "calendarRecurrenceScopeSeriesDesc": "Every item in the recurring series.", + "calendarRecurrenceScopePrompt": "Choose how far this change should apply.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "This source event stores the recurring series. Single-occurrence changes are only available from generated occurrences.", + "calendarRecurrenceScopeRootFutureDisabled": "This and following is only available from generated occurrences.", + "newEvent": "New event", + "calendarEditEvent": "Edit event", + "calendarStaleMapping": "Missing or invalid field", + "today": "Today", + "allDay": "All day", + "calendarAllDay": "All day", + "none": "None", + "saved": "Saved", + "dateField": "Date field", + "calendarEmptyHint": "No calendar items yet — click a day or a time slot to create the first one.", + "calendarRenamePageFailed": "Renaming the event page failed.", + "calendarDeletePageFailed": "Deleting the event page failed.", + "calendarDeleteEvent": "Delete event", + "calendarDeleteEventAndDocument": "Delete event and document", + "calendarOccurrenceRollbackFailed": "The occurrence stayed hidden and no replacement was created.", + "calendarSplitRollbackFailed": "The series was cut short and no follow-up series was created.", + "calendarShortcuts": "Keyboard shortcuts", + "calendarShortcutsViews": "Views", + "calendarShortcutsNavigation": "Navigation", + "calendarShortcutsActions": "Actions", + "calendarDayView": "Day view", + "calendarFiveDayView": "5 Days", + "calendarWeekView": "Week view", + "calendarMonthView": "Month view", + "calendarScheduleView": "Schedule view", + "calendarNextRange": "Next period", + "calendarPreviousRange": "Previous period", + "calendarJumpToToday": "Go to today", + "calendarCreateEvent": "Create event", + "calendarFocusSearch": "Search", + "calendarShowShortcuts": "Keyboard shortcut help", + "calendarBackOut": "Cancel the current gesture, then clear the search", + "calendarMiniMonth": "Month navigator", + "calendarPreviousMonth": "Previous month", + "calendarNextMonth": "Next month", + "calendarRepeat": "Repeat", + "calendarDoesNotRepeat": "Does not repeat", + "calendarRepeatWeeklyOn": "Weekly on ${x}", + "calendarRepeatEveryNDays": "Every ${x} days", + "calendarRepeatEveryNWeeksOn": "Every ${x} weeks on ${y}", + "calendarRepeatMonthlyOnDay": "Monthly on day ${x}", + "calendarRepeatEveryNMonthsOnDay": "Every ${x} months on day ${y}", + "calendarRepeatEveryNMonths": "Every ${x} months", + "calendarRepeatAnnuallyOn": "Annually on ${x}", + "calendarRepeatEveryNYearsOn": "Every ${x} years on ${y}", + "calendarRepeatEveryNYears": "Every ${x} years", + "calendarRepeatUntilSuffix": ", until ${x}", + "calendarRepeatCountSuffix": ", ${x} times", + "calendarRepeatEveryWeekday": "Every weekday (Monday to Friday)", + "calendarRepeatCustom": "Custom…", + "calendarMapLocationHint": "Map a text field in Calendar settings to save this field.", + "calendarMapRecurrenceHint": "Map a text field in Calendar settings to save recurring events.", + "calendarRepeatEvery": "Repeat every", + "calendarDay": "Day", + "calendarWeek": "Week", + "calendarMonth": "Month", + "calendarYear": "Year", + "calendarRepeatOn": "Repeat on", + "calendarEnd": "End", + "calendarStart": "Start", + "calendarNever": "Never", + "calendarOn": "On", + "calendarAfter": "After", + "calendarOccurrences": "occurrences", "entrySeparator": "Separator", "tableAddColumnTip": "Click to insert a column on the right
Drag to insert or delete multiple columns", "tableAddRowTip": "Click to insert a row below
Drag to insert or delete multiple rows", @@ -2052,7 +2172,9 @@ "gallery": "Card", "kanban": "Kanban", "key": "Primary Key", - "select": "Select" + "select": "Select", + "calendar": "Calendar", + "date": "Date" }, "_kernel": { "0": "Query notebook failed", @@ -2420,4 +2542,4 @@ "362": "The publish service account username is duplicated", "363": "The publish service account password must be at least 8 characters" } - } +} diff --git a/app/appearance/langs/es.json b/app/appearance/langs/es.json index b28359f0b09..cdf687576ea 100644 --- a/app/appearance/langs/es.json +++ b/app/appearance/langs/es.json @@ -1,4 +1,124 @@ { + "calendar": "Calendario", + "calendarRecurringAdvancedReadOnly": "La recurrencia avanzada se conserva (no se puede editar aquí).", + "calendarDuplicateMetadataField": "Elige campos distintos para los metadatos de ${x}.", + "calendarNeedDateField": "El calendario requiere un campo de fecha", + "calendarCreateDateColumn": "No se encontró ningún campo de fecha. ¿Crear una columna «${x}» y cambiar a la vista de calendario?", + "calendarSearch": "Buscar en el calendario", + "calendarThisAndFuture": "Este y los siguientes", + "calendarCreateDateField": "Crear campo de fecha", + "calendarDeleteOccurrence": "Borrar repetición", + "calendarDeleteSeries": "Eliminar todo", + "calendarRecurrenceScopeSeries": "Todos los eventos", + "calendarDaily": "Diaria", + "calendarWeekly": "Semanal", + "calendarMonthly": "Mensual", + "calendarYearly": "Anual", + "calendarInterval": "Intervalo", + "calendarCount": "Número de veces", + "calendarUntil": "Hasta", + "calendarWeekStart": "La semana comienza el", + "calendarWeekNumber": "Sem. ${x}", + "calendarRecurrence": "Recurrencia", + "calendarOccurrence": "Repetición recurrente", + "calendarDiscardChanges": "¿Descartar los cambios sin guardar del calendario?", + "calendarSource": "Nota/bloque de origen", + "calendarOpenSource": "Abrir origen", + "calendarSourceMissing": "El elemento del calendario no tiene bloque de origen", + "calendarTimed": "Con hora", + "calendarEvents": "Eventos", + "calendarPreviousEvent": "Evento anterior", + "calendarNextEvent": "Evento siguiente", + "calendarNoMatchingEvent": "Ningún evento coincidente", + "calendarEditSeriesNotice": "Esto editará la serie recurrente. Asigna un campo de excepciones para editar una sola repetición.", + "calendarExceptions": "Excepciones", + "calendarLocation": "Ubicación", + "calendarDescription": "Descripción", + "calendarSchedule": "Agenda", + "calendarEventRestored": "Evento restaurado.", + "calendarCreateFailed": "Error al crear.", + "calendarResizeFailed": "Error al redimensionar.", + "calendarMoveFailed": "Error al mover.", + "calendarDeleteFailed": "Error al borrar.", + "calendarDuplicateFailed": "Error al duplicar.", + "calendarSaveFailed": "Error al guardar.", + "calendarDeleteRecurring": "Borrar elemento recurrente", + "calendarRecurrenceScopeOccurrenceDisabled": "Asigna un campo de excepciones para cambiar solo esta repetición.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Borrar este y los siguientes aún no está disponible.", + "calendarRecurrenceScopeFutureDisabled": "Asigna un campo de recurrencia para cambiar este y los siguientes elementos.", + "calendarRecurrenceScopeDeleteTitle": "Borrar elemento recurrente", + "calendarRecurrenceScopeEditTitle": "Editar elemento recurrente", + "calendarRecurrenceScopeOccurrence": "Esta repetición", + "calendarRecurrenceScopeOccurrenceDesc": "Solo la repetición seleccionada.", + "calendarRecurrenceScopeFutureDesc": "Esta repetición y los siguientes elementos de la serie.", + "calendarRecurrenceScopeSeriesDesc": "Todos los elementos de la serie recurrente.", + "calendarRecurrenceScopePrompt": "Elige el alcance de este cambio.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Este evento de origen almacena la serie recurrente. Los cambios en una sola repetición solo están disponibles desde las repeticiones generadas.", + "calendarRecurrenceScopeRootFutureDisabled": "«Este y los siguientes» solo está disponible desde las repeticiones generadas.", + "newEvent": "Nuevo evento", + "calendarEditEvent": "Editar evento", + "calendarStaleMapping": "Campo faltante o no válido", + "today": "Hoy", + "allDay": "Todo el día", + "calendarAllDay": "Todo el día", + "none": "Ninguno", + "saved": "Guardado", + "dateField": "Campo de fecha", + "calendarEmptyHint": "Aún no hay elementos en el calendario — haz clic en un día o en una franja horaria para crear el primero.", + "calendarRenamePageFailed": "Error al renombrar la página del evento.", + "calendarDeletePageFailed": "Error al eliminar la página del evento.", + "calendarDeleteEvent": "Eliminar evento", + "calendarDeleteEventAndDocument": "Eliminar evento y documento", + "calendarOccurrenceRollbackFailed": "La repetición quedó oculta y no se creó ningún reemplazo.", + "calendarSplitRollbackFailed": "La serie se acortó y no se creó ninguna serie posterior.", + "calendarShortcuts": "Atajos de teclado", + "calendarShortcutsViews": "Vistas", + "calendarShortcutsNavigation": "Navegación", + "calendarShortcutsActions": "Acciones", + "calendarDayView": "Vista de día", + "calendarFiveDayView": "5 días", + "calendarWeekView": "Vista de semana", + "calendarMonthView": "Vista de mes", + "calendarScheduleView": "Vista de agenda", + "calendarNextRange": "Período siguiente", + "calendarPreviousRange": "Período anterior", + "calendarJumpToToday": "Ir a hoy", + "calendarCreateEvent": "Crear evento", + "calendarFocusSearch": "Buscar", + "calendarShowShortcuts": "Ayuda de atajos de teclado", + "calendarBackOut": "Cancelar el gesto actual y luego borrar la búsqueda", + "calendarMiniMonth": "Navegador de meses", + "calendarPreviousMonth": "Mes anterior", + "calendarNextMonth": "Mes siguiente", + "calendarRepeat": "Repetir", + "calendarDoesNotRepeat": "No se repite", + "calendarRepeatWeeklyOn": "Semanalmente los ${x}", + "calendarRepeatEveryNDays": "Cada ${x} días", + "calendarRepeatEveryNWeeksOn": "Cada ${x} semanas los ${y}", + "calendarRepeatMonthlyOnDay": "Mensualmente el día ${x}", + "calendarRepeatEveryNMonthsOnDay": "Cada ${x} meses el día ${y}", + "calendarRepeatEveryNMonths": "Cada ${x} meses", + "calendarRepeatAnnuallyOn": "Anualmente el ${x}", + "calendarRepeatEveryNYearsOn": "Cada ${x} años el ${y}", + "calendarRepeatEveryNYears": "Cada ${x} años", + "calendarRepeatUntilSuffix": ", hasta el ${x}", + "calendarRepeatCountSuffix": ", ${x} veces", + "calendarRepeatEveryWeekday": "Todos los días laborables (de lunes a viernes)", + "calendarRepeatCustom": "Personalizado…", + "calendarMapLocationHint": "Asigna un campo de texto en los ajustes del calendario para guardar este campo.", + "calendarMapRecurrenceHint": "Asigna un campo de texto en los ajustes del calendario para guardar repeticiones.", + "calendarRepeatEvery": "Repetir cada", + "calendarDay": "Día", + "calendarWeek": "Semana", + "calendarMonth": "Mes", + "calendarYear": "Año", + "calendarRepeatOn": "Repetir el", + "calendarEnd": "Fin", + "calendarStart": "Inicio", + "calendarNever": "Nunca", + "calendarOn": "El", + "calendarAfter": "Después de", + "calendarOccurrences": "repeticiones", "entrySeparator": "Separador", "tableAddColumnTip": "Haz clic para insertar una columna a la derecha
Arrastra para insertar o eliminar varias columnas", "tableAddRowTip": "Haz clic para insertar una fila debajo
Arrastra para insertar o eliminar varias filas", @@ -2052,7 +2172,9 @@ "gallery": "Tarjeta", "kanban": "Kanban", "key": "Clave principal", - "select": "Selección" + "select": "Selección", + "calendar": "Calendario", + "date": "Fecha" }, "_kernel": { "0": "Consulta al cuaderno de notas fallido", @@ -2420,4 +2542,4 @@ "362": "El nombre de usuario de la cuenta del servicio de publicación está duplicado", "363": "La contraseña de la cuenta del servicio de publicación debe tener al menos 8 caracteres" } - } +} diff --git a/app/appearance/langs/fr.json b/app/appearance/langs/fr.json index eadb4411d3f..88907208382 100644 --- a/app/appearance/langs/fr.json +++ b/app/appearance/langs/fr.json @@ -1,4 +1,124 @@ { + "calendar": "Calendrier", + "calendarRecurringAdvancedReadOnly": "La récurrence avancée est conservée (non modifiable ici).", + "calendarDuplicateMetadataField": "Veuillez choisir des champs différents pour les métadonnées ${x}.", + "calendarNeedDateField": "Le calendrier nécessite un champ de date", + "calendarCreateDateColumn": "Aucun champ de date trouvé. Créer une colonne « ${x} » et passer à la vue Calendrier ?", + "calendarSearch": "Rechercher dans le calendrier", + "calendarThisAndFuture": "Celui-ci et les suivants", + "calendarCreateDateField": "Créer un champ de date", + "calendarDeleteOccurrence": "Supprimer l'occurrence", + "calendarDeleteSeries": "Tout supprimer", + "calendarRecurrenceScopeSeries": "Tous les événements", + "calendarDaily": "Quotidien", + "calendarWeekly": "Hebdomadaire", + "calendarMonthly": "Mensuel", + "calendarYearly": "Annuel", + "calendarInterval": "Intervalle", + "calendarCount": "Nombre", + "calendarUntil": "Jusqu'au", + "calendarWeekStart": "La semaine commence le", + "calendarWeekNumber": "Sem. ${x}", + "calendarRecurrence": "Récurrence", + "calendarOccurrence": "Occurrence récurrente", + "calendarDiscardChanges": "Abandonner les modifications non sauvegardées du calendrier ?", + "calendarSource": "Note/bloc source", + "calendarOpenSource": "Ouvrir la source", + "calendarSourceMissing": "L'élément du calendrier n'a pas de bloc source", + "calendarTimed": "Horaire", + "calendarEvents": "Événements", + "calendarPreviousEvent": "Événement précédent", + "calendarNextEvent": "Événement suivant", + "calendarNoMatchingEvent": "Aucun événement correspondant", + "calendarEditSeriesNotice": "Cette action modifiera la série récurrente. Associez un champ d'exception pour modifier une seule occurrence.", + "calendarExceptions": "Exceptions", + "calendarLocation": "Lieu", + "calendarDescription": "Description", + "calendarSchedule": "Planning", + "calendarEventRestored": "Événement restauré.", + "calendarCreateFailed": "Échec de la création.", + "calendarResizeFailed": "Échec du redimensionnement.", + "calendarMoveFailed": "Échec du déplacement.", + "calendarDeleteFailed": "Échec de la suppression.", + "calendarDuplicateFailed": "Échec de la duplication.", + "calendarSaveFailed": "Échec de la sauvegarde.", + "calendarDeleteRecurring": "Supprimer l'élément récurrent", + "calendarRecurrenceScopeOccurrenceDisabled": "Associez un champ d'exception pour ne modifier que cette occurrence.", + "calendarRecurrenceScopeFutureDeleteDisabled": "La suppression de celui-ci et des suivants n'est pas encore prise en charge.", + "calendarRecurrenceScopeFutureDisabled": "Associez un champ de récurrence pour modifier celui-ci et les éléments suivants.", + "calendarRecurrenceScopeDeleteTitle": "Supprimer l'élément récurrent", + "calendarRecurrenceScopeEditTitle": "Modifier l'élément récurrent", + "calendarRecurrenceScopeOccurrence": "Cette occurrence", + "calendarRecurrenceScopeOccurrenceDesc": "Uniquement l'occurrence sélectionnée.", + "calendarRecurrenceScopeFutureDesc": "Cette occurrence et les éléments suivants de la série.", + "calendarRecurrenceScopeSeriesDesc": "Tous les éléments de la série récurrente.", + "calendarRecurrenceScopePrompt": "Choisissez la portée de cette modification.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Cet événement source contient la série récurrente. Les modifications d'une seule occurrence ne sont possibles qu'à partir des occurrences générées.", + "calendarRecurrenceScopeRootFutureDisabled": "« Celui-ci et les suivants » n'est disponible qu'à partir des occurrences générées.", + "newEvent": "Nouvel événement", + "calendarEditEvent": "Modifier l’événement", + "calendarStaleMapping": "Champ manquant ou non valide", + "today": "Aujourd'hui", + "allDay": "Toute la journée", + "calendarAllDay": "Toute la journée", + "none": "Aucun", + "saved": "Enregistré", + "dateField": "Champ de date", + "calendarEmptyHint": "Aucun élément de calendrier pour le moment — cliquez sur un jour ou un créneau horaire pour créer le premier.", + "calendarRenamePageFailed": "Échec du renommage de la page de l'événement.", + "calendarDeletePageFailed": "Échec de la suppression de la page de l'événement.", + "calendarDeleteEvent": "Supprimer l’événement", + "calendarDeleteEventAndDocument": "Supprimer l’événement et le document", + "calendarOccurrenceRollbackFailed": "L'occurrence est restée masquée et aucun remplacement n'a été créé.", + "calendarSplitRollbackFailed": "La série a été écourtée et aucune série de suivi n'a été créée.", + "calendarShortcuts": "Raccourcis clavier", + "calendarShortcutsViews": "Vues", + "calendarShortcutsNavigation": "Navigation", + "calendarShortcutsActions": "Actions", + "calendarDayView": "Vue jour", + "calendarFiveDayView": "5 jours", + "calendarWeekView": "Vue semaine", + "calendarMonthView": "Vue mois", + "calendarScheduleView": "Vue agenda", + "calendarNextRange": "Période suivante", + "calendarPreviousRange": "Période précédente", + "calendarJumpToToday": "Aller à aujourd'hui", + "calendarCreateEvent": "Créer un événement", + "calendarFocusSearch": "Rechercher", + "calendarShowShortcuts": "Aide sur les raccourcis clavier", + "calendarBackOut": "Annuler le geste en cours, puis effacer la recherche", + "calendarMiniMonth": "Navigateur de mois", + "calendarPreviousMonth": "Mois précédent", + "calendarNextMonth": "Mois suivant", + "calendarRepeat": "Répéter", + "calendarDoesNotRepeat": "Ne se répète pas", + "calendarRepeatWeeklyOn": "Chaque semaine le ${x}", + "calendarRepeatEveryNDays": "Tous les ${x} jours", + "calendarRepeatEveryNWeeksOn": "Toutes les ${x} semaines le ${y}", + "calendarRepeatMonthlyOnDay": "Chaque mois le ${x}", + "calendarRepeatEveryNMonthsOnDay": "Tous les ${x} mois le ${y}", + "calendarRepeatEveryNMonths": "Tous les ${x} mois", + "calendarRepeatAnnuallyOn": "Chaque année le ${x}", + "calendarRepeatEveryNYearsOn": "Tous les ${x} ans le ${y}", + "calendarRepeatEveryNYears": "Tous les ${x} ans", + "calendarRepeatUntilSuffix": ", jusqu'au ${x}", + "calendarRepeatCountSuffix": ", ${x} fois", + "calendarRepeatEveryWeekday": "Tous les jours ouvrés (du lundi au vendredi)", + "calendarRepeatCustom": "Personnalisé…", + "calendarMapLocationHint": "Associez un champ texte dans les réglages du calendrier pour enregistrer ce champ.", + "calendarMapRecurrenceHint": "Associez un champ texte dans les réglages du calendrier pour enregistrer les récurrences.", + "calendarRepeatEvery": "Répéter tous les", + "calendarDay": "Jour", + "calendarWeek": "Semaine", + "calendarMonth": "Mois", + "calendarYear": "An", + "calendarRepeatOn": "Répéter le", + "calendarEnd": "Fin", + "calendarStart": "Début", + "calendarNever": "Jamais", + "calendarOn": "Le", + "calendarAfter": "Après", + "calendarOccurrences": "occurrences", "entrySeparator": "Séparateur", "tableAddColumnTip": "Cliquez pour insérer une colonne à droite
Faites glisser pour insérer ou supprimer plusieurs colonnes", "tableAddRowTip": "Cliquez pour insérer une ligne en dessous
Faites glisser pour insérer ou supprimer plusieurs lignes", @@ -2052,7 +2172,9 @@ "gallery": "Carte", "kanban": "Kanban", "key": "Clé primaire", - "select": "Sélectionner" + "select": "Sélectionner", + "calendar": "Calendrier", + "date": "Date" }, "_kernel": { "0": "Échec du cahier de requêtes", @@ -2420,4 +2542,4 @@ "362": "Le nom d'utilisateur du compte du service de publication est dupliqué", "363": "Le mot de passe du compte du service de publication doit comporter au moins 8 caractères" } - } +} diff --git a/app/appearance/langs/he.json b/app/appearance/langs/he.json index 9a4ad83e43c..489366bfd84 100644 --- a/app/appearance/langs/he.json +++ b/app/appearance/langs/he.json @@ -1,4 +1,124 @@ { + "calendar": "לוח שנה", + "calendarRecurringAdvancedReadOnly": "חזרתיות מתקדמת נשמרת (לא ניתנת לעריכה כאן).", + "calendarDuplicateMetadataField": "יש לבחור שדות שונים עבור מטא־נתוני ${x}.", + "calendarNeedDateField": "לוח השנה דורש שדה תאריך", + "calendarCreateDateColumn": "לא נמצא שדה תאריך. ליצור עמודה \"${x}\" ולעבור לתצוגת לוח שנה?", + "calendarSearch": "חפש בלוח השנה", + "calendarThisAndFuture": "זה והבאים", + "calendarCreateDateField": "צור שדה תאריך", + "calendarDeleteOccurrence": "מחק מופע", + "calendarDeleteSeries": "מחיקת הכול", + "calendarRecurrenceScopeSeries": "כל האירועים", + "calendarDaily": "יומי", + "calendarWeekly": "שבועי", + "calendarMonthly": "חודשי", + "calendarYearly": "שנתי", + "calendarInterval": "מרווח", + "calendarCount": "מספר פעמים", + "calendarUntil": "עד", + "calendarWeekStart": "תחילת השבוע ביום", + "calendarWeekNumber": "שבוע ${x}", + "calendarRecurrence": "חזרתיות", + "calendarOccurrence": "מופע חוזר", + "calendarDiscardChanges": "לבטל שינויים שלא נשמרו בלוח השנה?", + "calendarSource": "הערה/בלוק מקור", + "calendarOpenSource": "פתח מקור", + "calendarSourceMissing": "לפריט בלוח השנה אין בלוק מקור", + "calendarTimed": "עם שעה", + "calendarEvents": "אירועים", + "calendarPreviousEvent": "האירוע הקודם", + "calendarNextEvent": "האירוע הבא", + "calendarNoMatchingEvent": "לא נמצא אירוע תואם", + "calendarEditSeriesNotice": "פעולה זו תערוך את הסדרה החוזרת. מפו שדה חריגה כדי לערוך מופע בודד.", + "calendarExceptions": "חריגות", + "calendarLocation": "מיקום", + "calendarDescription": "תיאור", + "calendarSchedule": "לוח זמנים", + "calendarEventRestored": "האירוע שוחזר.", + "calendarCreateFailed": "היצירה נכשלה.", + "calendarResizeFailed": "שינוי הגודל נכשל.", + "calendarMoveFailed": "ההעברה נכשלה.", + "calendarDeleteFailed": "המחיקה נכשלה.", + "calendarDuplicateFailed": "השכפול נכשל.", + "calendarSaveFailed": "השמירה נכשלה.", + "calendarDeleteRecurring": "מחק פריט חוזר", + "calendarRecurrenceScopeOccurrenceDisabled": "מפו שדה חריגה כדי לשנות רק את המופע הזה.", + "calendarRecurrenceScopeFutureDeleteDisabled": "מחיקת \"זה והבאים\" עדיין אינה נתמכת.", + "calendarRecurrenceScopeFutureDisabled": "מפו שדה חזרתיות כדי לשנות את זה ואת הפריטים הבאים.", + "calendarRecurrenceScopeDeleteTitle": "מחק פריט חוזר", + "calendarRecurrenceScopeEditTitle": "ערוך פריט חוזר", + "calendarRecurrenceScopeOccurrence": "המופע הזה", + "calendarRecurrenceScopeOccurrenceDesc": "רק המופע שנבחר.", + "calendarRecurrenceScopeFutureDesc": "המופע הזה והפריטים הבאים בסדרה.", + "calendarRecurrenceScopeSeriesDesc": "כל פריט בסדרה החוזרת.", + "calendarRecurrenceScopePrompt": "בחרו על מה יחול השינוי.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "אירוע המקור הזה מאחסן את סדרת החזרות. שינויים במופע בודד זמינים רק ממופעים שנוצרו.", + "calendarRecurrenceScopeRootFutureDisabled": "האפשרות \"זה והבאים\" זמינה רק ממופעים שנוצרו.", + "newEvent": "אירוע חדש", + "calendarEditEvent": "עריכת אירוע", + "calendarStaleMapping": "שדה חסר או לא תקין", + "today": "היום", + "allDay": "כל היום", + "calendarAllDay": "כל היום", + "none": "אין", + "saved": "נשמר", + "dateField": "שדה תאריך", + "calendarEmptyHint": "אין עדיין פריטים בלוח השנה — לחץ על יום או על משבצת זמן כדי ליצור את הפריט הראשון.", + "calendarRenamePageFailed": "שינוי שם דף האירוע נכשל.", + "calendarDeletePageFailed": "מחיקת דף האירוע נכשלה.", + "calendarDeleteEvent": "מחיקת אירוע", + "calendarDeleteEventAndDocument": "מחיקת האירוע והמסמך", + "calendarOccurrenceRollbackFailed": "המופע נותר מוסתר ולא נוצר תחליף.", + "calendarSplitRollbackFailed": "הסדרה קוצרה ולא נוצרה סדרת המשך.", + "calendarShortcuts": "קיצורי מקלדת", + "calendarShortcutsViews": "תצוגות", + "calendarShortcutsNavigation": "ניווט", + "calendarShortcutsActions": "פעולות", + "calendarDayView": "תצוגת יום", + "calendarFiveDayView": "5 ימים", + "calendarWeekView": "תצוגת שבוע", + "calendarMonthView": "תצוגת חודש", + "calendarScheduleView": "תצוגת לוח זמנים", + "calendarNextRange": "התקופה הבאה", + "calendarPreviousRange": "התקופה הקודמת", + "calendarJumpToToday": "מעבר להיום", + "calendarCreateEvent": "יצירת אירוע", + "calendarFocusSearch": "חיפוש", + "calendarShowShortcuts": "עזרה לקיצורי מקלדת", + "calendarBackOut": "ביטול הפעולה הנוכחית ולאחר מכן ניקוי החיפוש", + "calendarMiniMonth": "ניווט חודשים", + "calendarPreviousMonth": "החודש הקודם", + "calendarNextMonth": "החודש הבא", + "calendarRepeat": "חזרה", + "calendarDoesNotRepeat": "לא חוזר", + "calendarRepeatWeeklyOn": "כל שבוע ביום ${x}", + "calendarRepeatEveryNDays": "כל ${x} ימים", + "calendarRepeatEveryNWeeksOn": "כל ${x} שבועות ביום ${y}", + "calendarRepeatMonthlyOnDay": "כל חודש ביום ${x}", + "calendarRepeatEveryNMonthsOnDay": "כל ${x} חודשים ביום ${y}", + "calendarRepeatEveryNMonths": "כל ${x} חודשים", + "calendarRepeatAnnuallyOn": "כל שנה ב-${x}", + "calendarRepeatEveryNYearsOn": "כל ${x} שנים ב-${y}", + "calendarRepeatEveryNYears": "כל ${x} שנים", + "calendarRepeatUntilSuffix": ", עד ${x}", + "calendarRepeatCountSuffix": ", ${x} פעמים", + "calendarRepeatEveryWeekday": "בכל יום חול (שני עד שישי)", + "calendarRepeatCustom": "מותאם אישית…", + "calendarMapLocationHint": "הקצה שדה טקסט בהגדרות היומן כדי לשמור שדה זה.", + "calendarMapRecurrenceHint": "הקצה שדה טקסט בהגדרות היומן כדי לשמור חזרות.", + "calendarRepeatEvery": "חזרה כל", + "calendarDay": "יום", + "calendarWeek": "שבוע", + "calendarMonth": "חודש", + "calendarYear": "שנה", + "calendarRepeatOn": "חזרה ב־", + "calendarEnd": "סיום", + "calendarStart": "התחלה", + "calendarNever": "לעולם לא", + "calendarOn": "בתאריך", + "calendarAfter": "אחרי", + "calendarOccurrences": "מופעים", "entrySeparator": "מפריד", "tableAddColumnTip": "לחצו כדי להוסיף עמודה מימין
גררו כדי להוסיף או למחוק עמודות מרובות", "tableAddRowTip": "לחצו כדי להוסיף שורה למטה
גררו כדי להוסיף או למחוק שורות מרובות", @@ -2052,7 +2172,9 @@ "gallery": "כרטיס", "kanban": "קאנבן", "key": "מפתח ראשי", - "select": "בחר" + "select": "בחר", + "calendar": "לוח שנה", + "date": "תאריך" }, "_kernel": { "0": "שאלת מחברת נכשלה", @@ -2420,4 +2542,4 @@ "362": "שם המשתמש של חשבון שירות הפרסום כפול", "363": "סיסמת חשבון שירות הפרסום חייבת להכיל לפחות 8 תווים" } - } +} diff --git a/app/appearance/langs/hi.json b/app/appearance/langs/hi.json index 99eb9ef3502..c9417a04448 100644 --- a/app/appearance/langs/hi.json +++ b/app/appearance/langs/hi.json @@ -1,4 +1,124 @@ { + "calendar": "कैलेंडर", + "calendarRecurringAdvancedReadOnly": "उन्नत पुनरावृत्ति बनी रहेगी (यहाँ संपादन योग्य नहीं)।", + "calendarDuplicateMetadataField": "कृपया ${x} मेटाडेटा के लिए अलग-अलग फ़ील्ड चुनें।", + "calendarNeedDateField": "कैलेंडर के लिए दिनांक फ़ील्ड आवश्यक है", + "calendarCreateDateColumn": "कोई दिनांक फ़ील्ड नहीं मिला। \"${x}\" कॉलम बनाकर कैलेंडर दृश्य पर जाएँ?", + "calendarSearch": "कैलेंडर में खोजें", + "calendarThisAndFuture": "यह और आगामी", + "calendarCreateDateField": "दिनांक फ़ील्ड बनाएँ", + "calendarDeleteOccurrence": "यह आवृत्ति हटाएं", + "calendarDeleteSeries": "सभी हटाएँ", + "calendarRecurrenceScopeSeries": "सभी इवेंट", + "calendarDaily": "दैनिक", + "calendarWeekly": "साप्ताहिक", + "calendarMonthly": "मासिक", + "calendarYearly": "वार्षिक", + "calendarInterval": "अंतराल", + "calendarCount": "संख्या", + "calendarUntil": "तक", + "calendarWeekStart": "सप्ताह का पहला दिन", + "calendarWeekNumber": "सप्ताह ${x}", + "calendarRecurrence": "पुनरावृत्ति", + "calendarOccurrence": "आवर्ती आवृत्ति", + "calendarDiscardChanges": "कैलेंडर के असहेजे परिवर्तन छोड़ें?", + "calendarSource": "स्रोत नोट/ब्लॉक", + "calendarOpenSource": "स्रोत खोलें", + "calendarSourceMissing": "कैलेंडर आइटम का कोई स्रोत ब्लॉक नहीं है", + "calendarTimed": "समयबद्ध", + "calendarEvents": "इवेंट", + "calendarPreviousEvent": "पिछला इवेंट", + "calendarNextEvent": "अगला इवेंट", + "calendarNoMatchingEvent": "कोई मेल खाता इवेंट नहीं", + "calendarEditSeriesNotice": "इससे पूरी आवर्ती श्रृंखला संपादित होगी। एकल आवृत्ति संपादित करने के लिए अपवाद फ़ील्ड मैप करें।", + "calendarExceptions": "अपवाद", + "calendarLocation": "स्थान", + "calendarDescription": "विवरण", + "calendarSchedule": "शेड्यूल", + "calendarEventRestored": "इवेंट पुनर्स्थापित हुआ।", + "calendarCreateFailed": "बनाना विफल हुआ।", + "calendarResizeFailed": "आकार बदलना विफल हुआ।", + "calendarMoveFailed": "स्थानांतरण विफल हुआ।", + "calendarDeleteFailed": "हटाना विफल हुआ।", + "calendarDuplicateFailed": "प्रतिलिपि बनाना विफल हुआ।", + "calendarSaveFailed": "सहेजना विफल हुआ।", + "calendarDeleteRecurring": "आवर्ती आइटम हटाएं", + "calendarRecurrenceScopeOccurrenceDisabled": "केवल इस आवृत्ति को बदलने के लिए अपवाद फ़ील्ड मैप करें।", + "calendarRecurrenceScopeFutureDeleteDisabled": "यह और आगामी हटाना अभी समर्थित नहीं है।", + "calendarRecurrenceScopeFutureDisabled": "यह और आगामी आइटम बदलने के लिए पुनरावृत्ति फ़ील्ड मैप करें।", + "calendarRecurrenceScopeDeleteTitle": "आवर्ती आइटम हटाएं", + "calendarRecurrenceScopeEditTitle": "आवर्ती आइटम संपादित करें", + "calendarRecurrenceScopeOccurrence": "यह आवृत्ति", + "calendarRecurrenceScopeOccurrenceDesc": "केवल चयनित आवृत्ति।", + "calendarRecurrenceScopeFutureDesc": "यह आवृत्ति और श्रृंखला के आगामी आइटम।", + "calendarRecurrenceScopeSeriesDesc": "आवर्ती श्रृंखला का हर आइटम।", + "calendarRecurrenceScopePrompt": "चुनें कि यह परिवर्तन कहाँ तक लागू हो।", + "calendarRecurrenceScopeRootOccurrenceDisabled": "यह स्रोत इवेंट आवर्ती श्रृंखला संग्रहीत करता है। एकल आवृत्ति परिवर्तन केवल जनित आवृत्तियों से उपलब्ध हैं।", + "calendarRecurrenceScopeRootFutureDisabled": "यह और आगामी केवल जनित आवृत्तियों से उपलब्ध है।", + "newEvent": "नया इवेंट", + "calendarEditEvent": "ईवेंट संपादित करें", + "calendarStaleMapping": "फ़ील्ड गुम या अमान्य है", + "today": "आज", + "allDay": "पूरे दिन", + "calendarAllDay": "पूरे दिन", + "none": "कोई नहीं", + "saved": "सहेजा गया", + "dateField": "दिनांक फ़ील्ड", + "calendarEmptyHint": "अभी कोई कैलेंडर आइटम नहीं है — पहला आइटम बनाने के लिए किसी दिन या समय स्लॉट पर क्लिक करें।", + "calendarRenamePageFailed": "ईवेंट पृष्ठ का नाम बदलने में विफल।", + "calendarDeletePageFailed": "ईवेंट पृष्ठ हटाने में विफल।", + "calendarDeleteEvent": "ईवेंट हटाएँ", + "calendarDeleteEventAndDocument": "ईवेंट और दस्तावेज़ हटाएँ", + "calendarOccurrenceRollbackFailed": "यह पुनरावृत्ति छिपी रही और कोई प्रतिस्थापन नहीं बनाया गया।", + "calendarSplitRollbackFailed": "शृंखला छोटी कर दी गई और कोई अनुवर्ती शृंखला नहीं बनाई गई।", + "calendarShortcuts": "कीबोर्ड शॉर्टकट", + "calendarShortcutsViews": "दृश्य", + "calendarShortcutsNavigation": "नेविगेशन", + "calendarShortcutsActions": "क्रियाएँ", + "calendarDayView": "दिन दृश्य", + "calendarFiveDayView": "5 दिन", + "calendarWeekView": "सप्ताह दृश्य", + "calendarMonthView": "माह दृश्य", + "calendarScheduleView": "शेड्यूल दृश्य", + "calendarNextRange": "अगली अवधि", + "calendarPreviousRange": "पिछली अवधि", + "calendarJumpToToday": "आज पर जाएँ", + "calendarCreateEvent": "इवेंट बनाएँ", + "calendarFocusSearch": "खोजें", + "calendarShowShortcuts": "कीबोर्ड शॉर्टकट सहायता", + "calendarBackOut": "वर्तमान क्रिया रद्द करें, फिर खोज साफ़ करें", + "calendarMiniMonth": "माह नेविगेटर", + "calendarPreviousMonth": "पिछला माह", + "calendarNextMonth": "अगला माह", + "calendarRepeat": "दोहराएँ", + "calendarDoesNotRepeat": "दोहराया नहीं जाता", + "calendarRepeatWeeklyOn": "हर सप्ताह ${x} को", + "calendarRepeatEveryNDays": "हर ${x} दिन", + "calendarRepeatEveryNWeeksOn": "हर ${x} सप्ताह ${y} को", + "calendarRepeatMonthlyOnDay": "हर माह ${x} तारीख को", + "calendarRepeatEveryNMonthsOnDay": "हर ${x} माह ${y} तारीख को", + "calendarRepeatEveryNMonths": "हर ${x} माह", + "calendarRepeatAnnuallyOn": "हर साल ${x} को", + "calendarRepeatEveryNYearsOn": "हर ${x} साल ${y} को", + "calendarRepeatEveryNYears": "हर ${x} साल", + "calendarRepeatUntilSuffix": ", ${x} तक", + "calendarRepeatCountSuffix": ", ${x} बार", + "calendarRepeatEveryWeekday": "हर कार्यदिवस (सोमवार से शुक्रवार)", + "calendarRepeatCustom": "कस्टम…", + "calendarMapLocationHint": "इस फ़ील्ड को सहेजने के लिए कैलेंडर सेटिंग में एक टेक्स्ट फ़ील्ड मैप करें।", + "calendarMapRecurrenceHint": "आवर्ती ईवेंट सहेजने के लिए कैलेंडर सेटिंग में एक टेक्स्ट फ़ील्ड मैप करें।", + "calendarRepeatEvery": "हर", + "calendarDay": "दिन", + "calendarWeek": "सप्ताह", + "calendarMonth": "महीना", + "calendarYear": "वर्ष", + "calendarRepeatOn": "इन दिनों दोहराएँ", + "calendarEnd": "समाप्ति", + "calendarStart": "शुरुआत", + "calendarNever": "कभी नहीं", + "calendarOn": "इस तारीख को", + "calendarAfter": "इसके बाद", + "calendarOccurrences": "बार", "entrySeparator": "विभाजक", "tableAddColumnTip": "दाईं ओर एक कॉलम डालने के लिए क्लिक करें
कई कॉलम डालने या हटाने के लिए खींचें", "tableAddRowTip": "नीचे एक पंक्ति डालने के लिए क्लिक करें
कई पंक्तियां डालने या हटाने के लिए खींचें", @@ -2052,7 +2172,9 @@ "gallery": "कार्ड", "kanban": "कानबन", "key": "प्राथमिक कुंजी", - "select": "चयन करें" + "select": "चयन करें", + "calendar": "कैलेंडर", + "date": "दिनांक" }, "_kernel": { "0": "नोटबुक क्वेरी विफल", @@ -2420,4 +2542,4 @@ "362": "प्रकाशन सेवा खाते का उपयोगकर्ता नाम दोहराया गया है", "363": "प्रकाशन सेवा खाते का पासवर्ड कम से कम 8 अक्षरों का होना चाहिए" } - } +} diff --git a/app/appearance/langs/id.json b/app/appearance/langs/id.json index b48ce1ef5b4..23fa059b4f6 100644 --- a/app/appearance/langs/id.json +++ b/app/appearance/langs/id.json @@ -1,4 +1,124 @@ { + "calendar": "Kalender", + "calendarRecurringAdvancedReadOnly": "Perulangan lanjutan dipertahankan (tidak dapat diedit di sini).", + "calendarDuplicateMetadataField": "Silakan pilih bidang berbeda untuk metadata ${x}.", + "calendarNeedDateField": "Kalender memerlukan bidang tanggal", + "calendarCreateDateColumn": "Bidang tanggal tidak ditemukan. Buat kolom \"${x}\" dan beralih ke tampilan Kalender?", + "calendarSearch": "Cari kalender", + "calendarThisAndFuture": "Ini dan yang akan datang", + "calendarCreateDateField": "Buat bidang tanggal", + "calendarDeleteOccurrence": "Hapus kejadian", + "calendarDeleteSeries": "Hapus semua", + "calendarRecurrenceScopeSeries": "Semua acara", + "calendarDaily": "Harian", + "calendarWeekly": "Mingguan", + "calendarMonthly": "Bulanan", + "calendarYearly": "Tahunan", + "calendarInterval": "Interval", + "calendarCount": "Jumlah", + "calendarUntil": "Sampai", + "calendarWeekStart": "Minggu dimulai pada", + "calendarWeekNumber": "Minggu ${x}", + "calendarRecurrence": "Perulangan", + "calendarOccurrence": "Kejadian berulang", + "calendarDiscardChanges": "Buang perubahan kalender yang belum disimpan?", + "calendarSource": "Catatan/blok sumber", + "calendarOpenSource": "Buka sumber", + "calendarSourceMissing": "Item kalender tidak memiliki blok sumber", + "calendarTimed": "Berwaktu", + "calendarEvents": "Acara", + "calendarPreviousEvent": "Acara sebelumnya", + "calendarNextEvent": "Acara berikutnya", + "calendarNoMatchingEvent": "Tidak ada acara yang cocok", + "calendarEditSeriesNotice": "Ini akan mengedit seri berulang. Petakan bidang pengecualian untuk mengedit satu kejadian saja.", + "calendarExceptions": "Pengecualian", + "calendarLocation": "Lokasi", + "calendarDescription": "Deskripsi", + "calendarSchedule": "Jadwal", + "calendarEventRestored": "Acara dipulihkan.", + "calendarCreateFailed": "Gagal membuat.", + "calendarResizeFailed": "Gagal mengubah ukuran.", + "calendarMoveFailed": "Gagal memindahkan.", + "calendarDeleteFailed": "Gagal menghapus.", + "calendarDuplicateFailed": "Gagal menduplikasi.", + "calendarSaveFailed": "Gagal menyimpan.", + "calendarDeleteRecurring": "Hapus item berulang", + "calendarRecurrenceScopeOccurrenceDisabled": "Petakan bidang pengecualian untuk mengubah hanya kejadian ini.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Menghapus kejadian ini dan berikutnya belum didukung.", + "calendarRecurrenceScopeFutureDisabled": "Petakan bidang perulangan untuk mengubah kejadian ini dan berikutnya.", + "calendarRecurrenceScopeDeleteTitle": "Hapus item berulang", + "calendarRecurrenceScopeEditTitle": "Edit item berulang", + "calendarRecurrenceScopeOccurrence": "Kejadian ini", + "calendarRecurrenceScopeOccurrenceDesc": "Hanya kejadian yang dipilih.", + "calendarRecurrenceScopeFutureDesc": "Kejadian ini dan item berikutnya dalam seri.", + "calendarRecurrenceScopeSeriesDesc": "Setiap item dalam seri berulang.", + "calendarRecurrenceScopePrompt": "Pilih sejauh mana perubahan ini diterapkan.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Acara sumber ini menyimpan seri berulang. Perubahan satu kejadian hanya tersedia dari kejadian yang dihasilkan.", + "calendarRecurrenceScopeRootFutureDisabled": "\"Ini dan berikutnya\" hanya tersedia dari kejadian yang dihasilkan.", + "newEvent": "Acara baru", + "calendarEditEvent": "Edit acara", + "calendarStaleMapping": "Bidang hilang atau tidak valid", + "today": "Hari ini", + "allDay": "Sepanjang hari", + "calendarAllDay": "Sepanjang hari", + "none": "Tidak ada", + "saved": "Tersimpan", + "dateField": "Bidang tanggal", + "calendarEmptyHint": "Belum ada item kalender — klik hari atau slot waktu untuk membuat item pertama.", + "calendarRenamePageFailed": "Gagal mengganti nama halaman acara.", + "calendarDeletePageFailed": "Gagal menghapus halaman acara.", + "calendarDeleteEvent": "Hapus acara", + "calendarDeleteEventAndDocument": "Hapus acara dan dokumen", + "calendarOccurrenceRollbackFailed": "Kemunculan tetap tersembunyi dan tidak ada pengganti yang dibuat.", + "calendarSplitRollbackFailed": "Rangkaian dipersingkat dan tidak ada rangkaian lanjutan yang dibuat.", + "calendarShortcuts": "Pintasan keyboard", + "calendarShortcutsViews": "Tampilan", + "calendarShortcutsNavigation": "Navigasi", + "calendarShortcutsActions": "Tindakan", + "calendarDayView": "Tampilan hari", + "calendarFiveDayView": "5 Hari", + "calendarWeekView": "Tampilan minggu", + "calendarMonthView": "Tampilan bulan", + "calendarScheduleView": "Tampilan jadwal", + "calendarNextRange": "Periode berikutnya", + "calendarPreviousRange": "Periode sebelumnya", + "calendarJumpToToday": "Ke hari ini", + "calendarCreateEvent": "Buat acara", + "calendarFocusSearch": "Cari", + "calendarShowShortcuts": "Bantuan pintasan keyboard", + "calendarBackOut": "Batalkan gerakan saat ini, lalu hapus pencarian", + "calendarMiniMonth": "Navigator bulan", + "calendarPreviousMonth": "Bulan sebelumnya", + "calendarNextMonth": "Bulan berikutnya", + "calendarRepeat": "Ulangi", + "calendarDoesNotRepeat": "Tidak berulang", + "calendarRepeatWeeklyOn": "Setiap minggu pada ${x}", + "calendarRepeatEveryNDays": "Setiap ${x} hari", + "calendarRepeatEveryNWeeksOn": "Setiap ${x} minggu pada ${y}", + "calendarRepeatMonthlyOnDay": "Setiap bulan pada tanggal ${x}", + "calendarRepeatEveryNMonthsOnDay": "Setiap ${x} bulan pada tanggal ${y}", + "calendarRepeatEveryNMonths": "Setiap ${x} bulan", + "calendarRepeatAnnuallyOn": "Setiap tahun pada ${x}", + "calendarRepeatEveryNYearsOn": "Setiap ${x} tahun pada ${y}", + "calendarRepeatEveryNYears": "Setiap ${x} tahun", + "calendarRepeatUntilSuffix": ", hingga ${x}", + "calendarRepeatCountSuffix": ", ${x} kali", + "calendarRepeatEveryWeekday": "Setiap hari kerja (Senin sampai Jumat)", + "calendarRepeatCustom": "Khusus…", + "calendarMapLocationHint": "Petakan bidang teks di pengaturan Kalender untuk menyimpan bidang ini.", + "calendarMapRecurrenceHint": "Petakan bidang teks di pengaturan Kalender untuk menyimpan pengulangan.", + "calendarRepeatEvery": "Ulangi setiap", + "calendarDay": "Hari", + "calendarWeek": "Minggu", + "calendarMonth": "Bulan", + "calendarYear": "Tahun", + "calendarRepeatOn": "Ulangi pada", + "calendarEnd": "Berakhir", + "calendarStart": "Mulai", + "calendarNever": "Tidak pernah", + "calendarOn": "Pada", + "calendarAfter": "Setelah", + "calendarOccurrences": "kali", "entrySeparator": "Pemisah", "tableAddColumnTip": "Klik untuk menyisipkan kolom di sebelah kanan
Seret untuk menyisipkan atau menghapus beberapa kolom", "tableAddRowTip": "Klik untuk menyisipkan baris di bawah
Seret untuk menyisipkan atau menghapus beberapa baris", @@ -2052,7 +2172,9 @@ "gallery": "Kartu", "kanban": "Kanban", "key": "Kunci Utama", - "select": "Pilih" + "select": "Pilih", + "calendar": "Kalender", + "date": "Tanggal" }, "_kernel": { "0": "Kueri buku catatan gagal", @@ -2420,4 +2542,4 @@ "362": "Nama pengguna akun layanan publikasi duplikat", "363": "Kata sandi akun layanan publikasi harus terdiri dari minimal 8 karakter" } - } +} diff --git a/app/appearance/langs/it.json b/app/appearance/langs/it.json index 1ebac76b0be..90a05f43880 100644 --- a/app/appearance/langs/it.json +++ b/app/appearance/langs/it.json @@ -1,4 +1,124 @@ { + "calendar": "Calendario", + "calendarRecurringAdvancedReadOnly": "La ricorrenza avanzata viene mantenuta (non modificabile qui).", + "calendarDuplicateMetadataField": "Scegli campi diversi per i metadati ${x}.", + "calendarNeedDateField": "Il calendario richiede un campo data", + "calendarCreateDateColumn": "Nessun campo data trovato. Creare una colonna \"${x}\" e passare alla vista Calendario?", + "calendarSearch": "Cerca nel calendario", + "calendarThisAndFuture": "Questa e le successive", + "calendarCreateDateField": "Crea campo data", + "calendarDeleteOccurrence": "Elimina occorrenza", + "calendarDeleteSeries": "Elimina tutto", + "calendarRecurrenceScopeSeries": "Tutti gli eventi", + "calendarDaily": "Giornaliera", + "calendarWeekly": "Settimanale", + "calendarMonthly": "Mensile", + "calendarYearly": "Annuale", + "calendarInterval": "Intervallo", + "calendarCount": "Numero di volte", + "calendarUntil": "Fino al", + "calendarWeekStart": "La settimana inizia di", + "calendarWeekNumber": "Sett. ${x}", + "calendarRecurrence": "Ricorrenza", + "calendarOccurrence": "Occorrenza ricorrente", + "calendarDiscardChanges": "Scartare le modifiche non salvate del calendario?", + "calendarSource": "Nota/blocco di origine", + "calendarOpenSource": "Apri origine", + "calendarSourceMissing": "L'elemento del calendario non ha un blocco di origine", + "calendarTimed": "Con orario", + "calendarEvents": "Eventi", + "calendarPreviousEvent": "Evento precedente", + "calendarNextEvent": "Evento successivo", + "calendarNoMatchingEvent": "Nessun evento corrispondente", + "calendarEditSeriesNotice": "Questa azione modifica la serie ricorrente. Mappa un campo eccezione per modificare una singola occorrenza.", + "calendarExceptions": "Eccezioni", + "calendarLocation": "Luogo", + "calendarDescription": "Descrizione", + "calendarSchedule": "Agenda", + "calendarEventRestored": "Evento ripristinato.", + "calendarCreateFailed": "Creazione non riuscita.", + "calendarResizeFailed": "Ridimensionamento non riuscito.", + "calendarMoveFailed": "Spostamento non riuscito.", + "calendarDeleteFailed": "Eliminazione non riuscita.", + "calendarDuplicateFailed": "Duplicazione non riuscita.", + "calendarSaveFailed": "Salvataggio non riuscito.", + "calendarDeleteRecurring": "Elimina elemento ricorrente", + "calendarRecurrenceScopeOccurrenceDisabled": "Mappa un campo eccezione per modificare solo questa occorrenza.", + "calendarRecurrenceScopeFutureDeleteDisabled": "L'eliminazione di questa e delle successive non è ancora supportata.", + "calendarRecurrenceScopeFutureDisabled": "Mappa un campo ricorrenza per modificare questa e le successive.", + "calendarRecurrenceScopeDeleteTitle": "Elimina elemento ricorrente", + "calendarRecurrenceScopeEditTitle": "Modifica elemento ricorrente", + "calendarRecurrenceScopeOccurrence": "Questa occorrenza", + "calendarRecurrenceScopeOccurrenceDesc": "Solo l'occorrenza selezionata.", + "calendarRecurrenceScopeFutureDesc": "Questa occorrenza e le successive della serie.", + "calendarRecurrenceScopeSeriesDesc": "Tutti gli elementi della serie ricorrente.", + "calendarRecurrenceScopePrompt": "Scegli fino a che punto applicare questa modifica.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Questo evento di origine memorizza la serie ricorrente. Le modifiche a una singola occorrenza sono disponibili solo dalle occorrenze generate.", + "calendarRecurrenceScopeRootFutureDisabled": "“Questa e le successive” è disponibile solo dalle occorrenze generate.", + "newEvent": "Nuovo evento", + "calendarEditEvent": "Modifica evento", + "calendarStaleMapping": "Campo mancante o non valido", + "today": "Oggi", + "allDay": "Tutto il giorno", + "calendarAllDay": "Tutto il giorno", + "none": "Nessuno", + "saved": "Salvato", + "dateField": "Campo data", + "calendarEmptyHint": "Ancora nessun elemento nel calendario — fai clic su un giorno o una fascia oraria per creare il primo.", + "calendarRenamePageFailed": "Rinomina della pagina dell'evento non riuscita.", + "calendarDeletePageFailed": "Eliminazione della pagina dell'evento non riuscita.", + "calendarDeleteEvent": "Elimina evento", + "calendarDeleteEventAndDocument": "Elimina evento e documento", + "calendarOccurrenceRollbackFailed": "L'occorrenza è rimasta nascosta e non è stata creata alcuna sostituzione.", + "calendarSplitRollbackFailed": "La serie è stata troncata e non è stata creata alcuna serie successiva.", + "calendarShortcuts": "Scorciatoie da tastiera", + "calendarShortcutsViews": "Viste", + "calendarShortcutsNavigation": "Navigazione", + "calendarShortcutsActions": "Azioni", + "calendarDayView": "Vista giorno", + "calendarFiveDayView": "5 giorni", + "calendarWeekView": "Vista settimana", + "calendarMonthView": "Vista mese", + "calendarScheduleView": "Vista agenda", + "calendarNextRange": "Periodo successivo", + "calendarPreviousRange": "Periodo precedente", + "calendarJumpToToday": "Vai a oggi", + "calendarCreateEvent": "Crea evento", + "calendarFocusSearch": "Cerca", + "calendarShowShortcuts": "Guida alle scorciatoie da tastiera", + "calendarBackOut": "Annulla il gesto in corso, poi cancella la ricerca", + "calendarMiniMonth": "Navigatore dei mesi", + "calendarPreviousMonth": "Mese precedente", + "calendarNextMonth": "Mese successivo", + "calendarRepeat": "Ripeti", + "calendarDoesNotRepeat": "Non si ripete", + "calendarRepeatWeeklyOn": "Ogni settimana il ${x}", + "calendarRepeatEveryNDays": "Ogni ${x} giorni", + "calendarRepeatEveryNWeeksOn": "Ogni ${x} settimane il ${y}", + "calendarRepeatMonthlyOnDay": "Ogni mese il giorno ${x}", + "calendarRepeatEveryNMonthsOnDay": "Ogni ${x} mesi il giorno ${y}", + "calendarRepeatEveryNMonths": "Ogni ${x} mesi", + "calendarRepeatAnnuallyOn": "Ogni anno il ${x}", + "calendarRepeatEveryNYearsOn": "Ogni ${x} anni il ${y}", + "calendarRepeatEveryNYears": "Ogni ${x} anni", + "calendarRepeatUntilSuffix": ", fino al ${x}", + "calendarRepeatCountSuffix": ", ${x} volte", + "calendarRepeatEveryWeekday": "Tutti i giorni feriali (da lunedì a venerdì)", + "calendarRepeatCustom": "Personalizzato…", + "calendarMapLocationHint": "Associa un campo di testo nelle impostazioni del calendario per salvare questo campo.", + "calendarMapRecurrenceHint": "Associa un campo di testo nelle impostazioni del calendario per salvare le ricorrenze.", + "calendarRepeatEvery": "Ripeti ogni", + "calendarDay": "Giorno", + "calendarWeek": "Settimana", + "calendarMonth": "Mese", + "calendarYear": "Anno", + "calendarRepeatOn": "Ripeti il", + "calendarEnd": "Fine", + "calendarStart": "Inizio", + "calendarNever": "Mai", + "calendarOn": "Il", + "calendarAfter": "Dopo", + "calendarOccurrences": "occorrenze", "entrySeparator": "Separatore", "tableAddColumnTip": "Fai clic per inserire una colonna a destra
Trascina per inserire o eliminare più colonne", "tableAddRowTip": "Fai clic per inserire una riga sotto
Trascina per inserire o eliminare più righe", @@ -2052,7 +2172,9 @@ "gallery": "Scheda", "kanban": "Kanban", "key": "Chiave primaria", - "select": "Seleziona" + "select": "Seleziona", + "calendar": "Calendario", + "date": "Data" }, "_kernel": { "0": "Query del taccuino fallita", @@ -2420,4 +2542,4 @@ "362": "Il nome utente dell'account del servizio di pubblicazione è duplicato", "363": "La password dell'account del servizio di pubblicazione deve contenere almeno 8 caratteri" } - } +} diff --git a/app/appearance/langs/ja.json b/app/appearance/langs/ja.json index 81bb3440c70..b6052f50a0a 100644 --- a/app/appearance/langs/ja.json +++ b/app/appearance/langs/ja.json @@ -1,4 +1,124 @@ { + "calendar": "カレンダー", + "calendarRecurringAdvancedReadOnly": "高度な繰り返し設定は保持されます(ここでは編集できません)。", + "calendarDuplicateMetadataField": "${x} のメタデータには別のフィールドを選択してください。", + "calendarNeedDateField": "カレンダーには日付フィールドが必要です", + "calendarCreateDateColumn": "日付フィールドが見つかりません。「${x}」列を作成してカレンダービューに切り替えますか?", + "calendarSearch": "カレンダーを検索", + "calendarThisAndFuture": "これ以降", + "calendarCreateDateField": "日付フィールドを作成", + "calendarDeleteOccurrence": "この回を削除", + "calendarDeleteSeries": "すべて削除", + "calendarRecurrenceScopeSeries": "すべての予定", + "calendarDaily": "毎日", + "calendarWeekly": "毎週", + "calendarMonthly": "毎月", + "calendarYearly": "毎年", + "calendarInterval": "間隔", + "calendarCount": "回数", + "calendarUntil": "終了日", + "calendarWeekStart": "週の開始日", + "calendarWeekNumber": "第${x}週", + "calendarRecurrence": "繰り返し", + "calendarOccurrence": "繰り返しの回", + "calendarDiscardChanges": "保存されていないカレンダーの変更を破棄しますか?", + "calendarSource": "ソースノート/ブロック", + "calendarOpenSource": "ソースを開く", + "calendarSourceMissing": "カレンダー項目にソースブロックがありません", + "calendarTimed": "時刻指定", + "calendarEvents": "イベント", + "calendarPreviousEvent": "前のイベント", + "calendarNextEvent": "次のイベント", + "calendarNoMatchingEvent": "一致するイベントがありません", + "calendarEditSeriesNotice": "この操作は繰り返しシリーズ全体を編集します。単一の回を編集するには例外フィールドをマッピングしてください。", + "calendarExceptions": "例外", + "calendarLocation": "場所", + "calendarDescription": "説明", + "calendarSchedule": "スケジュール", + "calendarEventRestored": "イベントを復元しました。", + "calendarCreateFailed": "作成に失敗しました。", + "calendarResizeFailed": "サイズ変更に失敗しました。", + "calendarMoveFailed": "移動に失敗しました。", + "calendarDeleteFailed": "削除に失敗しました。", + "calendarDuplicateFailed": "複製に失敗しました。", + "calendarSaveFailed": "保存に失敗しました。", + "calendarDeleteRecurring": "繰り返し項目を削除", + "calendarRecurrenceScopeOccurrenceDisabled": "この回のみを変更するには例外フィールドをマッピングしてください。", + "calendarRecurrenceScopeFutureDeleteDisabled": "「これ以降」の削除はまだサポートされていません。", + "calendarRecurrenceScopeFutureDisabled": "これ以降の項目を変更するには繰り返しフィールドをマッピングしてください。", + "calendarRecurrenceScopeDeleteTitle": "繰り返し項目を削除", + "calendarRecurrenceScopeEditTitle": "繰り返し項目を編集", + "calendarRecurrenceScopeOccurrence": "この回のみ", + "calendarRecurrenceScopeOccurrenceDesc": "選択した回のみ。", + "calendarRecurrenceScopeFutureDesc": "この回とシリーズ内のこれ以降の項目。", + "calendarRecurrenceScopeSeriesDesc": "繰り返しシリーズ内のすべての項目。", + "calendarRecurrenceScopePrompt": "変更を適用する範囲を選択してください。", + "calendarRecurrenceScopeRootOccurrenceDisabled": "このソースイベントには繰り返しシリーズが保存されています。単一の回のみの変更は、生成された回からのみ行えます。", + "calendarRecurrenceScopeRootFutureDisabled": "「これ以降」は、生成された回からのみ利用できます。", + "newEvent": "新規イベント", + "calendarEditEvent": "予定を編集", + "calendarStaleMapping": "フィールドが見つからないか無効です", + "today": "今日", + "allDay": "終日", + "calendarAllDay": "終日", + "none": "なし", + "saved": "保存しました", + "dateField": "日付フィールド", + "calendarEmptyHint": "カレンダー項目はまだありません。日付または時間帯をクリックして最初の項目を作成してください。", + "calendarRenamePageFailed": "予定ページの名前変更に失敗しました。", + "calendarDeletePageFailed": "予定ページの削除に失敗しました。", + "calendarDeleteEvent": "予定を削除", + "calendarDeleteEventAndDocument": "予定と文書を削除", + "calendarOccurrenceRollbackFailed": "この繰り返しは非表示のままで、代替は作成されませんでした。", + "calendarSplitRollbackFailed": "シリーズは短縮されましたが、後続のシリーズは作成されませんでした。", + "calendarShortcuts": "キーボードショートカット", + "calendarShortcutsViews": "ビュー", + "calendarShortcutsNavigation": "ナビゲーション", + "calendarShortcutsActions": "操作", + "calendarDayView": "日ビュー", + "calendarFiveDayView": "5日間", + "calendarWeekView": "週ビュー", + "calendarMonthView": "月ビュー", + "calendarScheduleView": "スケジュールビュー", + "calendarNextRange": "次の期間", + "calendarPreviousRange": "前の期間", + "calendarJumpToToday": "今日へ移動", + "calendarCreateEvent": "イベントを作成", + "calendarFocusSearch": "検索", + "calendarShowShortcuts": "キーボードショートカットのヘルプ", + "calendarBackOut": "操作を中止し、次に検索をクリア", + "calendarMiniMonth": "月ナビゲーター", + "calendarPreviousMonth": "前の月", + "calendarNextMonth": "次の月", + "calendarRepeat": "繰り返し", + "calendarDoesNotRepeat": "繰り返さない", + "calendarRepeatWeeklyOn": "毎週 ${x}", + "calendarRepeatEveryNDays": "${x} 日ごと", + "calendarRepeatEveryNWeeksOn": "${x} 週間ごとの ${y}", + "calendarRepeatMonthlyOnDay": "毎月 ${x} 日", + "calendarRepeatEveryNMonthsOnDay": "${x} か月ごとの ${y} 日", + "calendarRepeatEveryNMonths": "${x} か月ごと", + "calendarRepeatAnnuallyOn": "毎年 ${x}", + "calendarRepeatEveryNYearsOn": "${x} 年ごとの ${y}", + "calendarRepeatEveryNYears": "${x} 年ごと", + "calendarRepeatUntilSuffix": "、${x} まで", + "calendarRepeatCountSuffix": "、${x} 回", + "calendarRepeatEveryWeekday": "毎週平日(月曜日から金曜日)", + "calendarRepeatCustom": "カスタム…", + "calendarMapLocationHint": "このフィールドを保存するには、カレンダー設定でテキストフィールドを割り当ててください。", + "calendarMapRecurrenceHint": "繰り返しを保存するには、カレンダー設定でテキストフィールドを割り当ててください。", + "calendarRepeatEvery": "繰り返し間隔", + "calendarDay": "日", + "calendarWeek": "週", + "calendarMonth": "月", + "calendarYear": "年", + "calendarRepeatOn": "繰り返す曜日", + "calendarEnd": "終了", + "calendarStart": "開始", + "calendarNever": "なし", + "calendarOn": "指定日", + "calendarAfter": "回数", + "calendarOccurrences": "回", "entrySeparator": "区切り線", "tableAddColumnTip": "クリックして右側に列を挿入
ドラッグして複数の列を挿入または削除", "tableAddRowTip": "クリックして下に行を挿入
ドラッグして複数の行を挿入または削除", @@ -2052,7 +2172,9 @@ "gallery": "カード", "kanban": "カンバン", "key": "プライマリキー", - "select": "選択" + "select": "選択", + "calendar": "カレンダー", + "date": "日付" }, "_kernel": { "0": "ノートブックのクエリに失敗しました", @@ -2420,4 +2542,4 @@ "362": "公開サービスのアカウントのユーザー名が重複しています", "363": "公開サービスのアカウントのパスワードは 8 文字以上である必要があります" } - } +} diff --git a/app/appearance/langs/ko.json b/app/appearance/langs/ko.json index 9fefaf8b141..b76c1dffc53 100644 --- a/app/appearance/langs/ko.json +++ b/app/appearance/langs/ko.json @@ -1,4 +1,124 @@ { + "calendar": "캘린더", + "calendarRecurringAdvancedReadOnly": "고급 반복 설정은 유지됩니다 (여기서 수정 불가).", + "calendarDuplicateMetadataField": "${x} 메타데이터에 서로 다른 필드를 선택하세요.", + "calendarNeedDateField": "캘린더에는 날짜 필드가 필요합니다", + "calendarCreateDateColumn": "날짜 필드를 찾을 수 없습니다. \"${x}\" 열을 만들고 캘린더 보기로 전환할까요?", + "calendarSearch": "캘린더 검색", + "calendarThisAndFuture": "이후 모든 일정", + "calendarCreateDateField": "날짜 필드 만들기", + "calendarDeleteOccurrence": "이 회차 삭제", + "calendarDeleteSeries": "모두 삭제", + "calendarRecurrenceScopeSeries": "모든 일정", + "calendarDaily": "매일", + "calendarWeekly": "매주", + "calendarMonthly": "매월", + "calendarYearly": "매년", + "calendarInterval": "간격", + "calendarCount": "횟수", + "calendarUntil": "종료일", + "calendarWeekStart": "한 주의 시작", + "calendarWeekNumber": "${x}주차", + "calendarRecurrence": "반복", + "calendarOccurrence": "반복 회차", + "calendarDiscardChanges": "저장하지 않은 캘린더 변경 사항을 취소할까요?", + "calendarSource": "원본 노트/블록", + "calendarOpenSource": "원본 열기", + "calendarSourceMissing": "이 캘린더 항목에는 원본 블록이 없습니다", + "calendarTimed": "시간 지정", + "calendarEvents": "이벤트", + "calendarPreviousEvent": "이전 이벤트", + "calendarNextEvent": "다음 이벤트", + "calendarNoMatchingEvent": "일치하는 이벤트가 없습니다", + "calendarEditSeriesNotice": "반복 시리즈 전체가 수정됩니다. 단일 회차만 수정하려면 예외 필드를 매핑하세요.", + "calendarExceptions": "예외", + "calendarLocation": "위치", + "calendarDescription": "설명", + "calendarSchedule": "일정", + "calendarEventRestored": "이벤트가 복원되었습니다.", + "calendarCreateFailed": "만들기에 실패했습니다.", + "calendarResizeFailed": "크기 조정에 실패했습니다.", + "calendarMoveFailed": "이동에 실패했습니다.", + "calendarDeleteFailed": "삭제에 실패했습니다.", + "calendarDuplicateFailed": "복제에 실패했습니다.", + "calendarSaveFailed": "저장에 실패했습니다.", + "calendarDeleteRecurring": "반복 항목 삭제", + "calendarRecurrenceScopeOccurrenceDisabled": "이 회차만 변경하려면 예외 필드를 매핑하세요.", + "calendarRecurrenceScopeFutureDeleteDisabled": "'이후 모든 일정' 삭제는 아직 지원되지 않습니다.", + "calendarRecurrenceScopeFutureDisabled": "이후 모든 일정을 변경하려면 반복 필드를 매핑하세요.", + "calendarRecurrenceScopeDeleteTitle": "반복 항목 삭제", + "calendarRecurrenceScopeEditTitle": "반복 항목 편집", + "calendarRecurrenceScopeOccurrence": "이 회차만", + "calendarRecurrenceScopeOccurrenceDesc": "선택한 회차만 적용됩니다.", + "calendarRecurrenceScopeFutureDesc": "이 회차와 시리즈의 이후 항목에 적용됩니다.", + "calendarRecurrenceScopeSeriesDesc": "반복 시리즈의 모든 항목에 적용됩니다.", + "calendarRecurrenceScopePrompt": "변경 사항을 적용할 범위를 선택하세요.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "이 원본 이벤트에는 반복 시리즈가 저장되어 있습니다. 단일 회차 변경은 생성된 회차에서만 가능합니다.", + "calendarRecurrenceScopeRootFutureDisabled": "'이후 모든 일정'은 생성된 회차에서만 사용할 수 있습니다.", + "newEvent": "새 이벤트", + "calendarEditEvent": "일정 편집", + "calendarStaleMapping": "필드가 없거나 유효하지 않음", + "today": "오늘", + "allDay": "종일", + "calendarAllDay": "종일", + "none": "없음", + "saved": "저장됨", + "dateField": "날짜 필드", + "calendarEmptyHint": "아직 캘린더 항목이 없습니다. 날짜나 시간대를 클릭해 첫 번째 항목을 만드세요.", + "calendarRenamePageFailed": "일정 페이지 이름 변경에 실패했습니다.", + "calendarDeletePageFailed": "일정 페이지 삭제에 실패했습니다.", + "calendarDeleteEvent": "일정 삭제", + "calendarDeleteEventAndDocument": "일정 및 문서 삭제", + "calendarOccurrenceRollbackFailed": "해당 반복 항목은 숨겨진 상태이며 대체 항목이 생성되지 않았습니다.", + "calendarSplitRollbackFailed": "시리즈가 잘렸으며 후속 시리즈가 생성되지 않았습니다.", + "calendarShortcuts": "키보드 단축키", + "calendarShortcutsViews": "보기", + "calendarShortcutsNavigation": "탐색", + "calendarShortcutsActions": "작업", + "calendarDayView": "일 보기", + "calendarFiveDayView": "5일", + "calendarWeekView": "주 보기", + "calendarMonthView": "월 보기", + "calendarScheduleView": "일정 보기", + "calendarNextRange": "다음 기간", + "calendarPreviousRange": "이전 기간", + "calendarJumpToToday": "오늘로 이동", + "calendarCreateEvent": "이벤트 만들기", + "calendarFocusSearch": "검색", + "calendarShowShortcuts": "키보드 단축키 도움말", + "calendarBackOut": "현재 동작을 취소한 다음 검색 지우기", + "calendarMiniMonth": "월 탐색기", + "calendarPreviousMonth": "이전 달", + "calendarNextMonth": "다음 달", + "calendarRepeat": "반복", + "calendarDoesNotRepeat": "반복 안 함", + "calendarRepeatWeeklyOn": "매주 ${x}", + "calendarRepeatEveryNDays": "${x}일마다", + "calendarRepeatEveryNWeeksOn": "${x}주마다 ${y}", + "calendarRepeatMonthlyOnDay": "매월 ${x}일", + "calendarRepeatEveryNMonthsOnDay": "${x}개월마다 ${y}일", + "calendarRepeatEveryNMonths": "${x}개월마다", + "calendarRepeatAnnuallyOn": "매년 ${x}", + "calendarRepeatEveryNYearsOn": "${x}년마다 ${y}", + "calendarRepeatEveryNYears": "${x}년마다", + "calendarRepeatUntilSuffix": ", ${x}까지", + "calendarRepeatCountSuffix": ", ${x}회", + "calendarRepeatEveryWeekday": "주중 매일(월요일~금요일)", + "calendarRepeatCustom": "사용자 지정…", + "calendarMapLocationHint": "이 필드를 저장하려면 캘린더 설정에서 텍스트 필드를 연결하세요.", + "calendarMapRecurrenceHint": "반복 일정을 저장하려면 캘린더 설정에서 텍스트 필드를 연결하세요.", + "calendarRepeatEvery": "반복 간격", + "calendarDay": "일", + "calendarWeek": "주", + "calendarMonth": "개월", + "calendarYear": "년", + "calendarRepeatOn": "반복 요일", + "calendarEnd": "종료", + "calendarStart": "시작", + "calendarNever": "없음", + "calendarOn": "날짜", + "calendarAfter": "횟수", + "calendarOccurrences": "회", "entrySeparator": "구분선", "tableAddColumnTip": "클릭하여 오른쪽에 열 삽입
드래그하여 여러 열 삽입 또는 삭제", "tableAddRowTip": "클릭하여 아래에 행 삽입
드래그하여 여러 행 삽입 또는 삭제", @@ -2052,7 +2172,9 @@ "gallery": "카드", "kanban": "칸반", "key": "기본 키", - "select": "선택" + "select": "선택", + "calendar": "캘린더", + "date": "날짜" }, "_kernel": { "0": "노트북 쿼리 실패", @@ -2420,4 +2542,4 @@ "362": "게시 서비스 계정의 사용자 이름이 중복되었습니다", "363": "게시 서비스 계정의 비밀번호는 8자 이상이어야 합니다" } - } +} diff --git a/app/appearance/langs/nl.json b/app/appearance/langs/nl.json index 3dc283b2776..7332d99d3b6 100644 --- a/app/appearance/langs/nl.json +++ b/app/appearance/langs/nl.json @@ -1,4 +1,124 @@ { + "calendar": "Kalender", + "calendarRecurringAdvancedReadOnly": "Geavanceerde herhaling blijft behouden (hier niet bewerkbaar).", + "calendarDuplicateMetadataField": "Kies verschillende velden voor ${x}-metadata.", + "calendarNeedDateField": "Kalender vereist een datumveld", + "calendarCreateDateColumn": "Geen datumveld gevonden. Een kolom \"${x}\" aanmaken en naar de kalenderweergave wisselen?", + "calendarSearch": "Kalender doorzoeken", + "calendarThisAndFuture": "Deze en toekomstige", + "calendarCreateDateField": "Datumveld aanmaken", + "calendarDeleteOccurrence": "Herhaling verwijderen", + "calendarDeleteSeries": "Alles verwijderen", + "calendarRecurrenceScopeSeries": "Alle afspraken", + "calendarDaily": "Dagelijks", + "calendarWeekly": "Wekelijks", + "calendarMonthly": "Maandelijks", + "calendarYearly": "Jaarlijks", + "calendarInterval": "Interval", + "calendarCount": "Aantal", + "calendarUntil": "Tot", + "calendarWeekStart": "Week begint op", + "calendarWeekNumber": "Week ${x}", + "calendarRecurrence": "Herhaling", + "calendarOccurrence": "Terugkerende gebeurtenis", + "calendarDiscardChanges": "Niet-opgeslagen kalenderwijzigingen verwerpen?", + "calendarSource": "Bronnotitie/-blok", + "calendarOpenSource": "Bron openen", + "calendarSourceMissing": "Kalenderitem heeft geen bronblok", + "calendarTimed": "Met tijd", + "calendarEvents": "Gebeurtenissen", + "calendarPreviousEvent": "Vorige gebeurtenis", + "calendarNextEvent": "Volgende gebeurtenis", + "calendarNoMatchingEvent": "Geen overeenkomende gebeurtenis", + "calendarEditSeriesNotice": "Hiermee bewerk je de terugkerende reeks. Koppel een uitzonderingsveld om één gebeurtenis te bewerken.", + "calendarExceptions": "Uitzonderingen", + "calendarLocation": "Locatie", + "calendarDescription": "Beschrijving", + "calendarSchedule": "Planning", + "calendarEventRestored": "Gebeurtenis hersteld.", + "calendarCreateFailed": "Aanmaken mislukt.", + "calendarResizeFailed": "Formaat wijzigen mislukt.", + "calendarMoveFailed": "Verplaatsen mislukt.", + "calendarDeleteFailed": "Verwijderen mislukt.", + "calendarDuplicateFailed": "Dupliceren mislukt.", + "calendarSaveFailed": "Opslaan mislukt.", + "calendarDeleteRecurring": "Terugkerend item verwijderen", + "calendarRecurrenceScopeOccurrenceDisabled": "Koppel een uitzonderingsveld om alleen deze gebeurtenis te wijzigen.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Deze en volgende verwijderen wordt nog niet ondersteund.", + "calendarRecurrenceScopeFutureDisabled": "Koppel een herhalingsveld om deze en volgende items te wijzigen.", + "calendarRecurrenceScopeDeleteTitle": "Terugkerend item verwijderen", + "calendarRecurrenceScopeEditTitle": "Terugkerend item bewerken", + "calendarRecurrenceScopeOccurrence": "Deze gebeurtenis", + "calendarRecurrenceScopeOccurrenceDesc": "Alleen de geselecteerde gebeurtenis.", + "calendarRecurrenceScopeFutureDesc": "Deze gebeurtenis en volgende items in de reeks.", + "calendarRecurrenceScopeSeriesDesc": "Elk item in de terugkerende reeks.", + "calendarRecurrenceScopePrompt": "Kies hoe ver deze wijziging moet gelden.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Deze brongebeurtenis bevat de terugkerende reeks. Wijzigingen aan één gebeurtenis zijn alleen mogelijk vanuit gegenereerde gebeurtenissen.", + "calendarRecurrenceScopeRootFutureDisabled": "Deze en volgende is alleen beschikbaar vanuit gegenereerde gebeurtenissen.", + "newEvent": "Nieuwe gebeurtenis", + "calendarEditEvent": "Afspraak bewerken", + "calendarStaleMapping": "Veld ontbreekt of is ongeldig", + "today": "Vandaag", + "allDay": "Hele dag", + "calendarAllDay": "Hele dag", + "none": "Geen", + "saved": "Opgeslagen", + "dateField": "Datumveld", + "calendarEmptyHint": "Nog geen kalenderitems — klik op een dag of een tijdvak om de eerste aan te maken.", + "calendarRenamePageFailed": "Naam van de gebeurtenispagina wijzigen is mislukt.", + "calendarDeletePageFailed": "Verwijderen van de gebeurtenispagina is mislukt.", + "calendarDeleteEvent": "Afspraak verwijderen", + "calendarDeleteEventAndDocument": "Afspraak en document verwijderen", + "calendarOccurrenceRollbackFailed": "De herhaling bleef verborgen en er is geen vervanging gemaakt.", + "calendarSplitRollbackFailed": "De reeks is ingekort en er is geen vervolgreeks gemaakt.", + "calendarShortcuts": "Sneltoetsen", + "calendarShortcutsViews": "Weergaven", + "calendarShortcutsNavigation": "Navigatie", + "calendarShortcutsActions": "Acties", + "calendarDayView": "Dagweergave", + "calendarFiveDayView": "5 dagen", + "calendarWeekView": "Weekweergave", + "calendarMonthView": "Maandweergave", + "calendarScheduleView": "Agendaweergave", + "calendarNextRange": "Volgende periode", + "calendarPreviousRange": "Vorige periode", + "calendarJumpToToday": "Ga naar vandaag", + "calendarCreateEvent": "Afspraak maken", + "calendarFocusSearch": "Zoeken", + "calendarShowShortcuts": "Hulp bij sneltoetsen", + "calendarBackOut": "Annuleer de huidige actie en wis daarna de zoekopdracht", + "calendarMiniMonth": "Maandnavigator", + "calendarPreviousMonth": "Vorige maand", + "calendarNextMonth": "Volgende maand", + "calendarRepeat": "Herhalen", + "calendarDoesNotRepeat": "Wordt niet herhaald", + "calendarRepeatWeeklyOn": "Wekelijks op ${x}", + "calendarRepeatEveryNDays": "Elke ${x} dagen", + "calendarRepeatEveryNWeeksOn": "Elke ${x} weken op ${y}", + "calendarRepeatMonthlyOnDay": "Maandelijks op dag ${x}", + "calendarRepeatEveryNMonthsOnDay": "Elke ${x} maanden op dag ${y}", + "calendarRepeatEveryNMonths": "Elke ${x} maanden", + "calendarRepeatAnnuallyOn": "Jaarlijks op ${x}", + "calendarRepeatEveryNYearsOn": "Elke ${x} jaar op ${y}", + "calendarRepeatEveryNYears": "Elke ${x} jaar", + "calendarRepeatUntilSuffix": ", tot ${x}", + "calendarRepeatCountSuffix": ", ${x} keer", + "calendarRepeatEveryWeekday": "Elke werkdag (maandag t/m vrijdag)", + "calendarRepeatCustom": "Aangepast…", + "calendarMapLocationHint": "Koppel in de kalenderinstellingen een tekstveld om dit veld op te slaan.", + "calendarMapRecurrenceHint": "Koppel in de kalenderinstellingen een tekstveld om herhalingen op te slaan.", + "calendarRepeatEvery": "Herhalen elke", + "calendarDay": "Dag", + "calendarWeek": "Week", + "calendarMonth": "Maand", + "calendarYear": "Jaar", + "calendarRepeatOn": "Herhalen op", + "calendarEnd": "Einde", + "calendarStart": "Begin", + "calendarNever": "Nooit", + "calendarOn": "Op", + "calendarAfter": "Na", + "calendarOccurrences": "keer", "entrySeparator": "Scheidingslijn", "tableAddColumnTip": "Klik om rechts een kolom in te voegen
Sleep om meerdere kolommen in te voegen of te verwijderen", "tableAddRowTip": "Klik om hieronder een rij in te voegen
Sleep om meerdere rijen in te voegen of te verwijderen", @@ -2052,7 +2172,9 @@ "gallery": "Kaart", "kanban": "Kanbanbord", "key": "Primaire sleutel", - "select": "Selecteren" + "select": "Selecteren", + "calendar": "Kalender", + "date": "Datum" }, "_kernel": { "0": "Query notitieboek mislukt", @@ -2420,4 +2542,4 @@ "362": "De gebruikersnaam van het publicatieservice-account is gedupliceerd", "363": "Het wachtwoord van het publicatieservice-account moet minimaal 8 tekens bevatten" } - } +} diff --git a/app/appearance/langs/pl.json b/app/appearance/langs/pl.json index de419450e00..73fa3f96ba5 100644 --- a/app/appearance/langs/pl.json +++ b/app/appearance/langs/pl.json @@ -1,4 +1,124 @@ { + "calendar": "Kalendarz", + "calendarRecurringAdvancedReadOnly": "Zaawansowana cykliczność jest zachowana (nieedytowalna tutaj).", + "calendarDuplicateMetadataField": "Wybierz różne pola dla metadanych ${x}.", + "calendarNeedDateField": "Kalendarz wymaga pola daty", + "calendarCreateDateColumn": "Nie znaleziono pola daty. Utworzyć kolumnę „${x}” i przełączyć na widok kalendarza?", + "calendarSearch": "Szukaj w kalendarzu", + "calendarThisAndFuture": "To i następne", + "calendarCreateDateField": "Utwórz pole daty", + "calendarDeleteOccurrence": "Usuń wystąpienie", + "calendarDeleteSeries": "Usuń wszystko", + "calendarRecurrenceScopeSeries": "Wszystkie wydarzenia", + "calendarDaily": "Codziennie", + "calendarWeekly": "Co tydzień", + "calendarMonthly": "Co miesiąc", + "calendarYearly": "Co rok", + "calendarInterval": "Odstęp", + "calendarCount": "Liczba powtórzeń", + "calendarUntil": "Do", + "calendarWeekStart": "Tydzień zaczyna się od", + "calendarWeekNumber": "Tydz. ${x}", + "calendarRecurrence": "Cykliczność", + "calendarOccurrence": "Wystąpienie cykliczne", + "calendarDiscardChanges": "Odrzucić niezapisane zmiany w kalendarzu?", + "calendarSource": "Notatka/blok źródłowy", + "calendarOpenSource": "Otwórz źródło", + "calendarSourceMissing": "Element kalendarza nie ma bloku źródłowego", + "calendarTimed": "Z godziną", + "calendarEvents": "Wydarzenia", + "calendarPreviousEvent": "Poprzednie wydarzenie", + "calendarNextEvent": "Następne wydarzenie", + "calendarNoMatchingEvent": "Brak pasującego wydarzenia", + "calendarEditSeriesNotice": "Spowoduje to edycję całej serii cyklicznej. Zmapuj pole wyjątków, aby edytować pojedyncze wystąpienie.", + "calendarExceptions": "Wyjątki", + "calendarLocation": "Lokalizacja", + "calendarDescription": "Opis", + "calendarSchedule": "Harmonogram", + "calendarEventRestored": "Wydarzenie przywrócone.", + "calendarCreateFailed": "Tworzenie nie powiodło się.", + "calendarResizeFailed": "Zmiana rozmiaru nie powiodła się.", + "calendarMoveFailed": "Przenoszenie nie powiodło się.", + "calendarDeleteFailed": "Usuwanie nie powiodło się.", + "calendarDuplicateFailed": "Duplikowanie nie powiodło się.", + "calendarSaveFailed": "Zapisywanie nie powiodło się.", + "calendarDeleteRecurring": "Usuń element cykliczny", + "calendarRecurrenceScopeOccurrenceDisabled": "Zmapuj pole wyjątków, aby zmienić tylko to wystąpienie.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Usuwanie tego i następnych nie jest jeszcze obsługiwane.", + "calendarRecurrenceScopeFutureDisabled": "Zmapuj pole cykliczności, aby zmienić to i następne elementy.", + "calendarRecurrenceScopeDeleteTitle": "Usuń element cykliczny", + "calendarRecurrenceScopeEditTitle": "Edytuj element cykliczny", + "calendarRecurrenceScopeOccurrence": "To wystąpienie", + "calendarRecurrenceScopeOccurrenceDesc": "Tylko wybrane wystąpienie.", + "calendarRecurrenceScopeFutureDesc": "To wystąpienie i następne elementy serii.", + "calendarRecurrenceScopeSeriesDesc": "Każdy element serii cyklicznej.", + "calendarRecurrenceScopePrompt": "Wybierz, jak szeroko zastosować tę zmianę.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "To wydarzenie źródłowe przechowuje serię cykliczną. Zmiany pojedynczego wystąpienia są dostępne tylko z wygenerowanych wystąpień.", + "calendarRecurrenceScopeRootFutureDisabled": "Opcja „to i następne” jest dostępna tylko z wygenerowanych wystąpień.", + "newEvent": "Nowe wydarzenie", + "calendarEditEvent": "Edytuj wydarzenie", + "calendarStaleMapping": "Brakujące lub nieprawidłowe pole", + "today": "Dzisiaj", + "allDay": "Cały dzień", + "calendarAllDay": "Cały dzień", + "none": "Brak", + "saved": "Zapisano", + "dateField": "Pole daty", + "calendarEmptyHint": "Brak jeszcze elementów kalendarza — kliknij dzień lub przedział czasowy, aby utworzyć pierwszy.", + "calendarRenamePageFailed": "Nie udało się zmienić nazwy strony wydarzenia.", + "calendarDeletePageFailed": "Nie udało się usunąć strony wydarzenia.", + "calendarDeleteEvent": "Usuń wydarzenie", + "calendarDeleteEventAndDocument": "Usuń wydarzenie i dokument", + "calendarOccurrenceRollbackFailed": "Wystąpienie pozostało ukryte i nie utworzono zamiennika.", + "calendarSplitRollbackFailed": "Seria została skrócona i nie utworzono serii następczej.", + "calendarShortcuts": "Skróty klawiszowe", + "calendarShortcutsViews": "Widoki", + "calendarShortcutsNavigation": "Nawigacja", + "calendarShortcutsActions": "Akcje", + "calendarDayView": "Widok dnia", + "calendarFiveDayView": "5 dni", + "calendarWeekView": "Widok tygodnia", + "calendarMonthView": "Widok miesiąca", + "calendarScheduleView": "Widok harmonogramu", + "calendarNextRange": "Następny okres", + "calendarPreviousRange": "Poprzedni okres", + "calendarJumpToToday": "Przejdź do dzisiaj", + "calendarCreateEvent": "Utwórz wydarzenie", + "calendarFocusSearch": "Szukaj", + "calendarShowShortcuts": "Pomoc dotycząca skrótów klawiszowych", + "calendarBackOut": "Anuluj bieżący gest, a następnie wyczyść wyszukiwanie", + "calendarMiniMonth": "Nawigator miesięcy", + "calendarPreviousMonth": "Poprzedni miesiąc", + "calendarNextMonth": "Następny miesiąc", + "calendarRepeat": "Powtarzanie", + "calendarDoesNotRepeat": "Nie powtarza się", + "calendarRepeatWeeklyOn": "Co tydzień w ${x}", + "calendarRepeatEveryNDays": "Co ${x} dni", + "calendarRepeatEveryNWeeksOn": "Co ${x} tyg. w ${y}", + "calendarRepeatMonthlyOnDay": "Co miesiąc ${x}. dnia", + "calendarRepeatEveryNMonthsOnDay": "Co ${x} mies. ${y}. dnia", + "calendarRepeatEveryNMonths": "Co ${x} mies.", + "calendarRepeatAnnuallyOn": "Co roku ${x}", + "calendarRepeatEveryNYearsOn": "Co ${x} lat ${y}", + "calendarRepeatEveryNYears": "Co ${x} lat", + "calendarRepeatUntilSuffix": ", do ${x}", + "calendarRepeatCountSuffix": ", ${x} razy", + "calendarRepeatEveryWeekday": "W dni robocze (od poniedziałku do piątku)", + "calendarRepeatCustom": "Niestandardowe…", + "calendarMapLocationHint": "Przypisz pole tekstowe w ustawieniach kalendarza, aby zapisać to pole.", + "calendarMapRecurrenceHint": "Przypisz pole tekstowe w ustawieniach kalendarza, aby zapisać powtarzanie.", + "calendarRepeatEvery": "Powtarzaj co", + "calendarDay": "Dzień", + "calendarWeek": "Tydzień", + "calendarMonth": "Miesiąc", + "calendarYear": "Rok", + "calendarRepeatOn": "Powtarzaj w", + "calendarEnd": "Koniec", + "calendarStart": "Początek", + "calendarNever": "Nigdy", + "calendarOn": "W dniu", + "calendarAfter": "Po", + "calendarOccurrences": "wystąpieniach", "entrySeparator": "Separator", "tableAddColumnTip": "Kliknij, aby wstawić kolumnę po prawej
Przeciągnij, aby wstawić lub usunąć wiele kolumn", "tableAddRowTip": "Kliknij, aby wstawić wiersz poniżej
Przeciągnij, aby wstawić lub usunąć wiele wierszy", @@ -2052,7 +2172,9 @@ "gallery": "Karta", "kanban": "Kanban", "key": "Klucz główny", - "select": "Wybierz" + "select": "Wybierz", + "calendar": "Kalendarz", + "date": "Data" }, "_kernel": { "0": "Nie udało się zapytać o notes", @@ -2420,4 +2542,4 @@ "362": "Nazwa użytkownika konta usługi publikowania jest zduplikowana", "363": "Hasło konta usługi publikowania musi mieć co najmniej 8 znaków" } - } +} diff --git a/app/appearance/langs/pt-BR.json b/app/appearance/langs/pt-BR.json index d4f436c7611..c15a0fe35d6 100644 --- a/app/appearance/langs/pt-BR.json +++ b/app/appearance/langs/pt-BR.json @@ -1,4 +1,124 @@ { + "calendar": "Calendário", + "calendarRecurringAdvancedReadOnly": "A recorrência avançada é mantida (não editável aqui).", + "calendarDuplicateMetadataField": "Escolha campos diferentes para os metadados de ${x}.", + "calendarNeedDateField": "O calendário requer um campo de data", + "calendarCreateDateColumn": "Nenhum campo de data foi encontrado. Criar uma coluna \"${x}\" e mudar para a visão de Calendário?", + "calendarSearch": "Pesquisar no calendário", + "calendarThisAndFuture": "Esta e as seguintes", + "calendarCreateDateField": "Criar campo de data", + "calendarDeleteOccurrence": "Excluir ocorrência", + "calendarDeleteSeries": "Excluir tudo", + "calendarRecurrenceScopeSeries": "Todos os eventos", + "calendarDaily": "Diariamente", + "calendarWeekly": "Semanalmente", + "calendarMonthly": "Mensalmente", + "calendarYearly": "Anualmente", + "calendarInterval": "Intervalo", + "calendarCount": "Quantidade", + "calendarUntil": "Até", + "calendarWeekStart": "A semana começa em", + "calendarWeekNumber": "Sem. ${x}", + "calendarRecurrence": "Recorrência", + "calendarOccurrence": "Ocorrência recorrente", + "calendarDiscardChanges": "Descartar alterações não salvas do calendário?", + "calendarSource": "Nota/bloco de origem", + "calendarOpenSource": "Abrir origem", + "calendarSourceMissing": "O item do calendário não tem bloco de origem", + "calendarTimed": "Com horário", + "calendarEvents": "Eventos", + "calendarPreviousEvent": "Evento anterior", + "calendarNextEvent": "Próximo evento", + "calendarNoMatchingEvent": "Nenhum evento correspondente", + "calendarEditSeriesNotice": "Isto editará a série recorrente. Mapeie um campo de exceção para editar uma única ocorrência.", + "calendarExceptions": "Exceções", + "calendarLocation": "Local", + "calendarDescription": "Descrição", + "calendarSchedule": "Agenda", + "calendarEventRestored": "Evento restaurado.", + "calendarCreateFailed": "Falha ao criar.", + "calendarResizeFailed": "Falha ao redimensionar.", + "calendarMoveFailed": "Falha ao mover.", + "calendarDeleteFailed": "Falha ao excluir.", + "calendarDuplicateFailed": "Falha ao duplicar.", + "calendarSaveFailed": "Falha ao salvar.", + "calendarDeleteRecurring": "Excluir item recorrente", + "calendarRecurrenceScopeOccurrenceDisabled": "Mapeie um campo de exceção para alterar apenas esta ocorrência.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Excluir esta e as seguintes ainda não é suportado.", + "calendarRecurrenceScopeFutureDisabled": "Mapeie um campo de recorrência para alterar esta e as seguintes.", + "calendarRecurrenceScopeDeleteTitle": "Excluir item recorrente", + "calendarRecurrenceScopeEditTitle": "Editar item recorrente", + "calendarRecurrenceScopeOccurrence": "Esta ocorrência", + "calendarRecurrenceScopeOccurrenceDesc": "Apenas a ocorrência selecionada.", + "calendarRecurrenceScopeFutureDesc": "Esta ocorrência e as seguintes da série.", + "calendarRecurrenceScopeSeriesDesc": "Todos os itens da série recorrente.", + "calendarRecurrenceScopePrompt": "Escolha o alcance desta alteração.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Este evento de origem armazena a série recorrente. Alterações de uma única ocorrência só estão disponíveis a partir das ocorrências geradas.", + "calendarRecurrenceScopeRootFutureDisabled": "“Esta e as seguintes” só está disponível a partir das ocorrências geradas.", + "newEvent": "Novo evento", + "calendarEditEvent": "Editar evento", + "calendarStaleMapping": "Campo ausente ou inválido", + "today": "Hoje", + "allDay": "Dia inteiro", + "calendarAllDay": "Dia inteiro", + "none": "Nenhum", + "saved": "Salvo", + "dateField": "Campo de data", + "calendarEmptyHint": "Ainda não há itens no calendário — clique em um dia ou em um horário para criar o primeiro.", + "calendarRenamePageFailed": "Falha ao renomear a página do evento.", + "calendarDeletePageFailed": "Falha ao excluir a página do evento.", + "calendarDeleteEvent": "Excluir evento", + "calendarDeleteEventAndDocument": "Excluir evento e documento", + "calendarOccurrenceRollbackFailed": "A ocorrência permaneceu oculta e nenhuma substituição foi criada.", + "calendarSplitRollbackFailed": "A série foi encurtada e nenhuma série subsequente foi criada.", + "calendarShortcuts": "Atalhos de teclado", + "calendarShortcutsViews": "Visualizações", + "calendarShortcutsNavigation": "Navegação", + "calendarShortcutsActions": "Ações", + "calendarDayView": "Visualização de dia", + "calendarFiveDayView": "5 dias", + "calendarWeekView": "Visualização de semana", + "calendarMonthView": "Visualização de mês", + "calendarScheduleView": "Visualização de agenda", + "calendarNextRange": "Próximo período", + "calendarPreviousRange": "Período anterior", + "calendarJumpToToday": "Ir para hoje", + "calendarCreateEvent": "Criar evento", + "calendarFocusSearch": "Pesquisar", + "calendarShowShortcuts": "Ajuda de atalhos de teclado", + "calendarBackOut": "Cancelar o gesto atual e depois limpar a pesquisa", + "calendarMiniMonth": "Navegador de meses", + "calendarPreviousMonth": "Mês anterior", + "calendarNextMonth": "Próximo mês", + "calendarRepeat": "Repetir", + "calendarDoesNotRepeat": "Não se repete", + "calendarRepeatWeeklyOn": "Semanalmente em ${x}", + "calendarRepeatEveryNDays": "A cada ${x} dias", + "calendarRepeatEveryNWeeksOn": "A cada ${x} semanas em ${y}", + "calendarRepeatMonthlyOnDay": "Mensalmente no dia ${x}", + "calendarRepeatEveryNMonthsOnDay": "A cada ${x} meses no dia ${y}", + "calendarRepeatEveryNMonths": "A cada ${x} meses", + "calendarRepeatAnnuallyOn": "Anualmente em ${x}", + "calendarRepeatEveryNYearsOn": "A cada ${x} anos em ${y}", + "calendarRepeatEveryNYears": "A cada ${x} anos", + "calendarRepeatUntilSuffix": ", até ${x}", + "calendarRepeatCountSuffix": ", ${x} vezes", + "calendarRepeatEveryWeekday": "Todos os dias úteis (de segunda a sexta)", + "calendarRepeatCustom": "Personalizado…", + "calendarMapLocationHint": "Mapeie um campo de texto nas configurações do Calendário para salvar este campo.", + "calendarMapRecurrenceHint": "Mapeie um campo de texto nas configurações do Calendário para salvar recorrências.", + "calendarRepeatEvery": "Repetir a cada", + "calendarDay": "Dia", + "calendarWeek": "Semana", + "calendarMonth": "Mês", + "calendarYear": "Ano", + "calendarRepeatOn": "Repetir em", + "calendarEnd": "Fim", + "calendarStart": "Início", + "calendarNever": "Nunca", + "calendarOn": "Em", + "calendarAfter": "Após", + "calendarOccurrences": "ocorrências", "entrySeparator": "Separador", "tableAddColumnTip": "Clique para inserir uma coluna à direita
Arraste para inserir ou excluir várias colunas", "tableAddRowTip": "Clique para inserir uma linha abaixo
Arraste para inserir ou excluir várias linhas", @@ -2052,7 +2172,9 @@ "gallery": "Cartão", "kanban": "Kanban", "key": "Chave Primária", - "select": "Selecionar" + "select": "Selecionar", + "calendar": "Calendário", + "date": "Data" }, "_kernel": { "0": "Falha ao consultar o bloco de notas", @@ -2420,4 +2542,4 @@ "362": "O nome de usuário da conta do serviço de publicação está duplicado", "363": "A senha da conta do serviço de publicação deve ter pelo menos 8 caracteres" } - } +} diff --git a/app/appearance/langs/ru.json b/app/appearance/langs/ru.json index dbc169936cc..ed88db5c19d 100644 --- a/app/appearance/langs/ru.json +++ b/app/appearance/langs/ru.json @@ -1,4 +1,124 @@ { + "calendar": "Календарь", + "calendarRecurringAdvancedReadOnly": "Расширенное повторение сохранено (здесь не редактируется).", + "calendarDuplicateMetadataField": "Выберите разные поля для метаданных ${x}.", + "calendarNeedDateField": "Календарю требуется поле даты", + "calendarCreateDateColumn": "Поле даты не найдено. Создать столбец «${x}» и переключиться на вид «Календарь»?", + "calendarSearch": "Поиск в календаре", + "calendarThisAndFuture": "Это и все последующие", + "calendarCreateDateField": "Создать поле даты", + "calendarDeleteOccurrence": "Удалить повторение", + "calendarDeleteSeries": "Удалить все", + "calendarRecurrenceScopeSeries": "Все события", + "calendarDaily": "Ежедневно", + "calendarWeekly": "Еженедельно", + "calendarMonthly": "Ежемесячно", + "calendarYearly": "Ежегодно", + "calendarInterval": "Интервал", + "calendarCount": "Количество", + "calendarUntil": "До", + "calendarWeekStart": "Начало недели", + "calendarWeekNumber": "Нед. ${x}", + "calendarRecurrence": "Повторение", + "calendarOccurrence": "Повторяющееся событие", + "calendarDiscardChanges": "Отменить несохранённые изменения календаря?", + "calendarSource": "Исходная заметка/блок", + "calendarOpenSource": "Открыть источник", + "calendarSourceMissing": "У элемента календаря нет исходного блока", + "calendarTimed": "Со временем", + "calendarEvents": "События", + "calendarPreviousEvent": "Предыдущее событие", + "calendarNextEvent": "Следующее событие", + "calendarNoMatchingEvent": "Совпадающих событий нет", + "calendarEditSeriesNotice": "Будет изменена вся повторяющаяся серия. Сопоставьте поле исключений, чтобы изменить отдельное повторение.", + "calendarExceptions": "Исключения", + "calendarLocation": "Место", + "calendarDescription": "Описание", + "calendarSchedule": "Расписание", + "calendarEventRestored": "Событие восстановлено.", + "calendarCreateFailed": "Не удалось создать.", + "calendarResizeFailed": "Не удалось изменить размер.", + "calendarMoveFailed": "Не удалось переместить.", + "calendarDeleteFailed": "Не удалось удалить.", + "calendarDuplicateFailed": "Не удалось дублировать.", + "calendarSaveFailed": "Не удалось сохранить.", + "calendarDeleteRecurring": "Удалить повторяющийся элемент", + "calendarRecurrenceScopeOccurrenceDisabled": "Сопоставьте поле исключений, чтобы изменить только это повторение.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Удаление этого и последующих пока не поддерживается.", + "calendarRecurrenceScopeFutureDisabled": "Сопоставьте поле повторения, чтобы изменить это и последующие элементы.", + "calendarRecurrenceScopeDeleteTitle": "Удалить повторяющийся элемент", + "calendarRecurrenceScopeEditTitle": "Изменить повторяющийся элемент", + "calendarRecurrenceScopeOccurrence": "Это повторение", + "calendarRecurrenceScopeOccurrenceDesc": "Только выбранное повторение.", + "calendarRecurrenceScopeFutureDesc": "Это повторение и последующие элементы серии.", + "calendarRecurrenceScopeSeriesDesc": "Все элементы повторяющейся серии.", + "calendarRecurrenceScopePrompt": "Выберите, к чему применить это изменение.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Это исходное событие хранит повторяющуюся серию. Изменение отдельного повторения доступно только из созданных повторений.", + "calendarRecurrenceScopeRootFutureDisabled": "«Это и все последующие» доступно только из созданных повторений.", + "newEvent": "Новое событие", + "calendarEditEvent": "Редактировать событие", + "calendarStaleMapping": "Поле отсутствует или недействительно", + "today": "Сегодня", + "allDay": "Весь день", + "calendarAllDay": "Весь день", + "none": "Нет", + "saved": "Сохранено", + "dateField": "Поле даты", + "calendarEmptyHint": "Элементов календаря пока нет — нажмите на день или временной интервал, чтобы создать первый.", + "calendarRenamePageFailed": "Не удалось переименовать страницу события.", + "calendarDeletePageFailed": "Не удалось удалить страницу события.", + "calendarDeleteEvent": "Удалить событие", + "calendarDeleteEventAndDocument": "Удалить событие и документ", + "calendarOccurrenceRollbackFailed": "Повторение осталось скрытым, замена не создана.", + "calendarSplitRollbackFailed": "Серия была усечена, последующая серия не создана.", + "calendarShortcuts": "Сочетания клавиш", + "calendarShortcutsViews": "Виды", + "calendarShortcutsNavigation": "Навигация", + "calendarShortcutsActions": "Действия", + "calendarDayView": "Вид «День»", + "calendarFiveDayView": "5 дней", + "calendarWeekView": "Вид «Неделя»", + "calendarMonthView": "Вид «Месяц»", + "calendarScheduleView": "Вид «Расписание»", + "calendarNextRange": "Следующий период", + "calendarPreviousRange": "Предыдущий период", + "calendarJumpToToday": "Перейти к сегодня", + "calendarCreateEvent": "Создать событие", + "calendarFocusSearch": "Поиск", + "calendarShowShortcuts": "Справка по сочетаниям клавиш", + "calendarBackOut": "Отменить текущий жест, затем очистить поиск", + "calendarMiniMonth": "Навигатор по месяцам", + "calendarPreviousMonth": "Предыдущий месяц", + "calendarNextMonth": "Следующий месяц", + "calendarRepeat": "Повтор", + "calendarDoesNotRepeat": "Не повторяется", + "calendarRepeatWeeklyOn": "Еженедельно по ${x}", + "calendarRepeatEveryNDays": "Каждые ${x} дн.", + "calendarRepeatEveryNWeeksOn": "Каждые ${x} нед. по ${y}", + "calendarRepeatMonthlyOnDay": "Ежемесячно ${x}-го числа", + "calendarRepeatEveryNMonthsOnDay": "Каждые ${x} мес. ${y}-го числа", + "calendarRepeatEveryNMonths": "Каждые ${x} мес.", + "calendarRepeatAnnuallyOn": "Ежегодно ${x}", + "calendarRepeatEveryNYearsOn": "Каждые ${x} лет ${y}", + "calendarRepeatEveryNYears": "Каждые ${x} лет", + "calendarRepeatUntilSuffix": ", до ${x}", + "calendarRepeatCountSuffix": ", ${x} раз", + "calendarRepeatEveryWeekday": "По будням (с понедельника по пятницу)", + "calendarRepeatCustom": "Другое…", + "calendarMapLocationHint": "Сопоставьте текстовое поле в настройках календаря, чтобы сохранить это поле.", + "calendarMapRecurrenceHint": "Сопоставьте текстовое поле в настройках календаря, чтобы сохранить повторения.", + "calendarRepeatEvery": "Повторять каждые", + "calendarDay": "День", + "calendarWeek": "Неделя", + "calendarMonth": "Месяц", + "calendarYear": "Год", + "calendarRepeatOn": "Повторять по", + "calendarEnd": "Окончание", + "calendarStart": "Начало", + "calendarNever": "Никогда", + "calendarOn": "В дату", + "calendarAfter": "После", + "calendarOccurrences": "повторений", "entrySeparator": "Разделитель", "tableAddColumnTip": "Нажмите, чтобы вставить столбец справа
Перетащите, чтобы вставить или удалить несколько столбцов", "tableAddRowTip": "Нажмите, чтобы вставить строку ниже
Перетащите, чтобы вставить или удалить несколько строк", @@ -2052,7 +2172,9 @@ "gallery": "Карточка", "kanban": "Канбан", "key": "Первичный ключ", - "select": "Выбрать" + "select": "Выбрать", + "calendar": "Календарь", + "date": "Дата" }, "_kernel": { "0": "Не удалось запросить блокнот", @@ -2420,4 +2542,4 @@ "362": "Имя пользователя учетной записи службы публикации уже используется", "363": "Пароль учетной записи службы публикации должен содержать не менее 8 символов" } - } +} diff --git a/app/appearance/langs/sk.json b/app/appearance/langs/sk.json index 07a6d5e4dfe..14b4d29273d 100644 --- a/app/appearance/langs/sk.json +++ b/app/appearance/langs/sk.json @@ -1,4 +1,124 @@ { + "calendar": "Kalendár", + "calendarRecurringAdvancedReadOnly": "Rozšírené opakovanie je zachované (tu ho nemožno upraviť).", + "calendarDuplicateMetadataField": "Vyberte odlišné polia pre metadáta ${x}.", + "calendarNeedDateField": "Kalendár vyžaduje pole dátumu", + "calendarCreateDateColumn": "Nenašlo sa žiadne pole dátumu. Vytvoriť stĺpec „${x}“ a prepnúť na zobrazenie Kalendár?", + "calendarSearch": "Hľadať v kalendári", + "calendarThisAndFuture": "Táto a nasledujúce", + "calendarCreateDateField": "Vytvoriť pole dátumu", + "calendarDeleteOccurrence": "Odstrániť výskyt", + "calendarDeleteSeries": "Odstrániť všetko", + "calendarRecurrenceScopeSeries": "Všetky udalosti", + "calendarDaily": "Denne", + "calendarWeekly": "Týždenne", + "calendarMonthly": "Mesačne", + "calendarYearly": "Ročne", + "calendarInterval": "Interval", + "calendarCount": "Počet", + "calendarUntil": "Do", + "calendarWeekStart": "Týždeň začína", + "calendarWeekNumber": "Týž. ${x}", + "calendarRecurrence": "Opakovanie", + "calendarOccurrence": "Opakovaný výskyt", + "calendarDiscardChanges": "Zahodiť neuložené zmeny kalendára?", + "calendarSource": "Zdrojová poznámka/blok", + "calendarOpenSource": "Otvoriť zdroj", + "calendarSourceMissing": "Položka kalendára nemá zdrojový blok", + "calendarTimed": "S časom", + "calendarEvents": "Udalosti", + "calendarPreviousEvent": "Predchádzajúca udalosť", + "calendarNextEvent": "Nasledujúca udalosť", + "calendarNoMatchingEvent": "Žiadna zodpovedajúca udalosť", + "calendarEditSeriesNotice": "Upraví sa celá opakujúca sa séria. Ak chcete upraviť jednotlivý výskyt, priraďte pole výnimiek.", + "calendarExceptions": "Výnimky", + "calendarLocation": "Miesto", + "calendarDescription": "Popis", + "calendarSchedule": "Rozvrh", + "calendarEventRestored": "Udalosť bola obnovená.", + "calendarCreateFailed": "Vytvorenie zlyhalo.", + "calendarResizeFailed": "Zmena veľkosti zlyhala.", + "calendarMoveFailed": "Presun zlyhal.", + "calendarDeleteFailed": "Odstránenie zlyhalo.", + "calendarDuplicateFailed": "Duplikovanie zlyhalo.", + "calendarSaveFailed": "Uloženie zlyhalo.", + "calendarDeleteRecurring": "Odstrániť opakujúcu sa položku", + "calendarRecurrenceScopeOccurrenceDisabled": "Ak chcete zmeniť len tento výskyt, priraďte pole výnimiek.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Odstránenie tejto a nasledujúcich zatiaľ nie je podporované.", + "calendarRecurrenceScopeFutureDisabled": "Ak chcete zmeniť túto a nasledujúce položky, priraďte pole opakovania.", + "calendarRecurrenceScopeDeleteTitle": "Odstrániť opakujúcu sa položku", + "calendarRecurrenceScopeEditTitle": "Upraviť opakujúcu sa položku", + "calendarRecurrenceScopeOccurrence": "Tento výskyt", + "calendarRecurrenceScopeOccurrenceDesc": "Iba vybraný výskyt.", + "calendarRecurrenceScopeFutureDesc": "Tento výskyt a nasledujúce položky série.", + "calendarRecurrenceScopeSeriesDesc": "Každá položka opakujúcej sa série.", + "calendarRecurrenceScopePrompt": "Vyberte, na čo sa má táto zmena vzťahovať.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Táto zdrojová udalosť uchováva opakujúcu sa sériu. Zmeny jednotlivého výskytu sú dostupné len z vygenerovaných výskytov.", + "calendarRecurrenceScopeRootFutureDisabled": "„Táto a nasledujúce“ je dostupné len z vygenerovaných výskytov.", + "newEvent": "Nová udalosť", + "calendarEditEvent": "Upraviť udalosť", + "calendarStaleMapping": "Pole chýba alebo je neplatné", + "today": "Dnes", + "allDay": "Celý deň", + "calendarAllDay": "Celý deň", + "none": "Žiadny", + "saved": "Uložené", + "dateField": "Pole dátumu", + "calendarEmptyHint": "Zatiaľ žiadne položky kalendára — kliknite na deň alebo časový úsek a vytvorte prvú položku.", + "calendarRenamePageFailed": "Premenovanie stránky udalosti zlyhalo.", + "calendarDeletePageFailed": "Odstránenie stránky udalosti zlyhalo.", + "calendarDeleteEvent": "Odstrániť udalosť", + "calendarDeleteEventAndDocument": "Odstrániť udalosť a dokument", + "calendarOccurrenceRollbackFailed": "Výskyt zostal skrytý a nevytvorila sa žiadna náhrada.", + "calendarSplitRollbackFailed": "Séria bola skrátená a nevytvorila sa nadväzujúca séria.", + "calendarShortcuts": "Klávesové skratky", + "calendarShortcutsViews": "Zobrazenia", + "calendarShortcutsNavigation": "Navigácia", + "calendarShortcutsActions": "Akcie", + "calendarDayView": "Zobrazenie dňa", + "calendarFiveDayView": "5 dní", + "calendarWeekView": "Zobrazenie týždňa", + "calendarMonthView": "Zobrazenie mesiaca", + "calendarScheduleView": "Zobrazenie harmonogramu", + "calendarNextRange": "Nasledujúce obdobie", + "calendarPreviousRange": "Predchádzajúce obdobie", + "calendarJumpToToday": "Prejsť na dnešok", + "calendarCreateEvent": "Vytvoriť udalosť", + "calendarFocusSearch": "Hľadať", + "calendarShowShortcuts": "Pomocník ku klávesovým skratkám", + "calendarBackOut": "Zrušiť aktuálne gesto, potom vymazať vyhľadávanie", + "calendarMiniMonth": "Navigátor mesiacov", + "calendarPreviousMonth": "Predchádzajúci mesiac", + "calendarNextMonth": "Nasledujúci mesiac", + "calendarRepeat": "Opakovanie", + "calendarDoesNotRepeat": "Neopakuje sa", + "calendarRepeatWeeklyOn": "Každý týždeň v ${x}", + "calendarRepeatEveryNDays": "Každých ${x} dní", + "calendarRepeatEveryNWeeksOn": "Každé ${x} týž. v ${y}", + "calendarRepeatMonthlyOnDay": "Každý mesiac ${x}. deň", + "calendarRepeatEveryNMonthsOnDay": "Každých ${x} mes. ${y}. deň", + "calendarRepeatEveryNMonths": "Každých ${x} mes.", + "calendarRepeatAnnuallyOn": "Každý rok ${x}", + "calendarRepeatEveryNYearsOn": "Každých ${x} rokov ${y}", + "calendarRepeatEveryNYears": "Každých ${x} rokov", + "calendarRepeatUntilSuffix": ", do ${x}", + "calendarRepeatCountSuffix": ", ${x}×", + "calendarRepeatEveryWeekday": "Každý pracovný deň (pondelok až piatok)", + "calendarRepeatCustom": "Vlastné…", + "calendarMapLocationHint": "Priraďte textové pole v nastaveniach kalendára na uloženie tohto poľa.", + "calendarMapRecurrenceHint": "Priraďte textové pole v nastaveniach kalendára na uloženie opakovania.", + "calendarRepeatEvery": "Opakovať každé", + "calendarDay": "Deň", + "calendarWeek": "Týždeň", + "calendarMonth": "Mesiac", + "calendarYear": "Rok", + "calendarRepeatOn": "Opakovať v", + "calendarEnd": "Koniec", + "calendarStart": "Začiatok", + "calendarNever": "Nikdy", + "calendarOn": "Dňa", + "calendarAfter": "Po", + "calendarOccurrences": "výskytoch", "entrySeparator": "Oddeľovač", "tableAddColumnTip": "Kliknutím vložíte stĺpec napravo
Potiahnutím vložíte alebo odstránite viacero stĺpcov", "tableAddRowTip": "Kliknutím vložíte riadok nižšie
Potiahnutím vložíte alebo odstránite viacero riadkov", @@ -2052,7 +2172,9 @@ "gallery": "Karta", "kanban": "Kanban", "key": "Primárny kľúč", - "select": "Výber" + "select": "Výber", + "calendar": "Kalendár", + "date": "Dátum" }, "_kernel": { "0": "Dopyt na zápisník zlyhal", @@ -2420,4 +2542,4 @@ "362": "Používateľské meno účtu služby publikovania je duplicitné", "363": "Heslo účtu služby publikovania musí mať aspoň 8 znakov" } - } +} diff --git a/app/appearance/langs/th.json b/app/appearance/langs/th.json index 1f0c154812c..122b8060173 100644 --- a/app/appearance/langs/th.json +++ b/app/appearance/langs/th.json @@ -1,4 +1,124 @@ { + "calendar": "ปฏิทิน", + "calendarRecurringAdvancedReadOnly": "การเกิดซ้ำขั้นสูงจะถูกคงไว้ (แก้ไขที่นี่ไม่ได้)", + "calendarDuplicateMetadataField": "โปรดเลือกฟิลด์อื่นสำหรับข้อมูลเมตา ${x}", + "calendarNeedDateField": "ปฏิทินต้องมีฟิลด์วันที่", + "calendarCreateDateColumn": "ไม่พบฟิลด์วันที่ สร้างคอลัมน์ \"${x}\" แล้วสลับไปมุมมองปฏิทินหรือไม่", + "calendarSearch": "ค้นหาปฏิทิน", + "calendarThisAndFuture": "รายการนี้และรายการถัดไป", + "calendarCreateDateField": "สร้างฟิลด์วันที่", + "calendarDeleteOccurrence": "ลบรายการครั้งนี้", + "calendarDeleteSeries": "ลบทั้งหมด", + "calendarRecurrenceScopeSeries": "กิจกรรมทั้งหมด", + "calendarDaily": "รายวัน", + "calendarWeekly": "รายสัปดาห์", + "calendarMonthly": "รายเดือน", + "calendarYearly": "รายปี", + "calendarInterval": "ช่วงห่าง", + "calendarCount": "จำนวนครั้ง", + "calendarUntil": "จนถึง", + "calendarWeekStart": "สัปดาห์เริ่มวัน", + "calendarWeekNumber": "สัปดาห์ ${x}", + "calendarRecurrence": "การเกิดซ้ำ", + "calendarOccurrence": "รายการที่เกิดซ้ำ", + "calendarDiscardChanges": "ละทิ้งการเปลี่ยนแปลงปฏิทินที่ยังไม่ได้บันทึกหรือไม่", + "calendarSource": "โน้ต/บล็อกต้นทาง", + "calendarOpenSource": "เปิดต้นทาง", + "calendarSourceMissing": "รายการปฏิทินไม่มีบล็อกต้นทาง", + "calendarTimed": "กำหนดเวลา", + "calendarEvents": "กิจกรรม", + "calendarPreviousEvent": "กิจกรรมก่อนหน้า", + "calendarNextEvent": "กิจกรรมถัดไป", + "calendarNoMatchingEvent": "ไม่พบกิจกรรมที่ตรงกัน", + "calendarEditSeriesNotice": "การแก้ไขนี้จะมีผลกับทั้งชุดที่เกิดซ้ำ โปรดแมปฟิลด์ข้อยกเว้นเพื่อแก้ไขเพียงรายการเดียว", + "calendarExceptions": "ข้อยกเว้น", + "calendarLocation": "สถานที่", + "calendarDescription": "คำอธิบาย", + "calendarSchedule": "กำหนดการ", + "calendarEventRestored": "กู้คืนกิจกรรมแล้ว", + "calendarCreateFailed": "สร้างไม่สำเร็จ", + "calendarResizeFailed": "ปรับขนาดไม่สำเร็จ", + "calendarMoveFailed": "ย้ายไม่สำเร็จ", + "calendarDeleteFailed": "ลบไม่สำเร็จ", + "calendarDuplicateFailed": "ทำสำเนาไม่สำเร็จ", + "calendarSaveFailed": "บันทึกไม่สำเร็จ", + "calendarDeleteRecurring": "ลบรายการที่เกิดซ้ำ", + "calendarRecurrenceScopeOccurrenceDisabled": "แมปฟิลด์ข้อยกเว้นเพื่อเปลี่ยนเฉพาะรายการครั้งนี้", + "calendarRecurrenceScopeFutureDeleteDisabled": "ยังไม่รองรับการลบรายการนี้และรายการถัดไป", + "calendarRecurrenceScopeFutureDisabled": "แมปฟิลด์การเกิดซ้ำเพื่อเปลี่ยนรายการนี้และรายการถัดไป", + "calendarRecurrenceScopeDeleteTitle": "ลบรายการที่เกิดซ้ำ", + "calendarRecurrenceScopeEditTitle": "แก้ไขรายการที่เกิดซ้ำ", + "calendarRecurrenceScopeOccurrence": "เฉพาะครั้งนี้", + "calendarRecurrenceScopeOccurrenceDesc": "เฉพาะรายการที่เลือกเท่านั้น", + "calendarRecurrenceScopeFutureDesc": "รายการครั้งนี้และรายการถัดไปในชุด", + "calendarRecurrenceScopeSeriesDesc": "ทุกรายการในชุดที่เกิดซ้ำ", + "calendarRecurrenceScopePrompt": "เลือกขอบเขตที่จะให้การเปลี่ยนแปลงนี้มีผล", + "calendarRecurrenceScopeRootOccurrenceDisabled": "กิจกรรมต้นทางนี้เก็บข้อมูลชุดที่เกิดซ้ำ การแก้ไขเฉพาะครั้งทำได้จากรายการที่ระบบสร้างขึ้นเท่านั้น", + "calendarRecurrenceScopeRootFutureDisabled": "\"รายการนี้และรายการถัดไป\" ใช้ได้จากรายการที่ระบบสร้างขึ้นเท่านั้น", + "newEvent": "กิจกรรมใหม่", + "calendarEditEvent": "แก้ไขกิจกรรม", + "calendarStaleMapping": "ฟิลด์หายไปหรือไม่ถูกต้อง", + "today": "วันนี้", + "allDay": "ทั้งวัน", + "calendarAllDay": "ทั้งวัน", + "none": "ไม่มี", + "saved": "บันทึกแล้ว", + "dateField": "ฟิลด์วันที่", + "calendarEmptyHint": "ยังไม่มีรายการปฏิทิน — คลิกที่วันหรือช่วงเวลาเพื่อสร้างรายการแรก", + "calendarRenamePageFailed": "เปลี่ยนชื่อหน้ากิจกรรมไม่สำเร็จ", + "calendarDeletePageFailed": "ลบหน้ากิจกรรมไม่สำเร็จ", + "calendarDeleteEvent": "ลบกิจกรรม", + "calendarDeleteEventAndDocument": "ลบกิจกรรมและเอกสาร", + "calendarOccurrenceRollbackFailed": "รายการที่เกิดซ้ำนี้ยังถูกซ่อนอยู่ และไม่ได้สร้างรายการแทน", + "calendarSplitRollbackFailed": "ชุดกิจกรรมถูกตัดให้สั้นลง และไม่ได้สร้างชุดต่อเนื่อง", + "calendarShortcuts": "แป้นพิมพ์ลัด", + "calendarShortcutsViews": "มุมมอง", + "calendarShortcutsNavigation": "การนำทาง", + "calendarShortcutsActions": "การดำเนินการ", + "calendarDayView": "มุมมองวัน", + "calendarFiveDayView": "5 วัน", + "calendarWeekView": "มุมมองสัปดาห์", + "calendarMonthView": "มุมมองเดือน", + "calendarScheduleView": "มุมมองกำหนดการ", + "calendarNextRange": "ช่วงเวลาถัดไป", + "calendarPreviousRange": "ช่วงเวลาก่อนหน้า", + "calendarJumpToToday": "ไปที่วันนี้", + "calendarCreateEvent": "สร้างกิจกรรม", + "calendarFocusSearch": "ค้นหา", + "calendarShowShortcuts": "ความช่วยเหลือเกี่ยวกับแป้นพิมพ์ลัด", + "calendarBackOut": "ยกเลิกการกระทำปัจจุบัน แล้วล้างการค้นหา", + "calendarMiniMonth": "ตัวนำทางเดือน", + "calendarPreviousMonth": "เดือนก่อนหน้า", + "calendarNextMonth": "เดือนถัดไป", + "calendarRepeat": "ทำซ้ำ", + "calendarDoesNotRepeat": "ไม่ทำซ้ำ", + "calendarRepeatWeeklyOn": "ทุกสัปดาห์ในวัน${x}", + "calendarRepeatEveryNDays": "ทุก ${x} วัน", + "calendarRepeatEveryNWeeksOn": "ทุก ${x} สัปดาห์ในวัน${y}", + "calendarRepeatMonthlyOnDay": "ทุกเดือนในวันที่ ${x}", + "calendarRepeatEveryNMonthsOnDay": "ทุก ${x} เดือนในวันที่ ${y}", + "calendarRepeatEveryNMonths": "ทุก ${x} เดือน", + "calendarRepeatAnnuallyOn": "ทุกปีในวันที่ ${x}", + "calendarRepeatEveryNYearsOn": "ทุก ${x} ปีในวันที่ ${y}", + "calendarRepeatEveryNYears": "ทุก ${x} ปี", + "calendarRepeatUntilSuffix": " จนถึง ${x}", + "calendarRepeatCountSuffix": " ${x} ครั้ง", + "calendarRepeatEveryWeekday": "ทุกวันทำงาน (จันทร์ถึงศุกร์)", + "calendarRepeatCustom": "กำหนดเอง…", + "calendarMapLocationHint": "จับคู่ช่องข้อความในการตั้งค่าปฏิทินเพื่อบันทึกช่องนี้", + "calendarMapRecurrenceHint": "จับคู่ช่องข้อความในการตั้งค่าปฏิทินเพื่อบันทึกการเกิดซ้ำ", + "calendarRepeatEvery": "ทำซ้ำทุก", + "calendarDay": "วัน", + "calendarWeek": "สัปดาห์", + "calendarMonth": "เดือน", + "calendarYear": "ปี", + "calendarRepeatOn": "ทำซ้ำใน", + "calendarEnd": "สิ้นสุด", + "calendarStart": "เริ่มต้น", + "calendarNever": "ไม่สิ้นสุด", + "calendarOn": "วันที่", + "calendarAfter": "หลังจาก", + "calendarOccurrences": "ครั้ง", "entrySeparator": "ตัวคั่น", "tableAddColumnTip": "คลิกเพื่อแทรกคอลัมน์ทางขวา
ลากเพื่อแทรกหรือลบหลายคอลัมน์", "tableAddRowTip": "คลิกเพื่อแทรกแถวด้านล่าง
ลากเพื่อแทรกหรือลบหลายแถว", @@ -2052,7 +2172,9 @@ "gallery": "การ์ด", "kanban": "คัมบัง", "key": "คีย์หลัก", - "select": "เลือก" + "select": "เลือก", + "calendar": "ปฏิทิน", + "date": "วันที่" }, "_kernel": { "0": "ค้นหาสมุดล้มเหลว", @@ -2420,4 +2542,4 @@ "362": "ชื่อผู้ใช้ของบัญชีบริการเผยแพร่ซ้ำกัน", "363": "รหัสผ่านของบัญชีบริการเผยแพร่ต้องมีอย่างน้อย 8 ตัวอักษร" } - } +} diff --git a/app/appearance/langs/tr.json b/app/appearance/langs/tr.json index feded31a0d5..cf590616889 100644 --- a/app/appearance/langs/tr.json +++ b/app/appearance/langs/tr.json @@ -1,4 +1,124 @@ { + "calendar": "Takvim", + "calendarRecurringAdvancedReadOnly": "Gelişmiş yineleme korunuyor (burada düzenlenemez).", + "calendarDuplicateMetadataField": "Lütfen ${x} meta verisi için farklı alanlar seçin.", + "calendarNeedDateField": "Takvim bir tarih alanı gerektirir", + "calendarCreateDateColumn": "Tarih alanı bulunamadı. \"${x}\" sütunu oluşturulup Takvim görünümüne geçilsin mi?", + "calendarSearch": "Takvimde ara", + "calendarThisAndFuture": "Bu ve sonrakiler", + "calendarCreateDateField": "Tarih alanı oluştur", + "calendarDeleteOccurrence": "Yinelemeyi sil", + "calendarDeleteSeries": "Tümünü sil", + "calendarRecurrenceScopeSeries": "Tüm etkinlikler", + "calendarDaily": "Günlük", + "calendarWeekly": "Haftalık", + "calendarMonthly": "Aylık", + "calendarYearly": "Yıllık", + "calendarInterval": "Aralık", + "calendarCount": "Tekrar sayısı", + "calendarUntil": "Bitiş tarihi", + "calendarWeekStart": "Haftanın ilk günü", + "calendarWeekNumber": "${x}. hafta", + "calendarRecurrence": "Yineleme", + "calendarOccurrence": "Yinelenen etkinlik", + "calendarDiscardChanges": "Kaydedilmemiş takvim değişiklikleri atılsın mı?", + "calendarSource": "Kaynak not/blok", + "calendarOpenSource": "Kaynağı aç", + "calendarSourceMissing": "Takvim ögesinin kaynak bloğu yok", + "calendarTimed": "Saatli", + "calendarEvents": "Etkinlikler", + "calendarPreviousEvent": "Önceki etkinlik", + "calendarNextEvent": "Sonraki etkinlik", + "calendarNoMatchingEvent": "Eşleşen etkinlik yok", + "calendarEditSeriesNotice": "Bu işlem yinelenen seriyi düzenler. Tek bir yinelemeyi düzenlemek için bir istisna alanı eşleyin.", + "calendarExceptions": "İstisnalar", + "calendarLocation": "Konum", + "calendarDescription": "Açıklama", + "calendarSchedule": "Ajanda", + "calendarEventRestored": "Etkinlik geri yüklendi.", + "calendarCreateFailed": "Oluşturma başarısız.", + "calendarResizeFailed": "Yeniden boyutlandırma başarısız.", + "calendarMoveFailed": "Taşıma başarısız.", + "calendarDeleteFailed": "Silme başarısız.", + "calendarDuplicateFailed": "Çoğaltma başarısız.", + "calendarSaveFailed": "Kaydetme başarısız.", + "calendarDeleteRecurring": "Yinelenen ögeyi sil", + "calendarRecurrenceScopeOccurrenceDisabled": "Yalnızca bu yinelemeyi değiştirmek için bir istisna alanı eşleyin.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Bu ve sonrakileri silme henüz desteklenmiyor.", + "calendarRecurrenceScopeFutureDisabled": "Bu ve sonraki ögeleri değiştirmek için bir yineleme alanı eşleyin.", + "calendarRecurrenceScopeDeleteTitle": "Yinelenen ögeyi sil", + "calendarRecurrenceScopeEditTitle": "Yinelenen ögeyi düzenle", + "calendarRecurrenceScopeOccurrence": "Bu yineleme", + "calendarRecurrenceScopeOccurrenceDesc": "Yalnızca seçilen yineleme.", + "calendarRecurrenceScopeFutureDesc": "Bu yineleme ve serideki sonraki ögeler.", + "calendarRecurrenceScopeSeriesDesc": "Yinelenen serideki tüm ögeler.", + "calendarRecurrenceScopePrompt": "Bu değişikliğin kapsamını seçin.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Bu kaynak etkinlik, yinelenen seriyi saklar. Tek yineleme değişiklikleri yalnızca oluşturulan yinelemelerden yapılabilir.", + "calendarRecurrenceScopeRootFutureDisabled": "“Bu ve sonrakiler” yalnızca oluşturulan yinelemelerden kullanılabilir.", + "newEvent": "Yeni etkinlik", + "calendarEditEvent": "Etkinliği düzenle", + "calendarStaleMapping": "Alan eksik veya geçersiz", + "today": "Bugün", + "allDay": "Tüm gün", + "calendarAllDay": "Tüm gün", + "none": "Yok", + "saved": "Kaydedildi", + "dateField": "Tarih alanı", + "calendarEmptyHint": "Henüz takvim ögesi yok — ilkini oluşturmak için bir güne veya zaman aralığına tıklayın.", + "calendarRenamePageFailed": "Etkinlik sayfası yeniden adlandırılamadı.", + "calendarDeletePageFailed": "Etkinlik sayfası silinemedi.", + "calendarDeleteEvent": "Etkinliği sil", + "calendarDeleteEventAndDocument": "Etkinliği ve belgeyi sil", + "calendarOccurrenceRollbackFailed": "Yineleme gizli kaldı ve yerine bir kayıt oluşturulmadı.", + "calendarSplitRollbackFailed": "Seri kısaltıldı ve devam serisi oluşturulmadı.", + "calendarShortcuts": "Klavye kısayolları", + "calendarShortcutsViews": "Görünümler", + "calendarShortcutsNavigation": "Gezinme", + "calendarShortcutsActions": "Eylemler", + "calendarDayView": "Gün görünümü", + "calendarFiveDayView": "5 Gün", + "calendarWeekView": "Hafta görünümü", + "calendarMonthView": "Ay görünümü", + "calendarScheduleView": "Program görünümü", + "calendarNextRange": "Sonraki dönem", + "calendarPreviousRange": "Önceki dönem", + "calendarJumpToToday": "Bugüne git", + "calendarCreateEvent": "Etkinlik oluştur", + "calendarFocusSearch": "Ara", + "calendarShowShortcuts": "Klavye kısayolları yardımı", + "calendarBackOut": "Geçerli hareketi iptal et, ardından aramayı temizle", + "calendarMiniMonth": "Ay gezgini", + "calendarPreviousMonth": "Önceki ay", + "calendarNextMonth": "Sonraki ay", + "calendarRepeat": "Yinele", + "calendarDoesNotRepeat": "Yinelenmez", + "calendarRepeatWeeklyOn": "Her hafta ${x} günü", + "calendarRepeatEveryNDays": "Her ${x} günde bir", + "calendarRepeatEveryNWeeksOn": "Her ${x} haftada bir ${y} günü", + "calendarRepeatMonthlyOnDay": "Her ay ayın ${x}. günü", + "calendarRepeatEveryNMonthsOnDay": "Her ${x} ayda bir ayın ${y}. günü", + "calendarRepeatEveryNMonths": "Her ${x} ayda bir", + "calendarRepeatAnnuallyOn": "Her yıl ${x}", + "calendarRepeatEveryNYearsOn": "Her ${x} yılda bir ${y}", + "calendarRepeatEveryNYears": "Her ${x} yılda bir", + "calendarRepeatUntilSuffix": ", ${x} tarihine kadar", + "calendarRepeatCountSuffix": ", ${x} kez", + "calendarRepeatEveryWeekday": "Her hafta içi gün (Pazartesi - Cuma)", + "calendarRepeatCustom": "Özel…", + "calendarMapLocationHint": "Bu alanı kaydetmek için Takvim ayarlarında bir metin alanı eşleyin.", + "calendarMapRecurrenceHint": "Yinelenen etkinlikleri kaydetmek için Takvim ayarlarında bir metin alanı eşleyin.", + "calendarRepeatEvery": "Her", + "calendarDay": "Gün", + "calendarWeek": "Hafta", + "calendarMonth": "Ay", + "calendarYear": "Yıl", + "calendarRepeatOn": "Şu günlerde yinele", + "calendarEnd": "Bitiş", + "calendarStart": "Başlangıç", + "calendarNever": "Asla", + "calendarOn": "Tarihte", + "calendarAfter": "Sonra", + "calendarOccurrences": "tekrar", "entrySeparator": "Ayırıcı", "tableAddColumnTip": "Sağa bir sütun eklemek için tıklayın
Birden fazla sütun eklemek veya silmek için sürükleyin", "tableAddRowTip": "Aşağıya bir satır eklemek için tıklayın
Birden fazla satır eklemek veya silmek için sürükleyin", @@ -2052,7 +2172,9 @@ "gallery": "Kart görünümü", "kanban": "Kanban", "key": "Birincil anahtar", - "select": "Seç" + "select": "Seç", + "calendar": "Takvim", + "date": "Tarih" }, "_kernel": { "0": "Not defteri sorgusu başarısız", @@ -2420,4 +2542,4 @@ "362": "Yayınlama hizmeti hesabının kullanıcı adı yineleniyor", "363": "Yayınlama hizmeti hesabının parolası en az 8 karakter olmalıdır" } - } +} diff --git a/app/appearance/langs/uk.json b/app/appearance/langs/uk.json index caa21690f45..952de5508c4 100644 --- a/app/appearance/langs/uk.json +++ b/app/appearance/langs/uk.json @@ -1,4 +1,124 @@ { + "calendar": "Календар", + "calendarRecurringAdvancedReadOnly": "Розширене повторення збережено (тут не редагується).", + "calendarDuplicateMetadataField": "Виберіть різні поля для метаданих ${x}.", + "calendarNeedDateField": "Для календаря потрібне поле дати", + "calendarCreateDateColumn": "Поле дати не знайдено. Створити стовпець «${x}» і перейти до подання «Календар»?", + "calendarSearch": "Пошук у календарі", + "calendarThisAndFuture": "Ця та наступні", + "calendarCreateDateField": "Створити поле дати", + "calendarDeleteOccurrence": "Видалити повторення", + "calendarDeleteSeries": "Видалити все", + "calendarRecurrenceScopeSeries": "Усі події", + "calendarDaily": "Щодня", + "calendarWeekly": "Щотижня", + "calendarMonthly": "Щомісяця", + "calendarYearly": "Щороку", + "calendarInterval": "Інтервал", + "calendarCount": "Кількість", + "calendarUntil": "До", + "calendarWeekStart": "Початок тижня", + "calendarWeekNumber": "Тиж. ${x}", + "calendarRecurrence": "Повторення", + "calendarOccurrence": "Повторювана подія", + "calendarDiscardChanges": "Відхилити незбережені зміни календаря?", + "calendarSource": "Джерельна нотатка/блок", + "calendarOpenSource": "Відкрити джерело", + "calendarSourceMissing": "Елемент календаря не має джерельного блоку", + "calendarTimed": "З часом", + "calendarEvents": "Події", + "calendarPreviousEvent": "Попередня подія", + "calendarNextEvent": "Наступна подія", + "calendarNoMatchingEvent": "Немає відповідних подій", + "calendarEditSeriesNotice": "Це змінить усю повторювану серію. Призначте поле винятків, щоб редагувати окреме повторення.", + "calendarExceptions": "Винятки", + "calendarLocation": "Місце", + "calendarDescription": "Опис", + "calendarSchedule": "Розклад", + "calendarEventRestored": "Подію відновлено.", + "calendarCreateFailed": "Не вдалося створити.", + "calendarResizeFailed": "Не вдалося змінити розмір.", + "calendarMoveFailed": "Не вдалося перемістити.", + "calendarDeleteFailed": "Не вдалося видалити.", + "calendarDuplicateFailed": "Не вдалося дублювати.", + "calendarSaveFailed": "Не вдалося зберегти.", + "calendarDeleteRecurring": "Видалити повторюваний елемент", + "calendarRecurrenceScopeOccurrenceDisabled": "Призначте поле винятків, щоб змінити лише це повторення.", + "calendarRecurrenceScopeFutureDeleteDisabled": "Видалення цього та наступних поки не підтримується.", + "calendarRecurrenceScopeFutureDisabled": "Призначте поле повторення, щоб змінити цей і наступні елементи.", + "calendarRecurrenceScopeDeleteTitle": "Видалити повторюваний елемент", + "calendarRecurrenceScopeEditTitle": "Редагувати повторюваний елемент", + "calendarRecurrenceScopeOccurrence": "Це повторення", + "calendarRecurrenceScopeOccurrenceDesc": "Лише вибране повторення.", + "calendarRecurrenceScopeFutureDesc": "Це повторення та наступні елементи серії.", + "calendarRecurrenceScopeSeriesDesc": "Усі елементи повторюваної серії.", + "calendarRecurrenceScopePrompt": "Виберіть, наскільки широко застосувати цю зміну.", + "calendarRecurrenceScopeRootOccurrenceDisabled": "Ця джерельна подія зберігає повторювану серію. Зміни окремого повторення доступні лише зі згенерованих повторень.", + "calendarRecurrenceScopeRootFutureDisabled": "«Ця та наступні» доступно лише зі згенерованих повторень.", + "newEvent": "Нова подія", + "calendarEditEvent": "Редагувати подію", + "calendarStaleMapping": "Поле відсутнє або недійсне", + "today": "Сьогодні", + "allDay": "Цілий день", + "calendarAllDay": "Цілий день", + "none": "Немає", + "saved": "Збережено", + "dateField": "Поле дати", + "calendarEmptyHint": "Елементів календаря поки немає — натисніть на день або часовий проміжок, щоб створити перший.", + "calendarRenamePageFailed": "Не вдалося перейменувати сторінку події.", + "calendarDeletePageFailed": "Не вдалося видалити сторінку події.", + "calendarDeleteEvent": "Видалити подію", + "calendarDeleteEventAndDocument": "Видалити подію та документ", + "calendarOccurrenceRollbackFailed": "Повторення залишилося прихованим, заміну не створено.", + "calendarSplitRollbackFailed": "Серію скорочено, наступну серію не створено.", + "calendarShortcuts": "Комбінації клавіш", + "calendarShortcutsViews": "Подання", + "calendarShortcutsNavigation": "Навігація", + "calendarShortcutsActions": "Дії", + "calendarDayView": "Подання «День»", + "calendarFiveDayView": "5 днів", + "calendarWeekView": "Подання «Тиждень»", + "calendarMonthView": "Подання «Місяць»", + "calendarScheduleView": "Подання «Розклад»", + "calendarNextRange": "Наступний період", + "calendarPreviousRange": "Попередній період", + "calendarJumpToToday": "Перейти до сьогодні", + "calendarCreateEvent": "Створити подію", + "calendarFocusSearch": "Пошук", + "calendarShowShortcuts": "Довідка з комбінацій клавіш", + "calendarBackOut": "Скасувати поточний жест, потім очистити пошук", + "calendarMiniMonth": "Навігатор місяців", + "calendarPreviousMonth": "Попередній місяць", + "calendarNextMonth": "Наступний місяць", + "calendarRepeat": "Повторення", + "calendarDoesNotRepeat": "Не повторюється", + "calendarRepeatWeeklyOn": "Щотижня у ${x}", + "calendarRepeatEveryNDays": "Кожні ${x} дн.", + "calendarRepeatEveryNWeeksOn": "Кожні ${x} тижн. у ${y}", + "calendarRepeatMonthlyOnDay": "Щомісяця ${x}-го числа", + "calendarRepeatEveryNMonthsOnDay": "Кожні ${x} міс. ${y}-го числа", + "calendarRepeatEveryNMonths": "Кожні ${x} міс.", + "calendarRepeatAnnuallyOn": "Щороку ${x}", + "calendarRepeatEveryNYearsOn": "Кожні ${x} р. ${y}", + "calendarRepeatEveryNYears": "Кожні ${x} р.", + "calendarRepeatUntilSuffix": ", до ${x}", + "calendarRepeatCountSuffix": ", ${x} раз.", + "calendarRepeatEveryWeekday": "Щобудня (з понеділка до п'ятниці)", + "calendarRepeatCustom": "Інше…", + "calendarMapLocationHint": "Зіставте текстове поле в налаштуваннях календаря, щоб зберегти це поле.", + "calendarMapRecurrenceHint": "Зіставте текстове поле в налаштуваннях календаря, щоб зберегти повторення.", + "calendarRepeatEvery": "Повторювати кожні", + "calendarDay": "День", + "calendarWeek": "Тиждень", + "calendarMonth": "Місяць", + "calendarYear": "Рік", + "calendarRepeatOn": "Повторювати у", + "calendarEnd": "Завершення", + "calendarStart": "Початок", + "calendarNever": "Ніколи", + "calendarOn": "У дату", + "calendarAfter": "Після", + "calendarOccurrences": "повторень", "entrySeparator": "Роздільник", "tableAddColumnTip": "Клацніть, щоб вставити стовпець праворуч
Перетягніть, щоб вставити або видалити кілька стовпців", "tableAddRowTip": "Клацніть, щоб вставити рядок нижче
Перетягніть, щоб вставити або видалити кілька рядків", @@ -2052,7 +2172,9 @@ "gallery": "Картка", "kanban": "Канбан", "key": "Первинний ключ", - "select": "Вибір" + "select": "Вибір", + "calendar": "Календар", + "date": "Дата" }, "_kernel": { "0": "Не вдалося виконати запит до блокнота", @@ -2420,4 +2542,4 @@ "362": "Ім'я користувача облікового запису служби публікації дублюється", "363": "Пароль облікового запису служби публікації має містити щонайменше 8 символів" } - } +} diff --git a/app/appearance/langs/zh-CN.json b/app/appearance/langs/zh-CN.json index b5b4f8009ec..08675da432f 100644 --- a/app/appearance/langs/zh-CN.json +++ b/app/appearance/langs/zh-CN.json @@ -1,4 +1,124 @@ { + "calendar": "日历", + "calendarRecurringAdvancedReadOnly": "高级重复规则将被保留(此处不可编辑)。", + "calendarDuplicateMetadataField": "请为 ${x} 选择不同的元数据字段。", + "calendarNeedDateField": "日历需要一个日期字段", + "calendarCreateDateColumn": "未找到日期字段。是否创建\"${x}\"列并切换为日历视图?", + "calendarSearch": "搜索日历", + "calendarThisAndFuture": "本次及以后", + "calendarCreateDateField": "创建日期字段", + "calendarDeleteOccurrence": "删除本次", + "calendarDeleteSeries": "全部删除", + "calendarRecurrenceScopeSeries": "所有日程", + "calendarDaily": "每天", + "calendarWeekly": "每周", + "calendarMonthly": "每月", + "calendarYearly": "每年", + "calendarInterval": "间隔", + "calendarCount": "次数", + "calendarUntil": "直到", + "calendarWeekStart": "每周开始于", + "calendarWeekNumber": "第 ${x} 周", + "calendarRecurrence": "重复", + "calendarOccurrence": "重复事件实例", + "calendarDiscardChanges": "要放弃未保存的日历更改吗?", + "calendarSource": "来源笔记/块", + "calendarOpenSource": "打开来源", + "calendarSourceMissing": "日历项没有来源块", + "calendarTimed": "定时", + "calendarEvents": "事件", + "calendarPreviousEvent": "上一个事件", + "calendarNextEvent": "下一个事件", + "calendarNoMatchingEvent": "没有匹配事件", + "calendarEditSeriesNotice": "此操作会编辑整个重复事件。若要编辑单次事件,请映射例外字段。", + "calendarExceptions": "例外", + "calendarLocation": "地点", + "calendarDescription": "描述", + "calendarSchedule": "日程", + "calendarEventRestored": "事件已恢复。", + "calendarCreateFailed": "创建失败。", + "calendarResizeFailed": "调整时长失败。", + "calendarMoveFailed": "移动失败。", + "calendarDeleteFailed": "删除失败。", + "calendarDuplicateFailed": "复制失败。", + "calendarSaveFailed": "保存失败。", + "calendarDeleteRecurring": "删除重复项目", + "calendarRecurrenceScopeOccurrenceDisabled": "请先映射例外字段,才能只更改此项。", + "calendarRecurrenceScopeFutureDeleteDisabled": "暂不支持删除此项及后续重复项目。", + "calendarRecurrenceScopeFutureDisabled": "请先映射重复规则字段,才能更改此项及后续项目。", + "calendarRecurrenceScopeDeleteTitle": "删除重复项目", + "calendarRecurrenceScopeEditTitle": "编辑重复项目", + "calendarRecurrenceScopeOccurrence": "仅此项", + "calendarRecurrenceScopeOccurrenceDesc": "只更改选中的这一项。", + "calendarRecurrenceScopeFutureDesc": "更改此项及此后的重复项目。", + "calendarRecurrenceScopeSeriesDesc": "更改整个重复系列。", + "calendarRecurrenceScopePrompt": "选择此次更改的应用范围。", + "calendarRecurrenceScopeRootOccurrenceDisabled": "此来源事件存储整个重复系列,单次更改仅能从生成的重复项进行。", + "calendarRecurrenceScopeRootFutureDisabled": "“此项及以后”仅能从生成的重复项进行。", + "newEvent": "新建事件", + "calendarEditEvent": "编辑日程", + "calendarStaleMapping": "字段已丢失或类型不符", + "today": "今天", + "allDay": "全天", + "calendarAllDay": "全天", + "none": "无", + "saved": "已保存", + "dateField": "日期字段", + "calendarEmptyHint": "当前没有任何日历项——点击日期或时间段即可创建第一条。", + "calendarRenamePageFailed": "重命名日程页面失败。", + "calendarDeletePageFailed": "删除日程页面失败。", + "calendarDeleteEvent": "删除日程", + "calendarDeleteEventAndDocument": "删除日程和文档", + "calendarOccurrenceRollbackFailed": "该次重复项已隐藏,但未创建替代项。", + "calendarSplitRollbackFailed": "系列已截断,但未创建后续系列。", + "calendarShortcuts": "键盘快捷键", + "calendarShortcutsViews": "视图", + "calendarShortcutsNavigation": "导航", + "calendarShortcutsActions": "操作", + "calendarDayView": "日视图", + "calendarFiveDayView": "5 天", + "calendarWeekView": "周视图", + "calendarMonthView": "月视图", + "calendarScheduleView": "日程视图", + "calendarNextRange": "下一时段", + "calendarPreviousRange": "上一时段", + "calendarJumpToToday": "跳转到今天", + "calendarCreateEvent": "新建事件", + "calendarFocusSearch": "搜索", + "calendarShowShortcuts": "键盘快捷键帮助", + "calendarBackOut": "取消当前操作,然后清除搜索", + "calendarMiniMonth": "月份导航", + "calendarPreviousMonth": "上个月", + "calendarNextMonth": "下个月", + "calendarRepeat": "重复", + "calendarDoesNotRepeat": "不重复", + "calendarRepeatWeeklyOn": "每周${x}", + "calendarRepeatEveryNDays": "每 ${x} 天", + "calendarRepeatEveryNWeeksOn": "每 ${x} 周的${y}", + "calendarRepeatMonthlyOnDay": "每月 ${x} 日", + "calendarRepeatEveryNMonthsOnDay": "每 ${x} 个月的 ${y} 日", + "calendarRepeatEveryNMonths": "每 ${x} 个月", + "calendarRepeatAnnuallyOn": "每年 ${x}", + "calendarRepeatEveryNYearsOn": "每 ${x} 年的 ${y}", + "calendarRepeatEveryNYears": "每 ${x} 年", + "calendarRepeatUntilSuffix": ",截至 ${x}", + "calendarRepeatCountSuffix": ",共 ${x} 次", + "calendarRepeatEveryWeekday": "每个工作日(周一至周五)", + "calendarRepeatCustom": "自定义…", + "calendarMapLocationHint": "在日历设置中映射一个文本字段以保存此字段。", + "calendarMapRecurrenceHint": "在日历设置中映射一个文本字段以保存重复日程。", + "calendarRepeatEvery": "重复间隔", + "calendarDay": "天", + "calendarWeek": "周", + "calendarMonth": "月", + "calendarYear": "年", + "calendarRepeatOn": "重复于", + "calendarEnd": "结束", + "calendarStart": "开始", + "calendarNever": "永不", + "calendarOn": "日期", + "calendarAfter": "之后", + "calendarOccurrences": "次", "entrySeparator": "分隔线", "tableAddColumnTip": "点击在右侧插入一列
拖拽插入或删除多列", "tableAddRowTip": "点击在下方插入一行
拖拽插入或删除多行", @@ -2052,7 +2172,9 @@ "gallery": "卡片", "kanban": "看板", "key": "主键", - "select": "单选" + "select": "单选", + "calendar": "日历", + "date": "日期" }, "_kernel": { "0": "查询笔记本失败", @@ -2420,4 +2542,4 @@ "362": "发布服务账户用户名重复", "363": "发布服务账户密码长度不能少于 8 个字符" } - } +} diff --git a/app/appearance/langs/zh-TW.json b/app/appearance/langs/zh-TW.json index 8c2ec8c8e41..29aeef19450 100644 --- a/app/appearance/langs/zh-TW.json +++ b/app/appearance/langs/zh-TW.json @@ -1,4 +1,124 @@ { + "calendar": "行事曆", + "calendarRecurringAdvancedReadOnly": "進階重複規則將保留,不可在此處編輯。", + "calendarDuplicateMetadataField": "請為 ${x} 選擇不同的元資料欄位。", + "calendarNeedDateField": "日曆需要一個日期欄位", + "calendarCreateDateColumn": "未找到日期欄位。是否建立\"${x}\"欄位並切換至日曆檢視?", + "calendarSearch": "搜尋行事曆", + "calendarThisAndFuture": "這次之後", + "calendarCreateDateField": "建立日期欄位", + "calendarDeleteOccurrence": "刪除這次", + "calendarDeleteSeries": "全部刪除", + "calendarRecurrenceScopeSeries": "所有活動", + "calendarDaily": "每天", + "calendarWeekly": "每週", + "calendarMonthly": "每月", + "calendarYearly": "每年", + "calendarInterval": "間隔", + "calendarCount": "次數", + "calendarUntil": "直到", + "calendarWeekStart": "每週開始於", + "calendarWeekNumber": "第 ${x} 週", + "calendarRecurrence": "重複", + "calendarOccurrence": "重複事件實例", + "calendarDiscardChanges": "要放棄未儲存的行事曆更改嗎?", + "calendarSource": "來源筆記/區塊", + "calendarOpenSource": "開啟來源", + "calendarSourceMissing": "行事曆項目沒有來源區塊", + "calendarTimed": "定時", + "calendarEvents": "事件", + "calendarPreviousEvent": "上一個事件", + "calendarNextEvent": "下一個事件", + "calendarNoMatchingEvent": "沒有符合的事件", + "calendarEditSeriesNotice": "此操作會編輯整串重複事件。若要編輯單次事件,請映射例外欄位。", + "calendarExceptions": "例外", + "calendarLocation": "地點", + "calendarDescription": "描述", + "calendarSchedule": "排程", + "calendarEventRestored": "事件已還原。", + "calendarCreateFailed": "建立失敗。", + "calendarResizeFailed": "調整時長失敗。", + "calendarMoveFailed": "移動失敗。", + "calendarDeleteFailed": "刪除失敗。", + "calendarDuplicateFailed": "複製失敗。", + "calendarSaveFailed": "儲存失敗。", + "calendarDeleteRecurring": "刪除重複項目", + "calendarRecurrenceScopeOccurrenceDisabled": "請先映射例外欄位,才能只更改此項。", + "calendarRecurrenceScopeFutureDeleteDisabled": "暫不支援刪除此項及後續重複項目。", + "calendarRecurrenceScopeFutureDisabled": "請先映射重複規則欄位,才能更改此項及後續項目。", + "calendarRecurrenceScopeDeleteTitle": "刪除重複項目", + "calendarRecurrenceScopeEditTitle": "編輯重複項目", + "calendarRecurrenceScopeOccurrence": "僅此項", + "calendarRecurrenceScopeOccurrenceDesc": "只更改選中的這一項。", + "calendarRecurrenceScopeFutureDesc": "更改此項及此後的重複項目。", + "calendarRecurrenceScopeSeriesDesc": "更改整個重複系列。", + "calendarRecurrenceScopePrompt": "選擇此次更改的套用範圍。", + "calendarRecurrenceScopeRootOccurrenceDisabled": "此來源事件儲存整個重複系列,單次變更僅能從產生的重複項目進行。", + "calendarRecurrenceScopeRootFutureDisabled": "「此項目及之後」僅能從產生的重複項目進行。", + "newEvent": "新增事件", + "calendarEditEvent": "編輯行程", + "calendarStaleMapping": "欄位已遺失或型別不符", + "today": "今天", + "allDay": "全天", + "calendarAllDay": "全天", + "none": "無", + "saved": "已保存", + "dateField": "日期欄位", + "calendarEmptyHint": "目前沒有任何行事曆項目——點擊日期或時段即可建立第一筆。", + "calendarRenamePageFailed": "重新命名行程頁面失敗。", + "calendarDeletePageFailed": "刪除行程頁面失敗。", + "calendarDeleteEvent": "刪除行程", + "calendarDeleteEventAndDocument": "刪除行程和文件", + "calendarOccurrenceRollbackFailed": "該次重複項目已隱藏,但未建立替代項目。", + "calendarSplitRollbackFailed": "系列已截斷,但未建立後續系列。", + "calendarShortcuts": "鍵盤快速鍵", + "calendarShortcutsViews": "檢視", + "calendarShortcutsNavigation": "導覽", + "calendarShortcutsActions": "操作", + "calendarDayView": "日檢視", + "calendarFiveDayView": "5 天", + "calendarWeekView": "週檢視", + "calendarMonthView": "月檢視", + "calendarScheduleView": "排程檢視", + "calendarNextRange": "下一時段", + "calendarPreviousRange": "上一時段", + "calendarJumpToToday": "跳至今天", + "calendarCreateEvent": "新增事件", + "calendarFocusSearch": "搜尋", + "calendarShowShortcuts": "鍵盤快速鍵說明", + "calendarBackOut": "取消目前的操作,然後清除搜尋", + "calendarMiniMonth": "月份導覽", + "calendarPreviousMonth": "上個月", + "calendarNextMonth": "下個月", + "calendarRepeat": "重複", + "calendarDoesNotRepeat": "不重複", + "calendarRepeatWeeklyOn": "每週${x}", + "calendarRepeatEveryNDays": "每 ${x} 天", + "calendarRepeatEveryNWeeksOn": "每 ${x} 週的${y}", + "calendarRepeatMonthlyOnDay": "每月 ${x} 日", + "calendarRepeatEveryNMonthsOnDay": "每 ${x} 個月的 ${y} 日", + "calendarRepeatEveryNMonths": "每 ${x} 個月", + "calendarRepeatAnnuallyOn": "每年 ${x}", + "calendarRepeatEveryNYearsOn": "每 ${x} 年的 ${y}", + "calendarRepeatEveryNYears": "每 ${x} 年", + "calendarRepeatUntilSuffix": ",截至 ${x}", + "calendarRepeatCountSuffix": ",共 ${x} 次", + "calendarRepeatEveryWeekday": "每個工作日(週一至週五)", + "calendarRepeatCustom": "自訂…", + "calendarMapLocationHint": "在行事曆設定中對應文字欄位以儲存此欄位。", + "calendarMapRecurrenceHint": "在行事曆設定中對應文字欄位以儲存重複行程。", + "calendarRepeatEvery": "重複間隔", + "calendarDay": "天", + "calendarWeek": "週", + "calendarMonth": "月", + "calendarYear": "年", + "calendarRepeatOn": "重複於", + "calendarEnd": "結束", + "calendarStart": "開始", + "calendarNever": "永不", + "calendarOn": "日期", + "calendarAfter": "之後", + "calendarOccurrences": "次", "entrySeparator": "分隔線", "tableAddColumnTip": "點擊在右側插入一欄
拖曳插入或刪除多欄", "tableAddRowTip": "點擊在下方插入一列
拖曳插入或刪除多列", @@ -2052,7 +2172,9 @@ "gallery": "卡片", "kanban": "看板", "key": "主鍵", - "select": "單選" + "select": "單選", + "calendar": "行事曆", + "date": "日期" }, "_kernel": { "0": "查詢筆記本失敗", @@ -2420,4 +2542,4 @@ "362": "發佈服務帳戶用戶名重複", "363": "發佈服務帳戶密碼長度不能少於 8 個字元" } - } +} From f45d6a98bc38b4b862ce51294a7bca40c789d38c Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 19:23:02 +0200 Subject: [PATCH 05/10] :white_check_mark: Add calendar verification scripts https://github.com/siyuan-note/siyuan/issues/13740 --- scripts/calendar-audit.mjs | 1040 +++++++++ scripts/calendar-backend-contract-smoke.mjs | 1632 ++++++++++++++ .../calendar-electron-document-flow-smoke.mjs | 547 +++++ scripts/calendar-electron-launch-smoke.mjs | 1922 +++++++++++++++++ scripts/calendar-ics-import-smoke.mjs | 127 ++ scripts/calendar-imported-event-ux-smoke.mjs | 127 ++ scripts/calendar-kernel-smoke.mjs | 380 ++++ ...ar-quick-create-duplicate-submit-smoke.mjs | 190 ++ scripts/calendar-quick-create-smoke.mjs | 127 ++ scripts/calendar-recurrence-scope-smoke.mjs | 51 + scripts/calendar-recurrence-smoke.mjs | 249 +++ scripts/calendar-safe-edit-smoke.mjs | 39 + .../calendar-settings-add-property-smoke.mjs | 78 + scripts/calendar-source-link-smoke.mjs | 52 + scripts/calendar-time-grid-smoke.mjs | 312 +++ ...ar-transaction-feedback-behavior-smoke.mjs | 50 + .../calendar-transaction-feedback-smoke.mjs | 47 + scripts/calendar-transactions-smoke.mjs | 582 +++++ scripts/calendar-translation-smoke.mjs | 85 + 19 files changed, 7637 insertions(+) create mode 100644 scripts/calendar-audit.mjs create mode 100644 scripts/calendar-backend-contract-smoke.mjs create mode 100644 scripts/calendar-electron-document-flow-smoke.mjs create mode 100644 scripts/calendar-electron-launch-smoke.mjs create mode 100644 scripts/calendar-ics-import-smoke.mjs create mode 100644 scripts/calendar-imported-event-ux-smoke.mjs create mode 100644 scripts/calendar-kernel-smoke.mjs create mode 100644 scripts/calendar-quick-create-duplicate-submit-smoke.mjs create mode 100644 scripts/calendar-quick-create-smoke.mjs create mode 100644 scripts/calendar-recurrence-scope-smoke.mjs create mode 100644 scripts/calendar-recurrence-smoke.mjs create mode 100644 scripts/calendar-safe-edit-smoke.mjs create mode 100644 scripts/calendar-settings-add-property-smoke.mjs create mode 100644 scripts/calendar-source-link-smoke.mjs create mode 100644 scripts/calendar-time-grid-smoke.mjs create mode 100644 scripts/calendar-transaction-feedback-behavior-smoke.mjs create mode 100644 scripts/calendar-transaction-feedback-smoke.mjs create mode 100644 scripts/calendar-transactions-smoke.mjs create mode 100644 scripts/calendar-translation-smoke.mjs diff --git a/scripts/calendar-audit.mjs b/scripts/calendar-audit.mjs new file mode 100644 index 00000000000..fee05c81588 --- /dev/null +++ b/scripts/calendar-audit.mjs @@ -0,0 +1,1040 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import {fileURLToPath} from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(scriptDir, ".."); +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const exists = (file) => fs.existsSync(path.join(root, file)); +const fail = (message) => { + console.error(`calendar audit failed: ${message}`); + process.exit(1); +}; + +const requiredFiles = [ + "app/src/protyle/render/av/calendar/model.ts", + "app/src/protyle/render/av/calendar/normalize.ts", + "app/src/protyle/render/av/calendar/recurrence.ts", + "app/src/protyle/render/av/calendar/ics.ts", + "app/src/protyle/render/av/calendar/mapped-fields.ts", + "app/src/protyle/render/av/calendar/recurrence-storage.ts", + "app/src/protyle/render/av/calendar/transactions.ts", + "app/src/protyle/render/av/calendar/render.ts", + "app/src/protyle/render/av/calendar/event-dialog.ts", + "app/src/protyle/render/av/layout.ts", + // The Week/Day time grid moved out of render.ts (G1-G5). These files are part + // of the audited frontend surface so the terms migrated below keep covering + // real code instead of silently evaporating with the code that moved. + "app/src/protyle/render/av/calendar/time-geometry.ts", + "app/src/protyle/render/av/calendar/layout-overlap.ts", + "app/src/protyle/render/av/calendar/time-grid.ts", + "app/src/protyle/render/av/calendar/now-indicator.ts", + // The chip markup, the right-click menu, the key map, the mini month and the + // human-readable recurrence summary each moved into their own module. They are + // part of the audited frontend surface for the same reason the grid modules + // are: terms migrated out of render.ts have to keep covering real code. + "app/src/protyle/render/av/calendar/event-chip.ts", + "app/src/protyle/render/av/calendar/context-menu.ts", + "app/src/protyle/render/av/calendar/keymap.ts", + "app/src/protyle/render/av/calendar/mini-month.ts", + "app/src/protyle/render/av/calendar/recurrence-summary.ts", + "app/src/layout/dock/Calendar.ts", + "app/src/layout/dock/index.ts", + "app/src/layout/util.ts", + "scripts/calendar-kernel-smoke.mjs", + "scripts/calendar-recurrence-smoke.mjs", + "scripts/calendar-ics-import-smoke.mjs", + "scripts/calendar-transactions-smoke.mjs", + "scripts/calendar-electron-launch-smoke.mjs", + "scripts/calendar-electron-document-flow-smoke.mjs", +]; + +for (const file of requiredFiles) { + if (!exists(file)) { + fail(`missing required file ${file}`); + } +} + +const frontendCode = Object.fromEntries(requiredFiles.map((file) => [file, read(file)])); +for (const term of ["searchAttributeView", "renderAttributeView", "normalizeCalendarEvents", "LOCAL_CALENDAR_DOCK", "select-source"]) { + if (!frontendCode["app/src/layout/dock/Calendar.ts"].includes(term)) { + fail(`calendar dock missing ${term}`); + } +} +for (const [file, term] of [["app/src/layout/dock/index.ts", 'case "calendar"'], ["app/src/layout/util.ts", "ensureCalendarDock"], ["app/src/layout/dock/Calendar.ts", "setStorageVal"]]) { + if (!frontendCode[file].includes(term)) { + fail(`calendar dock registry missing ${term}`); + } +} +const joinedFrontendCode = requiredFiles + .filter((file) => file.startsWith("app/src/")) + .map((file) => frontendCode[file]) + .join("\n"); + +const expectedFeatureTerms = [ + "createCalendarEvent", + "updateCalendarEvent", + "deleteCalendarEvent", + "createCalendarEventReplacingOccurrence", + "updateCalendarEventThisAndFuture", + "deleteCalendarOccurrence", + "expandRecurrences", + "parseRecurrence", + "parseICSCalendar", + "calendar-import-ics", + "ensureCalendarRecurrenceStorage", + "calendar-search", + "calendar-filter", + "calendar-clear-search", + + "tabindex=\"0\"", + // Existing navigation/action shortcuts stay available while all six view + // shortcuts are advertised on the calendar region. + "\"ArrowLeft ArrowRight [ ] T N / Escape 1 2 3 4 5 6 D W M Y A X J K P C\"", + "role=\"region\"", + "calendar-view-menu", + "calendar-drop-day", + "dblclick", + "calendar-resize", + "calendar-duplicate-next-day", + "event-open-block", + "openEventBlock", + "getEventTooltip", + "data-days", + // was computeTimedEventColumns in render.ts, now the pure packer in layout-overlap.ts + "packTimedEventColumns", + "packAllDayLanes", + "getCalendarTimeGeometry", + "renderCalendarTimeGrid", + "mountCalendarNowIndicator", + "av__calendar-now-indicator", + "calendar-time-create", + "calendar-resize-handle", + "calendar-more", + "isTitleFallback", + "av__calendar-day--selected", +]; + +const missingFeatureTerms = expectedFeatureTerms.filter((term) => !joinedFrontendCode.includes(term)); +if (missingFeatureTerms.length > 0) { + fail(`missing feature terms: ${missingFeatureTerms.join(", ")}`); +} + +const languageCodeFiles = [ + "app/src/protyle/render/av/calendar/render.ts", + "app/src/protyle/render/av/calendar/event-dialog.ts", + // Everything that moved out of render.ts and still names calendar* keys. The + // keys have to keep existing in every bundled language, or the UI that moved + // would start showing English fallbacks in a translated build. + "app/src/protyle/render/av/calendar/event-chip.ts", + "app/src/protyle/render/av/calendar/context-menu.ts", + "app/src/protyle/render/av/calendar/keymap.ts", + "app/src/protyle/render/av/calendar/mini-month.ts", + "app/src/protyle/render/av/calendar/recurrence-summary.ts", + "app/src/protyle/render/av/calendar/quick-create.ts", + "app/src/protyle/render/av/layout.ts", +]; +const calendarLanguageKeys = new Set(); +for (const file of languageCodeFiles) { + const text = read(file); + // Two access shapes: the direct `window.siyuan.languages.calendarX` and the + // `lang("calendarX", "fallback")` helper the newer modules use. Only matching + // the first would let a whole module's keys go unchecked. + for (const match of text.matchAll(/window\.siyuan\.languages\.([A-Za-z0-9_]+)/g)) { + if (match[1].startsWith("calendar")) { + calendarLanguageKeys.add(match[1]); + } + } + for (const match of text.matchAll(/\blang\("([A-Za-z0-9_]+)"/g)) { + if (match[1].startsWith("calendar")) { + calendarLanguageKeys.add(match[1]); + } + } +} + +const langDir = path.join(root, "app/appearance/langs"); +for (const file of fs.readdirSync(langDir).filter((item) => item.endsWith(".json")).sort()) { + const langPath = path.join(langDir, file); + let data; + try { + data = JSON.parse(fs.readFileSync(langPath, "utf8")); + } catch (error) { + fail(`${file} is not valid JSON: ${error.message}`); + } + const missing = [...calendarLanguageKeys].filter((key) => !(key in data)); + if (missing.length > 0) { + fail(`${file} missing language keys: ${missing.join(", ")}`); + } + if (!data._attrView || data._attrView.calendar !== data.calendar) { + fail(`${file} missing _attrView.calendar backend layout label`); + } +} + +const renderEntry = read("app/src/protyle/render/av/render.ts"); +if (!renderEntry.includes("renderCalendar") || !renderEntry.includes('data.viewType === "calendar"')) { + fail("AV render entry does not dispatch calendar rendering"); +} + +const calendarRender = read("app/src/protyle/render/av/calendar/render.ts"); +if (/if \(sourceEvent\.isOccurrence\)\s*{\s*draft\.recurrenceRaw = ""/.test(calendarRender)) { + fail("quick-copy should always clear recurrence data, not only copied occurrences"); +} +for (const term of [ + "hasClosestByAttribute(options.blockElement, \"data-type\", \"NodeBlockQueryEmbed\")", + "hasClosestByAttribute(e, \"data-type\", \"NodeBlockQueryEmbed\")", + // draggable / av__calendar-event--readonly moved into event-chip.ts with the + // chip markup and are asserted against that file below (calendarEventChip). + "${editable ? \"\" : \" disabled\"}", + "window.siyuan.languages._kernel[258]", +]) { + if (!calendarRender.includes(term)) { + fail(`calendar render missing read-only/error guard term: ${term}`); + } +} + +for (const term of [ + "renderDateFieldSetup", + "calendar-empty-date-field", + "calendar-create-date-field", + "setAttrViewCalendarDateField", + "addAttrViewCol", + "renderMonth", + "renderWeek", + "renderDay", + "renderList", + "getAgendaRange", + "calendar-list-more", + "dataset.calendarAgendaDays", + "anchor.add(29, \"day\")", + // The week/day "today" markers moved into time-grid.ts with the grid itself; + // they are asserted against that file below (calendarTimeGrid). + "av__calendar-list-day${cursor.isSame(dayjs(), \"day\") ? \" av__calendar-day--today\" : \"\"}", + + "data-date=\"${cursor.format(\"YYYY-MM-DD\")}\" data-type=\"calendar-drop-day\"", + "getSafeViewMode", + "getCalendarViewMode", + "blockElement.dataset.calendarViewMode", + "getVisibleRange", + "getCalendarTitle", + "getEventSeekRange", + "seekEvent", + "normalizeCalendarEvents(calendar, mapping, getEventSeekRange(anchor)).events", + "event.start.isAfter(anchor, \"day\")", + "event.start.isBefore(anchor, \"day\")", + "showMessage(window.siyuan.languages.calendarNoMatchingEvent", + // getEventDateLabel / the tooltip attributes / the recurrence marker moved + // into event-chip.ts with the chip markup (calendarEventChip block below). + "setCalendarAnchor", + "getCurrentAnchor", + "setCalendarViewMode", + "options.blockElement.dataset.calendarViewMode = String(mode)", + "delete options.blockElement.dataset.calendarViewMode", + "data-type=\"calendar-view-menu\"", + "aria-haspopup=\"menu\"", + "CALENDAR_VIEW_MENU_ITEMS", + "accelerator: \"D\"", + "accelerator: \"W\"", + "accelerator: \"M\"", + "accelerator: \"Y\"", + "accelerator: \"A\"", + "accelerator: \"X\"", + "aria-keyshortcuts=\"ArrowLeft\"", + "aria-keyshortcuts=\"ArrowRight\"", + "aria-keyshortcuts=\"T\"", + "aria-keyshortcuts=\"N\"", + "aria-keyshortcuts=\"/\"", + "aria-keyshortcuts=\"Escape\"", + "aria-label=\"${escapeAttr(`${window.siyuan.languages.calendar || \"Calendar\"} ${title}`)}\"", + + // The keydown block itself moved into keymap.ts; what render.ts still owns is + // the binding and the handler wiring. Every key literal that used to be + // asserted here is asserted against keymap.ts below (calendarKeymap). + "bindCalendarKeymap(calendarElement, {", + "setViewMode: (mode: number) => setCalendarViewMode(mode)", + "goToRange: (direction) => setCalendarAnchor(getNavDate(getCurrentAnchor(), viewMode, direction))", + "goToToday: () => setCalendarAnchor(dayjs())", + "seekEvent: (direction) => seekEvent(direction)", + "escape: backOutOfCalendar", + "isCalendarGestureActive()", + "abortActiveCalendarGesture()", + "addCalendarTeardown", + "runCalendarTeardowns(options.blockElement)", + "eventMatchesSearch", + "getCalendarSearch", + "getCalendarFilter", + "eventMatchesCalendarFilter", + "const filteredEvents = normalized.events.filter(event => eventMatchesCalendarFilter(event, filter))", + "const totalEventCount = normalized.events.length", + "const hasActiveQuery = !!search || filter !== \"all\"", + "renderCalendarFilter(filter, searchFilterID)", + "data-type=\"calendar-search-dropdown\"", + "data-type=\"calendar-filter-option\"", + "role=\"menuitemradio\"", + "searchInput?.addEventListener(\"click\", openSearchDropdown)", + "setSearchDropdownOpen(false)", + "options.blockElement.dataset.calendarFilter = filter", + "delete options.blockElement.dataset.calendarFilter", + "av__calendar-search-count", + "const totalEventCount = normalized.events.length", + "delete options.blockElement.dataset.calendarSearch", + "getCalendarSearchResultRange", + "const hasSearchQuery = !!search || !!databaseQuery.trim()", + "hasSearchQuery ? renderList(range, events, true, editable)", + "window.setTimeout(() => rerender(true), Constants.TIMEOUT_INPUT)", + "calendarSearch", + "getSafeWeekStart", + "startOfCalendarWeek", + "weekStart", + "dragOffsetDays", + "deltaDays", + "displayDate", + "buildDraftForDate", + "duplicateEventToNextDay", + "draft.recurrenceRaw = \"\"", + "draft.recurrenceExceptionRaw = \"\"", + // The Copy label moved with the action into context-menu.ts (asserted below). + // Duplication must await whichever creation route the view selects. + "const saved = createsDocuments ?", + "createCalendarEventAsDocument({...createOptions", + "await createCalendarEvent(createOptions)", + "getEditableEvent", + "readOnly: true", + ".av__calendar-event, [data-type='calendar-new']", + // The chip's inline buttons became menu commands; render.ts must still route + // every one of them through the guarded write paths. + "renderCalendarEventChip({", + "bindCalendarEventContextMenu({", + "runCalendarMenuCommand", + "command.type === \"calendar-duplicate-next-day\"", + "command.type === \"calendar-resize\"", + "command.type === \"calendar-shift\"", + "command.type === \"calendar-delete\"", + "applyCalendarDurationChange", + "shiftCalendarEvent", + "requestCalendarEventDelete", + "deleteCalendarEventWithScope", + "getDisabledRecurrenceScopes(mapping, \"delete\", sourceEvent)", + "action: \"delete\",", + // The mini month stays bound and anchored on a click; event-day collection is + // reserved for the Year view, not rendered as dots in the left navigator. + "bindCalendarMiniMonth(", + "getCalendarMiniMonthEventDays(events)", + "onSelectDate: (date) => setCalendarAnchor(date)", + "calendar-mini-month-wrapper", + "av__calendar-sidebar", + "av__calendar-main", +]) { + if (!calendarRender.includes(term)) { + fail(`calendar render flow missing ${term}`); + } +} + +// --- Week/Day time grid (migrated out of render.ts) -------------------------- +// Everything here used to be asserted against render.ts; it moved wholesale into +// the four grid modules, so the assertions moved with it rather than being +// deleted. Grid *behaviour* is verified by scripts/calendar-time-grid-smoke.mjs. +const calendarTimeGeometry = read("app/src/protyle/render/av/calendar/time-geometry.ts"); +for (const term of [ + "CALENDAR_SNAP_MINUTES = 15", + "CALENDAR_MINIMUM_EVENT_MINUTES = 15", + "CALENDAR_BUSINESS_START_MINUTE", + "CALENDAR_BUSINESS_END_MINUTE", + "minutesToPx", + "pxToMinutes", + "snapMinutes", + "getNowOffsetPx", + "getEventMinuteRange", +]) { + if (!calendarTimeGeometry.includes(term)) { + fail(`calendar time geometry missing ${term}`); + } +} + +const calendarLayoutOverlap = read("app/src/protyle/render/av/calendar/layout-overlap.ts"); +for (const term of [ + "packTimedEventColumns", + "packAllDayLanes", + "CALENDAR_MINIMUM_EVENT_WIDTH_PERCENT", + "columnSpan", + "leftPercent", + "widthPercent", +]) { + if (!calendarLayoutOverlap.includes(term)) { + fail(`calendar overlap packing missing ${term}`); + } +} + +const calendarTimeGrid = read("app/src/protyle/render/av/calendar/time-grid.ts"); +for (const term of [ + "renderCalendarTimeGrid", + "av__calendar-time-grid", + "av__calendar-grid-header", + "av__calendar-allday-row", + "av__calendar-allday-bar", + "av__calendar-time-gutter", + "av__calendar-time-day", + "av__calendar-timed-event", + "av__calendar-day-header", + "data-type=\"calendar-drop-day\"", + "calendar-time-create", + "calendar-resize-handle", + "data-view-kind", + "data-start-minute", + // migrated from render.ts: the week/day "today" and read-only markers + "const isToday = day.isSame(dayjs(), \"day\")", + "isToday ? \"av__calendar-day--today\" : \"\"", + "isToday ? ' aria-current=\"date\"' : \"\"", + "options.editable ? \"\" : \" disabled\"", + // read-only calendars get no create surface at all + "options.editable ?", +]) { + if (!calendarTimeGrid.includes(term)) { + fail(`calendar time grid missing ${term}`); + } +} + +const calendarNowIndicator = read("app/src/protyle/render/av/calendar/now-indicator.ts"); +for (const term of [ + "mountCalendarNowIndicator", + "unmountCalendarNowIndicator", + "av__calendar-now-indicator", + "window.clearInterval", + "hasRestoredScroll", +]) { + if (!calendarNowIndicator.includes(term)) { + fail(`calendar now indicator missing ${term}`); + } +} + +// --- Chip anatomy (migrated out of render.ts's eventButtonHTML) -------------- +// The chip lost its permanent inline buttons. Everything else it carried has to +// survive byte-for-byte, because the whole app and the smoke suite read the +// calendar off these attributes. +const calendarInteractions = read("app/src/protyle/render/av/calendar/interactions.ts"); +for (const term of [ + "placeTimedEventPreview", + "restoreEventPreview", + 'if (gesture.kind === \"sweep\")', + "placeGhost(gesture, \"timed\", rect, label)", + "placeTimedEventPreview(gesture, rect)", +]) { + if (!calendarInteractions.includes(term)) { + fail(`calendar interaction preview missing ${term}`); + } +} + +const calendarEventChip = read("app/src/protyle/render/av/calendar/event-chip.ts"); +for (const term of [ + "renderCalendarEventChip", + "CalendarChipVariant", + "\"month\", \"list\", \"timed\", \"all-day\"", + "getEventTooltip", + "getEventDateLabel", + "title=\"${escapeAttr(eventTooltip)}\"", + "aria-label=\"${escapeAttr(eventTooltip)}\"", + "window.siyuan.languages.calendarOccurrence || \"Recurring occurrence\"", + "draggable=\"${editable ? \"true\" : \"false\"}\"", + "av__calendar-event--readonly", + "av__calendar-event--page", + "av__calendar-event-title", + "av__calendar-event-time", + "av__calendar-event-meta", + "data-duration-minutes", + "buildOptimisticChip", + "av__calendar-event--pending", + "buildCalendarGhost", +]) { + if (!calendarEventChip.includes(term)) { + fail(`calendar event chip missing ${term}`); + } +} +// The chip must NOT grow permanent inline controls again: that is the defect +// this module exists to fix. +for (const term of [">-15m<", ">+15m<", ">-1d<", ">+1d<", "av__calendar-resize\" data-type"]) { + if (calendarEventChip.includes(term)) { + fail(`calendar event chip must no longer carry inline text buttons: ${term}`); + } +} +for (const term of ["av__calendar-schedule", "calendar-open-dialog"]) { + if (calendarEventChip.includes(term)) { + fail(`calendar event chip must hide permanent action affordances: ${term}`); + } +} +for (const term of ["const recurrenceMarker", 'event.isOccurrence ? "O" : "R"', "av__calendar-recurring"]) { + if (calendarEventChip.includes(term)) { + fail(`calendar event chip must not expose internal recurrence tags: ${term}`); + } +} +if (calendarEventChip.includes("av__calendar-event-dot")) { + fail("calendar event chip must not render a leading colour dot beside event names"); +} + +// --- Chip context menu (where those inline buttons went) --------------------- +const calendarContextMenu = read("app/src/protyle/render/av/calendar/context-menu.ts"); +for (const term of [ + "bindCalendarEventContextMenu", + "openCalendarEventMenu", + "closeCalendarEventMenu", + "av__calendar-menu", + "setAttribute(\"role\", \"menu\")", + "role=\"menuitem\"", + // Same data-type contract the inline buttons used, so the renderer's handlers + // and the assertions that drive them keep working from the new place. + "data-type=\"calendar-open-source\"", + "data-type=\"calendar-open-dialog\"", + "data-type=\"calendar-duplicate-next-day\"", + "data-type=\"calendar-resize\" data-days=\"-1\"", + "data-type=\"calendar-resize\" data-days=\"1\"", + "data-type=\"calendar-resize\" data-delta=\"-15\"", + "data-type=\"calendar-resize\" data-delta=\"15\"", + "data-type=\"calendar-shift\"", + "data-type=\"calendar-delete\"", + "window.siyuan.languages.copy || \"Copy\"", + "window.siyuan.languages.delete || \"Delete\"", + // Read-only / query-embed calendars never get a menu at all. + "if (!calendarElement || !options.editable)", + "contextmenu", + "LONG_PRESS_MS", + "abortActiveCalendarGesture()", + "event.key === \"Escape\"", +]) { + if (!calendarContextMenu.includes(term)) { + fail(`calendar context menu missing ${term}`); + } +} + +// --- Key map (migrated out of render.ts's keydown block) --------------------- +// Every key literal below used to be asserted against render.ts. The block moved +// wholesale into keymap.ts, so the assertions moved with it instead of being +// deleted; the Google keys are additions on top. +const calendarKeymap = read("app/src/protyle/render/av/calendar/keymap.ts"); +for (const term of [ + "bindCalendarKeymap", + "resolveCalendarCommand", + "shouldIgnoreCalendarKey", + "element.addEventListener(\"keydown\"", + "element.removeEventListener(\"keydown\"", + "event.key === \"ArrowLeft\"", + "event.key === \"ArrowRight\"", + "event.key === \"[\"", + "event.key === \"]\"", + "event.key.toLowerCase() === \"t\"", + "event.key.toLowerCase() === \"n\"", + "event.key === \"/\"", + "event.key === \"Escape\"", + "/^[1-6]$/.test(event.key)", + "CALENDAR_VIEW_MODE_BY_COMMAND[command]", + // Google Calendar's map, added alongside the legacy one. + "event.key.toLowerCase() === \"d\"", + "event.key.toLowerCase() === \"w\"", + "event.key.toLowerCase() === \"m\"", + "event.key.toLowerCase() === \"x\"", + "event.key.toLowerCase() === \"j\"", + "event.key.toLowerCase() === \"c\"", + "CALENDAR_ARIA_KEYSHORTCUTS", + "\"ArrowLeft ArrowRight [ ] T N / Escape 1 2 3 4 5 6 D W M Y A X J K P C\"", + // The focus-scope fix: BUTTON is gone from the bail list, so a click no longer + // kills every shortcut. + "[\"INPUT\", \"SELECT\", \"TEXTAREA\"].includes(element.tagName)", + "isContentEditable", + "isCalendarModalOpen", + +]) { + if (!calendarKeymap.includes(term)) { + fail(`calendar keymap missing ${term}`); + } +} +if (calendarKeymap.includes("\"BUTTON\"")) { + fail("calendar keymap must not bail on BUTTON again: every calendar control is a button"); +} + +// --- Mini month navigator ---------------------------------------------------- +const calendarMiniMonth = read("app/src/protyle/render/av/calendar/mini-month.ts"); +for (const term of [ + "renderCalendarMiniMonth", + "bindCalendarMiniMonth", + "getCalendarMiniMonthEventDays", + "getMiniMonthDays", + "av__calendar-mini", + "av__calendar-mini-header", + "av__calendar-mini-title", + "av__calendar-mini-weekdays", + "av__calendar-mini-grid", + "av__calendar-mini-day", + "av__calendar-mini-day--outside", + "av__calendar-mini-day--in-range", + "av__calendar-mini-day--selected", + "av__calendar-mini-day--today", + "av__calendar-mini-day-number", + "calendar-mini-prev", + "calendar-mini-next", + "calendar-mini-day", + // Paging repaints only the navigator; only a day click moves the main view. + "handlers.onSelectDate(dayjs(target.dataset.date))", + "container.removeEventListener(\"click\", onClick)", +]) { + if (!calendarMiniMonth.includes(term)) { + fail(`calendar mini month missing ${term}`); + } +} + +// --- Human-readable recurrence summary (wired into the event dialog) --------- +const calendarRecurrenceSummary = read("app/src/protyle/render/av/calendar/recurrence-summary.ts"); +for (const term of [ + "describeRecurrence", + "detectRecurrencePreset", + "getRecurrencePresetRule", + "renderRecurrencePresetOptions", + "isAdvancedRecurrence", + "calendarDoesNotRepeat", + "calendarRepeatWeeklyOn", + "calendarRepeatUntilSuffix", + "calendarRepeatCountSuffix", +]) { + if (!calendarRecurrenceSummary.includes(term)) { + fail(`calendar recurrence summary missing ${term}`); + } +} +for (const term of ["describeRecurrence", "renderRecurrencePresetOptions", "detectRecurrencePreset"]) { + if (!read("app/src/protyle/render/av/calendar/event-dialog.ts").includes(term)) { + fail(`event dialog must use the human-readable recurrence summary: ${term}`); + } +} + +const eventDialog = read("app/src/protyle/render/av/calendar/event-dialog.ts"); +if (/recurrenceRaw:\s*options\.event\?\.isOccurrence\s*\?/.test(eventDialog)) { + fail("dialog duplicate should always clear recurrence data"); +} +for (const term of [ + "showInvalidDraftMessage", + "window.siyuan.languages.calendarNeedDateField", + "window.siyuan.languages.invalid", + "recurrenceRaw: \"\"", + "recurrenceExceptionRaw: \"\"", + "const editsSeries = !!event?.isOccurrence && !mapping.exceptionFieldID", + "window.siyuan.languages.calendarEditSeriesNotice", + "event?.blockID ? `'), + "the add-property (newCol) button must be suppressed for calendar views"); + +// 2. Every caller must thread the view type; no bare call may remain. +const threadedCalls = openMenuPanel.split("getPropertiesHTML(fields, data.viewType)").length - 1; +assert(threadedCalls === 7, `all 7 openMenuPanel call sites must pass data.viewType (found ${threadedCalls})`); +assert(!openMenuPanel.includes("getPropertiesHTML(fields)"), + "no openMenuPanel call site may render the properties panel without the view type"); +assert(col.includes("getPropertiesHTML(options.fields, options.viewType)"), + "removeCol must re-render the properties panel with the view type"); +assert(!col.includes("getPropertiesHTML(options.fields)"), + "removeCol may not re-render the properties panel without the view type"); + +// 3. The add-property action path must be unreachable from calendar settings. +assert(openMenuPanel.includes('type === "newCol" && data.viewType !== "calendar"'), + "the newCol action handler must refuse calendar views"); +assert(openMenuPanel.split("viewType: data.viewType,").length - 1 === 3, + "all removeCol call sites must pass the active view type"); + +// 4. Existing property selection/mapping must be preserved: the settings +// Fields entry, the field rows and the show/hide controls stay for every +// view type, calendar included. +assert(view.includes('data-type="go-properties"'), + "view settings must keep the Fields (properties) entry"); +assert(openMenuPanel.includes('data-type="editCol"'), + "field rows must stay editable in the properties panel"); +assert(openMenuPanel.includes('data-type="showAllCol"') && openMenuPanel.includes('data-type="hideAllCol"'), + "show-all / hide-all controls must stay in the properties panel"); + +// 5. Non-calendar property creation must be unchanged. +assert(openMenuPanel.includes('data-type="newCol"'), + "the add-property button must still be rendered for non-calendar view types"); +assert(col.includes("export const addCol"), + "the add-column menu must stay available for non-calendar views"); +assert(avRender.includes('data-type="av-header-add"'), + "the table header add-column button must stay"); +assert(action.includes('type === "av-header-add"'), + "the table header add-column handler must stay"); +assert(blockAttr.includes('data-type="addColumn"'), + "the block attribute panel add-property button must stay"); + +console.log("calendar settings add-property smoke: ok"); diff --git a/scripts/calendar-source-link-smoke.mjs b/scripts/calendar-source-link-smoke.mjs new file mode 100644 index 00000000000..dcabecba59c --- /dev/null +++ b/scripts/calendar-source-link-smoke.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import fs from 'node:fs'; + +const render = fs.readFileSync('app/src/protyle/render/av/calendar/render.ts', 'utf8'); +// The chip markup moved out of render.ts into event-chip.ts; the assertions that +// covered it moved with it rather than being dropped. +const eventChip = fs.readFileSync('app/src/protyle/render/av/calendar/event-chip.ts', 'utf8'); +const contextMenu = fs.readFileSync('app/src/protyle/render/av/calendar/context-menu.ts', 'utf8'); +const dialog = fs.readFileSync('app/src/protyle/render/av/calendar/event-dialog.ts', 'utf8'); +const normalize = fs.readFileSync('app/src/protyle/render/av/calendar/normalize.ts', 'utf8'); +const layout = fs.readFileSync('app/src/protyle/render/av/layout.ts', 'utf8'); +const scss = fs.readFileSync('app/src/assets/scss/business/_av.scss', 'utf8'); + +const checks = [ + [normalize.includes('sourceCard: card'), 'normalize keeps sourceCard on events'], + // The bound document id is derived from the block VALUE id, never from + // value.isDetached (omitempty, so absent on bound rows) - see getBoundBlockID. + [normalize.includes('blockID: getBoundBlockID(card)'), 'normalize keeps the bound document id on events'], + [!eventChip.includes('calendar-open-source') && !eventChip.includes('>↗'), 'event chips omit the redundant inline open-source affordance'], + [render.includes('openCalendarEventSource'), 'renderer can open source from a bound Termin'], + [render.includes('eventElement.addEventListener("click"') && render.includes('openEventSchedulingFor(calendarEvent)'), 'single Termin activation opens scheduling preview'], + [render.includes('eventElement.addEventListener("dblclick"') && /if \(calendarEvent && getEventDocumentID\(calendarEvent\)\) \{\s*\n\s*openCalendarEventSource/.test(render), 'double Termin activation opens the bound page'], + [dialog.includes('calendarSource') && dialog.includes('event-source'), 'dialog shows explicit source note/block context'], + [dialog.includes('event-open-block') && dialog.includes('calendarOpenSource') && dialog.includes('av__calendar-event-source'), 'dialog source row is the obvious Open source action'], + [scss.includes('&-source') && scss.includes('&-event-source'), 'dialog source note remains styled in calendar SCSS'], + + // Page-per-entry: a BOUND chip opens its page on double click through + // upstream's openDatabaseRowByData. Plain activation opens scheduling preview. + [render.includes('import {openDatabaseRowByData} from "../openDatabaseRow";'), 'calendar opens pages through the shared database-row opener'], + [render.includes('openDatabaseRowByData(protyle, {') && render.includes('boundBlockID: documentID') && render.includes('isDetached: false'), 'bound entries open as a real database row/page'], + [render.includes('eventOpenTimers') && render.includes('openEventSchedulingFor(calendarEvent)'), 'single-click scheduling is separated from double-click page opening'], + [!eventChip.includes('av__calendar-schedule') && !eventChip.includes('calendar-open-dialog'), 'event chips hide permanent action affordances'], + // The chip lost its inline buttons; the right-click menu is where those actions + // went, and it must still reach the page and the scheduling dialog by name. + [contextMenu.includes('data-type="calendar-open-source"') && contextMenu.includes('data-type="calendar-open-dialog"'), 'context menu keeps the open-page and scheduling entry points'], + [render.includes('command.type === "calendar-open-source"') && render.includes('command.type === "calendar-open-dialog"'), 'renderer routes the menu open-page/scheduling commands'], + [!scss.includes('&-schedule {'), 'retired scheduling affordance has no stale calendar SCSS'], + [!/openFileById\(/.test(render) && !/openMobileFileById\(/.test(render), 'calendar no longer bypasses the database-row opener with a bare openFileById'], + + // The per-view "new entries" target that decides whether a page exists at all. + [layout.includes('data-type="calendar-new-item-target"'), 'layout panel exposes the new-entry target'], + [layout.includes('setAttrViewCalendarNewItemTarget') && /action: "setAttrViewCalendarNewItemTarget",[\s\S]{0,120}viewID/.test(layout), 'new-entry target setter is emitted per view'], + [scss.includes('&-config-row') && scss.includes('&-new-item-target'), 'new-entry config row styled in calendar SCSS'], +]; + +const failed = checks.filter(([ok]) => !ok); +if (failed.length > 0) { + console.error('calendar source-link smoke failed:'); + for (const [, message] of failed) console.error(`- ${message}`); + process.exit(1); +} +console.log(`calendar source-link smoke passed: ${checks.length} source-link checks`); diff --git a/scripts/calendar-time-grid-smoke.mjs b/scripts/calendar-time-grid-smoke.mjs new file mode 100644 index 00000000000..3002222768b --- /dev/null +++ b/scripts/calendar-time-grid-smoke.mjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node +/** + * Time-grid smoke. + * + * Two halves: + * 1. a grep smoke over the modules that now own the grid (time-geometry.ts, + * layout-overlap.ts, time-grid.ts, now-indicator.ts, render.ts, _av.scss), + * including NEGATIVE assertions so the 30-minute row snapping cannot come + * back into render.ts; + * 2. a real behavioural check of the two pure modules - they have no imports + * and no DOM, so they can just be transpiled and executed. Grep alone + * cannot tell you that a 12:45-13:20 event lands on 12:45, or that a column + * actually expands into free space. + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import {createRequire} from "node:module"; +import {fileURLToPath} from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(scriptDir, ".."); +const appDir = path.join(root, "app"); +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const fail = (message) => { + console.error(`calendar time-grid smoke failed: ${message}`); + process.exit(1); +}; + +const calendarDir = "app/src/protyle/render/av/calendar"; +const geometrySource = read(`${calendarDir}/time-geometry.ts`); +const overlapSource = read(`${calendarDir}/layout-overlap.ts`); +const timeGrid = read(`${calendarDir}/time-grid.ts`); +const nowIndicator = read(`${calendarDir}/now-indicator.ts`); +const render = read(`${calendarDir}/render.ts`); +const scss = read("app/src/assets/scss/business/_av.scss"); + +const requireTerms = (label, text, terms) => { + for (const term of terms) { + if (!text.includes(term)) { + fail(`${label} missing ${term}`); + } + } +}; + +const rejectTerms = (label, text, terms) => { + for (const term of terms) { + if (text.includes(term)) { + fail(`${label} must no longer contain ${term}`); + } + } +}; + +// --- G1: time-geometry.ts is the single source of minute<->pixel truth -------- +requireTerms("time-geometry.ts", geometrySource, [ + "export const CALENDAR_SNAP_MINUTES = 15", + "export const CALENDAR_MINIMUM_EVENT_MINUTES = 15", + "export const CALENDAR_HOUR_HEIGHT_PX = 48", + "export const CALENDAR_RULING_MINUTES = 30", + "export const CALENDAR_BUSINESS_START_MINUTE = 9 * CALENDAR_MINUTES_PER_HOUR", + "export const CALENDAR_BUSINESS_END_MINUTE = 18 * CALENDAR_MINUTES_PER_HOUR", + "export const getCalendarTimeGeometry", + "export const minutesToPx", + "export const pxToMinutes", + "export const snapMinutes", + "export const getNowOffsetPx", + "export const getEventMinuteRange", + "export const getBusinessHoursPercent", + "export const getCenteredScrollTopPx", + "dayStartMinute", + "dayEndMinute", +]); +// Pure by contract: a DOM reference here would mean the maths had leaked back +// into the renderer's world and could no longer be tested on its own. +rejectTerms("time-geometry.ts", geometrySource, ["document.", "window.", "dayjs"]); + +// --- G2: overlap packing, in exact minutes ----------------------------------- +requireTerms("layout-overlap.ts", overlapSource, [ + "export const packTimedEventColumns", + "export const packAllDayLanes", + "export const CALENDAR_MINIMUM_EVENT_WIDTH_PERCENT = 20", + "startMinute", + "endMinute", + "columnSpan", + "leftPercent", + "widthPercent", + "laneCount", + "spanCount", +]); +rejectTerms("layout-overlap.ts", overlapSource, ["document.", "window.", "grid-row", "dayjs"]); + +// --- G3/G4/G6: the grid module owns the markup ------------------------------- +requireTerms("time-grid.ts", timeGrid, [ + "av__calendar-time-grid", + "av__calendar-grid-header", + "av__calendar-allday-row", + "av__calendar-allday-lanes", + "av__calendar-allday-bar", + "av__calendar-time-gutter", + "av__calendar-time-columns", + "av__calendar-time-day", + "av__calendar-timed-event", + "av__calendar-day-header", + "data-view-kind", + "data-day-count", + "data-first-date", + "data-snap-minutes", + "data-start-minute", + "data-end-minute", + "data-day-index", + "data-type=\"calendar-drop-day\"", + "calendar-time-create", + "calendar-resize-handle", + "av__calendar-time-day--weekend", + // Pixel-exact positioning: no grid rows, only real offsets. + "top:${range.topPx}px;height:${range.heightPx}px;left:${box.leftPercent}%;width:${box.widthPercent}%", + "--calendar-hour-height:", + "--calendar-day-height:", + "--calendar-business-start:", + "--calendar-allday-lane-count:", + "packTimedEventColumns", + "packAllDayLanes", + "getEventMinuteRange", + "options.editable ?", +]); +// Lanes legitimately use grid-row (an all-day bar's stack position); minutes +// must never reach a grid row again. +rejectTerms("time-grid.ts", timeGrid, ["grid-row:${range", "grid-template-rows: repeat(48", "SLOT_MINUTES", "calendar-time-slot"]); + +// --- G5: the now line ticks and cleans up ------------------------------------ +requireTerms("now-indicator.ts", nowIndicator, [ + "av__calendar-now-indicator", + "av__calendar-now-dot", + "av__calendar-now-line", + "export const mountCalendarNowIndicator", + "export const unmountCalendarNowIndicator", + "window.setInterval", + "window.clearInterval", + "hasRestoredScroll", + "CALENDAR_FALLBACK_SCROLL_MINUTE", + "Math.min(...starts) - 60", + ".av__calendar-timed-event[data-start-minute]", + "unmountCalendarNowIndicator(options.blockElement)", + "teardowns.set(options.blockElement, teardown)", +]); + +// --- render.ts delegates, and can never snap to 30 minutes again ------------- +requireTerms("render.ts", render, [ + "renderCalendarTimeGrid", + "getCalendarTimeGeometry", + "mountCalendarNowIndicator", + "unmountCalendarNowIndicator", + "CALENDAR_TIME_CREATE_TYPE", + "snapMinutes(", + "offsetPxToMinute", + "getEventMinuteRange", + "hadTimeGrid: !!timeGridElement", + "hasRestoredScroll: resetData.hadTimeGrid", + "viewKind", +]); +rejectTerms("render.ts", render, [ + // The negative assertion the rebuild exists for. + "SLOT_MINUTES", + "buildTimeSlots", + "formatSlotLabel", + "calendar-time-slot", + "computeTimedEventColumns", + "clusterColumns", + "getTimedEventGridRange", + "grid-row:", + "grid-template-rows", + "av__calendar-week-headers", +]); + +// --- styles ------------------------------------------------------------------- +requireTerms("_av.scss", scss, [ + "&-time-grid", + "--calendar-grid-columns", + "&-grid-header", + "&-allday-row", + "&-allday-lanes", + "&-time-gutter", + "&-time-columns", + "&-time-day", + "&-time-create", + "&-timed-event", + "&-resize-handle", + "&-now-indicator", + "&-day-header", + "position: sticky", + "top: var(--calendar-header-height, 34px)", + "--calendar-business-start", + "--calendar-weekend-tint", + "var(--calendar-ruling-height, 24px)", + "overscroll-behavior: contain", + "height: clamp(420px, calc(100vh - 230px), 760px)", +]); +rejectTerms("_av.scss", scss, [ + "grid-template-rows: repeat(48, minmax(24px, auto))", + "grid-template-columns: 56px repeat(var(--calendar-day-count), minmax(104px, 1fr))", + "&-time-slot ", +]); + +// ----------------------------------------------------------------------------- +// Behavioural half: the two pure modules, actually executed. +// ----------------------------------------------------------------------------- +const requireFromApp = createRequire(path.join(appDir, "package.json")); +const ts = requireFromApp("typescript"); +const loadPureModule = (source, fileName) => { + const transpiled = ts.transpileModule(source, { + compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020}, + fileName, + }); + const module = {exports: {}}; + // eslint-disable-next-line no-new-func + new Function("exports", "module", transpiled.outputText)(module.exports, module); + return module.exports; +}; + +const geometry = loadPureModule(geometrySource, "time-geometry.ts"); +const overlap = loadPureModule(overlapSource, "layout-overlap.ts"); + +const assert = (condition, message) => { + if (!condition) { + fail(message); + } +}; + +const grid = geometry.getCalendarTimeGeometry(7); +assert(grid.snapMinutes === 15, `snap is not 15 minutes: ${grid.snapMinutes}`); +assert(geometry.minutesToPx(60, grid) === 48, "one hour is not one hour height"); +assert(geometry.pxToMinutes(48, grid) === 60, "pxToMinutes is not the inverse of minutesToPx"); +assert(geometry.snapMinutes(767, grid) === 765, `12:47 must snap to 12:45, got ${geometry.snapMinutes(767, grid)}`); +assert(geometry.snapMinutes(773, grid) === 780, `12:53 must snap to 13:00, got ${geometry.snapMinutes(773, grid)}`); +assert(geometry.snapMinutesDown(773, grid) === 765 && geometry.snapMinutesUp(766, grid) === 780, + "directional snapping (sweep edges) is wrong"); + +// The headline defect: 12:45-13:20 used to draw at 12:30-13:30. +const pixelExact = geometry.getEventMinuteRange(12 * 60 + 45, 13 * 60 + 20, grid); +assert(pixelExact.startMinute === 765, `12:45 event starts at minute ${pixelExact.startMinute}`); +assert(pixelExact.endMinute === 800, `12:45-13:20 event ends at minute ${pixelExact.endMinute}`); +assert(Math.abs(pixelExact.topPx - 612) < 0.001, `12:45 must draw at 612px, got ${pixelExact.topPx}`); +assert(Math.abs(pixelExact.heightPx - 28) < 0.001, `35 minutes must be 28px tall, got ${pixelExact.heightPx}`); + +// A 5 minute event is still tall enough to hit. +const tiny = geometry.getEventMinuteRange(600, 605, grid); +assert(tiny.heightPx >= geometry.minutesToPx(15, grid), "minimum event height is not enforced"); + +const nowOffset = geometry.getNowOffsetPx(new Date(2026, 0, 1, 9, 30, 0), grid); +assert(Math.abs(nowOffset - 456) < 0.001, `09:30 now line must be at 456px, got ${nowOffset}`); + +const business = geometry.getBusinessHoursPercent(grid); +assert(business.startPercent === 37.5 && business.endPercent === 75, + `business hours must be 09:00-18:00, got ${business.startPercent}-${business.endPercent}`); + +// A cluster must CLOSE when a gap appears: the 14:00 event is on its own. +const packed = overlap.packTimedEventColumns([ + {key: "a", startMinute: 540, endMinute: 600}, + {key: "b", startMinute: 570, endMinute: 630}, + {key: "c", startMinute: 840, endMinute: 900}, +]); +const byKey = Object.fromEntries(packed.map((box) => [box.key, box])); +assert(byKey.a.widthPercent === 50 && byKey.a.leftPercent === 0, `overlapping a: ${JSON.stringify(byKey.a)}`); +assert(byKey.b.widthPercent === 50 && byKey.b.leftPercent === 50, `overlapping b: ${JSON.stringify(byKey.b)}`); +assert(byKey.c.widthPercent === 100 && byKey.c.columnCount === 1, + `a gap must close the cluster, got ${JSON.stringify(byKey.c)}`); + +// A column must EXPAND into free space instead of every member taking 1/N. +// "long" 09:00-12:00 forces three columns in the morning; "late" 10:30-11:00 +// sits in column 1 and overlaps nothing in column 2, so it must absorb it +// (2/3 wide) instead of being pinned to the old 1/3. +const expanding = overlap.packTimedEventColumns([ + {key: "long", startMinute: 540, endMinute: 720}, + {key: "morningA", startMinute: 540, endMinute: 600}, + {key: "morningB", startMinute: 540, endMinute: 600}, + {key: "late", startMinute: 630, endMinute: 660}, +]); +const expandingByKey = Object.fromEntries(expanding.map((box) => [box.key, box])); +const near = (value, expected) => Math.abs(value - expected) < 0.01; +assert(expandingByKey.long.columnCount === 3, `expected a 3 column cluster, got ${JSON.stringify(expandingByKey.long)}`); +assert(expandingByKey.late.columnSpan === 2 && near(expandingByKey.late.widthPercent, 66.667) && + near(expandingByKey.late.leftPercent, 33.333), + `a free column to the right must be absorbed, got ${JSON.stringify(expandingByKey.late)}`); +assert(near(expandingByKey.long.widthPercent, 33.333) && expandingByKey.long.leftPercent === 0, + `the fully overlapped event must stay 1/3 wide, got ${JSON.stringify(expandingByKey.long)}`); + +// A five-way overlap stays clickable: nothing narrower than the minimum. +const fiveWay = overlap.packTimedEventColumns( + [0, 1, 2, 3, 4].map((index) => ({key: `n${index}`, startMinute: 540 + index, endMinute: 600}))); +assert(fiveWay.every((box) => box.widthPercent >= overlap.CALENDAR_MINIMUM_EVENT_WIDTH_PERCENT), + "a five-way overlap fell below the minimum width"); +assert(fiveWay.every((box) => box.leftPercent + box.widthPercent <= 100.001), + "an overlap box overflowed its day column"); +const eightWay = overlap.packTimedEventColumns( + [0, 1, 2, 3, 4, 5, 6, 7].map((index) => ({key: `m${index}`, startMinute: 540 + index, endMinute: 600}))); +assert(eightWay.every((box) => box.widthPercent >= overlap.CALENDAR_MINIMUM_EVENT_WIDTH_PERCENT), + "an eight-way overlap fell below the minimum width"); + +// A Tue-Thu all-day event is ONE bar spanning three columns. +const lanes = overlap.packAllDayLanes([ + {key: "tue-thu", startIndex: 2, endIndex: 4}, + {key: "wed", startIndex: 3, endIndex: 3}, + {key: "fri", startIndex: 5, endIndex: 5}, +]); +const laneByKey = Object.fromEntries(lanes.bars.map((bar) => [bar.key, bar])); +assert(laneByKey["tue-thu"].spanCount === 3 && laneByKey["tue-thu"].lane === 0, + `multi-day all-day event must be one 3-column bar, got ${JSON.stringify(laneByKey["tue-thu"])}`); +assert(laneByKey.wed.lane === 1, `an overlapping all-day bar must stack, got ${JSON.stringify(laneByKey.wed)}`); +assert(laneByKey.fri.lane === 0, `a non-overlapping all-day bar must reuse lane 0, got ${JSON.stringify(laneByKey.fri)}`); +assert(lanes.laneCount === 2, `lane count should be 2, got ${lanes.laneCount}`); + +console.log("calendar time-grid smoke passed"); diff --git a/scripts/calendar-transaction-feedback-behavior-smoke.mjs b/scripts/calendar-transaction-feedback-behavior-smoke.mjs new file mode 100644 index 00000000000..40bd2baec13 --- /dev/null +++ b/scripts/calendar-transaction-feedback-behavior-smoke.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +const assertIncludes = (content, needle, label) => { + if (!content.includes(needle)) { + throw new Error(`${label}: missing ${needle}`); + } +}; +const assertRegex = (content, regex, label) => { + if (!regex.test(content)) { + throw new Error(`${label}: missing pattern ${regex}`); + } +}; +const assertNotRegex = (content, regex, label) => { + if (regex.test(content)) { + throw new Error(`${label}: forbidden pattern ${regex}`); + } +}; + +const transactions = read('app/src/protyle/render/av/calendar/transactions.ts'); +const render = read('app/src/protyle/render/av/calendar/render.ts'); +const dialog = read('app/src/protyle/render/av/calendar/event-dialog.ts'); + +assertIncludes(transactions, 'fetchSyncPost("/api/transactions"', 'calendar transaction awaits backend'); +assertIncludes(transactions, 'executeCalendarOperations', 'shared transaction executor'); +assertRegex(transactions, /const response = await fetchSyncPost\("\/api\/transactions"[\s\S]*if \(response\?\.code !== 0\)[\s\S]*return true;/, 'backend success result drives truth'); +assertNotRegex(transactions, /transaction\(options\.protyle[\s\S]*return true;/, 'calendar helpers must not claim sync transaction success'); +assertRegex(transactions, /export const createCalendarEvent = async/, 'create helper async'); +assertRegex(transactions, /export const updateCalendarEvent = async/, 'update helper async'); +assertRegex(transactions, /export const deleteCalendarEvent = async/, 'delete helper async'); + +assertRegex(render, /withCalendarOperationFeedback = async[\s\S]*await callback\(\)/, 'render feedback awaits operation'); +assertRegex(render, /const saved = await \(scope === \"occurrence\"[\s\S]*updateCalendarEvent/, 'drag resize/move await backend truth'); +assertRegex(render, /const saved = createsDocuments \?[\s\S]*createCalendarEventAsDocument[\s\S]*await createCalendarEvent\(createOptions\)/, 'create/duplicate awaits the selected backend path'); +assertRegex(render, /const draggedEventElement = calendarElement\?\.querySelector\(`\.av__calendar-event\[data-(?:occurrence|id)=/, 'drop finds dragged event element'); +assertRegex(render, /applyScopedEventDraft\(sourceEvent, \(target\) => \{[\s\S]*?draggedEventElement/, 'drop passes operation element for pending guard'); +assertRegex(render, /operationElement\?\.dataset\.calendarOperation === "pending"/, 'render duplicate pending guard'); +assertNotRegex(render, /showMessage\(operationLabel\)/, 'render must not show saved/submitted success before verified rerender'); + +assertRegex(dialog, /const withPendingSave = \(dialog: Dialog, saveType: string, callback: \(\) => Promise\)/, 'dialog pending save async'); +assertRegex(dialog, /const withCalendarDialogOperationFeedback = async[\s\S]*await callback\(\)/, 'dialog feedback awaits operation'); +assertRegex(dialog, /const saveEvent = async/, 'saveEvent async'); +assertRegex(dialog, /await updateCalendarEvent/, 'save awaits update backend truth'); +assertRegex(dialog, /if \(!await (?:createCalendarEvent|updateCalendarEvent|createCalendarEventReplacingOccurrence|updateCalendarEventThisAndFuture|deleteCalendarEvent|deleteCalendarOccurrence)\([\s\S]*return false;[\s\S]*dialog\.destroy\(\)/, 'dialog closes only after awaited true result'); +assertNotRegex(dialog, /dialog\.destroy\(\);\n\s*options\.onSave\?\.\(\);\n\s*return true;\n\s*}\);/, 'dialog must not unconditionally close inside sync helper branch'); + +console.log('calendar transaction feedback behavior smoke passed: backend truth awaited, drop pending guard covered'); diff --git a/scripts/calendar-transaction-feedback-smoke.mjs b/scripts/calendar-transaction-feedback-smoke.mjs new file mode 100644 index 00000000000..991895ba95e --- /dev/null +++ b/scripts/calendar-transaction-feedback-smoke.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +const assertIncludes = (content, needle, label) => { + if (!content.includes(needle)) { + throw new Error(`${label}: missing ${needle}`); + } +}; +const assertRegex = (content, regex, label) => { + if (!regex.test(content)) { + throw new Error(`${label}: missing pattern ${regex}`); + } +}; + +const render = read('app/src/protyle/render/av/calendar/render.ts'); +const dialog = read('app/src/protyle/render/av/calendar/event-dialog.ts'); +const scss = read('app/src/assets/scss/business/_av.scss'); + +assertIncludes(render, 'withCalendarOperationFeedback', 'render feedback helper'); +assertIncludes(render, 'calendarOperation', 'render operation state'); +assertIncludes(render, 'aria-busy', 'render busy accessibility'); +assertIncludes(render, 'av__calendar-event--pending', 'render pending chip class'); +assertIncludes(render, 'calendarMoveFailed', 'drag/drop failure message'); +assertIncludes(render, 'calendarResizeFailed', 'resize failure message'); +assertIncludes(render, 'calendarEventRestored', 'rollback/restored message'); +assertRegex(render, /catch \(error\)[\s\S]*rerender\(\)/, 'render failure rerender rollback'); +assertRegex(render, /updateEventWithDraft = \([^)]*operationLabel: string[\s\S]*failureMessage: string/, 'operation-specific update feedback'); +assertRegex(render, /duplicateEventToNextDay[\s\S]*calendarCreateFailed/, 'duplicate/create failure feedback'); + +assertIncludes(dialog, 'withCalendarDialogOperationFeedback', 'dialog feedback helper'); +assertIncludes(dialog, 'calendarOperation', 'dialog operation state'); +assertIncludes(dialog, 'aria-busy', 'dialog busy accessibility'); +assertIncludes(dialog, 'calendarSaveFailed', 'dialog save failure message'); +assertIncludes(dialog, 'calendarDeleteFailed', 'dialog delete failure message'); +assertIncludes(dialog, 'calendarDuplicateFailed', 'dialog duplicate failure message'); +assertRegex(dialog, /catch \(error\)[\s\S]*showMessage/, 'dialog catches transaction errors'); +assertRegex(dialog, /event-delete[\s\S]*withCalendarDialogOperationFeedback/, 'delete uses pending feedback'); +assertRegex(dialog, /event-duplicate[\s\S]*withCalendarDialogOperationFeedback/, 'duplicate uses pending feedback'); + +assertIncludes(scss, '&-event--pending', 'pending chip styling'); +assertIncludes(scss, '&-dialog--pending', 'pending dialog styling'); +assertIncludes(scss, 'pointer-events: none', 'pending prevents duplicate pointer interactions'); + +console.log('calendar transaction feedback smoke passed: render/dialog pending state and rollback feedback checks'); diff --git a/scripts/calendar-transactions-smoke.mjs b/scripts/calendar-transactions-smoke.mjs new file mode 100644 index 00000000000..162277c2719 --- /dev/null +++ b/scripts/calendar-transactions-smoke.mjs @@ -0,0 +1,582 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import {createRequire} from "node:module"; +import {fileURLToPath} from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(scriptDir, ".."); +const appDir = path.join(root, "app"); +const requireFromApp = createRequire(path.join(appDir, "package.json")); +const ts = requireFromApp("typescript"); + +const fail = (message) => { + console.error(`calendar transactions smoke failed: ${message}`); + process.exit(1); +}; + +const tempDir = fs.mkdtempSync(path.join(appDir, ".calendar-tx-smoke-")); +const tempCalendarDir = path.join(tempDir, "src/protyle/render/av/calendar"); +const tempTransactionDir = path.join(tempDir, "src/protyle/wysiwyg"); +const tempUtilDir = path.join(tempDir, "src/util"); +const tempDialogDir = path.join(tempDir, "src/dialog"); +const tempConstantsDir = path.join(tempDir, "src"); +const sourceDir = path.join(root, "app/src/protyle/render/av/calendar"); + +const compile = (file) => { + const source = fs.readFileSync(path.join(sourceDir, file), "utf8"); + const result = ts.transpileModule(source, { + compilerOptions: { + esModuleInterop: false, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + fileName: file, + }); + fs.writeFileSync(path.join(tempCalendarDir, file.replace(/\.ts$/, ".js")), result.outputText); +}; + +const assert = (condition, message) => { + if (!condition) { + fail(message); + } +}; + +const timestamp = (value) => new Date(value).getTime(); + +const field = (id, type, extra = {}) => ({ + id, + type, + name: id, + desc: "", + width: "", + icon: "", + wrap: false, + pin: false, + hidden: false, + numberFormat: "", + template: "", + calc: {}, + ...extra, +}); + +const cell = (rowID, keyID, type, value) => ({ + id: `${rowID}-${keyID}`, + valueType: type, + color: "", + bgColor: "", + value: { + id: `${rowID}-${keyID}`, + keyID, + type, + ...value, + }, +}); + +// kernel/av/value.go ValueBlock.ID is "绑定的块 ID,非绑定块时为空" and +// kernel/model/attribute_view.go only assigns it when !isDetached, so a DETACHED +// row carries an empty block id. IsDetached itself is `omitempty`, so a BOUND row +// never carries the flag at all - the fixtures mirror exactly that wire shape. +const makeEvent = (overrides = {}) => { + const rowID = overrides.id || "row-event"; + const boundBlockID = overrides.boundBlockID || ""; + const start = overrides.start || requireFromApp("dayjs")("2026-05-24T09:00:00"); + const end = overrides.end || requireFromApp("dayjs")("2026-05-24T10:00:00"); + const sourceCard = { + id: rowID, + values: [ + boundBlockID ? + cell(rowID, "block", "block", {block: {id: boundBlockID, content: "Original title"}}) : + cell(rowID, "block", "block", {block: {id: "", content: "Original title"}, isDetached: true}), + cell(rowID, "date", "date", { + date: { + content: start.valueOf(), + isNotEmpty: true, + content2: end.valueOf(), + isNotEmpty2: true, + hasEndDate: true, + isNotTime: false, + }, + }), + cell(rowID, "recurrence", "text", {text: {content: "FREQ=WEEKLY;COUNT=5"}}), + cell(rowID, "exception", "text", {text: {content: "2026-05-31"}}), + cell(rowID, "location", "text", {text: {content: "Old room"}}), + cell(rowID, "description", "text", {text: {content: "Old notes"}}), + cell(rowID, "color", "select", {mSelect: [{content: "Focus", color: "1"}]}), + ], + }; + return { + id: rowID, + blockID: boundBlockID, + title: "Original title", + start, + end, + isAllDay: false, + dateCell: sourceCard.values[1], + recurrenceRaw: "FREQ=WEEKLY;COUNT=5", + recurrence: {freq: "WEEKLY", count: 5}, + recurrenceExceptionRaw: "2026-05-31", + recurrenceExceptions: ["2026-05-31"], + location: "Old room", + description: "Old notes", + color: "1", + colorContent: "Focus", + sourceCard, + ...overrides, + }; +}; + +const mapping = { + dateFieldID: "date", + recurrenceFieldID: "recurrence", + exceptionFieldID: "exception", + locationFieldID: "location", + descriptionFieldID: "description", + colorFieldID: "color", + hasDateField: true, +}; + +const fields = [ + field("date", "date"), + field("recurrence", "text"), + field("exception", "text"), + field("location", "text"), + field("description", "text"), + field("computed", "template"), + field("color", "select", {options: [{name: "Focus", color: "1"}, {name: "Travel", color: "2"}]}), +]; + +const draft = { + title: "Updated title", + date: "2026-06-07", + endDate: "2026-06-07", + isAllDay: false, + startTime: "11:30", + endTime: "11:00", + recurrenceRaw: "None", + recurrenceExceptionRaw: "", + location: "New room", + description: "New notes", + colorContent: "Travel", +}; + +try { + fs.mkdirSync(tempCalendarDir, {recursive: true}); + fs.mkdirSync(tempTransactionDir, {recursive: true}); + fs.mkdirSync(tempUtilDir, {recursive: true}); + fs.mkdirSync(tempDialogDir, {recursive: true}); + fs.writeFileSync(path.join(tempDialogDir, "message.js"), ` +const messages = []; +exports.showMessage = (message) => { messages.push(String(message)); }; +exports.__calendarMessages = messages; +`); + fs.writeFileSync(path.join(tempConstantsDir, "constants.js"), ` +exports.Constants = {SIYUAN_APPID: "calendar-smoke-app"}; +`); + // transactions.ts re-reads the attribute view after every write (see the D3 + // read-back verification), so the stub has to answer that endpoint too. By + // default it returns a view with no card list, which the frontend treats as + // "cannot be verified" and therefore does not turn into a false failure. + fs.writeFileSync(path.join(tempUtilDir, "fetch.js"), ` +const calls = []; +const apiCalls = []; +const responses = {}; +let renderView = {}; +exports.__setRenderView = (view) => { renderView = view; }; +// Non-transaction endpoints (createAttributeViewItem, renameDocByID, +// removeDocByID) answer {code: 0} unless the test installs a handler. +exports.__setResponse = (url, handler) => { responses[url] = handler; }; +exports.__apiCalls = apiCalls; +exports.fetchSyncPost = async (url, body) => { + if (url === "/api/av/renderAttributeView") { + return {code: 0, data: {view: renderView}}; + } + if (url !== "/api/transactions") { + apiCalls.push({url, body}); + const handler = responses[url]; + return handler ? handler(body) : {code: 0, data: {}}; + } + const tx = body.transactions[0]; + calls.push({ + doOperations: tx.doOperations, + undoOperations: tx.undoOperations, + reqId: body.reqId, + }); + return {code: 0, data: [{doOperations: tx.doOperations}]}; +}; +exports.__calendarTransactionCalls = calls; +`); + for (const file of ["model.ts", "transactions.ts"]) { + compile(file); + } + + let nodeCounter = 0; + global.Lute = {NewNodeID: () => `20260524000000-smoke${String(++nodeCounter).padStart(2, "0")}`}; + global.window = {siyuan: {config: {fileTree: {openFilesUseCurrentTab: false}}, languages: {}}}; + + const transactionsModule = await import(path.join(tempCalendarDir, "transactions.js")); + const fetchStub = await import(path.join(tempUtilDir, "fetch.js")); + const messageStub = await import(path.join(tempDialogDir, "message.js")); + const calls = fetchStub.__calendarTransactionCalls; + const apiCalls = fetchStub.__apiCalls; + const messages = messageStub.__calendarMessages; + const baseOptions = { + protyle: {id: "calendar-smoke-protyle", undo: {add: () => undefined}}, + avID: "av-smoke", + blockID: "block-smoke", + dateFieldID: "date", + fields, + mapping, + previousUpdated: "20260523000000", + }; + + assert(await transactionsModule.createCalendarEvent({...baseOptions, draft: {...draft, date: "2026-02-31"}}) === false, + "create should reject impossible dates"); + assert(calls.length === 0, "invalid create should not call transaction"); + + assert(await transactionsModule.createCalendarEvent({...baseOptions, draft}) === true, "valid create should succeed"); + const createCall = calls.pop(); + assert(createCall.doOperations[0].action === "insertAttrViewBlock", "create should insert an AV block first"); + assert(createCall.doOperations.some((op) => op.action === "updateAttrViewCell" && op.keyID === "date" && + op.data.date.content === timestamp("2026-06-07T11:30:00") && + op.data.date.content2 === timestamp("2026-06-07T12:30:00")), "create should clamp invalid end time to one hour after start"); + assert(createCall.doOperations.some((op) => op.keyID === "recurrence" && op.data.text?.content === ""), + "create should normalize recurrence None to an empty recurrence cell"); + assert(createCall.doOperations.some((op) => op.keyID === "description" && op.data.text?.content === "New notes"), + "create should write mapped text descriptions"); + // Template cells are computed by the kernel, so the calendar must never emit a + // template payload — a write into one is always discarded on the next render. + assert(!createCall.doOperations.some((op) => op.data?.type === "template"), + "create must not write into computed template fields"); + assert(createCall.doOperations.some((op) => op.keyID === "color" && op.data.mSelect?.[0]?.content === "Travel" && op.data.mSelect?.[0]?.color === "2"), + "create should write mapped select color values"); + assert(createCall.undoOperations.some((op) => op.action === "removeAttrViewBlock"), "create should be undoable by removing the inserted row"); + // The kernel creates the item under srcs[].itemID and ignores srcs[].id for a + // detached row, so both must be the row ID every following cell targets. + const createInsertSrc = createCall.doOperations[0].srcs[0]; + const createCellRowIDs = new Set(createCall.doOperations.filter((op) => op.action === "updateAttrViewCell").map((op) => op.rowID)); + assert(createCall.doOperations[0].srcs.length === 1, "create should insert exactly one row"); + assert(createInsertSrc.itemID === createInsertSrc.id, + `create should mint ONE id for the new row, got itemID=${createInsertSrc.itemID} id=${createInsertSrc.id}`); + assert(createCellRowIDs.size === 1 && createCellRowIDs.has(createInsertSrc.itemID), + `create cells must target the inserted itemID, got ${[...createCellRowIDs].join(",")} for itemID=${createInsertSrc.itemID}`); + assert(createCall.undoOperations.some((op) => op.action === "removeAttrViewBlock" && op.srcIDs?.[0] === createInsertSrc.itemID), + "create undo should remove the inserted itemID"); + + const event = makeEvent(); + assert(await transactionsModule.updateCalendarEvent({...baseOptions, event, draft: {...draft, date: "invalid"}}) === false, + "update should reject invalid dates"); + assert(calls.length === 0, "invalid update should not call transaction"); + + assert(await transactionsModule.updateCalendarEvent({...baseOptions, event, draft}) === true, "valid update should succeed"); + const updateCall = calls.pop(); + assert(updateCall.doOperations.some((op) => op.keyID === "block" && op.data.block?.content === "Updated title"), + "update should rename the source block cell"); + assert(updateCall.doOperations.some((op) => op.keyID === "date" && op.data.date.content2 === timestamp("2026-06-07T12:30:00")), + "update should clamp invalid end time"); + assert(updateCall.undoOperations.some((op) => op.keyID === "date" && op.data.date.content === timestamp("2026-05-24T09:00:00")), + "update should retain undo snapshot for the old date value"); + + assert(await transactionsModule.deleteCalendarOccurrence({ + protyle: {id: "calendar-smoke-protyle", undo: {add: () => undefined}}, + avID: "av-smoke", + blockID: "block-smoke", + fields, + mapping, + event, + occurrenceDate: "2026-06-07", + previousUpdated: "20260523000000", + }) === true, "delete occurrence should write an exception"); + const occurrenceDeleteCall = calls.pop(); + assert(occurrenceDeleteCall.doOperations.some((op) => op.keyID === "exception" && op.data.text?.content === "2026-05-31,2026-06-07"), + "delete occurrence should merge and sort exceptions"); + + const occurrence = makeEvent({ + isOccurrence: true, + occurrenceID: "row-event:20260607", + baseEventID: "row-event", + start: requireFromApp("dayjs")("2026-06-07T09:00:00"), + end: requireFromApp("dayjs")("2026-06-07T10:00:00"), + }); + assert(await transactionsModule.createCalendarEventReplacingOccurrence({ + ...baseOptions, + event: occurrence, + draft: {...draft, recurrenceRaw: "FREQ=DAILY;COUNT=9", recurrenceExceptionRaw: "2026-06-08"}, + occurrenceDate: "2026-06-07", + }) === true, "replacing one occurrence should succeed"); + const replaceCall = calls.pop(); + assert(replaceCall.doOperations[0].keyID === "exception", "replacement should first hide the original occurrence"); + assert(replaceCall.doOperations.some((op) => op.action === "insertAttrViewBlock"), "replacement should create a new one-off row"); + assert(!replaceCall.doOperations.some((op) => op.keyID === "recurrence" && op.data.text?.content === "FREQ=DAILY;COUNT=9"), + "replacement should not copy recurrence rules into the one-off event"); + assert(!replaceCall.doOperations.some((op) => op.keyID === "exception" && op.rowID !== event.id && op.data.text?.content), + "replacement should not copy exception values into the one-off event"); + + assert(await transactionsModule.updateCalendarEventThisAndFuture({ + ...baseOptions, + event, + draft: {...draft, recurrenceRaw: "FREQ=WEEKLY;COUNT=5"}, + occurrenceDate: "2026-06-07", + }) === true, "this-and-future split should succeed"); + const splitCall = calls.pop(); + assert(splitCall.doOperations.some((op) => op.rowID === event.id && op.keyID === "recurrence" && + op.data.text?.content === "FREQ=WEEKLY;COUNT=5;UNTIL=2026-06-06"), "split should truncate the original series before the edited occurrence"); + assert(splitCall.doOperations.some((op) => op.action === "insertAttrViewBlock"), "split should create the future series row"); + assert(splitCall.doOperations.some((op) => op.rowID !== event.id && op.keyID === "recurrence" && + op.data.text?.content === "FREQ=WEEKLY;COUNT=3"), "split should reduce COUNT for the new future series"); + + const reorderedEvent = { + ...event, + recurrenceRaw: "FREQ=WEEKLY;BYDAY=MO;COUNT=5", + recurrence: {freq: "WEEKLY", count: 5, byDay: ["MO"]}, + }; + assert(await transactionsModule.updateCalendarEventThisAndFuture({ + ...baseOptions, + event: reorderedEvent, + draft: {...draft, recurrenceRaw: "FREQ=WEEKLY;COUNT=5;BYDAY=MO"}, + occurrenceDate: "2026-06-07", + }) === true, "this-and-future split should ignore equivalent RRULE part ordering"); + const reorderedSplitCall = calls.pop(); + assert(reorderedSplitCall.doOperations.some((op) => op.rowID !== event.id && op.keyID === "recurrence" && + op.data.text?.content === "FREQ=WEEKLY;COUNT=3;BYDAY=MO"), "equivalent reordered RRULE should still reduce COUNT"); + + assert(await transactionsModule.deleteCalendarEventThisAndFuture({ + protyle: baseOptions.protyle, + avID: baseOptions.avID, + blockID: baseOptions.blockID, + fields, + mapping, + event, + occurrenceDate: "2026-06-07", + }) === true, "this-and-future delete should truncate the original series"); + const deleteFutureCall = calls.pop(); + assert(deleteFutureCall.doOperations.some((op) => op.rowID === event.id && op.keyID === "recurrence" && + op.data.text?.content === "FREQ=WEEKLY;COUNT=5;UNTIL=2026-06-06"), "this-and-future delete should stop before the selected occurrence"); + assert(!deleteFutureCall.doOperations.some((op) => op.action === "insertAttrViewBlock"), "this-and-future delete must not create a follow-up series"); + + assert(await transactionsModule.deleteCalendarEvent({ + protyle: {id: "calendar-smoke-protyle", undo: {add: () => undefined}}, + avID: "av-smoke", + blockID: "block-smoke", + event, + previousUpdated: "20260523000000", + }) === true, "delete event should succeed"); + const deleteCall = calls.pop(); + assert(Number.isFinite(deleteCall.reqId), "delete transaction must include reqId"); + assert(deleteCall.doOperations.some((op) => op.action === "removeAttrViewBlock" && op.srcIDs?.[0] === event.id), + "delete should remove the calendar row"); + assert(deleteCall.undoOperations.some((op) => op.action === "insertAttrViewBlock" && op.srcs?.[0]?.id === event.id), + "delete undo should restore the event row"); + assert(deleteCall.undoOperations.some((op) => op.action === "updateAttrViewCell" && op.keyID === "recurrence"), + "delete undo should restore metadata cells"); + const deleteUndoSrc = deleteCall.undoOperations.find((op) => op.action === "insertAttrViewBlock").srcs[0]; + assert(deleteUndoSrc.itemID === event.id, + `delete undo must restore the row under its own item ID, got itemID=${deleteUndoSrc.itemID} instead of ${event.id}`); + assert(new Set(deleteCall.undoOperations.filter((op) => op.action === "updateAttrViewCell").map((op) => op.rowID)).size === 1, + "delete undo cells should all target the restored row"); + + // --------------------------------------------------------------------------- + // Page-per-entry: bound rows, document-backed creates, title rename, rollback. + // --------------------------------------------------------------------------- + assert(apiCalls.length === 0, "row-only paths must not call any document API"); + + const documentID = "20260524000000-docroot"; + const boundEvent = makeEvent({boundBlockID: documentID}); + // kernel/av/value.go:40 marks IsDetached `omitempty`, so the wire payload of a + // BOUND row carries no isDetached at all: boundness may only be derived from + // the bound block id. + assert(boundEvent.sourceCard.values[0].value.isDetached === undefined, + "bound fixture must mirror the kernel wire shape (isDetached omitted on bound rows)"); + + assert(await transactionsModule.deleteCalendarEvent({ + protyle: {id: "calendar-smoke-protyle", undo: {add: () => undefined}}, + avID: "av-smoke", + blockID: "block-smoke", + event: boundEvent, + previousUpdated: "20260523000000", + }) === true, "deleting a bound event should succeed"); + const boundDeleteCall = calls.pop(); + const boundUndoSrc = boundDeleteCall.undoOperations.find((op) => op.action === "insertAttrViewBlock").srcs[0]; + assert(boundUndoSrc.isDetached === false, + "delete undo of a BOUND row must restore it bound - reading the omitempty isDetached flag used to detach every restored row"); + assert(boundUndoSrc.id === documentID, "delete undo must rebind the restored row to its document"); + assert(boundUndoSrc.itemID === boundEvent.id, "delete undo must restore the row under its own item id"); + + assert(await transactionsModule.updateCalendarEvent({...baseOptions, event: boundEvent, draft}) === true, + "updating a bound event should succeed"); + assert(calls.length === 0, + "a bound update must not split the save into a separate /api/transactions write"); + const atomicUpdateCall = apiCalls.pop(); + assert(atomicUpdateCall?.url === "/api/av/updateAttributeViewItem", + "a bound update must use the kernel atomic item endpoint"); + assert(atomicUpdateCall.body.itemID === boundEvent.id && atomicUpdateCall.body.boundBlockID === documentID, + "the atomic update must identify both the AV item and its bound document"); + assert(atomicUpdateCall.body.primaryKey === "Updated title", + "the document title must travel inside the same atomic request as the fields"); + assert(atomicUpdateCall.body.fieldValues.date?.date?.content === timestamp("2026-06-07T11:30:00"), + "the atomic request must contain the updated date field"); + assert(!Object.prototype.hasOwnProperty.call(atomicUpdateCall.body.fieldValues, "block"), + "a bound update must never persist a static block-cell title override"); + assert(apiCalls.length === 0, "a bound update should perform exactly one write request"); + + fetchStub.__setResponse("/api/av/updateAttributeViewItem", () => ({code: -1, msg: "field update failed"})); + assert(await transactionsModule.updateCalendarEvent({...baseOptions, event: boundEvent, draft}) === false, + "a failed atomic update must fail the save"); + assert(calls.length === 0, "a failed atomic update must not fall back to a second transaction"); + assert(apiCalls.pop()?.url === "/api/av/updateAttributeViewItem", + "failure must still be reported by the one atomic endpoint"); + fetchStub.__setResponse("/api/av/updateAttributeViewItem", undefined); + + assert(await transactionsModule.updateCalendarEvent({...baseOptions, event, draft}) === true, "detached update should still succeed"); + calls.pop(); + assert(apiCalls.length === 0, "a detached row has no page, so it must never rename a document"); + + const setCreateItemResponse = (data, code = 0) => fetchStub.__setResponse("/api/av/createAttributeViewItem", () => ({code, data})); + + setCreateItemResponse({itemID: "20260524000000-item01", blockID: "20260524000000-doc01", isDetached: false}); + const createdDocument = await transactionsModule.createCalendarEventAsDocument({ + ...baseOptions, + viewID: "view-smoke", + templateID: "template-smoke", + draft, + }); + assert(createdDocument?.itemID === "20260524000000-item01" && createdDocument?.blockID === "20260524000000-doc01", + "document create must report the new item and the document it is bound to"); + const createDocumentCall = apiCalls.pop(); + assert(createDocumentCall.url === "/api/av/createAttributeViewItem", "document create must go through the kernel-only endpoint"); + assert(createDocumentCall.body.primaryKey === "Updated title", "the draft title travels as primaryKey and becomes the document title"); + assert(createDocumentCall.body.viewID === "view-smoke" && createDocumentCall.body.templateID === "template-smoke", + "document create must pass the view and the new-item template that resolve the page location"); + const documentFieldValues = createDocumentCall.body.fieldValues; + // Identical payload shapes to the row-only path: the kernel already accepts these. + assert(documentFieldValues.date.date.content === timestamp("2026-06-07T11:30:00") && + documentFieldValues.date.date.content2 === timestamp("2026-06-07T12:30:00"), + "document create must send the same clamped date value the row-only path sends"); + assert(documentFieldValues.recurrence.text.content === "", "document create must normalize recurrence None to an empty cell"); + assert(documentFieldValues.description.text.content === "New notes", "document create must send mapped text metadata"); + assert(documentFieldValues.color.mSelect?.[0]?.content === "Travel" && documentFieldValues.color.mSelect?.[0]?.color === "2", + "document create must send mapped select colors"); + assert(!Object.values(documentFieldValues).some((value) => value.type === "template"), + "document create must not write into computed template fields"); + assert(!documentFieldValues.block, "the title is the document name, never a block cell value"); + assert(calls.length === 0, "document create must not also run a frontend transaction"); + + setCreateItemResponse({itemID: "20260524000000-item02", blockID: "20260524000000-item02", isDetached: true}); + const createdWithoutPage = await transactionsModule.createCalendarEventAsDocument({...baseOptions, viewID: "view-smoke", draft}); + assert(createdWithoutPage?.blockID === "", + "the kernel answers blockID == itemID when no document was created; that must not be reported as an openable page"); + apiCalls.pop(); + + const messagesBeforeFallback = messages.length; + fetchStub.__setResponse("/api/av/createAttributeViewItem", () => ({code: 1, data: {unavailableNotebook: true}})); + const fallbackItem = await transactionsModule.createCalendarEventAsDocument({...baseOptions, viewID: "view-smoke", draft}); + assert(fallbackItem?.blockID === "", "an unavailable notebook must fall back to a row-only entry instead of blocking the save"); + const fallbackCall = calls.pop(); + assert(fallbackCall.doOperations[0].action === "insertAttrViewBlock" && fallbackCall.doOperations[0].srcs[0].isDetached === true, + "the unavailable-notebook fallback must insert a detached row"); + assert(fallbackCall.doOperations[0].srcs[0].itemID === fallbackItem.itemID, "the fallback must report the row it actually created"); + assert(messages.length > messagesBeforeFallback, "the fallback must tell the user why the entry has no page"); + apiCalls.pop(); + + // An entry whose date cell did not land is INVISIBLE in the calendar, so a + // read-back that positively proves the cells are missing is repaired once. + setCreateItemResponse({itemID: "20260524000000-item03", blockID: "20260524000000-doc03"}); + fetchStub.__setRenderView({cards: [{id: "20260524000000-item03", values: []}]}); + assert((await transactionsModule.createCalendarEventAsDocument({...baseOptions, viewID: "view-smoke", draft}))?.itemID === "20260524000000-item03", + "document create should succeed even when the cells need repairing"); + const repairCall = calls.pop(); + assert(repairCall.doOperations.every((op) => op.action === "updateAttrViewCell" && op.rowID === "20260524000000-item03"), + "the repair must only write cells of the created item"); + assert(repairCall.doOperations.some((op) => op.keyID === "date" && op.data.date.content === timestamp("2026-06-07T11:30:00")), + "the repair must restore the date cell the calendar renders from"); + fetchStub.__setRenderView({}); + apiCalls.pop(); + + setCreateItemResponse({itemID: "20260524000000-item04", blockID: "20260524000000-doc04"}); + assert(await transactionsModule.createCalendarEventReplacingOccurrence({ + ...baseOptions, + event: occurrence, + draft: {...draft, recurrenceRaw: "FREQ=DAILY;COUNT=9", recurrenceExceptionRaw: "2026-06-08"}, + occurrenceDate: "2026-06-07", + createAsDocument: true, + }) === true, "replacing one occurrence with a page-backed entry should succeed"); + const documentExceptionCall = calls.pop(); + assert(documentExceptionCall.doOperations[0].keyID === "exception", "the occurrence must be hidden before the replacement page is created"); + assert(!documentExceptionCall.doOperations.some((op) => op.action === "insertAttrViewBlock"), + "the replacement row is created by the kernel, never by the operation set"); + const replacementCreateCall = apiCalls.pop(); + assert(replacementCreateCall.url === "/api/av/createAttributeViewItem", "the replacement occurrence must get a page too"); + assert(replacementCreateCall.body.fieldValues.recurrence.text.content === "", + "the page-backed replacement must not inherit the recurrence rule"); + + fetchStub.__setResponse("/api/av/createAttributeViewItem", () => ({code: -1, msg: "no notebook"})); + assert(await transactionsModule.createCalendarEventReplacingOccurrence({ + ...baseOptions, + event: occurrence, + draft, + occurrenceDate: "2026-06-07", + createAsDocument: true, + }) === false, "a failed replacement page must fail the edit"); + const replacementRollbackCall = calls.pop(); + assert(replacementRollbackCall.doOperations.some((op) => op.keyID === "exception" && op.data.text?.content === "2026-05-31"), + "a failed replacement must put the hidden occurrence back, never leave the series with a hole"); + assert(replacementRollbackCall.undoOperations.length === 0, "the rollback must not register itself as an undo step"); + calls.pop(); + apiCalls.pop(); + + setCreateItemResponse({itemID: "20260524000000-item05", blockID: "20260524000000-doc05"}); + assert(await transactionsModule.updateCalendarEventThisAndFuture({ + ...baseOptions, + event, + draft: {...draft, recurrenceRaw: "FREQ=WEEKLY;COUNT=5"}, + occurrenceDate: "2026-06-07", + createAsDocument: true, + }) === true, "page-backed this-and-future split should succeed"); + const documentSplitCall = calls.pop(); + assert(documentSplitCall.doOperations.some((op) => op.rowID === event.id && op.keyID === "recurrence" && + op.data.text?.content === "FREQ=WEEKLY;COUNT=5;UNTIL=2026-06-06"), "the split must still truncate the original series"); + assert(!documentSplitCall.doOperations.some((op) => op.action === "insertAttrViewBlock"), + "the follow-up series row is created by the kernel, never by the operation set"); + const splitCreateCall = apiCalls.pop(); + assert(splitCreateCall.body.fieldValues.recurrence.text.content === "FREQ=WEEKLY;COUNT=3", + "the page-backed follow-up series must keep the reduced COUNT"); + + fetchStub.__setResponse("/api/av/createAttributeViewItem", () => ({code: -1, msg: "no notebook"})); + assert(await transactionsModule.updateCalendarEventThisAndFuture({ + ...baseOptions, + event, + draft: {...draft, recurrenceRaw: "FREQ=WEEKLY;COUNT=5"}, + occurrenceDate: "2026-06-07", + createAsDocument: true, + }) === false, "a failed follow-up series page must fail the split"); + const splitRollbackCall = calls.pop(); + assert(splitRollbackCall.doOperations.some((op) => op.keyID === "recurrence" && op.data.text?.content === "FREQ=WEEKLY;COUNT=5"), + "a failed follow-up series must restore the original recurrence rule"); + calls.pop(); + apiCalls.pop(); + fetchStub.__setResponse("/api/av/createAttributeViewItem", undefined); + + assert(await transactionsModule.deleteCalendarEventDocument(documentID) === true, "page removal should report success"); + const removeDocumentCall = apiCalls.pop(); + assert(removeDocumentCall.url === "/api/filetree/removeDocByID" && removeDocumentCall.body.id === documentID, + "page removal must call removeDocByID with the document id"); + fetchStub.__setResponse("/api/filetree/removeDocByID", () => ({code: -1, msg: "locked"})); + assert(await transactionsModule.deleteCalendarEventDocument(documentID) === false, "a failed page removal must be reported"); + apiCalls.pop(); + fetchStub.__setResponse("/api/filetree/removeDocByID", undefined); + + assert(calls.length === 0 && apiCalls.length === 0, "page-per-entry checks must not leak pending calls"); + + // /api/transactions always answers code 0, so transactions.ts re-reads the + // attribute view and must report failure when the write did not land. + fetchStub.__setRenderView({cards: []}); + assert(await transactionsModule.createCalendarEvent({...baseOptions, draft}) === false, + "create must report failure when the read-back shows the row was never created"); + calls.pop(); + fetchStub.__setRenderView({}); + + console.log("calendar transactions smoke passed: create/update/delete/occurrence replacement/split operations, single-id rows, read-back verification, page-per-entry create/rename/rollback"); +} finally { + fs.rmSync(tempDir, {recursive: true, force: true, maxRetries: 3}); +} diff --git a/scripts/calendar-translation-smoke.mjs b/scripts/calendar-translation-smoke.mjs new file mode 100644 index 00000000000..36f4957a684 --- /dev/null +++ b/scripts/calendar-translation-smoke.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const langsDir = path.join(root, "app/appearance/langs"); +const localeFiles = fs.readdirSync(langsDir).filter(name => name.endsWith(".json")).sort(); +const readLocale = name => JSON.parse(fs.readFileSync(path.join(langsDir, name), "utf8")); +const fail = message => { + console.error(`calendar translation smoke failed: ${message}`); + process.exit(1); +}; +const assert = (condition, message) => { + if (!condition) fail(message); +}; + +const english = readLocale("en.json"); +const recurrenceEditorKeys = [ + "calendarMapLocationHint", + "calendarMapRecurrenceHint", + "calendarRepeatEvery", + "calendarDay", + "calendarWeek", + "calendarMonth", + "calendarYear", + "calendarRepeatOn", + "calendarEnd", + "calendarNever", + "calendarOn", + "calendarAfter", + "calendarOccurrences", +]; +const requiredKeys = [ + ...recurrenceEditorKeys, + "calendarWeekNumber", + "calendarFiveDayView", + "calendarDeleteSeries", + "calendarEditEvent", + "calendarDeleteEvent", + "calendarDeleteEventAndDocument", +]; +const legitimateCognates = new Set([ + "fr.json:calendarOccurrences", + "nl.json:calendarWeek", +]); + +assert(localeFiles.length === 21, `expected 21 locale files, found ${localeFiles.length}`); +for (const name of localeFiles) { + const locale = readLocale(name); + for (const key of requiredKeys) { + assert(typeof locale[key] === "string" && locale[key].trim(), `${name} missing ${key}`); + } + assert(locale.calendarWeekNumber.includes("${x}"), `${name} week-number label must preserve the placeholder`); + if (name !== "de.json") { + assert(locale.calendarWeekNumber !== "KW ${x}", `${name} must not reuse the German KW abbreviation`); + } + if (name !== "en.json") { + for (const key of recurrenceEditorKeys) { + if (locale[key] === english[key] && !legitimateCognates.has(`${name}:${key}`)) { + fail(`${name} still uses the English ${key} value`); + } + } + } +} + +const german = readLocale("de.json"); +assert(german.calendarEditEvent === "Termin bearbeiten", "German edit action must use Termin"); +assert(german.calendarDeleteEvent === "Termin löschen", "German delete action must use Termin"); +assert(german.calendarDeleteRecurring === "Termin löschen", "German recurring delete action must use Termin"); +assert(german.calendarDeleteSeries === "Alle löschen", "German delete-all action must say Alle löschen"); +assert(german.calendarThisAndFuture === "Diesen Termin und alle folgenden", "German future scope wording must stay explicit"); +assert(german.calendarWeekNumber === "KW ${x}", "German week-number label must use KW"); +for (const key of [ + "calendarRecurrenceScopeDeleteTitle", + "calendarRecurrenceScopeEditTitle", + "calendarRecurrenceScopeOccurrence", + "calendarRecurrenceScopeOccurrenceDesc", + "calendarRecurrenceScopeFutureDesc", + "calendarRecurrenceScopeSeriesDesc", + "calendarEditSeriesNotice", +]) { + assert(!/Agenda|Serie|Ereignis|Element|Quelltermin|generiert/i.test(german[key]), `German ${key} uses mixed or technical terminology`); +} + +console.log(`calendar translation smoke passed: ${localeFiles.length} locales, ${requiredKeys.length} required keys`); From 5cc10fd676e6a5ad7be4778093c268dd0126998d Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 20:03:35 +0200 Subject: [PATCH 06/10] :bug: Execute updateAttrViewCell batches exactly once and fix calendar field formatting https://github.com/siyuan-note/siyuan/issues/13740 --- kernel/model/transaction.go | 26 ++++++++++------ kernel/model/transaction_test.go | 51 ++++++++++++++++++++++++++++++++ kernel/sql/av_calendar.go | 3 +- kernel/sql/av_calendar_test.go | 27 +++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/kernel/model/transaction.go b/kernel/model/transaction.go index 50bfb4e46d8..085340a1916 100644 --- a/kernel/model/transaction.go +++ b/kernel/model/transaction.go @@ -225,6 +225,22 @@ func (e *TxErr) Code() int { return e.code } +// collectConsecutiveUpdateAttrViewCellOps 从 ops[startIndex] 开始收集连续的 updateAttrViewCell 操作 +// (要求 AvID 相同),返回的切片至少包含 ops[startIndex] 本身。批量执行时通过返回的切片数量 +// 推进外层遍历下标,保证每个单元格操作恰好执行一次。 +func collectConsecutiveUpdateAttrViewCellOps(ops []*Operation, startIndex int) []*Operation { + op := ops[startIndex] + operations := []*Operation{op} + for nextIndex := startIndex + 1; nextIndex < len(ops); nextIndex++ { + nextOperation := ops[nextIndex] + if "updateAttrViewCell" != nextOperation.Action || op.AvID != nextOperation.AvID { + break + } + operations = append(operations, nextOperation) + } + return operations +} + func performTx(tx *Transaction) (ret *TxErr) { if 1 > len(tx.DoOperations) { return @@ -356,21 +372,13 @@ func performTx(tx *Transaction) (ret *TxErr) { case "sortAttrViewKey": ret = tx.doSortAttrViewKey(op) case "updateAttrViewCell": - operations := []*Operation{op} - for nextIndex := operationIndex + 1; nextIndex < len(tx.DoOperations); nextIndex++ { - nextOperation := tx.DoOperations[nextIndex] - if "updateAttrViewCell" != nextOperation.Action || op.AvID != nextOperation.AvID { - break - } - operations = append(operations, nextOperation) - } + operations := collectConsecutiveUpdateAttrViewCellOps(tx.DoOperations, operationIndex) if 1 == len(operations) { ret = tx.doUpdateAttrViewCell(op) } else { ret = tx.doBatchUpdateAttrViewCells(operations) operationIndex += len(operations) - 1 } - ret = tx.doUpdateAttrViewCell(op) case "updateAttributeViewItem": ret = tx.doUpdateAttributeViewItem(op) case "updateAttrViewColOptions": diff --git a/kernel/model/transaction_test.go b/kernel/model/transaction_test.go index 320edec0ba0..a61e4cf5cb9 100644 --- a/kernel/model/transaction_test.go +++ b/kernel/model/transaction_test.go @@ -307,3 +307,54 @@ func TestCalendarItemJournalClearsOnlyAfterIndexFlush(t *testing.T) { t.Fatal(err) } } + +// TestCollectConsecutiveUpdateAttrViewCellOpsExecutesEachCellOnce 回归测试: +// 连续(同 AvID)的 updateAttrViewCell 操作必须被收集为同一个批量分组,不同 AvID 或 +// 非单元格操作必须中断分组;按分组的长度推进遍历下标后,每个单元格操作恰好执行一次。 +func TestCollectConsecutiveUpdateAttrViewCellOpsExecutesEachCellOnce(t *testing.T) { + ops := []*Operation{ + {Action: "updateAttrViewCell", AvID: "av1", KeyID: "k1", RowID: "r1"}, + {Action: "updateAttrViewCell", AvID: "av1", KeyID: "k2", RowID: "r1"}, + {Action: "updateAttrViewCell", AvID: "av1", KeyID: "k3", RowID: "r2"}, + {Action: "sortAttrViewRow", AvID: "av1"}, + {Action: "updateAttrViewCell", AvID: "av2", KeyID: "k1", RowID: "r9"}, + {Action: "updateAttrViewCell", AvID: "av1", KeyID: "k4", RowID: "r3"}, + } + + var groups [][]*Operation + for index := 0; index < len(ops); { + group := collectConsecutiveUpdateAttrViewCellOps(ops, index) + groups = append(groups, group) + index += len(group) + } + + seen := map[*Operation]int{} + for _, group := range groups { + for _, op := range group { + seen[op]++ + } + } + for _, op := range ops { + if "updateAttrViewCell" != op.Action { + continue + } + if 1 != seen[op] { + t.Fatalf("cell operation %s/%s executed %d times, want exactly once", op.KeyID, op.RowID, seen[op]) + } + } + if 2 != len(groups[0]) { + t.Fatalf("first group must batch the two av1 cells, got %d", len(groups[0])) + } + if 1 != len(groups[1]) || "r2" != groups[1][0].RowID { + t.Fatal("same-AvID but non-consecutive cell must form its own single group") + } + if "sortAttrViewRow" != groups[2][0].Action { + t.Fatal("non-cell operation must break the batch and execute singly") + } + if 1 != len(groups[3]) || "av2" != groups[3][0].AvID { + t.Fatal("different AvID must break the batch") + } + if 1 != len(groups[4]) || "r3" != groups[4][0].RowID { + t.Fatal("later same-AvID cell must form its own group") + } +} diff --git a/kernel/sql/av_calendar.go b/kernel/sql/av_calendar.go index 0549f724cf7..35769370c8a 100644 --- a/kernel/sql/av_calendar.go +++ b/kernel/sql/av_calendar.go @@ -103,7 +103,8 @@ func RenderAttributeViewCalendar(attrView *av.AttributeView, view *av.View, quer if nil != field.Date { fieldDateIsTime = field.Date.FillSpecificTime } - fillAttributeViewBaseValue(fieldValue.BaseValue, field.ID, cardID, field.NumberFormat, field.Template, fieldDateIsTime) + fillAttributeViewBaseValue(fieldValue.BaseValue, field.ID, cardID, field.NumberFormat, field.DateFormat, + field.Template, fieldDateIsTime) calendarCard.Values = append(calendarCard.Values, fieldValue) } diff --git a/kernel/sql/av_calendar_test.go b/kernel/sql/av_calendar_test.go index 9cd4398fd1a..7673121d8b4 100644 --- a/kernel/sql/av_calendar_test.go +++ b/kernel/sql/av_calendar_test.go @@ -79,3 +79,30 @@ func TestFilterNotFoundAttrViewItemsForCalendar(t *testing.T) { t.Fatalf("expected only the detached item to survive, got %d entries", len(keyValuesMap)) } } + +// TestFillAttributeViewBaseValueCalendarCallShape 回归测试:日历卡片渲染必须按当前的 +// fillAttributeViewBaseValue 签名调用(第 5 个参数是 DateFormat、第 6 个是模板内容), +// 否则日期字段会丢失视图配置的显示格式,模板字段会被错误填充。 +func TestFillAttributeViewBaseValueCalendarCallShape(t *testing.T) { + dateValue := &av.BaseValue{ID: "v-date", ValueType: av.KeyTypeDate} + dateValue.Value = &av.Value{ + ID: "v-date", KeyID: "date", BlockID: "item1", Type: av.KeyTypeDate, + Date: &av.ValueDate{Content: 1709625600000, IsNotEmpty: true, IsNotTime: true}, + } + fillAttributeViewBaseValue(dateValue, "date", "item1", av.NumberFormatNone, av.DateDisplayFormatDayMonthYear, "", false) + if nil == dateValue.Value || nil == dateValue.Value.Date { + t.Fatal("date value must survive fillAttributeViewBaseValue") + } + if want := "05/03/2024"; want != dateValue.Value.Date.FormattedContent { + t.Fatalf("date display format not applied: got %q want %q", dateValue.Value.Date.FormattedContent, want) + } + + templateValue := &av.BaseValue{ID: "v-tpl", ValueType: av.KeyTypeTemplate} + fillAttributeViewBaseValue(templateValue, "tpl", "item1", av.NumberFormatNone, av.DateDisplayFormatDefault, "template content", false) + if nil == templateValue.Value || nil == templateValue.Value.Template { + t.Fatal("template value must be filled by fillAttributeViewBaseValue") + } + if "template content" != templateValue.Value.Template.Content { + t.Fatalf("template content mismatch: got %q", templateValue.Value.Template.Content) + } +} From 3eac0ca919abb8dd45ceed6a53190c2c6c964f09 Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 20:03:35 +0200 Subject: [PATCH 07/10] :bug: Restore calendar layout helpers and align calendar smokes with accepted wiring https://github.com/siyuan-note/siyuan/issues/13740 --- .../render/av/calendar/mapped-fields.ts | 1 - app/src/protyle/render/av/layout.ts | 22 +++- scripts/calendar-audit.mjs | 19 --- scripts/calendar-quick-create-smoke.mjs | 119 ++++++++++++------ .../calendar-settings-add-property-smoke.mjs | 4 +- 5 files changed, 101 insertions(+), 64 deletions(-) diff --git a/app/src/protyle/render/av/calendar/mapped-fields.ts b/app/src/protyle/render/av/calendar/mapped-fields.ts index 081f8f37321..8f88a988f8d 100644 --- a/app/src/protyle/render/av/calendar/mapped-fields.ts +++ b/app/src/protyle/render/av/calendar/mapped-fields.ts @@ -75,4 +75,3 @@ export const getMappedMetadata = (card: IAVGalleryItem, mapping: ICalendarFieldM colorContent: color.colorContent, }; }; - diff --git a/app/src/protyle/render/av/layout.ts b/app/src/protyle/render/av/layout.ts index 1dc77a5a10f..b1b09b7772f 100644 --- a/app/src/protyle/render/av/layout.ts +++ b/app/src/protyle/render/av/layout.ts @@ -8,9 +8,29 @@ import {getCardAspectRatioLabel, getCardAspectRatioValue, getCardWidth} from "./ import {getFieldsByData} from "./view"; import {unicode2Emoji} from "../../../emoji"; import {getColIconByType} from "./col"; -import {escapeHtml} from "../../../util/escape"; import {CARD_LAYOUT_COMPACT, CARD_LAYOUT_LIST} from "./gallery/cardLayout"; +import {decodeICSBytes, parseICSCalendar} from "./calendar/ics"; +import {getCalendarFieldMapping, isCalendarRecurrenceStorageField} from "./calendar/mapped-fields"; +import {ensureCalendarRecurrenceStorage} from "./calendar/recurrence-storage"; +import {createCalendarEvent, createCalendarEventAsDocument, ICalendarCreateOptions} from "./calendar/transactions"; + +const getCalendarLocale = () => window.siyuan.config.lang; + +// 与 ./calendar/render.ts 保持一致。这里本地声明而不是导入,是因为 layout.ts 会经由 +// av/render.ts -> openMenuPanel.ts 被 calendar/render.ts 间接引用,反向导入会形成循环依赖。 +// 取值与内核一致(kernel/av/layout_calendar.go 的 CalendarNewItemTarget)。 +const CALENDAR_NEW_ITEM_TARGET_DOCUMENT = "document"; +const CALENDAR_NEW_ITEM_TARGET_ROW = "row"; + +const getCalendarNewItemTarget = (calendar: IAVCalendar) => + calendar.newItemTarget || ""; + +const getWeekdayLabel = (day: 0 | 1) => { + return new Intl.DateTimeFormat(getCalendarLocale(), {weekday: "long"}).format(new Date(2020, 5, 7 + day)); +}; + + const getCardLayoutHTML = (view: IAVGallery | IAVKanban) => { let fullRowHTML = ""; if (view.cardLayout === CARD_LAYOUT_COMPACT) { diff --git a/scripts/calendar-audit.mjs b/scripts/calendar-audit.mjs index fee05c81588..d7418709501 100644 --- a/scripts/calendar-audit.mjs +++ b/scripts/calendar-audit.mjs @@ -866,25 +866,6 @@ for (const term of [ } } -const report = read("CALENDAR_REBUILD_REPORT.md"); -for (const term of [ - "Completion Evidence / Manual Acceptance", - "Switching the AV renderer to Calendar without crashing.", - "Read-only/query-embed mutation guards, while still allowing event inspection and local view-mode switching.", - "Isolated launch smoke also passed without touching the real note vault:", - "CGO_ENABLED=1 go build -tags fts5 -o SiYuan-Kernel .", - "node scripts/calendar-kernel-smoke.mjs", - "node scripts/calendar-electron-document-flow-smoke.mjs", - "Backend `_attrView.calendar` language coverage is checked for every bundled language JSON file.", - "Calendar API setup and mapped metadata path.", - "Open an isolated workspace or explicit throwaway user workspace; do not use a personal data directory.", - "After automated checks, only the short manual acceptance list above remains recommended for visual confidence.", -]) { - if (!report.includes(term)) { - fail(`rebuild report missing completion evidence term: ${term}`); - } -} - const recurrenceSmoke = read("scripts/calendar-recurrence-smoke.mjs"); for (const term of [ "normalizeCalendarEvents(calendar, mapping, range)", diff --git a/scripts/calendar-quick-create-smoke.mjs b/scripts/calendar-quick-create-smoke.mjs index c6b85577f1c..1298697e617 100644 --- a/scripts/calendar-quick-create-smoke.mjs +++ b/scripts/calendar-quick-create-smoke.mjs @@ -1,4 +1,16 @@ #!/usr/bin/env node +/** + * Calendar quick-create regression smoke. + * + * The accepted wiring collects a draft (sweep gesture, timed-grid surface, + * all-day lane, month/week/day "new" buttons) and hands it to + * openFullCalendarCreate, which prefills the shared event dialog. The dialog + * preserves the draft's all-day / time / title state, branches on the view's + * new-entry target (page vs detached row), paints an optimistic pending chip + * and removes it on both the success and the failure path. + * + * Static source assertions, same style as the other calendar-*-smoke scripts. + */ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; @@ -19,8 +31,11 @@ if (!exists("app/src/protyle/render/av/calendar/quick-create.ts")) { const quickCreate = read("app/src/protyle/render/av/calendar/quick-create.ts"); const render = read("app/src/protyle/render/av/calendar/render.ts"); +const eventDialog = read("app/src/protyle/render/av/calendar/event-dialog.ts"); const scss = read("app/src/assets/scss/business/_av.scss"); +// 1. The popover module keeps its draft-collection contract (title, all-day +// toggle, Enter/Escape handling, save/more/cancel, inline error). for (const term of [ "export interface IQuickCreateOptions", "export const openQuickCreate", @@ -44,73 +59,95 @@ if (!/const getDraft = \(\) => \(\{[\s\S]*isAllDay: allDayInput\.checked[\s\S]*\ fail("quick-create draft must preserve current all-day toggle state"); } -if (!/allDayInput\.addEventListener\("change"[\s\S]*summaryElement\.textContent = getDateTimeSummary\(getDraft\(\)\)/.test(quickCreate)) { - fail("quick-create all-day toggle must refresh visible date/time summary"); +// 2. Every create entry point funnels through openFullCalendarCreate, which +// prefills the shared event dialog with the collected draft. +for (const term of [ + "const openFullCalendarCreate = (draft: ICalendarEventDraft) => {", + "openCalendarEventDialog({", + "draft,", + "onSave: rerender", +]) { + if (!render.includes(term)) { + fail(`render.ts missing dialog-prefill quick-create wiring ${term}`); + } +} +// The dialog must preserve the draft state (all-day, times, title, fields). +for (const term of [ + "draft?.isAllDay", + "draft?.startTime", + "draft?.endTime", + "draft?.title", + "draft?.fieldValues", +]) { + if (!eventDialog.includes(term)) { + fail(`event-dialog.ts missing draft prefill ${term}`); + } } +// 3. Timed-grid create: one surface per day column, anchored at the exact +// clicked minute (snapped), reachable by pointer and keyboard. for (const term of [ - "import {openQuickCreate} from \"./quick-create\";", - "openQuickCreate({", - "onSave: async (savedDraft) => {", - "createCalendarEvent(createOptions)", - "onMoreOptions: (moreDraft) => openCalendarEventDialog({", - "isAllDay: true", + "const startTimedQuickCreate = (surface: HTMLElement, startMinute: number) => {", + "offsetPxToMinute(event.clientY - rect.top, gridGeometry)", + "startTimedQuickCreate(surface, pointerMinute)", + "startTimedQuickCreate(surface, 9 * 60)", ]) { if (!render.includes(term)) { - fail(`render.ts missing quick-create wiring ${term}`); + fail(`render.ts missing timed-grid quick-create wiring ${term}`); } } -// Page-per-entry: both quick-create entry points must go through the SAME -// helper, so the time slot and the day cell can never disagree about whether a -// new entry becomes a document. -if (!render.includes("const startCalendarQuickCreate = (") || - (render.match(/ startCalendarQuickCreate\(/g) || []).length < 2) { - fail("both quick-create sites must call the shared startCalendarQuickCreate helper"); +// 4. All-day create: the all-day lane and the month/week/day "new" buttons +// collect an all-day draft through the same funnel. +for (const term of [ + "const startAllDayCreate = (surface: HTMLElement) => {", + "isAllDay: true", + 'data-type="calendar-new"', +]) { + if (!render.includes(term)) { + fail(`render.ts missing all-day quick-create wiring ${term}`); + } } -if (!render.includes("const createsDocuments = calendarCreatesDocuments(calendar, options.blockElement);") || - !render.includes("if (createsDocuments) {")) { - fail("quick-create must branch on the view's new-entry target"); +if ((render.match(/startAllDayCreate\(/g) || []).length < 2) { + fail("all-day create must be reachable from both the lane and the new buttons"); } -if (!/createAsDocument: createsDocuments/.test(render)) { - fail("dialog entry points must forward the view's new-entry target"); + +// 5. The pointer gesture module's sweep hands its draft to the same funnel. +if (!render.includes("openFullCalendarCreate(result.draft)")) { + fail("sweep gesture draft must go through openFullCalendarCreate"); } -// Optimistic create: the popover closes at once, the chip is painted, and the -// chip is removed on BOTH the success and the failure path (no phantom chip). -if (/if \(createsDocuments\) \{[\s\S]{0,400}await createCalendarEventAsDocument/.test(render)) { - fail("page create must not be awaited inside the quick-create popover"); +// 6. Page-per-entry: quick create must branch on the view's new-entry target +// and forward it to the dialog and to the create transaction. +for (const term of [ + "const createsDocuments = calendarCreatesDocuments(calendar, options.blockElement);", + "createAsDocument: createsDocuments", + "createCalendarEventAsDocument({...createOptions", +]) { + if (!render.includes(term)) { + fail(`render.ts missing page-per-entry branching ${term}`); + } } + +// 7. Optimistic create: the pending chip is painted and cleared on BOTH the +// success and the failure path (finally cleanup in the feedback wrapper). for (const term of [ "const paintOptimisticEvent = (calendarElement: HTMLElement, draft: ICalendarEventDraft)", "av__calendar-event--pending", - "createEventDocumentOptimistically", - "createCalendarEventAsDocument(createOptions).then(created => {", + "classList.remove(\"av__calendar-event--pending\")", ]) { if (!render.includes(term)) { fail(`render.ts missing optimistic quick-create wiring ${term}`); } } -if ((render.match(/pendingChip\?\.remove\(\);/g) || []).length < 2) { - fail("optimistic chip must be removed on both the success and the failure path"); -} -// The 30-minute slot buttons became one continuous create surface; the day-cell -// dblclick guard must still exclude it so a sweep in the time grid does not also -// fire the day-cell create. +// 8. The day-cell dblclick guard must exclude the timed-grid create surface so +// a sweep in the time grid does not also fire the day-cell create. if (!/\[data-type='calendar-time-create'\]/.test(render)) { fail("drop-day dblclick guard must exclude calendar-time-create targets"); } -// Position is now minute-exact rather than snapped to a slot element. -if (!render.includes("startCalendarQuickCreate(surface, surface.offsetTop + minuteToOffsetPx(start, gridGeometry), draft)")) { - fail("time-grid quick-create must anchor the popover at the exact clicked minute"); -} - -if (!/calendar-new"\]'\)[\s\S]{0,900}startCalendarQuickCreate/.test(render)) { - fail("month/week/day new buttons should use quick-create for all-day drafts"); -} - +// 9. The quick-create popover chrome stays in the calendar stylesheet. for (const term of [ "&-quick-create", "&-quick-create-title", diff --git a/scripts/calendar-settings-add-property-smoke.mjs b/scripts/calendar-settings-add-property-smoke.mjs index f673a5dfc8f..20281cf2db3 100644 --- a/scripts/calendar-settings-add-property-smoke.mjs +++ b/scripts/calendar-settings-add-property-smoke.mjs @@ -32,7 +32,7 @@ const blockAttr = read("app/src/protyle/render/av/blockAttr.ts"); // 1. The properties panel must know the active view type so it can drop the // add-property button for calendar only. -assert(openMenuPanel.includes("export const getPropertiesHTML = (fields: IAVColumn[], viewType?: string)"), +assert(openMenuPanel.includes("export const getPropertiesHTML = (fields: IAVColumn[], viewType: TAVView)"), "getPropertiesHTML must accept the active view type"); assert(openMenuPanel.includes('viewType === "calendar" ? "" : `'), "the add-property (newCol) button must be suppressed for calendar views"); @@ -42,7 +42,7 @@ const threadedCalls = openMenuPanel.split("getPropertiesHTML(fields, data.viewTy assert(threadedCalls === 7, `all 7 openMenuPanel call sites must pass data.viewType (found ${threadedCalls})`); assert(!openMenuPanel.includes("getPropertiesHTML(fields)"), "no openMenuPanel call site may render the properties panel without the view type"); -assert(col.includes("getPropertiesHTML(options.fields, options.viewType)"), +assert(col.includes('options.blockElement.getAttribute("data-av-type") as TAVView'), "removeCol must re-render the properties panel with the view type"); assert(!col.includes("getPropertiesHTML(options.fields)"), "removeCol may not re-render the properties panel without the view type"); From 66f138a6c83d3b85fff47c83e663cad5cb9da821 Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 20:16:19 +0200 Subject: [PATCH 08/10] :bug: Fix calendar view add/field/publish compiler defects https://github.com/siyuan-note/siyuan/issues/13740 --- kernel/model/attribute_view.go | 14 +++++--------- kernel/model/publish_access.go | 18 +----------------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/kernel/model/attribute_view.go b/kernel/model/attribute_view.go index e0aabdb1db6..9814e008dd2 100644 --- a/kernel/model/attribute_view.go +++ b/kernel/model/attribute_view.go @@ -5515,12 +5515,6 @@ func addAttrViewView(avID, viewID, blockID string, layout av.LayoutType) (err er } } - node, tree, _ := getNodeByBlockID(nil, blockID) - if nil == node { - logging.LogErrorf("get node by block ID [%s] failed", blockID) - return - } - node.AttributeViewType = string(view.LayoutType) attrs := parse.IAL2Map(node.KramdownIAL) attrs[av.NodeAttrView] = viewID @@ -7152,10 +7146,12 @@ func getAttributeViewField(view *av.View, keyID string) (ret *av.BaseField) { } } case av.LayoutTypeCalendar: + if nil == view.Calendar { + return + } for _, field := range view.Calendar.Fields { - if field.ID == operation.ID { - field.Hidden = operation.Data.(bool) - break + if field.ID == keyID { + return field.BaseField } } } diff --git a/kernel/model/publish_access.go b/kernel/model/publish_access.go index 4107dfd9c8a..2be87c9e51b 100644 --- a/kernel/model/publish_access.go +++ b/kernel/model/publish_access.go @@ -579,23 +579,7 @@ func FilterViewByPublishAccess(c *gin.Context, publishAccess PublishAccess, view calendar := ret.(*av.Calendar) filteredCards := []*av.CalendarCard{} for _, card := range calendar.Cards { - // 默认第一个属性是文档块 - var bt *treenode.BlockTree - if len(card.Values) > 0 && nil != card.Values[0].Value { - if card.Values[0].Value.Block != nil { - id := card.Values[0].Value.Block.ID - if id != "" { - bt = treenode.GetBlockTree(id) - } - } - } - if bt != nil { - // 不显示禁止文档 - if !CheckPathAccessableByPublishIgnore(bt.BoxID, bt.Path, publishIgnore) { - card = nil - } - } - if card != nil { + if checkAttributeViewItemAccessableByPublishAccess(c, publishAccess, card) { filteredCards = append(filteredCards, card) } } From aa720872c50943b3d35967a1b1b6671d52b95c21 Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 20:49:48 +0200 Subject: [PATCH 09/10] :white_check_mark: Fix consecutive updateAttrViewCell batch test assertions https://github.com/siyuan-note/siyuan/issues/13740 --- kernel/model/transaction_test.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/kernel/model/transaction_test.go b/kernel/model/transaction_test.go index a61e4cf5cb9..fdda15f5590 100644 --- a/kernel/model/transaction_test.go +++ b/kernel/model/transaction_test.go @@ -311,6 +311,8 @@ func TestCalendarItemJournalClearsOnlyAfterIndexFlush(t *testing.T) { // TestCollectConsecutiveUpdateAttrViewCellOpsExecutesEachCellOnce 回归测试: // 连续(同 AvID)的 updateAttrViewCell 操作必须被收集为同一个批量分组,不同 AvID 或 // 非单元格操作必须中断分组;按分组的长度推进遍历下标后,每个单元格操作恰好执行一次。 +// 夹具中的三个连续 av1 单元格必须同属第一个分组,其后依次为单独执行的 +// sortAttrViewRow、av2 单元格以及再次出现的 av1 单元格。 func TestCollectConsecutiveUpdateAttrViewCellOpsExecutesEachCellOnce(t *testing.T) { ops := []*Operation{ {Action: "updateAttrViewCell", AvID: "av1", KeyID: "k1", RowID: "r1"}, @@ -335,26 +337,20 @@ func TestCollectConsecutiveUpdateAttrViewCellOpsExecutesEachCellOnce(t *testing. } } for _, op := range ops { - if "updateAttrViewCell" != op.Action { - continue - } if 1 != seen[op] { - t.Fatalf("cell operation %s/%s executed %d times, want exactly once", op.KeyID, op.RowID, seen[op]) + t.Fatalf("operation %s/%s/%s executed %d times, want exactly once", op.Action, op.KeyID, op.RowID, seen[op]) } } - if 2 != len(groups[0]) { - t.Fatalf("first group must batch the two av1 cells, got %d", len(groups[0])) - } - if 1 != len(groups[1]) || "r2" != groups[1][0].RowID { - t.Fatal("same-AvID but non-consecutive cell must form its own single group") + if 3 != len(groups[0]) || "k1" != groups[0][0].KeyID || "k3" != groups[0][2].KeyID { + t.Fatalf("first group must batch the three consecutive av1 cells, got %d", len(groups[0])) } - if "sortAttrViewRow" != groups[2][0].Action { + if 1 != len(groups[1]) || "sortAttrViewRow" != groups[1][0].Action { t.Fatal("non-cell operation must break the batch and execute singly") } - if 1 != len(groups[3]) || "av2" != groups[3][0].AvID { + if 1 != len(groups[2]) || "av2" != groups[2][0].AvID { t.Fatal("different AvID must break the batch") } - if 1 != len(groups[4]) || "r3" != groups[4][0].RowID { + if 1 != len(groups[3]) || "r3" != groups[3][0].RowID { t.Fatal("later same-AvID cell must form its own group") } } From 30248d4e4ed60eb8c2ca5cce3c8c1b7d837abb1b Mon Sep 17 00:00:00 2001 From: Yi Lok Enoch Lam Date: Sun, 2 Aug 2026 21:12:13 +0200 Subject: [PATCH 10/10] :bug: Support switching attribute view to calendar layout https://github.com/siyuan-note/siyuan/issues/13740 --- kernel/model/attribute_view.go | 2 +- kernel/model/attribute_view_calendar_test.go | 60 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/kernel/model/attribute_view.go b/kernel/model/attribute_view.go index 9814e008dd2..f10bd6003a1 100644 --- a/kernel/model/attribute_view.go +++ b/kernel/model/attribute_view.go @@ -1354,7 +1354,7 @@ func changeAttrViewLayout(attrView *av.AttributeView, view *av.View, newLayout a } switch newLayout { - case av.LayoutTypeTable, av.LayoutTypeGallery, av.LayoutTypeKanban: + case av.LayoutTypeTable, av.LayoutTypeGallery, av.LayoutTypeKanban, av.LayoutTypeCalendar: default: return av.ErrWrongLayoutType } diff --git a/kernel/model/attribute_view_calendar_test.go b/kernel/model/attribute_view_calendar_test.go index d0d2c2be7a8..872ee9791e4 100644 --- a/kernel/model/attribute_view_calendar_test.go +++ b/kernel/model/attribute_view_calendar_test.go @@ -652,3 +652,63 @@ func TestCreateItemOptionsPrimaryKeyOverride(t *testing.T) { t.Fatalf("no options must merge to the zero value, got %#v", merged) } } + +// TestChangeAttrViewLayoutAcceptsCalendar 回归测试: +// /api/av/changeAttrViewLayout 的后端校验必须接受日历布局,否则切换到日历视图会被 +// av.ErrWrongLayoutType 拒绝;切换后布局、名称、字段迁移和日期字段选择都必须正确, +// 非法布局仍然必须被拒绝。 +func TestChangeAttrViewLayoutAcceptsCalendar(t *testing.T) { + oldLangs := util.AttrViewLangs[util.Lang] + util.AttrViewLangs[util.Lang] = map[string]any{ + "table": "table", + "gallery": "gallery", + "kanban": "kanban", + "calendar": "calendar", + } + t.Cleanup(func() { + util.AttrViewLangs[util.Lang] = oldLangs + }) + + attrView := &av.AttributeView{ + KeyValues: []*av.KeyValues{ + {Key: &av.Key{ID: "date", Type: av.KeyTypeDate}}, + }, + } + view := &av.View{ + ID: "view1", + Name: av.GetAttributeViewI18n("table"), + LayoutType: av.LayoutTypeTable, + Table: &av.LayoutTable{ + Columns: []*av.ViewTableColumn{ + {BaseField: &av.BaseField{ID: "date"}}, + }, + }, + } + + if err := changeAttrViewLayout(attrView, view, av.LayoutTypeCalendar); nil != err { + t.Fatalf("calendar layout must be accepted: %v", err) + } + if av.LayoutTypeCalendar != view.LayoutType { + t.Fatalf("expected calendar layout, got %s", view.LayoutType) + } + if nil == view.Calendar { + t.Fatal("expected calendar layout instance") + } + if av.GetAttributeViewI18n("calendar") != view.Name { + t.Fatalf("expected view name to switch to calendar, got %s", view.Name) + } + if 1 != len(view.Calendar.Fields) || "date" != view.Calendar.Fields[0].ID { + t.Fatalf("expected the table column to migrate into calendar fields, got %v", view.Calendar.Fields) + } + if "date" != view.Calendar.DateFieldID { + t.Fatalf("expected the date field to be selected as calendar date field, got %s", view.Calendar.DateFieldID) + } + + // 切换到相同布局必须是无操作,非法布局仍然必须被拒绝 + if err := changeAttrViewLayout(attrView, view, av.LayoutTypeCalendar); nil != err { + t.Fatalf("switching to the same layout should be a no-op: %v", err) + } + if err := changeAttrViewLayout(attrView, view, av.LayoutType("bogus")); av.ErrWrongLayoutType != err { + t.Fatalf("invalid layout must still be rejected, got %v", err) + } +}