diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 4e1398d0..a9059094 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -4,7 +4,7 @@ import {BaseAPI, IOptions} from 'app/common/BaseAPI'; import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI'; import {BrowserSettings} from 'app/common/BrowserSettings'; import {BulkColValues, TableColValues, UserAction} from 'app/common/DocActions'; -import {DocCreationInfo} from 'app/common/DocListAPI'; +import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI'; import {Features} from 'app/common/Features'; import {isClient} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; @@ -104,11 +104,22 @@ export interface Workspace extends WorkspaceProperties { isSupportWorkspace?: boolean; } +// Non-core options for a document. +// "Non-core" means bundled into a single options column in the database. +// TODO: consider smoothing over this distinction in the API. +export interface DocumentOptions { + description?: string|null; + icon?: string|null; + openMode?: OpenDocMode|null; +} + export interface DocumentProperties extends CommonProperties { isPinned: boolean; urlId: string|null; + options: DocumentOptions|null; } -export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId']; + +export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId', 'options']; export interface Document extends DocumentProperties { id: string; diff --git a/app/gen-server/entity/Document.ts b/app/gen-server/entity/Document.ts index e3328c0b..661aa337 100644 --- a/app/gen-server/entity/Document.ts +++ b/app/gen-server/entity/Document.ts @@ -1,6 +1,6 @@ import {ApiError} from 'app/common/ApiError'; import {Role} from 'app/common/roles'; -import {DocumentProperties, documentPropertyKeys, NEW_DOCUMENT_CODE} from "app/common/UserAPI"; +import {DocumentOptions, DocumentProperties, documentPropertyKeys, NEW_DOCUMENT_CODE} from "app/common/UserAPI"; import {nativeValues} from 'app/gen-server/lib/values'; import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm"; import {AclRuleDoc} from "./AclRule"; @@ -55,6 +55,9 @@ export class Document extends Resource { @OneToMany(type => Alias, alias => alias.doc) public aliases: Alias[]; + @Column({name: 'options', type: nativeValues.jsonEntityType, nullable: true}) + public options: DocumentOptions | null; + public checkProperties(props: any): props is Partial { return super.checkProperties(props, documentPropertyKeys); } @@ -68,5 +71,45 @@ export class Document extends Resource { } this.urlId = props.urlId; } + if (props.options !== undefined) { + // Options are merged over the existing state - unless options + // object is set to "null", in which case the state is wiped + // completely. + if (props.options === null) { + this.options = null; + } else { + this.options = this.options || {}; + if (props.options.description !== undefined) { + this.options.description = props.options.description; + } + if (props.options.openMode !== undefined) { + this.options.openMode = props.options.openMode; + } + if (props.options.icon !== undefined) { + this.options.icon = sanitizeIcon(props.options.icon); + } + // Normalize so that null equates with absence. + for (const key of Object.keys(this.options) as Array) { + if (this.options[key] === null) { + delete this.options[key]; + } + } + // Normalize so that no options set equates with absense. + if (Object.keys(this.options).length === 0) { + this.options = null; + } + } + } + } +} + +// Check that icon points to an expected location. This will definitely +// need changing, it is just a placeholder as the icon feature is developed. +function sanitizeIcon(icon: string|null) { + if (icon === null) { return icon; } + const url = new URL(icon); + if (url.protocol !== 'https:' || url.host !== 'grist-static.com' || !url.pathname.startsWith('/icons/')) { + throw new ApiError('invalid document icon', 400); } + return url.href; } diff --git a/app/gen-server/migration/1626369037484-DocOptions.ts b/app/gen-server/migration/1626369037484-DocOptions.ts new file mode 100644 index 00000000..0b032b9d --- /dev/null +++ b/app/gen-server/migration/1626369037484-DocOptions.ts @@ -0,0 +1,17 @@ +import { nativeValues } from 'app/gen-server/lib/values'; +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class DocOptions1626369037484 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn('docs', new TableColumn({ + name: 'options', + type: nativeValues.jsonType, + isNullable: true, + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('docs', 'options'); + } +} diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index e82bd4fc..41482abc 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -193,6 +193,8 @@ export function pruneAPIResult(data: T): T { // Do not include removedAt field if it is not set. It is not relevant to regular // situations where the user is working with non-deleted resources. if (key === 'removedAt' && value === null) { return undefined; } + // Don't bother sending option fields if there are no options set. + if (key === 'options' && value === null) { return undefined; } return INTERNAL_FIELDS.has(key) ? undefined : value; }); return JSON.parse(output);