diff --git a/core/database/scripts/autobuild/db_00017.sql b/core/database/scripts/autobuild/db_00017.sql index 54353da2..81ea9786 100644 --- a/core/database/scripts/autobuild/db_00017.sql +++ b/core/database/scripts/autobuild/db_00017.sql @@ -4,7 +4,18 @@ ALTER TABLE document ADD COLUMN `protection` INT NOT NULL DEFAULT 0 AFTER `template`; ALTER TABLE document ADD COLUMN `approval` INT NOT NULL DEFAULT 0 AFTER `protection`; +-- page workflow status +ALTER TABLE page ADD COLUMN `status` INT NOT NULL DEFAULT 0 AFTER `revisions`; +-- links pending changes to another page +ALTER TABLE page ADD COLUMN `relativeid` CHAR(16) DEFAULT '' NOT NULL COLLATE utf8_bin AFTER `approval`; + +-- useraction captures what is being actioned +ALTER TABLE useraction ADD COLUMN `reftype` CHAR(1) DEFAULT 'D' NOT NULL COLLATE utf8_bin AFTER `iscomplete`; +ALTER TABLE useraction ADD COLUMN `reftypeid` CHAR(16) NOT NULL COLLATE utf8_bin AFTER `reftype`; + -- data migration clean up from previous releases DROP TABLE IF EXISTS `audit`; DROP TABLE IF EXISTS `search_old`; ALTER TABLE document DROP COLUMN `layout`; +DELETE FROM categorymember WHERE documentid NOT IN (SELECT refid FROM document); +UPDATE page SET level=1 WHERE level=0; diff --git a/core/env/logger.go b/core/env/logger.go index 12f15b8d..46db21d2 100644 --- a/core/env/logger.go +++ b/core/env/logger.go @@ -15,6 +15,7 @@ package env // Logger provides the interface for Documize compatible loggers. type Logger interface { Info(message string) + Trace(message string) Error(message string, err error) // SetDB(l Logger, db *sqlx.DB) Logger } diff --git a/domain/document/endpoint.go b/domain/document/endpoint.go index 7b628049..c304d99e 100644 --- a/domain/document/endpoint.go +++ b/domain/document/endpoint.go @@ -16,6 +16,7 @@ import ( "encoding/json" "io/ioutil" "net/http" + "sort" "github.com/documize/community/core/env" "github.com/documize/community/core/request" @@ -155,7 +156,7 @@ func (h *Handler) BySpace(w http.ResponseWriter, r *http.Request) { // get complete list of documents documents, err := h.Store.Document.GetBySpace(ctx, spaceID) - if err != nil && err != sql.ErrNoRows { + if err != nil { response.WriteServerError(w, method, err) h.Runtime.Log.Error(method, err) return @@ -164,6 +165,8 @@ func (h *Handler) BySpace(w http.ResponseWriter, r *http.Request) { documents = []doc.Document{} } + sort.Sort(doc.ByTitle(documents)) + // remove documents that cannot be seen due to lack of // category view/access permission filtered := []doc.Document{} @@ -411,7 +414,6 @@ func (h *Handler) FetchDocumentData(w http.ResponseWriter, r *http.Request) { if len(perms) == 0 { perms = []pm.Permission{} } - record := pm.DecodeUserPermissions(perms) roles, err := h.Store.Permission.GetUserDocumentPermissions(ctx, document.RefID) @@ -422,7 +424,6 @@ func (h *Handler) FetchDocumentData(w http.ResponseWriter, r *http.Request) { if len(roles) == 0 { roles = []pm.Permission{} } - rolesRecord := pm.DecodeUserDocumentPermissions(roles) // links diff --git a/domain/document/mysql/store.go b/domain/document/mysql/store.go index 3774d757..814abd32 100644 --- a/domain/document/mysql/store.go +++ b/domain/document/mysql/store.go @@ -90,9 +90,9 @@ func (s Scope) DocumentMeta(ctx domain.RequestContext, id string) (meta doc.Docu return } -// GetAll returns a slice containg all of the the documents for the client's organisation, with the most recient first. +// GetAll returns a slice containg all of the the documents for the client's organisation. func (s Scope) GetAll() (ctx domain.RequestContext, documents []doc.Document, err error) { - err = s.Runtime.Db.Select(&documents, "SELECT id, refid, orgid, labelid, userid, job, location, title, excerpt, slug, tags, template, protection, approval, created, revised FROM document WHERE orgid=? AND template=0 ORDER BY revised DESC", ctx.OrgID) + err = s.Runtime.Db.Select(&documents, "SELECT id, refid, orgid, labelid, userid, job, location, title, excerpt, slug, tags, template, protection, approval, created, revised FROM document WHERE orgid=? AND template=0 ORDER BY title", ctx.OrgID) if err != nil { err = errors.Wrap(err, "select documents") @@ -118,6 +118,9 @@ func (s Scope) GetBySpace(ctx domain.RequestContext, spaceID string) (documents ) ORDER BY title`, ctx.OrgID, ctx.OrgID, ctx.OrgID, spaceID, ctx.OrgID, ctx.UserID, ctx.OrgID, spaceID, ctx.UserID) + if err == sql.ErrNoRows { + err = nil + } if err != nil { err = errors.Wrap(err, "select documents by space") } @@ -277,6 +280,11 @@ func (s Scope) Delete(ctx domain.RequestContext, documentID string) (rows int64, return } + _, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from categorymember WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID)) + if err != nil { + return + } + return b.DeleteConstrained(ctx.Transaction, "document", ctx.OrgID, documentID) } diff --git a/domain/page/endpoint.go b/domain/page/endpoint.go index 40872709..3f01764a 100644 --- a/domain/page/endpoint.go +++ b/domain/page/endpoint.go @@ -31,8 +31,9 @@ import ( "github.com/documize/community/domain/section/provider" "github.com/documize/community/model/activity" "github.com/documize/community/model/audit" - "github.com/documize/community/model/doc" "github.com/documize/community/model/page" + pm "github.com/documize/community/model/permission" + "github.com/documize/community/model/workflow" htmldiff "github.com/documize/html-diff" ) @@ -53,17 +54,14 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { return } + // check param documentID := request.Param(r, "documentID") if len(documentID) == 0 { response.WriteMissingDataError(w, method, "documentID") return } - if !permission.CanChangeDocument(ctx, *h.Store, documentID) { - response.WriteForbiddenError(w) - return - } - + // read payload defer streamutil.Close(r.Body) body, err := ioutil.ReadAll(r.Body) if err != nil { @@ -90,6 +88,38 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { return } + // Check protection and approval process + document, err := h.Store.Document.Get(ctx, documentID) + if err != nil { + response.WriteBadRequestError(w, method, err.Error()) + h.Runtime.Log.Error(method, err) + return + } + + // Protect locked + if document.Protection == workflow.ProtectionLock { + response.WriteForbiddenError(w) + h.Runtime.Log.Info("attempted write to locked document") + return + } + + // Check edit permission + if !permission.CanChangeDocument(ctx, *h.Store, documentID) { + response.WriteForbiddenError(w) + return + } + + // if document review process then we must mark page as pending + if document.Protection == workflow.ProtectionReview { + if model.Page.RelativeID == "" { + model.Page.Status = workflow.ChangePendingNew + } else { + model.Page.Status = workflow.ChangePending + } + } else { + model.Page.Status = workflow.ChangePublished + } + pageID := uniqueid.Generate() model.Page.RefID = pageID model.Meta.PageID = pageID @@ -97,7 +127,6 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { model.Meta.UserID = ctx.UserID // required for Render call below model.Page.SetDefaults() model.Meta.SetDefaults() - // page.Title = template.HTMLEscapeString(page.Title) doc, err := h.Store.Document.Get(ctx, documentID) if err != nil { @@ -229,16 +258,11 @@ func (h *Handler) GetPages(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, pages) } -// Delete deletes a page. -func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { - method := "page.delete" +// GetMeta gets page meta data for specified document page. +func (h *Handler) GetMeta(w http.ResponseWriter, r *http.Request) { + method := "page.meta" ctx := domain.GetRequestContext(r) - if !h.Runtime.Product.License.IsValid() { - response.WriteBadLicense(w) - return - } - documentID := request.Param(r, "documentID") if len(documentID) == 0 { response.WriteMissingDataError(w, method, "documentID") @@ -251,156 +275,31 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } - if !permission.CanChangeDocument(ctx, *h.Store, documentID) { + if !permission.CanViewDocument(ctx, *h.Store, documentID) { response.WriteForbiddenError(w) return } - doc, err := h.Store.Document.Get(ctx, documentID) + meta, err := h.Store.Page.GetPageMeta(ctx, pageID) + if err == sql.ErrNoRows { + response.WriteNotFoundError(w, method, pageID) + h.Runtime.Log.Info(method + " no record") + meta = page.Meta{} + response.WriteJSON(w, meta) + return + } if err != nil { response.WriteServerError(w, method, err) h.Runtime.Log.Error(method, err) return } - - ctx.Transaction, err = h.Runtime.Db.Beginx() - if err != nil { - response.WriteServerError(w, method, err) + if meta.DocumentID != documentID { + response.WriteBadRequestError(w, method, "documentID mismatch") h.Runtime.Log.Error(method, err) return } - page, err := h.Store.Page.Get(ctx, pageID) - if err != nil { - ctx.Transaction.Rollback() - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - - if len(page.BlockID) > 0 { - h.Store.Block.DecrementUsage(ctx, page.BlockID) - } - - _, err = h.Store.Page.Delete(ctx, documentID, pageID) - if err != nil { - ctx.Transaction.Rollback() - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - - h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ - LabelID: doc.LabelID, - SourceID: documentID, - SourceType: activity.SourceTypeDocument, - ActivityType: activity.TypeDeleted}) - - h.Store.Audit.Record(ctx, audit.EventTypeSectionDelete) - - go h.Indexer.DeleteContent(ctx, pageID) - - h.Store.Link.DeleteSourcePageLinks(ctx, pageID) - - h.Store.Link.MarkOrphanPageLink(ctx, pageID) - - h.Store.Page.DeletePageRevisions(ctx, pageID) - - ctx.Transaction.Commit() - - response.WriteEmpty(w) -} - -// DeletePages batch deletes pages. -func (h *Handler) DeletePages(w http.ResponseWriter, r *http.Request) { - method := "page.delete.pages" - ctx := domain.GetRequestContext(r) - - if !h.Runtime.Product.License.IsValid() { - response.WriteBadLicense(w) - return - } - - documentID := request.Param(r, "documentID") - if len(documentID) == 0 { - response.WriteMissingDataError(w, method, "documentID") - return - } - - if !permission.CanChangeDocument(ctx, *h.Store, documentID) { - response.WriteForbiddenError(w) - return - } - - defer streamutil.Close(r.Body) - body, err := ioutil.ReadAll(r.Body) - if err != nil { - response.WriteBadRequestError(w, method, "Bad body") - return - } - - model := new([]page.LevelRequest) - err = json.Unmarshal(body, &model) - if err != nil { - response.WriteBadRequestError(w, method, "JSON marshal") - return - } - - doc, err := h.Store.Document.Get(ctx, documentID) - if err != nil { - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - - ctx.Transaction, err = h.Runtime.Db.Beginx() - if err != nil { - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - - for _, page := range *model { - pageData, err := h.Store.Page.Get(ctx, page.PageID) - if err != nil { - ctx.Transaction.Rollback() - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - - if len(pageData.BlockID) > 0 { - h.Store.Block.DecrementUsage(ctx, pageData.BlockID) - } - - _, err = h.Store.Page.Delete(ctx, documentID, page.PageID) - if err != nil { - ctx.Transaction.Rollback() - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - - go h.Indexer.DeleteContent(ctx, page.PageID) - - h.Store.Link.DeleteSourcePageLinks(ctx, page.PageID) - - h.Store.Link.MarkOrphanPageLink(ctx, page.PageID) - - h.Store.Page.DeletePageRevisions(ctx, page.PageID) - } - - h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ - LabelID: doc.LabelID, - SourceID: documentID, - SourceType: activity.SourceTypeDocument, - ActivityType: activity.TypeDeleted}) - - h.Store.Audit.Record(ctx, audit.EventTypeSectionDelete) - - ctx.Transaction.Commit() - - response.WriteEmpty(w) + response.WriteJSON(w, meta) } // Update will persist changed page and note the fact @@ -415,11 +314,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } - // if !ctx.Editor { - // response.WriteForbiddenError(w) - // return - // } - + // Check params documentID := request.Param(r, "documentID") if len(documentID) == 0 { response.WriteMissingDataError(w, method, "documentID") @@ -432,11 +327,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } - if !permission.CanChangeDocument(ctx, *h.Store, documentID) { - response.WriteForbiddenError(w) - return - } - + // Read payload defer streamutil.Close(r.Body) body, err := ioutil.ReadAll(r.Body) if err != nil { @@ -458,13 +349,26 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } + // Check protection and approval process doc, err := h.Store.Document.Get(ctx, documentID) if err != nil { - response.WriteServerError(w, method, err) + response.WriteBadRequestError(w, method, err.Error()) h.Runtime.Log.Error(method, err) return } + if doc.Protection == workflow.ProtectionLock { + response.WriteForbiddenError(w) + h.Runtime.Log.Info("attempted write to locked document") + return + } + + // Check edit permission + if !permission.CanChangeDocument(ctx, *h.Store, documentID) { + response.WriteForbiddenError(w) + return + } + ctx.Transaction, err = h.Runtime.Db.Beginx() if err != nil { response.WriteServerError(w, method, err) @@ -492,6 +396,11 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { skipRevision := false skipRevision, err = strconv.ParseBool(request.Query(r, "r")) + // We don't track revisions for non-published pages + if model.Page.Status != workflow.ChangePublished { + skipRevision = true + } + err = h.Store.Page.Update(ctx, model.Page, refID, ctx.UserID, skipRevision) if err != nil { response.WriteServerError(w, method, err) @@ -562,6 +471,258 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, updatedPage) } +// Delete deletes a page. +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + method := "page.delete" + ctx := domain.GetRequestContext(r) + + if !h.Runtime.Product.License.IsValid() { + response.WriteBadLicense(w) + return + } + + documentID := request.Param(r, "documentID") + if len(documentID) == 0 { + response.WriteMissingDataError(w, method, "documentID") + return + } + + pageID := request.Param(r, "pageID") + if len(pageID) == 0 { + response.WriteMissingDataError(w, method, "pageID") + return + } + + if !permission.CanChangeDocument(ctx, *h.Store, documentID) { + response.WriteForbiddenError(w) + return + } + + doc, err := h.Store.Document.Get(ctx, documentID) + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + // Protect locked + if doc.Protection == workflow.ProtectionLock { + response.WriteForbiddenError(w) + h.Runtime.Log.Info("attempted delete section on locked document") + return + } + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + p, err := h.Store.Page.Get(ctx, pageID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + if len(p.BlockID) > 0 { + h.Store.Block.DecrementUsage(ctx, p.BlockID) + } + + _, err = h.Store.Page.Delete(ctx, documentID, pageID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + LabelID: doc.LabelID, + SourceID: documentID, + SourceType: activity.SourceTypeDocument, + ActivityType: activity.TypeDeleted}) + + h.Store.Audit.Record(ctx, audit.EventTypeSectionDelete) + + go h.Indexer.DeleteContent(ctx, pageID) + + h.Store.Link.DeleteSourcePageLinks(ctx, pageID) + + h.Store.Link.MarkOrphanPageLink(ctx, pageID) + + h.Store.Page.DeletePageRevisions(ctx, pageID) + + ctx.Transaction.Commit() + + // Re-level all pages in document + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + p2, err := h.Store.Page.GetPages(ctx, documentID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + page.Levelize(p2) + + for _, i := range p2 { + err = h.Store.Page.UpdateLevel(ctx, documentID, i.RefID, int(i.Level)) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + } + + ctx.Transaction.Commit() + + response.WriteEmpty(w) +} + +// DeletePages batch deletes pages. +func (h *Handler) DeletePages(w http.ResponseWriter, r *http.Request) { + method := "page.delete.pages" + ctx := domain.GetRequestContext(r) + + if !h.Runtime.Product.License.IsValid() { + response.WriteBadLicense(w) + return + } + + documentID := request.Param(r, "documentID") + if len(documentID) == 0 { + response.WriteMissingDataError(w, method, "documentID") + return + } + + if !permission.CanChangeDocument(ctx, *h.Store, documentID) { + response.WriteForbiddenError(w) + return + } + + defer streamutil.Close(r.Body) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, "Bad body") + return + } + + model := new([]page.LevelRequest) + err = json.Unmarshal(body, &model) + if err != nil { + response.WriteBadRequestError(w, method, "JSON marshal") + return + } + + doc, err := h.Store.Document.Get(ctx, documentID) + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + // Protect locked + if doc.Protection == workflow.ProtectionLock { + response.WriteForbiddenError(w) + h.Runtime.Log.Info("attempted delete sections on locked document") + return + } + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + for _, page := range *model { + pageData, err := h.Store.Page.Get(ctx, page.PageID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + if len(pageData.BlockID) > 0 { + h.Store.Block.DecrementUsage(ctx, pageData.BlockID) + } + + _, err = h.Store.Page.Delete(ctx, documentID, page.PageID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + go h.Indexer.DeleteContent(ctx, page.PageID) + + h.Store.Link.DeleteSourcePageLinks(ctx, page.PageID) + + h.Store.Link.MarkOrphanPageLink(ctx, page.PageID) + + h.Store.Page.DeletePageRevisions(ctx, page.PageID) + } + + h.Store.Activity.RecordUserActivity(ctx, activity.UserActivity{ + LabelID: doc.LabelID, + SourceID: documentID, + SourceType: activity.SourceTypeDocument, + ActivityType: activity.TypeDeleted}) + + h.Store.Audit.Record(ctx, audit.EventTypeSectionDelete) + + ctx.Transaction.Commit() + + // Re-level all pages in document + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + p2, err := h.Store.Page.GetPages(ctx, documentID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + page.Levelize(p2) + + for _, i := range p2 { + err = h.Store.Page.UpdateLevel(ctx, documentID, i.RefID, int(i.Level)) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + } + + ctx.Transaction.Commit() + + response.WriteEmpty(w) +} + +//************************************************** +// Table of Contents +//************************************************** + // ChangePageSequence will swap page sequence for a given number of pages. func (h *Handler) ChangePageSequence(w http.ResponseWriter, r *http.Request) { method := "page.sequence" @@ -684,75 +845,10 @@ func (h *Handler) ChangePageLevel(w http.ResponseWriter, r *http.Request) { response.WriteEmpty(w) } -// GetMeta gets page meta data for specified document page. -func (h *Handler) GetMeta(w http.ResponseWriter, r *http.Request) { - method := "page.meta" - ctx := domain.GetRequestContext(r) - - documentID := request.Param(r, "documentID") - if len(documentID) == 0 { - response.WriteMissingDataError(w, method, "documentID") - return - } - - pageID := request.Param(r, "pageID") - if len(pageID) == 0 { - response.WriteMissingDataError(w, method, "pageID") - return - } - - if !permission.CanViewDocument(ctx, *h.Store, documentID) { - response.WriteForbiddenError(w) - return - } - - meta, err := h.Store.Page.GetPageMeta(ctx, pageID) - if err == sql.ErrNoRows { - response.WriteNotFoundError(w, method, pageID) - h.Runtime.Log.Info(method + " no record") - meta = page.Meta{} - response.WriteJSON(w, meta) - return - } - if err != nil { - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - if meta.DocumentID != documentID { - response.WriteBadRequestError(w, method, "documentID mismatch") - h.Runtime.Log.Error(method, err) - return - } - - response.WriteJSON(w, meta) -} - //************************************************** // Copy Move Page //************************************************** -// GetMoveCopyTargets returns available documents for page copy/move axction. -func (h *Handler) GetMoveCopyTargets(w http.ResponseWriter, r *http.Request) { - method := "page.targets" - ctx := domain.GetRequestContext(r) - - var d []doc.Document - var err error - - d, err = h.Store.Document.DocumentList(ctx) - if len(d) == 0 { - d = []doc.Document{} - } - if err != nil { - response.WriteServerError(w, method, err) - h.Runtime.Log.Error(method, err) - return - } - - response.WriteJSON(w, d) -} - // Copy copies page to either same or different document. func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) { method := "page.targets" @@ -790,6 +886,12 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) { return } + // workflow check + if doc.Protection == workflow.ProtectionLock || doc.Protection == workflow.ProtectionReview { + response.WriteForbiddenError(w) + return + } + p, err := h.Store.Page.Get(ctx, pageID) if err == sql.ErrNoRows { response.WriteNotFoundError(w, method, documentID) @@ -1101,3 +1203,176 @@ func (h *Handler) Rollback(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, p) } + +//************************************************** +// Bulk data fetching (reduce network traffic) +//************************************************** + +// FetchPages returns all page data for given document: page, meta data, pending changes. +func (h *Handler) FetchPages(w http.ResponseWriter, r *http.Request) { + method := "page.FetchPages" + ctx := domain.GetRequestContext(r) + model := []page.BulkRequest{} + + // check params + documentID := request.Param(r, "documentID") + if len(documentID) == 0 { + response.WriteMissingDataError(w, method, "documentID") + return + } + + doc, err := h.Store.Document.Get(ctx, documentID) + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + // published pages and new pages awaiting approval + pages, err := h.Store.Page.GetPages(ctx, documentID) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + if len(pages) == 0 { + pages = []page.Page{} + } + + // unpublished pages + unpublished, err := h.Store.Page.GetUnpublishedPages(ctx, documentID) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + if len(unpublished) == 0 { + unpublished = []page.Page{} + } + + // meta for all pages + meta, err := h.Store.Page.GetDocumentPageMeta(ctx, documentID, false) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + if len(meta) == 0 { + meta = []page.Meta{} + } + + // permissions + perms, err := h.Store.Permission.GetUserSpacePermissions(ctx, doc.LabelID) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + return + } + if len(perms) == 0 { + perms = []pm.Permission{} + } + permissions := pm.DecodeUserPermissions(perms) + + roles, err := h.Store.Permission.GetUserDocumentPermissions(ctx, doc.RefID) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + return + } + if len(roles) == 0 { + roles = []pm.Permission{} + } + docRoles := pm.DecodeUserDocumentPermissions(roles) + + // check document view permissions + if !permissions.SpaceView && !permissions.SpaceManage && !permissions.SpaceOwner { + response.WriteForbiddenError(w) + return + } + + // process published pages + for _, p := range pages { + // only send back pages that user can see + process := false + forcePending := false + + if process == false && p.Status == workflow.ChangePublished { + process = true + } + if process == false && p.Status == workflow.ChangePendingNew && p.RelativeID == "" && p.UserID == ctx.UserID { + process = true + forcePending = true // user has newly created page which should be treated as pending + } + if process == false && p.Status == workflow.ChangeUnderReview && p.RelativeID == "" && p.UserID == ctx.UserID { + process = true + forcePending = true // user has newly created page which should be treated as pending + } + if process == false && p.Status == workflow.ChangeUnderReview && p.RelativeID == "" && (permissions.DocumentApprove || docRoles.DocumentRoleApprove) { + process = true + forcePending = true // user has newly created page which should be treated as pending + } + + if process { + d := page.BulkRequest{} + d.Page = p + + for _, m := range meta { + if p.RefID == m.PageID { + d.Meta = m + break + } + } + + d.Pending = []page.PendingPage{} + + // process pending pages + for _, up := range unpublished { + if up.RelativeID == p.RefID { + ud := page.PendingPage{} + ud.Page = up + + for _, m := range meta { + if up.RefID == m.PageID { + ud.Meta = m + break + } + } + + owner, err := h.Store.User.Get(ctx, up.UserID) + if err == nil { + ud.Owner = owner.Fullname() + } + + d.Pending = append(d.Pending, ud) + } + } + + // Handle situation where we need approval, and user has created new page + if forcePending && len(d.Pending) == 0 && doc.Protection == workflow.ProtectionReview { + ud := page.PendingPage{} + ud.Page = d.Page + ud.Meta = d.Meta + + owner, err := h.Store.User.Get(ctx, d.Page.UserID) + if err == nil { + ud.Owner = owner.Fullname() + } + + d.Pending = append(d.Pending, ud) + } + + model = append(model, d) + } + } + + // Attach numbers to pages, 1.1, 2.1.1 etc. + t := []page.Page{} + for _, i := range model { + t = append(t, i.Page) + } + page.Numberize(t) + for i, j := range t { + model[i].Page = j + } + + // deliver payload + response.WriteJSON(w, model) +} diff --git a/domain/page/mysql/store.go b/domain/page/mysql/store.go index e9aa035f..49b38856 100644 --- a/domain/page/mysql/store.go +++ b/domain/page/mysql/store.go @@ -28,6 +28,10 @@ type Scope struct { Runtime *env.Runtime } +//************************************************** +// Page Revisions +//************************************************** + // Add inserts the given page into the page table, adds that page to the queue of pages to index and audits that the page has been added. func (s Scope) Add(ctx domain.RequestContext, model page.NewPage) (err error) { model.Page.OrgID = ctx.OrgID @@ -54,8 +58,8 @@ func (s Scope) Add(ctx domain.RequestContext, model page.NewPage) (err error) { model.Page.Sequence = maxSeq * 2 } - _, err = ctx.Transaction.Exec("INSERT INTO page (refid, orgid, documentid, userid, contenttype, pagetype, level, title, body, revisions, sequence, blockid, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - model.Page.RefID, model.Page.OrgID, model.Page.DocumentID, model.Page.UserID, model.Page.ContentType, model.Page.PageType, model.Page.Level, model.Page.Title, model.Page.Body, model.Page.Revisions, model.Page.Sequence, model.Page.BlockID, model.Page.Created, model.Page.Revised) + _, err = ctx.Transaction.Exec("INSERT INTO page (refid, orgid, documentid, userid, contenttype, pagetype, level, title, body, revisions, sequence, blockid, status, relativeid, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + model.Page.RefID, model.Page.OrgID, model.Page.DocumentID, model.Page.UserID, model.Page.ContentType, model.Page.PageType, model.Page.Level, model.Page.Title, model.Page.Body, model.Page.Revisions, model.Page.Sequence, model.Page.BlockID, model.Page.Status, model.Page.RelativeID, model.Page.Created, model.Page.Revised) _, err = ctx.Transaction.Exec("INSERT INTO pagemeta (pageid, orgid, userid, documentid, rawbody, config, externalsource, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", model.Meta.PageID, model.Meta.OrgID, model.Meta.UserID, model.Meta.DocumentID, model.Meta.RawBody, model.Meta.Config, model.Meta.ExternalSource, model.Meta.Created, model.Meta.Revised) @@ -69,7 +73,7 @@ func (s Scope) Add(ctx domain.RequestContext, model page.NewPage) (err error) { // Get returns the pageID page record from the page table. func (s Scope) Get(ctx domain.RequestContext, pageID string) (p page.Page, err error) { - err = s.Runtime.Db.Get(&p, "SELECT a.id, a.refid, a.orgid, a.documentid, a.userid, a.contenttype, a.pagetype, a.level, a.sequence, a.title, a.body, a.revisions, a.blockid, a.created, a.revised FROM page a WHERE a.orgid=? AND a.refid=?", + err = s.Runtime.Db.Get(&p, "SELECT a.id, a.refid, a.orgid, a.documentid, a.userid, a.contenttype, a.pagetype, a.level, a.sequence, a.title, a.body, a.revisions, a.blockid, a.status, a.relativeid, a.created, a.revised FROM page a WHERE a.orgid=? AND a.refid=?", ctx.OrgID, pageID) if err != nil { @@ -79,9 +83,9 @@ func (s Scope) Get(ctx domain.RequestContext, pageID string) (p page.Page, err e return } -// GetPages returns a slice containing all the page records for a given documentID, in presentation sequence. +// GetPages returns a slice containing all published page records for a given documentID, in presentation sequence. func (s Scope) GetPages(ctx domain.RequestContext, documentID string) (p []page.Page, err error) { - err = s.Runtime.Db.Select(&p, "SELECT a.id, a.refid, a.orgid, a.documentid, a.userid, a.contenttype, a.pagetype, a.level, a.sequence, a.title, a.body, a.revisions, a.blockid, a.created, a.revised FROM page a WHERE a.orgid=? AND a.documentid=? ORDER BY a.sequence", ctx.OrgID, documentID) + err = s.Runtime.Db.Select(&p, "SELECT a.id, a.refid, a.orgid, a.documentid, a.userid, a.contenttype, a.pagetype, a.level, a.sequence, a.title, a.body, a.revisions, a.blockid, a.status, a.relativeid, a.created, a.revised FROM page a WHERE a.orgid=? AND a.documentid=? AND (a.status=0 OR ((a.status=4 OR a.status=2) AND a.relativeid='')) ORDER BY a.sequence", ctx.OrgID, documentID) if err != nil { err = errors.Wrap(err, "execute get pages") @@ -90,10 +94,21 @@ func (s Scope) GetPages(ctx domain.RequestContext, documentID string) (p []page. return } +// GetUnpublishedPages returns a slice containing all published page records for a given documentID, in presentation sequence. +func (s Scope) GetUnpublishedPages(ctx domain.RequestContext, documentID string) (p []page.Page, err error) { + err = s.Runtime.Db.Select(&p, "SELECT a.id, a.refid, a.orgid, a.documentid, a.userid, a.contenttype, a.pagetype, a.level, a.sequence, a.title, a.body, a.revisions, a.blockid, a.status, a.relativeid, a.created, a.revised FROM page a WHERE a.orgid=? AND a.documentid=? AND a.status!=0 AND a.relativeid!='' ORDER BY a.sequence", ctx.OrgID, documentID) + + if err != nil { + err = errors.Wrap(err, "execute get unpublished pages") + } + + return +} + // GetPagesWithoutContent returns a slice containing all the page records for a given documentID, in presentation sequence, // but without the body field (which holds the HTML content). func (s Scope) GetPagesWithoutContent(ctx domain.RequestContext, documentID string) (pages []page.Page, err error) { - err = s.Runtime.Db.Select(&pages, "SELECT id, refid, orgid, documentid, userid, contenttype, pagetype, sequence, level, title, revisions, blockid, created, revised FROM page WHERE orgid=? AND documentid=? ORDER BY sequence", ctx.OrgID, documentID) + err = s.Runtime.Db.Select(&pages, "SELECT id, refid, orgid, documentid, userid, contenttype, pagetype, sequence, level, title, revisions, blockid, status, relativeid, created, revised FROM page WHERE orgid=? AND documentid=? AND status=0 ORDER BY sequence", ctx.OrgID, documentID) if err != nil { err = errors.Wrap(err, fmt.Sprintf("Unable to execute select pages for org %s and document %s", ctx.OrgID, documentID)) @@ -119,7 +134,7 @@ func (s Scope) Update(ctx domain.RequestContext, page page.Page, refID, userID s } // Update page - _, err = ctx.Transaction.NamedExec("UPDATE page SET documentid=:documentid, level=:level, title=:title, body=:body, revisions=:revisions, sequence=:sequence, revised=:revised WHERE orgid=:orgid AND refid=:refid", + _, err = ctx.Transaction.NamedExec("UPDATE page SET documentid=:documentid, level=:level, title=:title, body=:body, revisions=:revisions, sequence=:sequence, status=:status, relativeid=:relativeid, revised=:revised WHERE orgid=:orgid AND refid=:refid", &page) if err != nil { @@ -139,6 +154,27 @@ func (s Scope) Update(ctx domain.RequestContext, page page.Page, refID, userID s return } +// Delete deletes the pageID page in the document. +// It then propagates that change into the search table, adds a delete the page revisions history, and audits that the page has been removed. +func (s Scope) Delete(ctx domain.RequestContext, documentID, pageID string) (rows int64, err error) { + b := mysql.BaseQuery{} + rows, err = b.DeleteConstrained(ctx.Transaction, "page", ctx.OrgID, pageID) + + if err == nil { + _, _ = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM pagemeta WHERE orgid='%s' AND pageid='%s'", ctx.OrgID, pageID)) + } + + if err == nil { + _, _ = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM useraction WHERE orgid='%s' AND reftypeid='%s' AND reftype='P'", ctx.OrgID, pageID)) + } + + return +} + +//************************************************** +// Page Meta +//************************************************** + // UpdateMeta persists meta information associated with a document page. func (s Scope) UpdateMeta(ctx domain.RequestContext, meta page.Meta, updateUserID bool) (err error) { meta.Revised = time.Now().UTC() @@ -157,43 +193,6 @@ func (s Scope) UpdateMeta(ctx domain.RequestContext, meta page.Meta, updateUserI return } -// UpdateSequence changes the presentation sequence of the pageID page in the document. -// It then propagates that change into the search table and audits that it has occurred. -func (s Scope) UpdateSequence(ctx domain.RequestContext, documentID, pageID string, sequence float64) (err error) { - _, err = ctx.Transaction.Exec("UPDATE page SET sequence=? WHERE orgid=? AND refid=?", sequence, ctx.OrgID, pageID) - - if err != nil { - err = errors.Wrap(err, "execute page sequence update") - } - - return -} - -// UpdateLevel changes the heading level of the pageID page in the document. -// It then propagates that change into the search table and audits that it has occurred. -func (s Scope) UpdateLevel(ctx domain.RequestContext, documentID, pageID string, level int) (err error) { - _, err = ctx.Transaction.Exec("UPDATE page SET level=? WHERE orgid=? AND refid=?", level, ctx.OrgID, pageID) - - if err != nil { - err = errors.Wrap(err, "execute page level update") - } - - return -} - -// Delete deletes the pageID page in the document. -// It then propagates that change into the search table, adds a delete the page revisions history, and audits that the page has been removed. -func (s Scope) Delete(ctx domain.RequestContext, documentID, pageID string) (rows int64, err error) { - b := mysql.BaseQuery{} - rows, err = b.DeleteConstrained(ctx.Transaction, "page", ctx.OrgID, pageID) - - if err == nil { - _, _ = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM pagemeta WHERE orgid='%s' AND pageid='%s'", ctx.OrgID, pageID)) - } - - return -} - // GetPageMeta returns the meta information associated with the page. func (s Scope) GetPageMeta(ctx domain.RequestContext, pageID string) (meta page.Meta, err error) { err = s.Runtime.Db.Get(&meta, "SELECT id, pageid, orgid, userid, documentid, rawbody, coalesce(config,JSON_UNQUOTE('{}')) as config, externalsource, created, revised FROM pagemeta WHERE orgid=? AND pageid=?", @@ -222,9 +221,51 @@ func (s Scope) GetDocumentPageMeta(ctx domain.RequestContext, documentID string, return } -/******************** -* Page Revisions -********************/ +//************************************************** +// Table of contents +//************************************************** + +// UpdateSequence changes the presentation sequence of the pageID page in the document. +// It then propagates that change into the search table and audits that it has occurred. +func (s Scope) UpdateSequence(ctx domain.RequestContext, documentID, pageID string, sequence float64) (err error) { + _, err = ctx.Transaction.Exec("UPDATE page SET sequence=? WHERE orgid=? AND refid=?", sequence, ctx.OrgID, pageID) + + if err != nil { + err = errors.Wrap(err, "execute page sequence update") + } + + return +} + +// UpdateLevel changes the heading level of the pageID page in the document. +// It then propagates that change into the search table and audits that it has occurred. +func (s Scope) UpdateLevel(ctx domain.RequestContext, documentID, pageID string, level int) (err error) { + _, err = ctx.Transaction.Exec("UPDATE page SET level=? WHERE orgid=? AND refid=?", level, ctx.OrgID, pageID) + + if err != nil { + err = errors.Wrap(err, "execute page level update") + } + + return +} + +// GetNextPageSequence returns the next sequence numbner to use for a page in given document. +func (s Scope) GetNextPageSequence(ctx domain.RequestContext, documentID string) (maxSeq float64, err error) { + row := s.Runtime.Db.QueryRow("SELECT max(sequence) FROM page WHERE orgid=? AND documentid=?", ctx.OrgID, documentID) + + err = row.Scan(&maxSeq) + if err != nil { + maxSeq = 2048 + } + + maxSeq = maxSeq * 2 + + return +} + +//************************************************** +// Page Revisions +//************************************************** // GetPageRevision returns the revisionID page revision record. func (s Scope) GetPageRevision(ctx domain.RequestContext, revisionID string) (revision page.Revision, err error) { @@ -273,17 +314,3 @@ func (s Scope) DeletePageRevisions(ctx domain.RequestContext, pageID string) (ro return } - -// GetNextPageSequence returns the next sequence numbner to use for a page in given document. -func (s Scope) GetNextPageSequence(ctx domain.RequestContext, documentID string) (maxSeq float64, err error) { - row := s.Runtime.Db.QueryRow("SELECT max(sequence) FROM page WHERE orgid=? AND documentid=?", ctx.OrgID, documentID) - - err = row.Scan(&maxSeq) - if err != nil { - maxSeq = 2048 - } - - maxSeq = maxSeq * 2 - - return -} diff --git a/domain/permission/endpoint.go b/domain/permission/endpoint.go index 5e705d8c..2fe3a4af 100644 --- a/domain/permission/endpoint.go +++ b/domain/permission/endpoint.go @@ -432,7 +432,7 @@ func (h *Handler) GetDocumentPermissions(w http.ResponseWriter, r *http.Request) response.WriteJSON(w, records) } -// GetUserDocumentPermissions returns permissions for the requested space, for current user. +// GetUserDocumentPermissions returns permissions for the requested document, for current user. func (h *Handler) GetUserDocumentPermissions(w http.ResponseWriter, r *http.Request) { method := "space.GetUserDocumentPermissions" ctx := domain.GetRequestContext(r) diff --git a/domain/permission/permission.go b/domain/permission/permission.go index ca70c654..a23e9118 100644 --- a/domain/permission/permission.go +++ b/domain/permission/permission.go @@ -16,6 +16,7 @@ import ( "github.com/documize/community/domain" pm "github.com/documize/community/model/permission" + u "github.com/documize/community/model/user" ) // CanViewSpaceDocument returns if the user has permission to view a document within the specified folder. @@ -153,7 +154,6 @@ func CanViewSpace(ctx domain.RequestContext, s domain.Store, spaceID string) boo if err != nil { return false } - for _, role := range roles { if role.RefID == spaceID && role.Location == "space" && role.Scope == "object" && pm.ContainsPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) { @@ -187,3 +187,40 @@ func HasPermission(ctx domain.RequestContext, s domain.Store, spaceID string, ac return false } + +// GetDocumentApprovers returns list of users who can approve given document in given space +func GetDocumentApprovers(ctx domain.RequestContext, s domain.Store, spaceID, documentID string) (users []u.User, err error) { + users = []u.User{} + prev := make(map[string]bool) // used to ensure we only process user once + + // check space permissions + sp, err := s.Permission.GetSpacePermissions(ctx, spaceID) + for _, p := range sp { + if p.Action == pm.DocumentApprove { + user, err := s.User.Get(ctx, p.WhoID) + if err == nil { + prev[user.RefID] = true + users = append(users, user) + } else { + return users, err + } + } + } + + // check document permissions + dp, err := s.Permission.GetDocumentPermissions(ctx, documentID) + for _, p := range dp { + if p.Action == pm.DocumentApprove { + user, err := s.User.Get(ctx, p.WhoID) + if err == nil { + if _, isExisting := prev[user.RefID]; !isExisting { + users = append(users, user) + } + } else { + return users, err + } + } + } + + return +} diff --git a/domain/search/mysql/store.go b/domain/search/mysql/store.go index 09412f1c..689ca5ae 100644 --- a/domain/search/mysql/store.go +++ b/domain/search/mysql/store.go @@ -24,6 +24,7 @@ import ( "github.com/documize/community/model/doc" "github.com/documize/community/model/page" "github.com/documize/community/model/search" + "github.com/documize/community/model/workflow" "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) @@ -93,6 +94,11 @@ func (s Scope) DeleteDocument(ctx domain.RequestContext, ID string) (err error) // IndexContent adds search index entry for document context. // Any existing document entries are removed. func (s Scope) IndexContent(ctx domain.RequestContext, p page.Page) (err error) { + // we do not index pending pages + if p.Status == workflow.ChangePending || p.Status == workflow.ChangePendingNew { + return + } + // remove previous search entries _, err = ctx.Transaction.Exec("DELETE FROM search WHERE orgid=? AND documentid=? AND itemid=? AND itemtype='page'", ctx.OrgID, p.DocumentID, p.RefID) diff --git a/domain/storer.go b/domain/storer.go index 7adffedb..86aa956a 100644 --- a/domain/storer.go +++ b/domain/storer.go @@ -248,17 +248,18 @@ type PageStorer interface { Add(ctx RequestContext, model page.NewPage) (err error) Get(ctx RequestContext, pageID string) (p page.Page, err error) GetPages(ctx RequestContext, documentID string) (p []page.Page, err error) + GetUnpublishedPages(ctx RequestContext, documentID string) (p []page.Page, err error) GetPagesWithoutContent(ctx RequestContext, documentID string) (pages []page.Page, err error) Update(ctx RequestContext, page page.Page, refID, userID string, skipRevision bool) (err error) + Delete(ctx RequestContext, documentID, pageID string) (rows int64, err error) + GetPageMeta(ctx RequestContext, pageID string) (meta page.Meta, err error) + GetDocumentPageMeta(ctx RequestContext, documentID string, externalSourceOnly bool) (meta []page.Meta, err error) UpdateMeta(ctx RequestContext, meta page.Meta, updateUserID bool) (err error) UpdateSequence(ctx RequestContext, documentID, pageID string, sequence float64) (err error) UpdateLevel(ctx RequestContext, documentID, pageID string, level int) (err error) - Delete(ctx RequestContext, documentID, pageID string) (rows int64, err error) - GetPageMeta(ctx RequestContext, pageID string) (meta page.Meta, err error) + GetNextPageSequence(ctx RequestContext, documentID string) (maxSeq float64, err error) GetPageRevision(ctx RequestContext, revisionID string) (revision page.Revision, err error) GetPageRevisions(ctx RequestContext, pageID string) (revisions []page.Revision, err error) GetDocumentRevisions(ctx RequestContext, documentID string) (revisions []page.Revision, err error) - GetDocumentPageMeta(ctx RequestContext, documentID string, externalSourceOnly bool) (meta []page.Meta, err error) DeletePageRevisions(ctx RequestContext, pageID string) (rows int64, err error) - GetNextPageSequence(ctx RequestContext, documentID string) (maxSeq float64, err error) } diff --git a/edition/community.go b/edition/community.go index 1ebdffcc..0ec35b0d 100644 --- a/edition/community.go +++ b/edition/community.go @@ -33,7 +33,7 @@ func main() { rt := env.Runtime{} // wire up logging implementation - rt.Log = logging.NewLogger() + rt.Log = logging.NewLogger(false) // wire up embedded web assets handler web.Embed = embed.NewEmbedder() diff --git a/edition/logging/logger.go b/edition/logging/logger.go index c83fdc17..0a41e3cb 100644 --- a/edition/logging/logger.go +++ b/edition/logging/logger.go @@ -25,8 +25,9 @@ import ( // Logger is how we log. type Logger struct { - db *sqlx.DB - log *log.Logger + db *sqlx.DB + log *log.Logger + trace bool // shows Info() entries } // Info logs message. @@ -34,6 +35,13 @@ func (l Logger) Info(message string) { l.log.Println(message) } +// Trace logs message if tracing enabled. +func (l Logger) Trace(message string) { + if l.trace { + l.log.Println(message) + } +} + // Error logs error with message. func (l Logger) Error(message string, err error) { l.log.Println(message) @@ -56,13 +64,14 @@ func (l Logger) SetDB(logger env.Logger, db *sqlx.DB) env.Logger { } // NewLogger returns initialized logging instance. -func NewLogger() env.Logger { +func NewLogger(trace bool) env.Logger { l := log.New(os.Stdout, "", 0) l.SetOutput(os.Stdout) // log.SetOutput(os.Stdout) var logger Logger logger.log = l + logger.trace = trace return logger } diff --git a/gui/app/components/document/document-page.js b/gui/app/components/document/document-page.js index d0278dd5..ca7b2106 100644 --- a/gui/app/components/document/document-page.js +++ b/gui/app/components/document/document-page.js @@ -10,12 +10,10 @@ // https://documize.com import { inject as service } from '@ember/service'; - import Component from '@ember/component'; -import NotifierMixin from '../../mixins/notifier'; import TooltipMixin from '../../mixins/tooltip'; -export default Component.extend(NotifierMixin, TooltipMixin, { +export default Component.extend(TooltipMixin, { documentService: service('document'), sectionService: service('section'), editMode: false, diff --git a/gui/app/components/document/document-toc.js b/gui/app/components/document/document-toc.js index 7ba3c421..e5af682f 100644 --- a/gui/app/components/document/document-toc.js +++ b/gui/app/components/document/document-toc.js @@ -10,7 +10,7 @@ // https://documize.com import { computed } from '@ember/object'; -import { schedule } from '@ember/runloop' +import { schedule } from '@ember/runloop'; import { inject as service } from '@ember/service'; import Component from '@ember/component'; import tocUtil from '../../utils/toc'; diff --git a/gui/app/components/section/base-editor.js b/gui/app/components/section/base-editor.js index 5994e5a9..d373c02b 100644 --- a/gui/app/components/section/base-editor.js +++ b/gui/app/components/section/base-editor.js @@ -57,7 +57,6 @@ export default Component.extend(ModalMixin, { this.attrs.onCancel(); }, - onAction() { if (this.get('busy')) { return; diff --git a/gui/app/models/page.js b/gui/app/models/page.js index 46f98d93..9f3951aa 100644 --- a/gui/app/models/page.js +++ b/gui/app/models/page.js @@ -12,7 +12,6 @@ import { computed } from '@ember/object'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; -// import { hasMany } from 'ember-data/relationships'; export default Model.extend({ documentId: attr('string'), @@ -28,10 +27,11 @@ export default Model.extend({ body: attr('string'), rawBody: attr('string'), meta: attr(), + approval: attr('number', { defaultValue: 0 }), + relativeId: attr('string'), tagName: computed('level', function () { return "h2"; - // return "h" + (this.get('level') + 1); }), tocIndent: computed('level', function () { diff --git a/gui/app/services/document.js b/gui/app/services/document.js index 5bc1d3d8..1dffd46c 100644 --- a/gui/app/services/document.js +++ b/gui/app/services/document.js @@ -10,7 +10,6 @@ // https://documize.com import { set } from '@ember/object'; - import { A } from '@ember/array'; import ArrayProxy from '@ember/array/proxy'; import Service, { inject as service } from '@ember/service'; @@ -21,6 +20,10 @@ export default Service.extend({ ajax: service(), store: service(), + //************************************************** + // Document + //************************************************** + // Returns document model for specified document id. getDocument(documentId) { return this.get('ajax').request(`documents/${documentId}`, { @@ -62,24 +65,6 @@ export default Service.extend({ }); }, - changePageSequence(documentId, payload) { - let url = `documents/${documentId}/pages/sequence`; - - return this.get('ajax').post(url, { - data: JSON.stringify(payload), - contentType: 'json' - }); - }, - - changePageLevel(documentId, payload) { - let url = `documents/${documentId}/pages/level`; - - return this.get('ajax').post(url, { - data: JSON.stringify(payload), - contentType: 'json' - }); - }, - deleteDocument(documentId) { let url = `documents/${documentId}`; @@ -88,6 +73,20 @@ export default Service.extend({ }); }, + //************************************************** + // Page + //************************************************** + + // addPage inserts new page to an existing document. + addPage(documentId, payload) { + let url = `documents/${documentId}/pages`; + + return this.get('ajax').post(url, { + data: JSON.stringify(payload), + contentType: 'json' + }); + }, + updatePage(documentId, pageId, payload, skipRevision) { var revision = skipRevision ? "?r=true" : "?r=false"; let url = `documents/${documentId}/pages/${pageId}${revision}`; @@ -104,106 +103,6 @@ export default Service.extend({ }); }, - // addPage inserts new page to an existing document. - addPage(documentId, payload) { - let url = `documents/${documentId}/pages`; - - return this.get('ajax').post(url, { - data: JSON.stringify(payload), - contentType: 'json' - }); - }, - - // Nukes multiple pages from the document. - deletePages(documentId, pageId, payload) { - let url = `documents/${documentId}/pages`; - - return this.get('ajax').request(url, { - data: JSON.stringify(payload), - contentType: 'json', - method: 'DELETE' - }); - }, - - // Nukes a single page from the document. - deletePage(documentId, pageId) { - let url = `documents/${documentId}/pages/${pageId}`; - - return this.get('ajax').request(url, { - method: 'DELETE' - }); - }, - - getDocumentRevisions(documentId) { - let url = `documents/${documentId}/revisions`; - - return this.get('ajax').request(url, { - method: "GET" - }); - }, - - getPageRevisions(documentId, pageId) { - let url = `documents/${documentId}/pages/${pageId}/revisions`; - - return this.get('ajax').request(url, { - method: "GET" - }); - }, - - getPageRevisionDiff(documentId, pageId, revisionId) { - let url = `documents/${documentId}/pages/${pageId}/revisions/${revisionId}`; - - return this.get('ajax').request(url, { - method: "GET", - dataType: 'text' - }).then((response) => { - return response; - }).catch(() => { - return ""; - }); - }, - - rollbackPage(documentId, pageId, revisionId) { - let url = `documents/${documentId}/pages/${pageId}/revisions/${revisionId}`; - - return this.get('ajax').request(url, { - method: "POST" - }); - }, - - // document meta referes to number of views, edits, approvals, etc. - getActivity(documentId) { - return this.get('ajax').request(`documents/${documentId}/activity`, { - method: "GET" - }).then((response) => { - let data = []; - data = response.map((obj) => { - let data = this.get('store').normalize('documentActivity', obj); - return this.get('store').push(data); - }); - - return data; - }).catch(() => { - return []; - }); - }, - - // Returns all pages without the content - getTableOfContents(documentId) { - - return this.get('ajax').request(`documents/${documentId}/pages?content=0`, { - method: 'GET' - }).then((response) => { - let data = []; - data = response.map((obj) => { - let data = this.get('store').normalize('page', obj); - return this.get('store').push(data); - }); - - return data; - }); - }, - // Returns all document pages with content getPages(documentId) { return this.get('ajax').request(`documents/${documentId}/pages`, { @@ -243,6 +142,130 @@ export default Service.extend({ }); }, + // Nukes multiple pages from the document. + deletePages(documentId, pageId, payload) { + let url = `documents/${documentId}/pages`; + + return this.get('ajax').request(url, { + data: JSON.stringify(payload), + contentType: 'json', + method: 'DELETE' + }); + }, + + // Nukes a single page from the document. + deletePage(documentId, pageId) { + let url = `documents/${documentId}/pages/${pageId}`; + + return this.get('ajax').request(url, { + method: 'DELETE' + }); + }, + + //************************************************** + // Page Revisions + //************************************************** + + getDocumentRevisions(documentId) { + let url = `documents/${documentId}/revisions`; + + return this.get('ajax').request(url, { + method: "GET" + }); + }, + + getPageRevisions(documentId, pageId) { + let url = `documents/${documentId}/pages/${pageId}/revisions`; + + return this.get('ajax').request(url, { + method: "GET" + }); + }, + + getPageRevisionDiff(documentId, pageId, revisionId) { + let url = `documents/${documentId}/pages/${pageId}/revisions/${revisionId}`; + + return this.get('ajax').request(url, { + method: "GET", + dataType: 'text' + }).then((response) => { + return response; + }).catch(() => { + return ""; + }); + }, + + rollbackPage(documentId, pageId, revisionId) { + let url = `documents/${documentId}/pages/${pageId}/revisions/${revisionId}`; + + return this.get('ajax').request(url, { + method: "POST" + }); + }, + + //************************************************** + // Activity + //************************************************** + + // document meta referes to number of views, edits, approvals, etc. + getActivity(documentId) { + return this.get('ajax').request(`documents/${documentId}/activity`, { + method: "GET" + }).then((response) => { + let data = []; + data = response.map((obj) => { + let data = this.get('store').normalize('documentActivity', obj); + return this.get('store').push(data); + }); + + return data; + }).catch(() => { + return []; + }); + }, + + //************************************************** + // Table of contents + //************************************************** + + // Returns all pages without the content + getTableOfContents(documentId) { + + return this.get('ajax').request(`documents/${documentId}/pages?content=0`, { + method: 'GET' + }).then((response) => { + let data = []; + data = response.map((obj) => { + let data = this.get('store').normalize('page', obj); + return this.get('store').push(data); + }); + + return data; + }); + }, + + changePageSequence(documentId, payload) { + let url = `documents/${documentId}/pages/sequence`; + + return this.get('ajax').post(url, { + data: JSON.stringify(payload), + contentType: 'json' + }); + }, + + changePageLevel(documentId, payload) { + let url = `documents/${documentId}/pages/level`; + + return this.get('ajax').post(url, { + data: JSON.stringify(payload), + contentType: 'json' + }); + }, + + //************************************************** + // Attachments + //************************************************** + // document attachments without the actual content getAttachments(documentId) { diff --git a/gui/app/styles/base.scss b/gui/app/styles/base.scss index 63e76733..053b5055 100644 --- a/gui/app/styles/base.scss +++ b/gui/app/styles/base.scss @@ -10,18 +10,20 @@ // https://documize.com html { - overflow-y: scroll; - font-family: $font-regular; - font-size: 0.875rem; height: 100%; + width: 100%; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -ms-overflow-style: -ms-autohiding-scrollbar; + overflow-y: scroll; + font-size: 0.875rem; } body { height: 100%; min-height: 100%; + width: 100%; + overflow-y: scroll; } a { diff --git a/model/activity/activity.go b/model/activity/activity.go index a50ec583..bd56736b 100644 --- a/model/activity/activity.go +++ b/model/activity/activity.go @@ -38,6 +38,9 @@ const ( // SourceTypeDocument indicates activity against a document. SourceTypeDocument SourceType = 2 + + // SourceTypePage indicates activity against a document page. + SourceTypePage SourceType = 3 ) const ( @@ -70,6 +73,9 @@ const ( // TypeFeedback records user providing document feedback TypeFeedback Type = 10 + + // TypeRejected records user rejecting document + TypeRejected Type = 11 ) // DocumentActivity represents an activity taken against a document. diff --git a/model/doc/doc.go b/model/doc/doc.go index 7a787fbb..c536de4b 100644 --- a/model/doc/doc.go +++ b/model/doc/doc.go @@ -45,6 +45,13 @@ func (d *Document) SetDefaults() { } } +// ByTitle sorts a collection of documents by document title. +type ByTitle []Document + +func (a ByTitle) Len() int { return len(a) } +func (a ByTitle) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByTitle) Less(i, j int) bool { return strings.ToLower(a[i].Title) < strings.ToLower(a[j].Title) } + // DocumentMeta details who viewed the document. type DocumentMeta struct { Viewers []DocumentMetaViewer `json:"viewers"` diff --git a/model/page/numbering.go b/model/page/numbering.go index 51560ab6..4203ee64 100644 --- a/model/page/numbering.go +++ b/model/page/numbering.go @@ -72,3 +72,71 @@ func Numberize(pages []Page) { prevPageLevel = p.Level } } + +// Levelize ensure page level increments are consistent +// after a page is inserted or removed. +// +// Valid: 1, 2, 3, 4, 4, 4, 2, 1 +// Invalid: 1, 2, 4, 4, 2, 1 (note the jump from 2 --> 4) +// +// Rules: +// 1. levels can increase by 1 only (e.g. from 1 to 2 to 3 to 4) +// 2. levels can decrease by any amount (e.g. drop from 4 to 1) +func Levelize(pages []Page) { + var prevLevel uint64 + prevLevel = 1 + + for i := 0; i < len(pages); i++ { + currLevel := pages[i].Level + + // handle deprecated level value of 0 + if pages[i].Level == 0 { + pages[i].Level = 1 + } + + // first time thru + if i == 0 { + // first time thru + pages[i].Level = 1 + prevLevel = 1 + continue + } + + if currLevel == prevLevel { + // nothing doing + continue + } + + if currLevel > prevLevel+1 { + // bad data detected e.g. prevLevel=1 and pages[i].Level=3 + // so we re-level to pages[i].Level=2 and all child pages + // and then increment i to correct point + prevLevel++ + pages[i].Level = prevLevel + + // safety check before entering loop and renumbering child pages + if i+1 <= len(pages) { + + for j := i + 1; j < len(pages); j++ { + if pages[j].Level < prevLevel { + i = j + break + } + + if pages[j].Level == currLevel { + pages[j].Level = prevLevel + } else if (pages[j].Level - prevLevel) > 1 { + currLevel = pages[j].Level + prevLevel++ + pages[j].Level = prevLevel + } + + i = j + } + } + continue + } + + prevLevel = currLevel + } +} diff --git a/model/page/numbering_test.go b/model/page/numbering_test.go index f9606329..a8f62839 100644 --- a/model/page/numbering_test.go +++ b/model/page/numbering_test.go @@ -111,4 +111,124 @@ func TestNumberize3(t *testing.T) { } } -// go test github.com/documize/community/core/model -run TestNumberize +// Tests that numbering does not crash because of bad data +func TestNumberize4(t *testing.T) { + pages := []Page{} + + pages = append(pages, Page{Level: 0, Sequence: 1000}) + pages = append(pages, Page{Level: 1, Sequence: 2000}) + pages = append(pages, Page{Level: 1, Sequence: 3000}) + + // corruption starts here with Level=3 instead of Level=2 + pages = append(pages, Page{Level: 3, Sequence: 4000}) + pages = append(pages, Page{Level: 4, Sequence: 4000}) + pages = append(pages, Page{Level: 1, Sequence: 5000}) + pages = append(pages, Page{Level: 2, Sequence: 6000}) + + Numberize(pages) + + expecting := []string{ + "1", + "2", + "3", + "3.1", + "3.1.1", + // data below cannot be processed due to corruption + "", // should be 4 + "1", // should be 5 + } + + for i, p := range pages { + if p.Numbering != expecting[i] { + t.Errorf("(Test 4) Position %d: expecting %s got %s\n", i, expecting[i], p.Numbering) + } + } +} + +// Tests that good level data is not messed with +func TestLevelize1(t *testing.T) { + pages := []Page{} + + pages = append(pages, Page{Level: 1, Sequence: 1000}) + pages = append(pages, Page{Level: 1, Sequence: 2000}) + pages = append(pages, Page{Level: 2, Sequence: 3000}) + pages = append(pages, Page{Level: 3, Sequence: 4000}) + pages = append(pages, Page{Level: 4, Sequence: 5000}) + pages = append(pages, Page{Level: 1, Sequence: 6000}) + pages = append(pages, Page{Level: 2, Sequence: 7000}) + + Levelize(pages) + + expecting := []uint64{1, 1, 2, 3, 4, 1, 2} + + for i, p := range pages { + if p.Level != expecting[i] { + t.Errorf("(TestLevelize1) Position %d: expecting %d got %d (sequence: %f)\n", i+1, expecting[i], p.Level, p.Sequence) + } + } +} + +// Tests that bad level data +func TestLevelize2(t *testing.T) { + pages := []Page{} + + pages = append(pages, Page{Level: 1, Sequence: 1000}) + pages = append(pages, Page{Level: 1, Sequence: 2000}) + pages = append(pages, Page{Level: 3, Sequence: 3000}) + pages = append(pages, Page{Level: 3, Sequence: 4000}) + pages = append(pages, Page{Level: 4, Sequence: 5000}) + pages = append(pages, Page{Level: 1, Sequence: 6000}) + pages = append(pages, Page{Level: 2, Sequence: 7000}) + + Levelize(pages) + + expecting := []uint64{1, 1, 2, 2, 3, 1, 2} + + for i, p := range pages { + if p.Level != expecting[i] { + t.Errorf("(TestLevelize2) Position %d: expecting %d got %d (sequence: %f)\n", i+1, expecting[i], p.Level, p.Sequence) + } + } +} + +func TestLevelize3(t *testing.T) { + pages := []Page{} + + pages = append(pages, Page{Level: 1, Sequence: 1000}) + pages = append(pages, Page{Level: 4, Sequence: 2000}) + pages = append(pages, Page{Level: 5, Sequence: 3000}) + + Levelize(pages) + + expecting := []uint64{1, 2, 3} + + for i, p := range pages { + if p.Level != expecting[i] { + t.Errorf("(TestLevelize3) Position %d: expecting %d got %d (sequence: %f)\n", i+1, expecting[i], p.Level, p.Sequence) + } + } +} + +func TestLevelize4(t *testing.T) { + pages := []Page{} + + pages = append(pages, Page{Level: 1, Sequence: 1000}) + pages = append(pages, Page{Level: 4, Sequence: 2000}) + pages = append(pages, Page{Level: 5, Sequence: 3000}) + pages = append(pages, Page{Level: 5, Sequence: 4000}) + pages = append(pages, Page{Level: 6, Sequence: 5000}) + pages = append(pages, Page{Level: 6, Sequence: 6000}) + pages = append(pages, Page{Level: 2, Sequence: 7000}) + + Levelize(pages) + + expecting := []uint64{1, 2, 3, 3, 4, 4, 2} + + for i, p := range pages { + if p.Level != expecting[i] { + t.Errorf("(TestLevelize4) Position %d: expecting %d got %d (sequence: %f)\n", i+1, expecting[i], p.Level, p.Sequence) + } + } +} + +// go test github.com/documize/community/core/model -run TestNumberiz, 3, 4, 4, 2e diff --git a/model/page/page.go b/model/page/page.go index e5168650..84e68aab 100644 --- a/model/page/page.go +++ b/model/page/page.go @@ -16,23 +16,26 @@ import ( "time" "github.com/documize/community/model" + "github.com/documize/community/model/workflow" ) // Page represents a section within a document. type Page struct { model.BaseEntity - OrgID string `json:"orgId"` - DocumentID string `json:"documentId"` - UserID string `json:"userId"` - ContentType string `json:"contentType"` - PageType string `json:"pageType"` - BlockID string `json:"blockId"` - Level uint64 `json:"level"` - Sequence float64 `json:"sequence"` - Numbering string `json:"numbering"` - Title string `json:"title"` - Body string `json:"body"` - Revisions uint64 `json:"revisions"` + OrgID string `json:"orgId"` + DocumentID string `json:"documentId"` + UserID string `json:"userId"` + ContentType string `json:"contentType"` + PageType string `json:"pageType"` + BlockID string `json:"blockId"` + Level uint64 `json:"level"` + Sequence float64 `json:"sequence"` + Numbering string `json:"numbering"` + Title string `json:"title"` + Body string `json:"body"` + Revisions uint64 `json:"revisions"` + Status workflow.ChangeStatus `json:"status"` + RelativeID string `json:"relativeId"` // links page to pending page edits } // SetDefaults ensures no blank values. @@ -41,6 +44,10 @@ func (p *Page) SetDefaults() { p.ContentType = "wysiwyg" } + if p.Level == 0 { + p.Level = 1 + } + p.Title = strings.TrimSpace(p.Title) } @@ -114,3 +121,18 @@ type LevelRequest struct { PageID string `json:"pageId"` Level int `json:"level"` } + +// BulkRequest details page, it's meta, pending page changes. +// Used to bulk load data by GUI so as to reduce network requests. +type BulkRequest struct { + Page Page `json:"page"` + Meta Meta `json:"meta"` + Pending []PendingPage `json:"pending"` +} + +// PendingPage details page that is yet to be published +type PendingPage struct { + Page Page `json:"page"` + Meta Meta `json:"meta"` + Owner string `json:"owner"` +} diff --git a/model/workflow/workflow.go b/model/workflow/workflow.go index b2fa50e0..095771dd 100644 --- a/model/workflow/workflow.go +++ b/model/workflow/workflow.go @@ -15,29 +15,51 @@ package workflow type Protection int const ( - // NoProtection means no protection so data item changes are permitted - NoProtection Protection = 0 + // ProtectionNone means no protection so data item changes are permitted + ProtectionNone Protection = 0 - // Lock means no data itme changes - Lock Protection = 1 + // ProtectionLock means no data itme changes + ProtectionLock Protection = 1 - // Review means changes must be reviewed and approved - Review Protection = 2 + // ProtectionReview means changes must be reviewed and approved + ProtectionReview Protection = 2 ) // Approval tells us how some data item change is to be approved type Approval int const ( - // NoApproval means no approval necessary - NoApproval Approval = 0 + // ApprovalNone means no approval necessary + ApprovalNone Approval = 0 - // Anybody can approve data item change - Anybody Approval = 1 + // ApprovalAnybody can approve data item change + ApprovalAnybody Approval = 1 - // Majority must approve data item change - Majority Approval = 2 + // ApprovalMajority must approve data item change + ApprovalMajority Approval = 2 - // Unanimous approval must be given for data item change - Unanimous Approval = 3 + // ApprovalUnanimous approval must be given for data item change + ApprovalUnanimous Approval = 3 +) + +// ChangeStatus tells us the state of a data item +type ChangeStatus int + +const ( + // ChangePublished means data item is visible all + ChangePublished ChangeStatus = 0 + + // ChangePending means data item is still being edited and not yet requesting review + ChangePending ChangeStatus = 1 + + // ChangeUnderReview means data item is being reviewed + // Next step would be to mark data item as either + // Published or Rejected + ChangeUnderReview ChangeStatus = 2 + + // ChangeRejected means data item was not approved for publication + ChangeRejected ChangeStatus = 3 + + // ChangePendingNew means a new section to a document is pending review + ChangePendingNew ChangeStatus = 4 ) diff --git a/server/routing/routes.go b/server/routing/routes.go index fe679d1f..e3ac511c 100644 --- a/server/routing/routes.go +++ b/server/routing/routes.go @@ -164,7 +164,6 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) { Add(rt, RoutePrefixPrivate, "sections/blocks/{blockID}", []string{"PUT", "OPTIONS"}, nil, block.Update) Add(rt, RoutePrefixPrivate, "sections/blocks/{blockID}", []string{"DELETE", "OPTIONS"}, nil, block.Delete) Add(rt, RoutePrefixPrivate, "sections/blocks", []string{"POST", "OPTIONS"}, nil, block.Add) - Add(rt, RoutePrefixPrivate, "sections/targets", []string{"GET", "OPTIONS"}, nil, page.GetMoveCopyTargets) Add(rt, RoutePrefixPrivate, "links/{folderID}/{documentID}/{pageID}", []string{"GET", "OPTIONS"}, nil, link.GetLinkCandidates) Add(rt, RoutePrefixPrivate, "links", []string{"GET", "OPTIONS"}, nil, link.SearchLinkCandidates) @@ -185,6 +184,7 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) { // fetch methods exist to speed up UI rendering by returning data in bulk Add(rt, RoutePrefixPrivate, "fetch/category/space/{spaceID}", []string{"GET", "OPTIONS"}, nil, category.FetchSpaceData) Add(rt, RoutePrefixPrivate, "fetch/document/{documentID}", []string{"GET", "OPTIONS"}, nil, document.FetchDocumentData) + Add(rt, RoutePrefixPrivate, "fetch/page/{documentID}", []string{"GET", "OPTIONS"}, nil, page.FetchPages) Add(rt, RoutePrefixRoot, "robots.txt", []string{"GET", "OPTIONS"}, nil, meta.RobotsTxt) Add(rt, RoutePrefixRoot, "sitemap.xml", []string{"GET", "OPTIONS"}, nil, meta.Sitemap)